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.
757 lines
30 KiB
Python
757 lines
30 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
ppl2psd.py — Convert Photo Editor (dev.macgyver, com.iudesk.android.photo.editor)
|
||
multi-layer project files (.ppl) to Adobe Photoshop PSD.
|
||
|
||
.ppl is a ZIP archive containing:
|
||
- layers.ini : INI-style description of every layer (bitmap/shape/text)
|
||
- *.img : layer bitmaps as PNG (referenced via uri=@name.img)
|
||
|
||
The converter rasterizes every layer (bitmaps, gradient rounded-rect shapes,
|
||
text rendered with the original custom fonts when available), then writes a
|
||
valid PSD with one independent layer per project layer, preserving order,
|
||
position, opacity, visibility and blend mode.
|
||
|
||
Usage:
|
||
python ppl2psd.py input.ppl [-o output.psd] [--preview preview.png]
|
||
"""
|
||
|
||
import argparse
|
||
import math
|
||
import os
|
||
import re
|
||
import struct
|
||
import sys
|
||
import urllib.parse
|
||
import zipfile
|
||
from pathlib import Path
|
||
from typing import List, Optional, Tuple
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
from tqdm import tqdm
|
||
|
||
try:
|
||
import numpy as np
|
||
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
|
||
# --------------------------------------------------------------------------
|
||
FONT_ROOT = Path("/storage/emulated/0/Fonts")
|
||
FALLBACK_FONT = "/system/fonts/NotoSansCJK-Regular.ttc"
|
||
|
||
# Per-font rendering profiles, calibrated against a full-resolution export
|
||
# from the app:
|
||
# k - font size multiplier (app px = textSize * k)
|
||
# coef - advance multiplier (full-width glyphs, half-width glyphs); the app
|
||
# renders Source Han ExtraLight/Sans ~3% narrower than the font
|
||
# advance, so we scale per character class
|
||
# ls - letter spacing as a fraction of textSize
|
||
FONT_PROFILES = [
|
||
(("SourceHanSerifCN-ExtraLight",), dict(k=0.80, coef=(1.0, 1.05), ls=0.0, narrow=True)),
|
||
(("SourceHanSansCN",), dict(k=1.00, coef=(1.0, 1.05), ls=0.0, narrow=True)),
|
||
(("SourceHanSerifCN-Heavy",), dict(k=0.80, coef=(1.0, 1.0), ls=0.0)),
|
||
(("SourceHanSerifCN",), dict(k=0.80, coef=(1.0, 1.0), ls=0.09)),
|
||
(("汉仪花冠",), dict(k=0.80, coef=(1.0, 1.0), ls=0.09)),
|
||
(("AaMaShangXingKai",), dict(k=0.84, coef=(1.0, 1.0), ls=0.0)),
|
||
((), dict(k=0.80, coef=(1.0, 1.0), ls=0.09)), # fallback
|
||
]
|
||
|
||
|
||
def font_profile(font_path: str) -> dict:
|
||
name = os.path.basename(font_path or "")
|
||
for keys, prof in FONT_PROFILES:
|
||
if any(k in name for k in keys):
|
||
return prof
|
||
return FONT_PROFILES[-1][1]
|
||
|
||
|
||
def _is_fullwidth(ch: str) -> bool:
|
||
"""CJK / full-width punctuation (advance ~1em)."""
|
||
return ord(ch) > 0x2E80 or ch in "()“”·—…"
|
||
|
||
|
||
# Narrow full-width punctuation: PIL reports 1em advance but the app renders
|
||
# these glyphs far narrower (e.g. brackets ~0.45em), verified against the app
|
||
# export where '(' measures ~29px vs 65px em.
|
||
NARROW_PUNCT = set("()()“”‘’《》【】、,。!?:;·—…")
|
||
|
||
|
||
def resolve_font(spec: str) -> str:
|
||
"""Map a textFont= spec like '*思源宋体/SourceHanSerifCN-ExtraLight.otf'
|
||
to a real font file under FONT_ROOT, falling back to the system CJK font."""
|
||
spec = spec.lstrip("*")
|
||
name = spec.split("/")[-1]
|
||
if FONT_ROOT.is_dir():
|
||
# 1) exact relative path under FONT_ROOT
|
||
cand = FONT_ROOT / spec
|
||
if cand.is_file():
|
||
return str(cand)
|
||
# 2) recursive filename match
|
||
for hit in FONT_ROOT.rglob(name):
|
||
if hit.is_file():
|
||
return str(hit)
|
||
if os.path.isfile(FALLBACK_FONT):
|
||
return FALLBACK_FONT
|
||
return None
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Parsing
|
||
# --------------------------------------------------------------------------
|
||
def parse_layers(text: str) -> List[dict]:
|
||
"""Parse layers.ini into an ordered list of layer dicts (bottom -> top)."""
|
||
layers = []
|
||
for block in re.split(r"\n(?=\[)", text):
|
||
if not block.startswith("["):
|
||
continue
|
||
m = re.match(r"\[(\w+)\]", block)
|
||
if not m:
|
||
continue
|
||
ly = {"_type": m.group(1)}
|
||
for kv in re.finditer(r"^(\w+)=(.*)$", block, re.M):
|
||
ly[kv.group(1)] = kv.group(2)
|
||
layers.append(ly)
|
||
return layers
|
||
|
||
|
||
def argb_to_rgba(v: int) -> Tuple[int, int, int, int]:
|
||
"""Signed 32-bit ARGB int (as stored by the app) -> RGBA tuple."""
|
||
v &= 0xFFFFFFFF
|
||
return ((v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF, (v >> 24) & 0xFF)
|
||
|
||
|
||
def parse_gradient(spec: str) -> dict:
|
||
"""Decode 'type=gd&gd=...&gm=...&angle=..&gs=..' style color spec.
|
||
|
||
Returns {'type': 'solid'|'gradient', 'colors': [(pos, rgba), ...],
|
||
'angle': float}. gd= holds color stops, gm= maps stop
|
||
positions ('-1:0.0,...'), angle is the gradient direction in degrees."""
|
||
q = urllib.parse.unquote(spec)
|
||
fields = dict(kv.split("=", 1) for kv in q.split("&") if "=" in kv)
|
||
colors = []
|
||
for i, c in enumerate(fields.get("gd", "").split(",")):
|
||
c = c.strip()
|
||
if not c or not c.lstrip("-").isdigit():
|
||
continue
|
||
# optional alpha suffix tokens like 'A0'/'A1' are not colors - skip
|
||
colors.append(argb_to_rgba(int(c)))
|
||
angle = float(fields.get("angle", 180) or 180)
|
||
if not colors:
|
||
return {"type": "solid", "colors": [(0, (0, 0, 0, 255))], "angle": 0}
|
||
if all(c == colors[0] for c in colors):
|
||
return {"type": "solid", "colors": [(0, colors[0])], "angle": 0}
|
||
# build (pos, color) stops: distribute evenly if no gm= info
|
||
stops = [(i / max(1, len(colors) - 1), c) for i, c in enumerate(colors)]
|
||
return {"type": "gradient", "colors": stops, "angle": angle}
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Layer rendering
|
||
# --------------------------------------------------------------------------
|
||
def bounding_rect(ly: dict) -> Tuple[float, float, float, float]:
|
||
"""boundingRect is (left, top, right, bottom), not (x, y, w, h)."""
|
||
l, t, r, b = (float(v) for v in ly["boundingRect"].split(","))
|
||
return l, t, r, b
|
||
|
||
|
||
def crop_to_canvas(l: float, t: float, r: float, b: float, cw: int, ch: int
|
||
) -> Optional[Tuple[int, int, int, int]]:
|
||
"""Intersect a float (l, t, r, b) rect with the canvas; return int rect."""
|
||
ix0, iy0 = math.floor(l), math.floor(t)
|
||
ix1, iy1 = math.ceil(r), math.ceil(b)
|
||
ix0, iy0 = max(0, ix0), max(0, iy0)
|
||
ix1, iy1 = min(cw, ix1), min(ch, iy1)
|
||
if ix1 <= ix0 or iy1 <= iy0:
|
||
return None
|
||
return ix0, iy0, ix1, iy1
|
||
|
||
|
||
def make_linear_gradient(w: int, h: int, grad: dict) -> Image.Image:
|
||
"""Build an RGBA image filled with the linear gradient."""
|
||
stops = grad["colors"]
|
||
angle = math.radians(grad["angle"])
|
||
dx, dy = math.sin(angle), -math.cos(angle)
|
||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
|
||
proj = xx * dx + yy * dy
|
||
pmin, pmax = proj.min(), proj.max()
|
||
t = (proj - pmin) / (pmax - pmin) if pmax > pmin else np.zeros_like(proj)
|
||
# sample colors per channel across stops
|
||
chans = []
|
||
for ci in range(4):
|
||
vals = np.zeros(h * w, dtype=np.float64)
|
||
ts = t.ravel()
|
||
for k, ((p0, c0), (p1, c1)) in enumerate(zip(stops, stops[1:])):
|
||
seg = (ts >= p0) & (ts <= p1)
|
||
f = (ts[seg] - p0) / (p1 - p0) if p1 > p0 else 0.0
|
||
vals[seg] = c0[ci] * (1 - f) + c1[ci] * f
|
||
chans.append(vals.reshape(h, w))
|
||
arr = np.stack(chans, axis=-1).astype(np.uint8)
|
||
return Image.fromarray(arr, "RGBA")
|
||
|
||
|
||
def render_bitmap(ly: dict, img_path: Path, cw: int, ch: int
|
||
) -> Optional[Tuple[Image.Image, Tuple[int, int]]]:
|
||
l, t, r, b = bounding_rect(ly)
|
||
rect = crop_to_canvas(l, t, r, b, cw, ch)
|
||
if rect is None:
|
||
return None
|
||
ix0, iy0, ix1, iy1 = rect
|
||
w, h = r - l, b - t
|
||
with Image.open(img_path) as src:
|
||
src = src.convert("RGBA")
|
||
# stretch the bitmap onto the layer rect, then crop to canvas
|
||
layer = src.resize((max(1, int(round(w))), max(1, int(round(h)))),
|
||
Image.LANCZOS)
|
||
L, T = math.floor(l), math.floor(t)
|
||
layer = layer.crop((ix0 - L, iy0 - T, ix1 - L, iy1 - T))
|
||
apply_opacity(layer, ly.get("alpha", 255))
|
||
return layer, (ix0, iy0)
|
||
|
||
|
||
def render_shape(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tuple[int, int]]]:
|
||
l, t, r, b = bounding_rect(ly)
|
||
rect = crop_to_canvas(l, t, r, b, cw, ch)
|
||
if rect is None:
|
||
return None
|
||
ix0, iy0, ix1, iy1 = rect
|
||
ww, hh = ix1 - ix0, iy1 - iy0
|
||
img = Image.new("RGBA", (ww, hh), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
shape_type = ly.get("shapeType", "Rect")
|
||
round_v = ly.get("round")
|
||
radius = 0
|
||
if shape_type == "Circle":
|
||
radius = min(ww, hh) // 2
|
||
elif round_v:
|
||
# round is a percentage (0-100) of the smaller side
|
||
radius = int(min(r - l, b - t) * float(round_v) / 100)
|
||
r_ = max(0, min(radius, min(ww, hh) // 2))
|
||
bbox = (0, 0, ww - 1, hh - 1)
|
||
|
||
grad = parse_gradient(ly.get("shapeColor", ""))
|
||
mask = Image.new("L", (ww, hh), 0)
|
||
ImageDraw.Draw(mask).rounded_rectangle(bbox, radius=r_, fill=255)
|
||
if grad["type"] == "solid":
|
||
fill = grad["colors"][0][1]
|
||
draw.rounded_rectangle(bbox, radius=r_, fill=fill)
|
||
else:
|
||
grad_img = make_linear_gradient(ww, hh, grad)
|
||
img.paste(grad_img, (0, 0), mask)
|
||
|
||
# stroke
|
||
thickness = int(float(ly.get("shapeStrokeThickness", "0") or 0))
|
||
if thickness > 0:
|
||
stroke = parse_gradient(ly.get("shapeStrokeColor", ""))
|
||
if stroke["type"] == "solid":
|
||
draw.rounded_rectangle(bbox, radius=r_, outline=stroke["colors"][0][1],
|
||
width=thickness)
|
||
else:
|
||
draw.rounded_rectangle(bbox, radius=r_, outline=(0, 0, 0, 255),
|
||
width=thickness)
|
||
|
||
apply_opacity(img, ly.get("alpha", 255))
|
||
return img, (ix0, iy0)
|
||
|
||
|
||
def _font_path_for(ly: dict) -> str:
|
||
spec = ly.get("textFont", "")
|
||
return resolve_font(spec)
|
||
|
||
|
||
def render_text(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tuple[int, int]]]:
|
||
"""Render a text layer at its natural size, centered on the boundingRect.
|
||
|
||
Photo Editor does not clip text to the layer box (text may overflow), so
|
||
we render without cropping; only the part outside the canvas is removed.
|
||
|
||
Calibration: the app renders glyphs at textSize * 0.80 (verified against a
|
||
full-resolution app export: PIL glyphs were 1.24x the app's on both axes),
|
||
and applies letter-spacing of textLetterSpacing% of textSize per glyph.
|
||
"""
|
||
l, t, r, b = bounding_rect(ly)
|
||
text = ly.get("text", "") or ""
|
||
text = urllib.parse.unquote(text) # e.g. '%0a' -> newline (vertical text)
|
||
if not text:
|
||
return None
|
||
size_raw = float(ly.get("textSize", "12") or 12)
|
||
font_path = _font_path_for(ly)
|
||
prof = font_profile(font_path)
|
||
size = size_raw * prof["k"]
|
||
if font_path:
|
||
font = ImageFont.truetype(font_path, int(size))
|
||
else:
|
||
font = ImageFont.load_default(size=int(size))
|
||
ls = size_raw * prof["ls"]
|
||
coef = prof["coef"]
|
||
|
||
def char_advance(ch_: str, leading: bool = False) -> float:
|
||
"""Width to advance after a character (app-scaled font advance)."""
|
||
adv = probe.textlength(ch_, font=font)
|
||
if prof.get("narrow"):
|
||
if ch_ == " ":
|
||
# app renders spaces ~1.29x the font advance (0.335em vs 0.26em)
|
||
return adv * 1.29
|
||
if ch_ in NARROW_PUNCT:
|
||
# app renders full-width punctuation far narrower than 1em
|
||
return adv * 0.45
|
||
if ch_.isdigit():
|
||
# app digits are not tabular: '0/3/5...' ~1.07em, '1' ~0.98em
|
||
return adv * (0.98 if ch_ == "1" else 1.07)
|
||
c = coef[0] if _is_fullwidth(ch_) else coef[1]
|
||
return adv * c
|
||
|
||
grad = parse_gradient(ly.get("textColor", ""))
|
||
fill = grad["colors"][0][1] if grad["colors"] else (0, 0, 0, 255)
|
||
|
||
# --- measure natural text size (multi-line aware, per-char spacing) ---
|
||
lines = text.split("\n")
|
||
# line height never below the font size, otherwise glyphs overlap and
|
||
# the last line gets clipped at the image bottom (vertical text)
|
||
line_h = max(size * float(ly.get("textLineHeight", "100") or 100) / 100.0,
|
||
size)
|
||
probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
|
||
widths = []
|
||
for ln in lines:
|
||
w = 0.0
|
||
for ch_ in ln:
|
||
w += char_advance(ch_) + ls
|
||
widths.append(w)
|
||
tw = max(widths) if widths else 0.0
|
||
th = len(lines) * line_h + size * 0.25 # slack so the last line never clips
|
||
|
||
# --- render at natural size, centered on the layer box ---
|
||
raw_w, raw_h = max(1, int(round(tw))), max(1, int(round(th)))
|
||
img = Image.new("RGBA", (raw_w, raw_h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
for i, ln in enumerate(lines):
|
||
x = 0
|
||
for ch_ in ln:
|
||
draw.text((x, int(round(i * line_h))), ch_, font=font, fill=fill)
|
||
x += char_advance(ch_) + ls
|
||
|
||
# center on the boundingRect. Narrow-profile fonts (Source Han
|
||
# ExtraLight/Sans) are centered on the full advance width like the app;
|
||
# other fonts are centered on the glyph content (verified against the app
|
||
# export for the title layers).
|
||
cx, cy = (l + r) / 2, (t + b) / 2
|
||
if prof.get("narrow"):
|
||
left_full = int(round(cx - raw_w / 2))
|
||
top_full = int(round(cy - raw_h / 2))
|
||
content = img.getchannel("A").getbbox()
|
||
if content:
|
||
img = img.crop(content)
|
||
left = left_full + content[0]
|
||
top = top_full + content[1]
|
||
else:
|
||
left, top = left_full, top_full
|
||
else:
|
||
# trim to glyph content first, then center the trimmed box
|
||
content = img.getchannel("A").getbbox()
|
||
if content:
|
||
img = img.crop(content)
|
||
left = int(round(cx - img.width / 2))
|
||
top = int(round(cy - img.height / 2))
|
||
|
||
# clip only to the canvas
|
||
ix0, iy0 = max(0, left), max(0, top)
|
||
ix1 = min(cw, left + img.width)
|
||
iy1 = min(ch, top + img.height)
|
||
if ix1 <= ix0 or iy1 <= iy0:
|
||
return None
|
||
img = img.crop((ix0 - left, iy0 - top, ix1 - left, iy1 - top))
|
||
|
||
apply_opacity(img, ly.get("alpha", 255))
|
||
return img, (ix0, iy0)
|
||
|
||
|
||
def apply_opacity(img: Image.Image, alpha: str) -> None:
|
||
"""Multiply the image alpha channel by the layer opacity (0-255)."""
|
||
try:
|
||
a = int(alpha)
|
||
except (TypeError, ValueError):
|
||
return
|
||
if a >= 255:
|
||
return
|
||
alpha_ch = img.getchannel("A").point(lambda p: p * a // 255)
|
||
img.putalpha(alpha_ch)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# PSD writer
|
||
# --------------------------------------------------------------------------
|
||
def packbits(data: bytes) -> bytes:
|
||
"""PackBits (the PSD RLE flavour): literal runs up to 128 bytes,
|
||
repeated-byte runs up to 128 bytes, skip codes are not emitted."""
|
||
n = len(data)
|
||
out = bytearray()
|
||
i = 0
|
||
while i < n:
|
||
b = data[i]
|
||
j = i + 1
|
||
while j < n and j - i < 128 and data[j] == b:
|
||
j += 1
|
||
run = j - i
|
||
if run >= 2:
|
||
out.append((1 - run) & 0xFF) # signed negative count
|
||
out.append(b)
|
||
i = j
|
||
else:
|
||
start = i
|
||
j = i + 1
|
||
while j < n and j - start < 128 and data[j] != data[j - 1]:
|
||
j += 1
|
||
lit = j - start
|
||
out.append(lit - 1)
|
||
out += data[start:j]
|
||
i = j
|
||
return bytes(out)
|
||
|
||
|
||
def _pack_channel_worker(args):
|
||
plane, w = args
|
||
h = len(plane) // w
|
||
rows = []
|
||
for y in range(h):
|
||
rows.append(packbits(plane[y * w:(y + 1) * w]))
|
||
sizes = struct.pack(">%dH" % h, *[len(r) for r in rows])
|
||
return b"\x00\x01" + sizes + b"".join(rows) # compression=1 (RLE)
|
||
|
||
|
||
def channel_blocks(img: Image.Image, workers: int,
|
||
progress: Optional[tqdm] = None) -> List[Tuple[int, bytes]]:
|
||
"""RLE-encode the planes of a cropped RGB(A) layer (parallel).
|
||
|
||
RGBA layers yield channel ids [-1, 0, 1, 2], RGB images yield [0, 1, 2]."""
|
||
arr = np.asarray(img) if np is not None else None
|
||
if arr is not None and arr.ndim == 3:
|
||
nch = arr.shape[2]
|
||
if nch == 4:
|
||
# zero RGB where alpha==0 for better compression
|
||
arr = arr.copy()
|
||
arr[arr[..., 3] == 0] = 0
|
||
# PSD layer channel ids: -1=alpha mask, 0=R, 1=G, 2=B
|
||
planes = [arr[..., 3].tobytes()] + [arr[..., i].tobytes() for i in range(3)]
|
||
else:
|
||
planes = [arr[..., i].tobytes() for i in range(nch)]
|
||
else:
|
||
bands = img.getbands()
|
||
if bands == ("R", "G", "B", "A"):
|
||
planes = [img.getchannel("A").tobytes()] + \
|
||
[img.getchannel(c).tobytes() for c in "RGB"]
|
||
else:
|
||
planes = [img.getchannel(c).tobytes() for c in bands]
|
||
w = img.width
|
||
jobs = [(plane, w) for plane in planes]
|
||
if workers > 1 and len(jobs) > 1:
|
||
from multiprocessing import Pool
|
||
with Pool(workers) as pool:
|
||
enc = []
|
||
for res in pool.imap(_pack_channel_worker, jobs, chunksize=1):
|
||
enc.append(res)
|
||
if progress:
|
||
progress.update(1)
|
||
else:
|
||
enc = []
|
||
for j in jobs:
|
||
enc.append(_pack_channel_worker(j))
|
||
if progress:
|
||
progress.update(1)
|
||
ids = [-1, 0, 1, 2] if img.mode == "RGBA" else [0, 1, 2]
|
||
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
|
||
next bytes are channel data and PS mis-parses the record, so we write
|
||
the usual block set like PS itself does."""
|
||
items = [
|
||
(b"lnsr", b"\x00\x00\x00\x00"), # name source
|
||
(b"lyid", struct.pack(">I", layer_id)), # layer id
|
||
(b"clbl", b"\x00\x00\x00\x00"), # blend clipping
|
||
(b"infx", b"\x00\x00\x00\x00"), # blend interior
|
||
(b"knko", b"\x00\x00\x00\x00"), # knockout
|
||
(b"lspf", b"\x00\x00\x00\x00"), # protected
|
||
(b"lclr", b"\x00" * 8), # sheet color
|
||
(b"fxrp", b"\x00" * 16), # reference point
|
||
]
|
||
out = bytearray()
|
||
for key, payload in items:
|
||
out += b"8BIM" + key + struct.pack(">I", len(payload)) + payload
|
||
return bytes(out)
|
||
|
||
|
||
def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
|
||
layer_id=1, tysh: Optional[bytes] = None) -> bytes:
|
||
rec = bytearray()
|
||
rec += struct.pack(">4i", top, left, bottom, right)
|
||
rec += struct.pack(">H", len(channels))
|
||
for ch_id, payload in channels:
|
||
rec += struct.pack(">hI", ch_id, len(payload))
|
||
rec += b"8BIM" + b"norm"
|
||
rec += struct.pack(">B", opacity)
|
||
rec += struct.pack(">B", 0) # clipping
|
||
# flags: bit3 = 1 for Photoshop 5.0+ (tells PS the extra data contains
|
||
# tagged blocks); bit1 = hidden
|
||
rec += struct.pack(">B", 0x08 | (0 if visible else 0x02))
|
||
rec += struct.pack(">B", 0) # filler
|
||
# extra data
|
||
extra = bytearray()
|
||
extra += struct.pack(">I", 0) # layer mask data length
|
||
# blending ranges: composite + 4 channels, ALL full range (0..65535).
|
||
# PS writes these as 0x0000/0xffff; a zero composite range tells PS the
|
||
# layer does not participate in blending -> the canvas renders empty.
|
||
extra += struct.pack(">I", 40) + b"\x00\x00\xff\xff\x00\x00\xff\xff" * 5
|
||
nb = name.encode("utf-8")
|
||
extra += bytes([min(len(nb), 255)]) + nb[:255]
|
||
# pad pascal string to a multiple of 4 bytes
|
||
pad = (-len(extra)) % 4
|
||
if pad:
|
||
extra += b"\x00" * pad
|
||
# luni: unicode layer name. Data = 4-byte CHARACTER count + utf-16-be
|
||
# chars. (Writing raw utf-16 without the count shifts the extra-data
|
||
# parse and Photoshop reads every layer's pixels as empty.)
|
||
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))
|
||
rec += extra
|
||
return bytes(rec)
|
||
|
||
|
||
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[, 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 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, 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, 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 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
|
||
# Merged image data is written RAW with a single compression field and
|
||
# all planes in planar order (R G B A). 4 channels because the document
|
||
# has transparency (negative layer count references the merged alpha).
|
||
if np is not None:
|
||
arr = np.asarray(merged)
|
||
merged_block = b"\x00\x00" + b"".join(
|
||
arr[..., i].tobytes() for i in range(4))
|
||
else:
|
||
merged_block = b"\x00\x00" + b"".join(
|
||
merged.getchannel(c).tobytes() for c in "RGBA")
|
||
|
||
# assemble file. Header channels = 4 (document has transparency, like PS
|
||
# writes when layers have alpha).
|
||
fh = open(out_path, "wb")
|
||
fh.write(struct.pack(">4sH6sHIIHH", b"8BPS", 1, b"\x00" * 6, 4, height, width, 8, 3))
|
||
fh.write(struct.pack(">I", 0)) # color mode data
|
||
fh.write(struct.pack(">I", 0)) # image resources
|
||
|
||
# Layer info section: [Length(4)] + [layer count(2) + records + channel data]
|
||
# Negative layer count: abs = layer count, first alpha channel of the
|
||
# merged image holds the merged transparency (PS writes this when the
|
||
# document has transparent layers).
|
||
layer_info_body = struct.pack(">h", -len(layers)) + bytes(records) + bytes(payload)
|
||
layer_info = struct.pack(">I", len(layer_info_body)) + layer_info_body
|
||
# layer & mask section: length = layer info + global layer mask info
|
||
# (the outer length does NOT include its own 4-byte length field)
|
||
lam = layer_info + struct.pack(">I", 0) # global layer mask info
|
||
fh.write(struct.pack(">I", len(lam)) + lam)
|
||
|
||
# merged image data (RGB, RAW)
|
||
fh.write(merged_block)
|
||
fh.close()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Main
|
||
# --------------------------------------------------------------------------
|
||
def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
||
workers: int = 4, type_layer: bool = False) -> None:
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
with zipfile.ZipFile(ppl_path) as zf:
|
||
zf.extractall(tmp)
|
||
ini = Path(tmp) / "layers.ini"
|
||
text = ini.read_text(encoding="utf-8", errors="replace")
|
||
layers = parse_layers(text)
|
||
mw = re.search(r"^width=(\d+)", text, re.M)
|
||
mh = re.search(r"^height=(\d+)", text, re.M)
|
||
width = int(mw.group(1)) if mw else 0
|
||
height = int(mh.group(1)) if mh else 0
|
||
|
||
rendered = []
|
||
skipped = 0
|
||
rp = tqdm(total=len(layers), desc="渲染图层", unit="层", file=sys.stderr)
|
||
for i, ly in enumerate(layers):
|
||
img_path = None
|
||
if ly["_type"] == "bitmap":
|
||
uri = ly.get("uri", "")
|
||
if uri.startswith("@"):
|
||
img_path = Path(tmp) / uri[1:]
|
||
if not img_path or not img_path.is_file():
|
||
rp.update(1)
|
||
skipped += 1
|
||
continue
|
||
res = render_bitmap(ly, img_path, width, height)
|
||
elif ly["_type"] == "shape":
|
||
res = render_shape(ly, width, height)
|
||
elif ly["_type"] == "text":
|
||
res = render_text(ly, width, height)
|
||
else:
|
||
res = None
|
||
if res is None:
|
||
rp.update(1)
|
||
skipped += 1
|
||
continue
|
||
img, (lx, ly_) = res
|
||
visible = ly.get("visible", "1") != "0"
|
||
opacity = int(ly.get("alpha", "255") or 255)
|
||
name = f"{ly['_type']}_{ly.get('objectId', i)}"
|
||
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()
|
||
|
||
print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), "
|
||
f"画布 {width}x{height}")
|
||
ep = tqdm(total=len(rendered) * 4 + 3, desc="编码通道", unit="通道",
|
||
file=sys.stderr)
|
||
build_psd(rendered, width, height, out_path, workers, ep)
|
||
ep.close()
|
||
print(f"PSD 已写出: {out_path}")
|
||
|
||
if preview_path:
|
||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||
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
|
||
preview = canvas.convert("RGB")
|
||
if preview.width > 2000:
|
||
scale = 2000 / preview.width
|
||
preview = preview.resize(
|
||
(int(preview.width * scale), int(preview.height * scale)),
|
||
Image.LANCZOS)
|
||
preview.save(preview_path)
|
||
print(f"预览图已写出: {preview_path}")
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="Convert Photo Editor .ppl to PSD")
|
||
ap.add_argument("input", help="input .ppl file")
|
||
ap.add_argument("-o", "--output", help="output .psd path")
|
||
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, args.type_layer)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|