280 lines
9.2 KiB
Python
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()
|