#!/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()