From c6a24a4fdb6c0a47466980eecb0d86eb1718b8b8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 27 Aug 2026 10:31:36 +0800 Subject: [PATCH] Add --type-layer: convert text layers to editable TypeLayers (TySh) Reuses a reference TySh block from a PS-generated PSD as a template and mutates its engine data (Text/FontSize/FillColor/FontSet/transform) per ppl text layer. Rasterized pixels are kept as channel data so the layer always displays, and Photoshop treats it as an editable text layer. Font PostScript names are read from the font files via fontTools. Verified: 54 TypeLayers, psd_tools reads correct text/engine data. --- ppl2psd.py | 98 ++++++++++++++++++++++++++++++++++++---- tools_tysh_template.bin | Bin 0 -> 8988 bytes 2 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 tools_tysh_template.bin 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 0000000000000000000000000000000000000000..a7cbf33e7ea9df1552691fc628efe823668ec373 GIT binary patch literal 8988 zcmeHNO>Er86&}ZSVJc4I7BP@C2}&a<5Cg7eRa_N;Vr@jT#?Lt%35suqCrNOxLzSo z9}E>VYDYQ$%x`DE=i7!^CA*G)=KJ4y=3_&DRBHeh&dS{ZHOdXZdY3LUZ$52BRCKxW zOPD)z4C?a8>d!2FK$kn>JxlhQ;PIt6 z+6K{5V;&NK5(*^~xYLgwMS1=UpYjkTlP? zgRG-~hKc~)4pHK7M4W>rMUzKuuZG&|aNX=P%>ca;+2e^4JT(NPu+<^I(0X_?OI4T0 z%J4ZhTHh6^zvkTXVwb!8miFg{Kw#?Y)QLv$&B*C7)i5;xO!t94)hDR@h z-eOy{0wrYZpEJ67bU`GkT7mh*X^s`@=$aZTNc8Lvk}t;L5Z+$U;y7x6ey+}{p5DR*P$QO*RSZ^>%$eG+s7PARKdZ`2U6g)Wn z?zCaxQ8asn2e^AdV{U^bHTQbpI;ZGjfQzg>=$zRs3DTe)B*Iromiug3#HrlL){+q2 z>?B;g#gjdp*g4tl$rPH3J|_HZ{DEQ*Kd80Q7}+a4ZmY<~w_lqwex*z4WhvrdtHb>o zXlz!xUX}-3<;4=#2IuRrpu2q`UsBHA11!Em%E6_>T9UX@+yvm031C)QREshZx1lBI zG+F^zKmKAKoXsR??ck(O=Q1#4mU$8*wU|DSI$F-S0`ZasZ3yo3`S_yKnUfv8{gyXD zh*lkI=y!e4SA$N*-R|YWwzPve$3f3U5r<+m>u&KREeGd7M45*N2mxFRCIm3OFU8vu z2xdyDrlf0eF&b?|@+~BnNcgCoU7I$GTlaITZDOKC%zff^+LAX4SCm(I?h0dAS@c5L z$)8(by(8K&uGK{@04+2nnzPUe+J%pedjPozU=C7$5bGbzt0e=QLT!fjq8Tnn}VP3I!f2@@WhEalRaXC#zd zs8eZ+r+N?@pA>DYBvy~^GZNTUjODgr<9Te7a!1&$&#Da6^++n2MzRNX;*qxu(GS=G zTFq*lS4zy&)05}NYiD7Hs!g7Mahy$@uergoN08^JlJKF@VRuEQRuSP2%m zTmTXWnQB&(Jk+!q6{d-jy169hnS7C2g1u-yeUCqQLQJGGOIm!s1k`K}X>g_I*Z7aF z23@Y<+B%7dBnr+{-}T5c5hR8uNp?s^7D+%75+Z2x=&IDn0Xwoo?2-QQ{^RW5{V(^Q z>3^kvh<)mx^q=;h>wD~E{~P@q%wvzT$Ji6>i|jw_GxihqQ+AZq*&MsbF0m!nV&7u# zuy3<>+57AR_6znQ`-uIH{h58j{>uKw{=xpqe#CxEULmK*_0!}Ad7GRe=+wk$JyiT1iAhK*(7)RpY)HA5Bfg&kbS9t z*z{cHyr(;!^uKB-E;n2IV8Aojpq>1nj%$Dew~2B}-otVCbD9%zPdg3MgPDX8M6bx3 zVma%IN`Nduk(I#SbB{ooB3Ho1n-DI`5GY-^HVB7Ms#SgVKggPRh_WUhqO7yIEbYW* z#8+WgfYd)&f6%-kuXT#zq*4e}CcrsaSWCPz1!)s=yEjYXS|8bLd|n9h%ds1&kZ4k#fN6yp-3Fw2Go3ZDP=S6wr-=8rOpB zoSZ{z%Qh5OS5URi`5gc*jYw=4g|W78F6gBW7@_S550KT-t|IEvgxvjr6;vX2xQY^) zwWG4pE215!ECC2!m^@z)OCsDj1v;}%Z>AZfYFB5#Q1F5S-zy-n#J3f;WTttYU>&h4 zXA>Uml(iXvv^m%rs2K{FjCiFCl{MUOPy{WOJ5YeSBE*KvU;%PaVW<~k;*yh$d`&Dl zNi*VWjS}bU#f+G`hpuG`CrATktkq5Y%ti3FsRc=`@UU&!r}Cl8K^loLFnAiaT@kbp zLAPK(GET3oJ6#s;1G8>Jc8nlP#$*q2W=z@|ZVAYig1kwZW@B%F{GQSUfGIf(@J$BB_%QWN$$ZUV;&Bf#j4d=+P_+vGTRqI@pWN>A~bL!Wc&P23Qc1uR(g^ zU{gjq2p8{VjgIPJqnWKMgI9H_c7xt%#?MXba;QY=a;Io3GaC&&J>sI?|MnQnqW7xV1|K2)I z{hw7Rvn?RF;|XM1hti;je_h<-mF6ca&1+C9B^91A$$0<( literal 0 HcmV?d00001