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.
This commit is contained in:
AskaEth committed 2026-08-27 10:31:36 +08:00
1 parent edef8799ce
commit c6a24a4fdb
2 files changed
+88 -10

No files matched your search

+88 -10
View File
@@ -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__":
Binary file not shown.