Files

280 lines
9.2 KiB
Python

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