Add ppl<->psd conversion tools (ppl2psd, psd2ppl)
This commit is contained in:
commit
60624f01c2
3 files changed
+905
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
samples/
|
||||||
|
*.ppl
|
||||||
|
*.psd
|
||||||
|
__pycache__/
|
||||||
+622
@@ -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()
|
||||||
+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