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:
1 parent
edef8799ce
commit
c6a24a4fdb
2 files changed
+88
-10
No files matched your search
+88
-10
@@ -35,6 +35,14 @@ try:
|
|||||||
except ImportError: # pragma: no cover
|
except ImportError: # pragma: no cover
|
||||||
np = None
|
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
|
# Fonts
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -463,6 +471,55 @@ def channel_blocks(img: Image.Image, workers: int,
|
|||||||
return list(zip(ids, enc))
|
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:
|
def _standard_blocks(layer_id: int) -> bytes:
|
||||||
"""Standard tagged blocks Photoshop writes for every layer. PS reads
|
"""Standard tagged blocks Photoshop writes for every layer. PS reads
|
||||||
tagged blocks until it hits a non-8BIM signature; with only 'luni' the
|
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,
|
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 = bytearray()
|
||||||
rec += struct.pack(">4i", top, left, bottom, right)
|
rec += struct.pack(">4i", top, left, bottom, right)
|
||||||
rec += struct.pack(">H", len(channels))
|
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")
|
uni = name.encode("utf-16-be")
|
||||||
luni_data = struct.pack(">I", len(uni) // 2) + uni
|
luni_data = struct.pack(">I", len(uni) // 2) + uni
|
||||||
extra += b"8BIMluni" + struct.pack(">I", len(luni_data)) + luni_data
|
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)
|
# standard blocks Photoshop expects (see _standard_blocks)
|
||||||
extra += _standard_blocks(layer_id)
|
extra += _standard_blocks(layer_id)
|
||||||
rec += struct.pack(">I", len(extra))
|
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,
|
def build_psd(layers, width: int, height: int, out_path: str, workers: int,
|
||||||
progress: Optional[tqdm] = None) -> None:
|
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)
|
# encode all channels (parallel)
|
||||||
chan_data = []
|
chan_data = []
|
||||||
for name, opacity, visible, (lx, ly), img in layers:
|
for layer in layers:
|
||||||
|
img = layer[4]
|
||||||
ch = channel_blocks(img, workers, progress)
|
ch = channel_blocks(img, workers, progress)
|
||||||
chan_data.append(ch)
|
chan_data.append(ch)
|
||||||
|
|
||||||
# layer records + channel payloads
|
# layer records + channel payloads
|
||||||
records = bytearray()
|
records = bytearray()
|
||||||
payload = 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]
|
ch = chan_data[li]
|
||||||
l, t = lx, ly
|
l, t = lx, ly
|
||||||
r = l + img.width
|
r = l + img.width
|
||||||
b = t + img.height
|
b = t + img.height
|
||||||
records += _layer_record(t, l, b, r, name, opacity, visible, ch,
|
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:
|
for _, p in ch:
|
||||||
payload += p
|
payload += p
|
||||||
|
|
||||||
# merged preview: composite visible layers over transparent
|
# merged preview: composite visible layers over transparent
|
||||||
merged = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
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:
|
if visible and opacity > 0:
|
||||||
merged.paste(img, (lx, ly), img)
|
merged.paste(img, (lx, ly), img)
|
||||||
merged_rgb = None # keep the RGBA merged image for the 4-channel output
|
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
|
# Main
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
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
|
import tempfile
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
with zipfile.ZipFile(ppl_path) as zf:
|
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"
|
visible = ly.get("visible", "1") != "0"
|
||||||
opacity = int(ly.get("alpha", "255") or 255)
|
opacity = int(ly.get("alpha", "255") or 255)
|
||||||
name = f"{ly['_type']}_{ly.get('objectId', i)}"
|
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.update(1)
|
||||||
rp.close()
|
rp.close()
|
||||||
|
|
||||||
@@ -645,7 +719,8 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
|||||||
|
|
||||||
if preview_path:
|
if preview_path:
|
||||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
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:
|
if visible and opacity > 0:
|
||||||
canvas.paste(img, (lx, ly), img)
|
canvas.paste(img, (lx, ly), img)
|
||||||
# downscale for easy viewing
|
# downscale for easy viewing
|
||||||
@@ -666,12 +741,15 @@ def main():
|
|||||||
ap.add_argument("--preview", help="also write a downscaled composite PNG")
|
ap.add_argument("--preview", help="also write a downscaled composite PNG")
|
||||||
ap.add_argument("-j", "--workers", type=int, default=4,
|
ap.add_argument("-j", "--workers", type=int, default=4,
|
||||||
help="parallel workers for RLE encoding (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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
out = args.output
|
out = args.output
|
||||||
if not out:
|
if not out:
|
||||||
out = str(Path(args.input).with_suffix(".psd"))
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Binary file not shown.
Reference in new issue
Block a user