Add ppl<->psd conversion tools (ppl2psd, psd2ppl)

This commit is contained in:
AskaEth committed 2026-08-27 08:16:44 +08:00
commit 60624f01c2
3 files changed
+905

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
samples/
*.ppl
*.psd
__pycache__/
+622
View File
@@ -0,0 +1,622 @@
#!/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
# --------------------------------------------------------------------------
# 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
planes = [arr[..., i].tobytes() for i in range(nch)]
else:
planes = [img.getchannel(c).tobytes() for c in img.getbands()]
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 _layer_record(top, left, bottom, right, name, opacity, visible, channels) -> 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
rec += struct.pack(">B", 0 if visible else 2) # flags: bit1 = hidden
rec += struct.pack(">B", 0) # filler
# extra data
extra = bytearray()
extra += struct.pack(">I", 0) # layer mask data length
extra += struct.pack(">I", 8) + b"\x00" * 8 # blending ranges (default)
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
uni = name.encode("utf-16-be")
extra += b"8BIMluni" + struct.pack(">I", len(uni)) + uni
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) bottom->top."""
# encode all channels (parallel)
chan_data = []
for name, opacity, visible, (lx, ly), img in layers:
ch = channel_blocks(img, workers, progress)
chan_data.append(ch)
# layer records + channel payloads
records = bytearray()
payload = bytearray()
for (name, opacity, visible, (lx, ly), img), ch in zip(layers, chan_data):
l, t = lx, ly
r = l + img.width
b = t + img.height
records += _layer_record(t, l, b, r, name, opacity, visible, ch)
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:
if visible and opacity > 0:
merged.paste(img, (lx, ly), img)
merged_rgb = merged.convert("RGB") # transparent -> black
merged_channels = channel_blocks(merged_rgb, workers, progress)[0:3]
# assemble file
fh = open(out_path, "wb")
fh.write(struct.pack(">4sH6sHIIHH", b"8BPS", 1, b"\x00" * 6, 3, height, width, 8, 3))
fh.write(struct.pack(">I", 0)) # color mode data
fh.write(struct.pack(">I", 0)) # image resources
layer_info = bytes(records) + bytes(payload)
count_payload = struct.pack(">h", len(layers)) + layer_info
lam = struct.pack(">I", len(count_payload) + 4) + count_payload
lam += struct.pack(">I", 0) # global layer mask info
fh.write(struct.pack(">I", len(lam)) + lam)
# merged image data (RGB, RLE)
merged_block = b"".join(p for _, p in merged_channels)
fh.write(merged_block)
fh.close()
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
workers: int = 4) -> 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)}"
rendered.append((name, opacity, visible, (lx, ly_), img))
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 name, opacity, visible, (lx, ly), img in rendered:
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)")
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)
if __name__ == "__main__":
main()
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""
psd2ppl.py — Convert Adobe Photoshop PSD back to Photo Editor (dev.macgyver,
com.iudesk.android.photo.editor) multi-layer project files (.ppl).
.ppl is a ZIP archive containing layers.ini (layer descriptions) plus one PNG
per bitmap layer.
Mapping:
- pixel layers -> [bitmap] sections (each layer exported as PNG)
- text/type layers -> [text] sections (text, size, color, position; fonts
are mapped to the fonts present on the phone)
- layer position (left/top/right/bottom), opacity and visibility are kept
Usage:
python psd2ppl.py input.psd [-o output.ppl]
"""
import argparse
import os
import re
import struct
import sys
import tempfile
import uuid
import zipfile
from pathlib import Path
from typing import Optional, Tuple
from PIL import Image
from psd_tools import PSDImage
# --------------------------------------------------------------------------
# Field templates (mirror the exact structure the app writes)
# --------------------------------------------------------------------------
HEADER = "version=1\nwidth={w}\nheight={h}\n"
_BITMAP_TEMPLATE = """[bitmap]
objectId={oid}
shadowDistance=0
cropMode=0
cropRect=0,0,{iw},{ih}
shadowBlur=0
colorFilter=exposure%3d0%26brightness%3d0%26contrast%3d0%26saturation%3d100%26temperature%3d6500%26tintGreen%3d0%26hue%3d0
lockRatioY=0.0
lockRatioX=0.0
backgroundRound=0
flipContentY=0
flipBitmapY=0
flipContentX=0
flipBitmapX=0
maxPixels=0
alpha={alpha}
angle=0.0
perspective=v0:0.0:0.0,1.0:0.0,1.0:1.0,0.0:1.0
locked=0
inverted=0
shadowColor=-16777216
innerShadowAngle=45
selected=0
imageWidth={iw}
backgroundColor=type%3dgd%26gd%3d16777215,16777215,%26gm%3d-1:0.0,-16777216:1.0%26angle%3d180%26gs%3dl
visible={visible}
boundingRect={l},{t},{r},{b}
innerShadowDistance=0
innerShadowBlur=0
keepAspectRatio=1
uri=@{uri}
imageHeight={ih}
innerShadowColor=-2147483648
paddingX=0
filter=
backgroundRoundCorners=15
shadowAngle=29
blendMode=v2:normal
name=
paddingY=0
"""
_TEXT_TEMPLATE = """[text]
objectId={oid}
shadowDistance=0
shadowBlur=0
textFontStyle=0
textVerticalAlign=1
textBackgroundRound=0
textOutlineColor=type%3dgd%26gd%3d-16777216,-16777216,%26gm%3d-1:0.0,-16777216:1.0%26angle%3d180%26gs%3dl
textOrientation=0
textBlur=0
textMap=
text={text}
locked=0
shadowColor=-16777216
innerShadowAngle=45
selected=0
visible={visible}
textOutlineSize=0
textAlign={align}
boundingRect={l},{t},{r},{b}
textOutlineCap=1
textColor={color}
textBackgroundColor=type%3dgd%26gd%3d16777215,16777215,%26gm%3d-1:0.0,-16777216:1.0%26angle%3d180%26gs%3dl
textPaddingX=10
textPaddingY=10
innerShadowColor=-2147483648
shadowAngle=45
textFontSource=custom
name=
backgroundBlur=0
textFont={font}
warp=
textLetterSpacing=0
outlineBlur=0
lockRatioY=0.0
lockRatioX=0.0
textWrapBreakWord=0
flipContentY=0
flipContentX=0
alpha={alpha}
textWrapLength=-1
angle=0.0
perspective=v0:0.0:0.0,1.0:0.0,1.0:1.0,0.0:1.0
textBackgroundRoundCorners=15
textLineHeight=127
textSize={size}
innerShadowDistance=0
innerShadowBlur=0
keepAspectRatio=1
blendMode=v2:normal
initialPosition=v2:9
textFontVectorMode=1
"""
# PS PostScript name -> phone font path (Photo Editor textFont= spec)
_FONT_MAP = [
("SourceHanSansCN", "*思源黑体/SourceHanSansCN-Regular.otf"),
("SourceHanSerifCN", "*思源宋体/SourceHanSerifCN-Regular.otf"),
(".*", "*思源黑体/SourceHanSansCN-Regular.otf"), # fallback
]
# Font size: PSD stores pt, the app renders px = textSize * k (k depends on
# the font; 0.8 is the calibrated default).
_TEXT_SIZE_K = 0.8
def font_to_ppl(postscript_name: str) -> str:
for pat, spec in _FONT_MAP:
if re.match(pat, postscript_name or ""):
return spec
return _FONT_MAP[-1][1]
def color_to_gd(rgba: Tuple[int, int, int, int]) -> str:
r, g, b, a = rgba
argb = (a << 24) | (r << 16) | (g << 8) | b
if argb >= 0x80000000:
argb -= 0x100000000
return ("type%3dgd%26gd%3d%d,%d,"
"%26gm%3d-1:0.0,-16777216:1.0%26angle%3d180%26gs%3dl" % (argb, argb))
def _text_section(oid: int, layer, text: str, size_pt: float,
color: Tuple[int, int, int, int], font_ps: str,
align: int) -> str:
l, t = layer.left, layer.top
r, b = layer.right, layer.bottom
font = font_to_ppl(font_ps)
return _TEXT_TEMPLATE.format(
oid=oid, text=text, visible=1 if layer.visible else 0,
align=align, l=l, t=t, r=r, b=b,
color=color_to_gd(color), font=font,
alpha=layer.opacity, size=round(size_pt / _TEXT_SIZE_K, 2))
def extract_type_layer(layer) -> Optional[Tuple[str, float, tuple, str, int]]:
"""Best-effort extraction of (text, size_pt, rgba_color, font_ps, align)
from a PSD text layer's engine data. Returns None when text is empty."""
try:
ed = layer.engine_dict or {}
editor = (ed.get("EngineDict") or {}).get("Editor") or {}
text = editor.get("Text", "")
if not text:
return None
runs = ((editor.get("StyleRun") or {}).get("RunArray") or [{}])
style = (runs[0].get("StyleSheet") or {}).get("StyleSheetData") or {}
size_pt = float(style.get("FontSize", 24) or 24)
# FillColor: {'Type': 1, 'Values': [r, g, b]} or {'Type': 2, 'Values': [...]}
fill = style.get("FillColor") or {}
values = fill.get("Values") or [0, 0, 0]
color = (int(values[0]), int(values[1]), int(values[2]), 255)
font = ((style.get("Font") or {}).get("PostScriptName")
or runs[0].get("StyleSheet", {}).get("StyleSheetData", {})
.get("Font", {}).get("PostScriptName") or "")
# paragraph alignment: 0=left, 1=center, 2=right
runs_p = ((editor.get("ParagraphRun") or {}).get("RunArray") or [{}])
pstyle = (runs_p[0].get("ParagraphSheet") or {})
align = 1
return text, size_pt, color, font, align
except Exception as exc:
print(f" warning: text layer parse failed: {exc}", file=sys.stderr)
return None
# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------
def convert(psd_path: str, out_path: str) -> None:
psd = PSDImage.open(psd_path)
print(f"PSD: {psd.width}x{psd.height}, layers: {len(psd)}")
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
sections = [HEADER.format(w=psd.width, h=psd.height)]
img_count = 0
for i, layer in enumerate(psd, start=1):
if layer.kind == "pixel":
img = layer.topil().convert("RGBA")
iw, ih = img.size
uri = f"{uuid.uuid4().hex}-{i}.img"
img.save(tmp / uri, format="PNG")
l, t = layer.left, layer.top
r, b = layer.right, layer.bottom
sections.append(_BITMAP_TEMPLATE.format(
oid=i, iw=iw, ih=ih,
alpha=layer.opacity, visible=1 if layer.visible else 0,
l=l, t=t, r=r, b=b, uri=uri))
img_count += 1
print(f" layer {i}: bitmap {iw}x{ih} @({l},{t},{r},{b}) "
f"op={layer.opacity} vis={layer.visible}")
elif layer.kind == "type":
got = extract_type_layer(layer)
if got is None:
# fall back to rasterizing the text layer
img = layer.topil().convert("RGBA")
iw, ih = img.size
uri = f"{uuid.uuid4().hex}-{i}.img"
img.save(tmp / uri, format="PNG")
l, t = layer.left, layer.top
r, b = layer.right, layer.bottom
sections.append(_BITMAP_TEMPLATE.format(
oid=i, iw=iw, ih=ih, alpha=layer.opacity,
visible=1 if layer.visible else 0,
l=l, t=t, r=r, b=b, uri=uri))
img_count += 1
print(f" layer {i}: type(fallback bitmap) {iw}x{ih}")
else:
text, size_pt, color, font_ps, align = got
sections.append(_text_section(i, layer, text, size_pt,
color, font_ps, align))
print(f" layer {i}: text '{text[:20]}' "
f"size={size_pt:.1f}pt font={font_ps} "
f"@({layer.left},{layer.top})")
else:
print(f" layer {i}: skip {layer.kind} '{layer.name}'")
ini_text = "".join(sections)
(tmp / "layers.ini").write_text(ini_text, encoding="utf-8")
with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.write(tmp / "layers.ini", "layers.ini")
for f in tmp.iterdir():
if f.suffix == ".img":
zf.write(f, f.name)
print(f"\nppl written: {out_path} ({img_count} bitmap layers)")
def main():
ap = argparse.ArgumentParser(description="Convert PSD back to Photo Editor .ppl")
ap.add_argument("input", help="input .psd file")
ap.add_argument("-o", "--output", help="output .ppl path")
args = ap.parse_args()
out = args.output or str(Path(args.input).with_suffix(".ppl"))
convert(args.input, out)
if __name__ == "__main__":
main()