commit 60624f01c2b3335bb54d6474d6ad4cbe2773ac10 Author: AskaEth Date: Thu Aug 27 08:16:44 2026 +0800 Add ppl<->psd conversion tools (ppl2psd, psd2ppl) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f91d8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +samples/ +*.ppl +*.psd +__pycache__/ diff --git a/ppl2psd.py b/ppl2psd.py new file mode 100644 index 0000000..1c519b5 --- /dev/null +++ b/ppl2psd.py @@ -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() diff --git a/psd2ppl.py b/psd2ppl.py new file mode 100644 index 0000000..ab0c426 --- /dev/null +++ b/psd2ppl.py @@ -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()