Add ppl<->psd conversion tools (ppl2psd, psd2ppl)
This commit is contained in:
commit
60624f01c2
3 files changed
+905
No files matched your search
+279
@@ -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()
|
||||
Reference in new issue
Block a user