Files
ppl2psd/ppl2psd.py
T

970 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# We render huge 2x canvases (e.g. 21528x10764); disable PIL's decompression
# bomb guard so it does not reject our own output.
Image.MAX_IMAGE_PIXELS = None
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, Bool,
Dict, List)
_TYSHA = True
except ImportError: # pragma: no cover
_TYSHA = False
# Global output scale: everything (canvas, layer rects, text size, shadows)
# is multiplied by _SCALE, so the whole vector project is upscaled.
_SCALE = 1.0
def set_scale(s: float) -> None:
global _SCALE
_SCALE = max(0.1, float(s))
# --------------------------------------------------------------------------
# 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) -> Tuple[List[dict], List[dict]]:
"""Parse layers.ini -> (layers, groups), each ordered bottom -> top.
Groups are [group] sections; layers carry a groupId field."""
layers, groups = [], []
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)
if m.group(1) == "group":
groups.append(ly)
else:
layers.append(ly)
return layers, groups
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), scaled by _SCALE."""
l, t, r, b = (float(v) for v in ly["boundingRect"].split(","))
return l * _SCALE, t * _SCALE, r * _SCALE, b * _SCALE
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))
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) * _SCALE)
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:
# gradient stroke: paste gradient clipped to a rounded-rect ring
stroke_img = make_linear_gradient(ww, hh, stroke)
ring = Image.new("L", (ww, hh), 0)
ImageDraw.Draw(ring).rounded_rectangle(bbox, radius=r_,
outline=255, width=thickness)
img.paste(stroke_img, (0, 0), ring)
# shadow (ppl renders a soft offset shadow per layer)
sd = float(ly.get("shadowDistance", "0") or 0) * _SCALE
sb = float(ly.get("shadowBlur", "0") or 0) * _SCALE
if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1":
img, ox, oy = _add_shadow(img, sd, sb,
float(ly.get("shadowAngle", "0") or 0),
ly.get("shadowColor", "-16777216"))
return img, (ix0 - ox, iy0 - oy)
return img, (ix0, iy0)
def _add_shadow(img, distance, blur, angle_deg, color_spec):
"""Render a soft offset shadow under the layer content. Returns
(expanded_img, offset_x, offset_y)."""
import math
from PIL import ImageFilter
rgba = argb_to_rgba(int(color_spec or "-16777216"))
rad = math.radians(angle_deg)
dx = int(round(distance * math.cos(rad)))
dy = int(round(distance * math.sin(rad)))
pad = max(int(blur) * 2 + abs(dx) + abs(dy) + 4, 8)
w, h = img.size
canvas = Image.new("RGBA", (w + 2 * pad, h + 2 * pad), (0, 0, 0, 0))
mask = img.getchannel("A")
shadow_fill = Image.new("RGBA", (w, h), rgba)
shadow_fill.putalpha(mask)
sh_layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
sh_layer.paste(shadow_fill, (pad + dx, pad + dy), mask)
sh_layer = sh_layer.filter(ImageFilter.GaussianBlur(blur))
canvas.alpha_composite(sh_layer)
canvas.alpha_composite(img, (pad, pad))
return canvas, pad, pad
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"] * _SCALE
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", ""))
if grad["type"] == "gradient":
fill = (255, 255, 255, 255) # base color; gradient is applied after
grad = grad # keep for the mask pass
else:
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 ---
# faux bold (textFontStyle=1) via stroke width
stroke_w = max(1, int(size / 40)) if ly.get("textFontStyle") == "1" else 0
pad = stroke_w # room for the stroke so glyphs don't clip
tail = max(int(size * 0.3), 8) # right padding so the last glyph never clips
raw_w, raw_h = max(1, int(round(tw)) + 2 * pad + tail), max(1, int(round(th)) + 2 * pad)
img = Image.new("RGBA", (raw_w, raw_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
for i, ln in enumerate(lines):
x = pad
for ch_ in ln:
off = 0
if ch_ in NARROW_PUNCT:
# PIL draws full-width punctuation offset inside the advance
# box (e.g. '(' ink sits at x+42..x+61). Shift the draw x so
# the ink starts at the layout x; otherwise it overlaps the
# next glyph or clips at line end.
t_img = Image.new("RGBA", (int(size) + 16, int(size) + 16), (0, 0, 0, 0))
ImageDraw.Draw(t_img).text((4, 4), ch_, font=font,
fill=(255, 255, 255, 255))
t_bb = t_img.getchannel("A").getbbox()
off = t_bb[0] - 4 if t_bb else 0
draw.text((x - off, pad + int(round(i * line_h))), ch_, font=font,
fill=fill, stroke_width=stroke_w, stroke_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"):
# center on the advance width (excluding the clip-padding tail)
left_full = int(round(cx - (tw + 2 * pad) / 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))
# text gradient fill: paint a gradient over the glyph alpha
if grad["type"] == "gradient":
gimg = make_linear_gradient(img.width, img.height, grad)
img.paste(gimg, (0, 0), img.getchannel("A"))
# soft shadow (used by rasterized output / previews)
sd = float(ly.get("shadowDistance", "0") or 0) * _SCALE
sb = float(ly.get("shadowBlur", "0") or 0) * _SCALE
if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1":
img, ox, oy = _add_shadow(img, sd, sb,
float(ly.get("shadowAngle", "0") or 0),
ly.get("shadowColor", "-16777216"))
return img, (ix0 - ox, iy0 - oy)
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)
def _opacity_paste(canvas: Image.Image, img: Image.Image, pos, opacity: int) -> None:
"""Composite a layer onto the merged/preview canvas, applying the layer
opacity (PSD opacity field is applied by Photoshop itself, but the
in-tool merged preview must apply it manually)."""
if opacity < 255:
img = img.copy()
img.putalpha(img.getchannel("A").point(lambda p: p * opacity // 255))
canvas.paste(img, pos, img)
# --------------------------------------------------------------------------
# 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].
Bands are encoded one at a time and their raw bytes released right away,
so at most two full-image buffers (the raw plane + its RLE result) exist
at once. On 2x canvases the old copy-all-planes approach multiplied the
layer size by ~5 and blew up RAM/ZRAM/SWAP, freezing the phone."""
w = img.width
ids = [-1, 0, 1, 2] if img.mode == "RGBA" else [0, 1, 2]
order = ["A", "R", "G", "B"] if img.mode == "RGBA" else ["R", "G", "B"]
if workers > 1 and len(order) > 1:
from multiprocessing import Pool
with Pool(workers) as pool:
enc = []
for band in order:
plane = img.getchannel(band).tobytes()
enc.append(pool.apply(_pack_channel_worker, ((plane, w),)))
del plane
if progress:
progress.update(1)
else:
enc = []
for band in order:
plane = img.getchannel(band).tobytes()
enc.append(_pack_channel_worker((plane, w)))
del plane
if progress:
progress.update(1)
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, text_w: Optional[float] = None) -> 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"
# Style/paragraph run lengths must match the new text length, otherwise
# Photoshop warns the text layer needs conversion / editing changes layout.
text_len = len(text)
eng["StyleRun"]["RunLengthArray"] = List([Integer(text_len)])
eng["ParagraphRun"]["RunLengthArray"] = List([Integer(text_len)])
# Left-align (template is justify, which spreads short text across the
# box). ppl rows read left-aligned; user prefers left over spread.
try:
props = eng["ParagraphRun"]["RunArray"][0]["ParagraphSheet"]["Properties"]
props["Justification"] = Integer(0) # left
except Exception:
pass
prof = font_profile(_font_path_for(ly))
size_px = float(ly.get("textSize", "12") or 12) * prof["k"] * _SCALE
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
# bold / italic (textFontStyle: 1 = faux bold)
if ly.get("textFontStyle") == "1":
ssd["FauxBold"] = Bool(True)
res["FontSet"][0]["Name"] = String(font_ps)
# DocumentResources mirrors ResourceDict; keep both font tables in sync
# or PS flags the text layer as needing conversion.
ed["DocumentResources"]["FontSet"][0]["Name"] = String(font_ps)
# StyleSheetSet preset: set color too (PS may read the preset style)
try:
res["StyleSheetSet"][0]["StyleSheetData"]["FillColor"] = fc
except Exception:
pass
l, t, rr, bb2 = bounding_rect(ly)
# transform origin = boundingRect top-left; boundingBox = the ACTUAL
# text box (0,0,tw,th) so the edit box is small and sits at the text.
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(bb2)
tw = (text_w if text_w else (rr - l)) * 1.1 + size_px * 0.3 # headroom so the last glyph is not clipped
th = size_px * 1.1
bbd = td[b"boundingBox"]
bbd[b"Left"].value = 0.0
bbd[b"Top "].value = 0.0
bbd[b"Rght"].value = float(tw)
bbd[b"Btom"].value = float(th)
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,
lsct: Optional[int] = 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
# lsct: layer section divider (1=group open, 3=group close)
if lsct is not None:
from psd_tools.psd.tagged_blocks import SectionDividerSetting
from psd_tools.constants import SectionDivider, BlendMode
kind = (SectionDivider.OPEN_FOLDER if lsct == 1
else SectionDivider.BOUNDING_SECTION_DIVIDER)
sd = SectionDividerSetting(kind=kind, signature=b"8BIM",
blend_mode=BlendMode.NORMAL)
sd_data = sd.tobytes()
extra += b"8BIMlsct" + struct.pack(">I", len(sd_data)) + sd_data
# 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(records: bytearray, payload_f, merged: Image.Image, layers: list,
width: int, height: int, out_path: str) -> None:
"""Assemble the PSD file from pre-encoded pieces.
The renderer already streamed every layer's RLE payloads into `payload_f`
(temp file) and composited the visible layers onto `merged`, so this only
writes header + records + streamed payloads + merged image. Nothing is
loaded back into RAM."""
import shutil
payload_f.seek(0, 2)
payload_len = payload_f.tell()
payload_f.seek(0)
# 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.
body_len = 2 + len(records) + payload_len
# layer & mask section: length = [4-byte inner length] + body +
# global layer mask info (4); the outer length does NOT include its own
# 4-byte length field
fh.write(struct.pack(">I", 4 + body_len + 4))
fh.write(struct.pack(">I", body_len))
fh.write(struct.pack(">h", -len(layers)))
fh.write(bytes(records))
shutil.copyfileobj(payload_f, fh) # stream, never load payloads into RAM
fh.write(struct.pack(">I", 0)) # global layer mask info
# merged image data (RGB, RAW), one plane at a time
fh.write(b"\x00\x00")
for c in "RGBA":
fh.write(merged.getchannel(c).tobytes())
del merged
fh.close()
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def _safe_workers(workers: int) -> int:
"""Keep at least half the CPU cores free - on phones the RLE-encoding
workers plus PIL's internal threading can otherwise stall the whole
device (a previous cores-1 cap still froze the host)."""
available = max(1, (os.cpu_count() or 2) // 2)
return max(1, min(workers, available))
def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
workers: int = 4, type_layer: bool = False) -> None:
workers = _safe_workers(workers)
if _SCALE >= 2.0:
# 2x canvases are 4x the pixels; parallel channel encoding would
# duplicate layer buffers across processes and exhaust RAM/ZRAM/SWAP
workers = 1
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, groups = parse_layers(text)
mw = re.search(r"^width=(\d+)", text, re.M)
mh = re.search(r"^height=(\d+)", text, re.M)
width = int(float(mw.group(1)) * _SCALE) if mw else 0
height = int(float(mh.group(1)) * _SCALE) if mh else 0
# --- group mapping ---
group_map = {}
for g in groups:
gid = g.get("objectId")
if gid:
group_map[gid] = g
group_ranges = {}
for i, ly in enumerate(layers):
gid = ly.get("groupId")
if gid:
if gid not in group_ranges:
group_ranges[gid] = [i, i]
else:
group_ranges[gid][1] = i
# The app renders group children offset by the group bbox movement
# (moving a group updates the group bbox but not the child layer
# bboxes). Apply that offset so children land where the app shows them.
group_offset = {}
for gid, (first, last) in group_ranges.items():
g = group_map.get(gid)
if not g or "boundingRect" not in g:
continue
gl, gt, gr, gb = bounding_rect(g)
# only this group's OWN layers (nested groups' layers excluded)
own = [i for i in range(first, last + 1)
if layers[i].get("groupId") == gid]
if not own:
continue
rects = [bounding_rect(layers[i]) for i in own]
inner_l = min(r[0] for r in rects)
inner_t = min(r[1] for r in rects)
inner_r = max(r[2] for r in rects)
inner_b = max(r[3] for r in rects)
# If the group bbox fully contains its content, the children sit
# at their ppl coordinates (the bbox is just a bigger frame, e.g.
# group_64). Only offset when the group was moved away from its
# content (bbox disjoint, e.g. groups 169/181).
if gl <= inner_l and gt <= inner_t and gr >= inner_r and gb >= inner_b:
group_offset[gid] = (0, 0)
else:
group_offset[gid] = (gl - inner_l, gt - inner_t)
rendered = []
skipped = 0
# Streaming render+encode: each layer is rendered, composited onto
# the merged canvas, RLE-encoded into the payload temp file and then
# released. Only ~1 layer + the merged canvas stay in RAM, instead of
# all 142 layers' images (the 2x build previously exhausted RAM and
# swap and got OOM-killed).
merged = Image.new("RGBA", (width, height), (0, 0, 0, 0))
payload_f = tempfile.TemporaryFile()
records = bytearray()
rp = tqdm(total=len(layers), desc="渲染图层", unit="层", file=sys.stderr)
ep = tqdm(total=len(layers) * 4, desc="编码通道", unit="通道",
file=sys.stderr)
for i, ly in enumerate(layers):
gid = ly.get("groupId")
gname = gvis = gb = None
layer_id = len(rendered) + 1
if gid and gid in group_ranges:
g = group_map.get(gid, {})
gname = g.get("name", "") or f"group_{gid}"
gvis = g.get("visible", "1") != "0"
gb = bounding_rect(g) if g else None
# psd_tools: kind 3 (bounding) opens a group (bottom marker),
# kind 1 (open folder) closes it (top marker, gives the name)
if group_ranges[gid][0] == i:
rendered.append((gname, 255, gvis, (0, 0), None, None,
(3, gb)))
# group open marker: 4 empty channels
ch = [(cid, b"\x00\x01") for cid in (-1, 0, 1, 2)]
cr = crop_to_canvas(*gb, width, height) if gb else None
l = t = r = b = 0
if cr:
l, t, r, b = cr
records += _layer_record(t, l, b, r, gname, 255, gvis,
ch, layer_id=layer_id, lsct=3)
for _, p in ch:
payload_f.write(p)
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
if gid and gid in group_offset:
lx = int(lx + group_offset[gid][0])
ly_ = int(ly_ + group_offset[gid][1])
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),
text_w=img.width)
except Exception as exc:
print(f" warning: text layer {name} -> TySh failed: {exc}",
file=sys.stderr)
if visible and opacity > 0:
_opacity_paste(merged, img, (lx, ly_), opacity)
ch = channel_blocks(img, workers, ep)
records += _layer_record(ly_, lx, ly_ + img.height, lx + img.width,
name, opacity, visible,
ch, layer_id=layer_id, tysh=tysh)
for _, p in ch:
payload_f.write(p)
del ch, img
rendered.append((name, opacity, visible, (lx, ly_), None, tysh,
None))
rp.update(1)
# group close marker (top of the app group)
if gid and gid in group_ranges and group_ranges[gid][1] == i:
rendered.append((gname, 255, gvis, (0, 0), None, None,
(1, None)))
ch = [(cid, b"\x00\x01") for cid in (-1, 0, 1, 2)]
records += _layer_record(0, 0, 0, 0, gname, 255, gvis,
ch, layer_id=layer_id + 1, lsct=1)
for _, p in ch:
payload_f.write(p)
rp.close()
ep.close()
print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), "
f"画布 {width}x{height}")
build_psd(records, payload_f, merged, rendered, width, height, out_path)
payload_f.close()
print(f"PSD 已写出: {out_path}")
if preview_path:
# downscale for easy viewing, unless the project was upscaled
# (then keep the full-res composite so the 2x detail is visible)
preview = merged.convert("RGB")
if _SCALE == 1.0 and 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=max(1, (os.cpu_count() or 2) // 2),
help="parallel workers for RLE encoding (default: cores/2)")
ap.add_argument("--type-layer", action="store_true",
help="convert text layers to editable TypeLayers (TySh) "
"instead of rasterized pixels")
ap.add_argument("--scale", type=float, default=1.0,
help="upscale the whole vector project by this factor "
"(canvas, layer rects, text size, shadows) "
"(default: 1.0)")
args = ap.parse_args()
set_scale(args.scale)
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()