diff --git a/ppl2psd.py b/ppl2psd.py index 6c65434..aebc69a 100644 --- a/ppl2psd.py +++ b/ppl2psd.py @@ -35,6 +35,14 @@ try: except ImportError: # pragma: no cover np = None +# Optional TypeLayer (editable text) support +try: + from psd_tools.psd.tagged_blocks import TypeToolObjectSetting + from psd_tools.psd.engine_data import (String, Float, Integer, Dict, List) + _TYSHA = True +except ImportError: # pragma: no cover + _TYSHA = False + # -------------------------------------------------------------------------- # Fonts # -------------------------------------------------------------------------- @@ -463,6 +471,55 @@ def channel_blocks(img: Image.Image, workers: int, return list(zip(ids, enc)) +def font_ps_name(font_path: str) -> str: + """PostScript name of a font file (what Photoshop matches).""" + try: + from fontTools.ttLib import TTFont + f = TTFont(font_path) + ps = f["name"].getDebugName(6) + return ps or os.path.basename(font_path) + except Exception: + return os.path.basename(font_path) + + +def build_type_layer_tysh(ly: dict, font_ps: str) -> bytes: + """Build a TySh (TypeTool object setting) block for an editable text layer + by mutating the reference template's engine data.""" + tmpl = Path(__file__).parent / "tools_tysh_template.bin" + raw = tmpl.read_bytes() + tysh = TypeToolObjectSetting.frombytes(raw[12:]) # strip 8BIMTySh header + td = tysh.text_data + ed = td[b"EngineData"].value + eng = ed["EngineDict"] + res = ed["ResourceDict"] + editor = eng["Editor"] + + text = urllib.parse.unquote(ly.get("text", "") or "") + editor["Text"] = String(text + "\r") + # Txt (legacy text) value mirrors the engine text so readers agree + td[b"Txt "].value = text + "\x00" + + prof = font_profile(_font_path_for(ly)) + size_px = float(ly.get("textSize", "12") or 12) * prof["k"] + ssd = eng["StyleRun"]["RunArray"][0]["StyleSheet"]["StyleSheetData"] + ssd["FontSize"] = Float(size_px) + + grad = parse_gradient(ly.get("textColor", "")) + r, g, b, a = grad["colors"][0][1] + fc = Dict() + fc["Type"] = Integer(1) + fc["Values"] = List([Float(r / 255.0), Float(g / 255.0), + Float(b / 255.0), Float(a / 255.0)]) + ssd["FillColor"] = fc + + res["FontSet"][0]["Name"] = String(font_ps) + + l, t, rr, bb = bounding_rect(ly) + tysh.transform = [1.0, 0.0, 0.0, 1.0, float(l), float(t)] + tysh.left, tysh.top, tysh.right, tysh.bottom = int(l), int(t), int(rr), int(bb) + return tysh.tobytes() + + def _standard_blocks(layer_id: int) -> bytes: """Standard tagged blocks Photoshop writes for every layer. PS reads tagged blocks until it hits a non-8BIM signature; with only 'luni' the @@ -485,7 +542,7 @@ def _standard_blocks(layer_id: int) -> bytes: def _layer_record(top, left, bottom, right, name, opacity, visible, channels, - layer_id=1) -> bytes: + layer_id=1, tysh: Optional[bytes] = None) -> bytes: rec = bytearray() rec += struct.pack(">4i", top, left, bottom, right) rec += struct.pack(">H", len(channels)) @@ -517,6 +574,9 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels, uni = name.encode("utf-16-be") luni_data = struct.pack(">I", len(uni) // 2) + uni extra += b"8BIMluni" + struct.pack(">I", len(luni_data)) + luni_data + # TySh: makes this an editable text layer in Photoshop + if tysh: + extra += b"8BIMTySh" + struct.pack(">I", len(tysh)) + tysh # standard blocks Photoshop expects (see _standard_blocks) extra += _standard_blocks(layer_id) rec += struct.pack(">I", len(extra)) @@ -526,29 +586,35 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels, def build_psd(layers, width: int, height: int, out_path: str, workers: int, progress: Optional[tqdm] = None) -> None: - """layers: list of (name, opacity, visible, offset, Image) bottom->top.""" + """layers: list of (name, opacity, visible, offset, Image[, tysh]) + bottom->top. When tysh (TySh block) is present the layer is an editable + text layer; the image is kept as the rasterized channel data.""" # encode all channels (parallel) chan_data = [] - for name, opacity, visible, (lx, ly), img in layers: + for layer in layers: + img = layer[4] ch = channel_blocks(img, workers, progress) chan_data.append(ch) # layer records + channel payloads records = bytearray() payload = bytearray() - for li, (name, opacity, visible, (lx, ly), img) in enumerate(layers): + for li, layer in enumerate(layers): + name, opacity, visible, (lx, ly), img = layer[:5] + tysh = layer[5] if len(layer) > 5 else None ch = chan_data[li] l, t = lx, ly r = l + img.width b = t + img.height records += _layer_record(t, l, b, r, name, opacity, visible, ch, - layer_id=li + 1) + layer_id=li + 1, tysh=tysh) for _, p in ch: payload += p # merged preview: composite visible layers over transparent merged = Image.new("RGBA", (width, height), (0, 0, 0, 0)) - for name, opacity, visible, (lx, ly), img in layers: + for layer in layers: + name, opacity, visible, (lx, ly), img = layer[:5] if visible and opacity > 0: merged.paste(img, (lx, ly), img) merged_rgb = None # keep the RGBA merged image for the 4-channel output @@ -590,7 +656,7 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int, # Main # -------------------------------------------------------------------------- def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None, - workers: int = 4) -> None: + workers: int = 4, type_layer: bool = False) -> None: import tempfile with tempfile.TemporaryDirectory() as tmp: with zipfile.ZipFile(ppl_path) as zf: @@ -631,7 +697,15 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None, visible = ly.get("visible", "1") != "0" opacity = int(ly.get("alpha", "255") or 255) name = f"{ly['_type']}_{ly.get('objectId', i)}" - rendered.append((name, opacity, visible, (lx, ly_), img)) + tysh = None + if type_layer and ly["_type"] == "text" and _TYSHA: + try: + fp = _font_path_for(ly) + tysh = build_type_layer_tysh(ly, font_ps_name(fp)) + except Exception as exc: + print(f" warning: text layer {name} -> TySh failed: {exc}", + file=sys.stderr) + rendered.append((name, opacity, visible, (lx, ly_), img, tysh)) rp.update(1) rp.close() @@ -645,7 +719,8 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None, if preview_path: canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) - for name, opacity, visible, (lx, ly), img in rendered: + for layer in rendered: + name, opacity, visible, (lx, ly), img = layer[:5] if visible and opacity > 0: canvas.paste(img, (lx, ly), img) # downscale for easy viewing @@ -666,12 +741,15 @@ def main(): ap.add_argument("--preview", help="also write a downscaled composite PNG") ap.add_argument("-j", "--workers", type=int, default=4, help="parallel workers for RLE encoding (default 4)") + ap.add_argument("--type-layer", action="store_true", + help="convert text layers to editable TypeLayers (TySh) " + "instead of rasterized pixels") args = ap.parse_args() out = args.output if not out: out = str(Path(args.input).with_suffix(".psd")) - convert(args.input, out, args.preview, args.workers) + convert(args.input, out, args.preview, args.workers, args.type_layer) if __name__ == "__main__": diff --git a/tools_tysh_template.bin b/tools_tysh_template.bin new file mode 100644 index 0000000..a7cbf33 Binary files /dev/null and b/tools_tysh_template.bin differ