970 lines
40 KiB
Python
970 lines
40 KiB
Python
#!/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
|
||
# We render huge 2x canvases (e.g. 21528x10764); disable PIL's decompression
|
||
# bomb guard so it does not reject our own output.
|
||
Image.MAX_IMAGE_PIXELS = None
|
||
from tqdm import tqdm
|
||
|
||
try:
|
||
import numpy as np
|
||
except ImportError: # pragma: no cover
|
||
np = None
|
||
|
||
# Optional TypeLayer (editable text) support
|
||
try:
|
||
from psd_tools.psd.tagged_blocks import TypeToolObjectSetting
|
||
from psd_tools.psd.engine_data import (String, Float, Integer, Bool,
|
||
Dict, List)
|
||
_TYSHA = True
|
||
except ImportError: # pragma: no cover
|
||
_TYSHA = False
|
||
|
||
# Global output scale: everything (canvas, layer rects, text size, shadows)
|
||
# is multiplied by _SCALE, so the whole vector project is upscaled.
|
||
_SCALE = 1.0
|
||
|
||
|
||
def set_scale(s: float) -> None:
|
||
global _SCALE
|
||
_SCALE = max(0.1, float(s))
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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) -> Tuple[List[dict], List[dict]]:
|
||
"""Parse layers.ini -> (layers, groups), each ordered bottom -> top.
|
||
Groups are [group] sections; layers carry a groupId field."""
|
||
layers, groups = [], []
|
||
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)
|
||
if m.group(1) == "group":
|
||
groups.append(ly)
|
||
else:
|
||
layers.append(ly)
|
||
return layers, groups
|
||
|
||
|
||
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), scaled by _SCALE."""
|
||
l, t, r, b = (float(v) for v in ly["boundingRect"].split(","))
|
||
return l * _SCALE, t * _SCALE, r * _SCALE, b * _SCALE
|
||
|
||
|
||
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))
|
||
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) * _SCALE)
|
||
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:
|
||
# gradient stroke: paste gradient clipped to a rounded-rect ring
|
||
stroke_img = make_linear_gradient(ww, hh, stroke)
|
||
ring = Image.new("L", (ww, hh), 0)
|
||
ImageDraw.Draw(ring).rounded_rectangle(bbox, radius=r_,
|
||
outline=255, width=thickness)
|
||
img.paste(stroke_img, (0, 0), ring)
|
||
|
||
# shadow (ppl renders a soft offset shadow per layer)
|
||
sd = float(ly.get("shadowDistance", "0") or 0) * _SCALE
|
||
sb = float(ly.get("shadowBlur", "0") or 0) * _SCALE
|
||
if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1":
|
||
img, ox, oy = _add_shadow(img, sd, sb,
|
||
float(ly.get("shadowAngle", "0") or 0),
|
||
ly.get("shadowColor", "-16777216"))
|
||
return img, (ix0 - ox, iy0 - oy)
|
||
return img, (ix0, iy0)
|
||
|
||
|
||
def _add_shadow(img, distance, blur, angle_deg, color_spec):
|
||
"""Render a soft offset shadow under the layer content. Returns
|
||
(expanded_img, offset_x, offset_y)."""
|
||
import math
|
||
from PIL import ImageFilter
|
||
rgba = argb_to_rgba(int(color_spec or "-16777216"))
|
||
rad = math.radians(angle_deg)
|
||
dx = int(round(distance * math.cos(rad)))
|
||
dy = int(round(distance * math.sin(rad)))
|
||
pad = max(int(blur) * 2 + abs(dx) + abs(dy) + 4, 8)
|
||
w, h = img.size
|
||
canvas = Image.new("RGBA", (w + 2 * pad, h + 2 * pad), (0, 0, 0, 0))
|
||
mask = img.getchannel("A")
|
||
shadow_fill = Image.new("RGBA", (w, h), rgba)
|
||
shadow_fill.putalpha(mask)
|
||
sh_layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||
sh_layer.paste(shadow_fill, (pad + dx, pad + dy), mask)
|
||
sh_layer = sh_layer.filter(ImageFilter.GaussianBlur(blur))
|
||
canvas.alpha_composite(sh_layer)
|
||
canvas.alpha_composite(img, (pad, pad))
|
||
return canvas, pad, pad
|
||
|
||
|
||
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"] * _SCALE
|
||
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", ""))
|
||
if grad["type"] == "gradient":
|
||
fill = (255, 255, 255, 255) # base color; gradient is applied after
|
||
grad = grad # keep for the mask pass
|
||
else:
|
||
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 ---
|
||
# faux bold (textFontStyle=1) via stroke width
|
||
stroke_w = max(1, int(size / 40)) if ly.get("textFontStyle") == "1" else 0
|
||
pad = stroke_w # room for the stroke so glyphs don't clip
|
||
tail = max(int(size * 0.3), 8) # right padding so the last glyph never clips
|
||
raw_w, raw_h = max(1, int(round(tw)) + 2 * pad + tail), max(1, int(round(th)) + 2 * pad)
|
||
img = Image.new("RGBA", (raw_w, raw_h), (0, 0, 0, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
for i, ln in enumerate(lines):
|
||
x = pad
|
||
for ch_ in ln:
|
||
off = 0
|
||
if ch_ in NARROW_PUNCT:
|
||
# PIL draws full-width punctuation offset inside the advance
|
||
# box (e.g. '(' ink sits at x+42..x+61). Shift the draw x so
|
||
# the ink starts at the layout x; otherwise it overlaps the
|
||
# next glyph or clips at line end.
|
||
t_img = Image.new("RGBA", (int(size) + 16, int(size) + 16), (0, 0, 0, 0))
|
||
ImageDraw.Draw(t_img).text((4, 4), ch_, font=font,
|
||
fill=(255, 255, 255, 255))
|
||
t_bb = t_img.getchannel("A").getbbox()
|
||
off = t_bb[0] - 4 if t_bb else 0
|
||
draw.text((x - off, pad + int(round(i * line_h))), ch_, font=font,
|
||
fill=fill, stroke_width=stroke_w, stroke_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"):
|
||
# center on the advance width (excluding the clip-padding tail)
|
||
left_full = int(round(cx - (tw + 2 * pad) / 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))
|
||
|
||
|
||
# text gradient fill: paint a gradient over the glyph alpha
|
||
if grad["type"] == "gradient":
|
||
gimg = make_linear_gradient(img.width, img.height, grad)
|
||
img.paste(gimg, (0, 0), img.getchannel("A"))
|
||
|
||
# soft shadow (used by rasterized output / previews)
|
||
sd = float(ly.get("shadowDistance", "0") or 0) * _SCALE
|
||
sb = float(ly.get("shadowBlur", "0") or 0) * _SCALE
|
||
if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1":
|
||
img, ox, oy = _add_shadow(img, sd, sb,
|
||
float(ly.get("shadowAngle", "0") or 0),
|
||
ly.get("shadowColor", "-16777216"))
|
||
return img, (ix0 - ox, iy0 - oy)
|
||
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)
|
||
|
||
|
||
def _opacity_paste(canvas: Image.Image, img: Image.Image, pos, opacity: int) -> None:
|
||
"""Composite a layer onto the merged/preview canvas, applying the layer
|
||
opacity (PSD opacity field is applied by Photoshop itself, but the
|
||
in-tool merged preview must apply it manually)."""
|
||
if opacity < 255:
|
||
img = img.copy()
|
||
img.putalpha(img.getchannel("A").point(lambda p: p * opacity // 255))
|
||
canvas.paste(img, pos, img)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 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].
|
||
Bands are encoded one at a time and their raw bytes released right away,
|
||
so at most two full-image buffers (the raw plane + its RLE result) exist
|
||
at once. On 2x canvases the old copy-all-planes approach multiplied the
|
||
layer size by ~5 and blew up RAM/ZRAM/SWAP, freezing the phone."""
|
||
w = img.width
|
||
ids = [-1, 0, 1, 2] if img.mode == "RGBA" else [0, 1, 2]
|
||
order = ["A", "R", "G", "B"] if img.mode == "RGBA" else ["R", "G", "B"]
|
||
if workers > 1 and len(order) > 1:
|
||
from multiprocessing import Pool
|
||
with Pool(workers) as pool:
|
||
enc = []
|
||
for band in order:
|
||
plane = img.getchannel(band).tobytes()
|
||
enc.append(pool.apply(_pack_channel_worker, ((plane, w),)))
|
||
del plane
|
||
if progress:
|
||
progress.update(1)
|
||
else:
|
||
enc = []
|
||
for band in order:
|
||
plane = img.getchannel(band).tobytes()
|
||
enc.append(_pack_channel_worker((plane, w)))
|
||
del plane
|
||
if progress:
|
||
progress.update(1)
|
||
return list(zip(ids, enc))
|
||
|
||
|
||
def font_ps_name(font_path: str) -> str:
|
||
"""PostScript name of a font file (what Photoshop matches)."""
|
||
try:
|
||
from fontTools.ttLib import TTFont
|
||
f = TTFont(font_path)
|
||
ps = f["name"].getDebugName(6)
|
||
return ps or os.path.basename(font_path)
|
||
except Exception:
|
||
return os.path.basename(font_path)
|
||
|
||
|
||
def build_type_layer_tysh(ly: dict, font_ps: str, text_w: Optional[float] = None) -> bytes:
|
||
"""Build a TySh (TypeTool object setting) block for an editable text layer
|
||
by mutating the reference template's engine data."""
|
||
tmpl = Path(__file__).parent / "tools_tysh_template.bin"
|
||
raw = tmpl.read_bytes()
|
||
tysh = TypeToolObjectSetting.frombytes(raw[12:]) # strip 8BIMTySh header
|
||
td = tysh.text_data
|
||
ed = td[b"EngineData"].value
|
||
eng = ed["EngineDict"]
|
||
res = ed["ResourceDict"]
|
||
editor = eng["Editor"]
|
||
|
||
text = urllib.parse.unquote(ly.get("text", "") or "")
|
||
editor["Text"] = String(text + "\r")
|
||
# Txt (legacy text) value mirrors the engine text so readers agree
|
||
td[b"Txt "].value = text + "\x00"
|
||
# Style/paragraph run lengths must match the new text length, otherwise
|
||
# Photoshop warns the text layer needs conversion / editing changes layout.
|
||
text_len = len(text)
|
||
eng["StyleRun"]["RunLengthArray"] = List([Integer(text_len)])
|
||
eng["ParagraphRun"]["RunLengthArray"] = List([Integer(text_len)])
|
||
# Left-align (template is justify, which spreads short text across the
|
||
# box). ppl rows read left-aligned; user prefers left over spread.
|
||
try:
|
||
props = eng["ParagraphRun"]["RunArray"][0]["ParagraphSheet"]["Properties"]
|
||
props["Justification"] = Integer(0) # left
|
||
except Exception:
|
||
pass
|
||
|
||
prof = font_profile(_font_path_for(ly))
|
||
size_px = float(ly.get("textSize", "12") or 12) * prof["k"] * _SCALE
|
||
ssd = eng["StyleRun"]["RunArray"][0]["StyleSheet"]["StyleSheetData"]
|
||
ssd["FontSize"] = Float(size_px)
|
||
|
||
grad = parse_gradient(ly.get("textColor", ""))
|
||
r, g, b, a = grad["colors"][0][1]
|
||
fc = Dict()
|
||
fc["Type"] = Integer(1)
|
||
fc["Values"] = List([Float(r / 255.0), Float(g / 255.0),
|
||
Float(b / 255.0), Float(a / 255.0)])
|
||
ssd["FillColor"] = fc
|
||
|
||
# bold / italic (textFontStyle: 1 = faux bold)
|
||
if ly.get("textFontStyle") == "1":
|
||
ssd["FauxBold"] = Bool(True)
|
||
|
||
res["FontSet"][0]["Name"] = String(font_ps)
|
||
# DocumentResources mirrors ResourceDict; keep both font tables in sync
|
||
# or PS flags the text layer as needing conversion.
|
||
ed["DocumentResources"]["FontSet"][0]["Name"] = String(font_ps)
|
||
# StyleSheetSet preset: set color too (PS may read the preset style)
|
||
try:
|
||
res["StyleSheetSet"][0]["StyleSheetData"]["FillColor"] = fc
|
||
except Exception:
|
||
pass
|
||
|
||
l, t, rr, bb2 = bounding_rect(ly)
|
||
# transform origin = boundingRect top-left; boundingBox = the ACTUAL
|
||
# text box (0,0,tw,th) so the edit box is small and sits at the text.
|
||
tysh.transform = [1.0, 0.0, 0.0, 1.0, float(l), float(t)]
|
||
tysh.left, tysh.top, tysh.right, tysh.bottom = int(l), int(t), int(rr), int(bb2)
|
||
tw = (text_w if text_w else (rr - l)) * 1.1 + size_px * 0.3 # headroom so the last glyph is not clipped
|
||
th = size_px * 1.1
|
||
bbd = td[b"boundingBox"]
|
||
bbd[b"Left"].value = 0.0
|
||
bbd[b"Top "].value = 0.0
|
||
bbd[b"Rght"].value = float(tw)
|
||
bbd[b"Btom"].value = float(th)
|
||
return tysh.tobytes()
|
||
|
||
|
||
def _standard_blocks(layer_id: int) -> bytes:
|
||
"""Standard tagged blocks Photoshop writes for every layer. PS reads
|
||
tagged blocks until it hits a non-8BIM signature; with only 'luni' the
|
||
next bytes are channel data and PS mis-parses the record, so we write
|
||
the usual block set like PS itself does."""
|
||
items = [
|
||
(b"lnsr", b"\x00\x00\x00\x00"), # name source
|
||
(b"lyid", struct.pack(">I", layer_id)), # layer id
|
||
(b"clbl", b"\x00\x00\x00\x00"), # blend clipping
|
||
(b"infx", b"\x00\x00\x00\x00"), # blend interior
|
||
(b"knko", b"\x00\x00\x00\x00"), # knockout
|
||
(b"lspf", b"\x00\x00\x00\x00"), # protected
|
||
(b"lclr", b"\x00" * 8), # sheet color
|
||
(b"fxrp", b"\x00" * 16), # reference point
|
||
]
|
||
out = bytearray()
|
||
for key, payload in items:
|
||
out += b"8BIM" + key + struct.pack(">I", len(payload)) + payload
|
||
return bytes(out)
|
||
|
||
|
||
def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
|
||
layer_id=1, tysh: Optional[bytes] = None,
|
||
lsct: Optional[int] = None) -> 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
|
||
# flags: bit3 = 1 for Photoshop 5.0+ (tells PS the extra data contains
|
||
# tagged blocks); bit1 = hidden
|
||
rec += struct.pack(">B", 0x08 | (0 if visible else 0x02))
|
||
rec += struct.pack(">B", 0) # filler
|
||
# extra data
|
||
extra = bytearray()
|
||
extra += struct.pack(">I", 0) # layer mask data length
|
||
# blending ranges: composite + 4 channels, ALL full range (0..65535).
|
||
# PS writes these as 0x0000/0xffff; a zero composite range tells PS the
|
||
# layer does not participate in blending -> the canvas renders empty.
|
||
extra += struct.pack(">I", 40) + b"\x00\x00\xff\xff\x00\x00\xff\xff" * 5
|
||
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. Data = 4-byte CHARACTER count + utf-16-be
|
||
# chars. (Writing raw utf-16 without the count shifts the extra-data
|
||
# parse and Photoshop reads every layer's pixels as empty.)
|
||
uni = name.encode("utf-16-be")
|
||
luni_data = struct.pack(">I", len(uni) // 2) + uni
|
||
extra += b"8BIMluni" + struct.pack(">I", len(luni_data)) + luni_data
|
||
# TySh: makes this an editable text layer in Photoshop
|
||
if tysh:
|
||
extra += b"8BIMTySh" + struct.pack(">I", len(tysh)) + tysh
|
||
# lsct: layer section divider (1=group open, 3=group close)
|
||
if lsct is not None:
|
||
from psd_tools.psd.tagged_blocks import SectionDividerSetting
|
||
from psd_tools.constants import SectionDivider, BlendMode
|
||
kind = (SectionDivider.OPEN_FOLDER if lsct == 1
|
||
else SectionDivider.BOUNDING_SECTION_DIVIDER)
|
||
sd = SectionDividerSetting(kind=kind, signature=b"8BIM",
|
||
blend_mode=BlendMode.NORMAL)
|
||
sd_data = sd.tobytes()
|
||
extra += b"8BIMlsct" + struct.pack(">I", len(sd_data)) + sd_data
|
||
# standard blocks Photoshop expects (see _standard_blocks)
|
||
extra += _standard_blocks(layer_id)
|
||
rec += struct.pack(">I", len(extra))
|
||
rec += extra
|
||
return bytes(rec)
|
||
|
||
|
||
def build_psd(records: bytearray, payload_f, merged: Image.Image, layers: list,
|
||
width: int, height: int, out_path: str) -> None:
|
||
"""Assemble the PSD file from pre-encoded pieces.
|
||
|
||
The renderer already streamed every layer's RLE payloads into `payload_f`
|
||
(temp file) and composited the visible layers onto `merged`, so this only
|
||
writes header + records + streamed payloads + merged image. Nothing is
|
||
loaded back into RAM."""
|
||
import shutil
|
||
payload_f.seek(0, 2)
|
||
payload_len = payload_f.tell()
|
||
payload_f.seek(0)
|
||
# Header: channels=4 (document has transparency, like PS writes when
|
||
# layers have alpha)
|
||
fh = open(out_path, "wb")
|
||
fh.write(struct.pack(">4sH6sHIIHH", b"8BPS", 1, b"\x00" * 6, 4, height, width, 8, 3))
|
||
fh.write(struct.pack(">I", 0)) # color mode data
|
||
fh.write(struct.pack(">I", 0)) # image resources
|
||
|
||
# Layer info section: [Length(4)] + [layer count(2) + records + channel data]
|
||
# Negative layer count: abs = layer count, first alpha channel of the
|
||
# merged image holds the merged transparency.
|
||
body_len = 2 + len(records) + payload_len
|
||
# layer & mask section: length = [4-byte inner length] + body +
|
||
# global layer mask info (4); the outer length does NOT include its own
|
||
# 4-byte length field
|
||
fh.write(struct.pack(">I", 4 + body_len + 4))
|
||
fh.write(struct.pack(">I", body_len))
|
||
fh.write(struct.pack(">h", -len(layers)))
|
||
fh.write(bytes(records))
|
||
shutil.copyfileobj(payload_f, fh) # stream, never load payloads into RAM
|
||
fh.write(struct.pack(">I", 0)) # global layer mask info
|
||
|
||
# merged image data (RGB, RAW), one plane at a time
|
||
fh.write(b"\x00\x00")
|
||
for c in "RGBA":
|
||
fh.write(merged.getchannel(c).tobytes())
|
||
del merged
|
||
fh.close()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Main
|
||
# --------------------------------------------------------------------------
|
||
def _safe_workers(workers: int) -> int:
|
||
"""Keep at least half the CPU cores free - on phones the RLE-encoding
|
||
workers plus PIL's internal threading can otherwise stall the whole
|
||
device (a previous cores-1 cap still froze the host)."""
|
||
available = max(1, (os.cpu_count() or 2) // 2)
|
||
return max(1, min(workers, available))
|
||
|
||
|
||
def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
||
workers: int = 4, type_layer: bool = False) -> None:
|
||
workers = _safe_workers(workers)
|
||
if _SCALE >= 2.0:
|
||
# 2x canvases are 4x the pixels; parallel channel encoding would
|
||
# duplicate layer buffers across processes and exhaust RAM/ZRAM/SWAP
|
||
workers = 1
|
||
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, groups = parse_layers(text)
|
||
mw = re.search(r"^width=(\d+)", text, re.M)
|
||
mh = re.search(r"^height=(\d+)", text, re.M)
|
||
width = int(float(mw.group(1)) * _SCALE) if mw else 0
|
||
height = int(float(mh.group(1)) * _SCALE) if mh else 0
|
||
|
||
# --- group mapping ---
|
||
group_map = {}
|
||
for g in groups:
|
||
gid = g.get("objectId")
|
||
if gid:
|
||
group_map[gid] = g
|
||
group_ranges = {}
|
||
for i, ly in enumerate(layers):
|
||
gid = ly.get("groupId")
|
||
if gid:
|
||
if gid not in group_ranges:
|
||
group_ranges[gid] = [i, i]
|
||
else:
|
||
group_ranges[gid][1] = i
|
||
# The app renders group children offset by the group bbox movement
|
||
# (moving a group updates the group bbox but not the child layer
|
||
# bboxes). Apply that offset so children land where the app shows them.
|
||
group_offset = {}
|
||
for gid, (first, last) in group_ranges.items():
|
||
g = group_map.get(gid)
|
||
if not g or "boundingRect" not in g:
|
||
continue
|
||
gl, gt, gr, gb = bounding_rect(g)
|
||
# only this group's OWN layers (nested groups' layers excluded)
|
||
own = [i for i in range(first, last + 1)
|
||
if layers[i].get("groupId") == gid]
|
||
if not own:
|
||
continue
|
||
rects = [bounding_rect(layers[i]) for i in own]
|
||
inner_l = min(r[0] for r in rects)
|
||
inner_t = min(r[1] for r in rects)
|
||
inner_r = max(r[2] for r in rects)
|
||
inner_b = max(r[3] for r in rects)
|
||
# If the group bbox fully contains its content, the children sit
|
||
# at their ppl coordinates (the bbox is just a bigger frame, e.g.
|
||
# group_64). Only offset when the group was moved away from its
|
||
# content (bbox disjoint, e.g. groups 169/181).
|
||
if gl <= inner_l and gt <= inner_t and gr >= inner_r and gb >= inner_b:
|
||
group_offset[gid] = (0, 0)
|
||
else:
|
||
group_offset[gid] = (gl - inner_l, gt - inner_t)
|
||
|
||
rendered = []
|
||
skipped = 0
|
||
# Streaming render+encode: each layer is rendered, composited onto
|
||
# the merged canvas, RLE-encoded into the payload temp file and then
|
||
# released. Only ~1 layer + the merged canvas stay in RAM, instead of
|
||
# all 142 layers' images (the 2x build previously exhausted RAM and
|
||
# swap and got OOM-killed).
|
||
merged = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||
payload_f = tempfile.TemporaryFile()
|
||
records = bytearray()
|
||
rp = tqdm(total=len(layers), desc="渲染图层", unit="层", file=sys.stderr)
|
||
ep = tqdm(total=len(layers) * 4, desc="编码通道", unit="通道",
|
||
file=sys.stderr)
|
||
for i, ly in enumerate(layers):
|
||
gid = ly.get("groupId")
|
||
gname = gvis = gb = None
|
||
layer_id = len(rendered) + 1
|
||
if gid and gid in group_ranges:
|
||
g = group_map.get(gid, {})
|
||
gname = g.get("name", "") or f"group_{gid}"
|
||
gvis = g.get("visible", "1") != "0"
|
||
gb = bounding_rect(g) if g else None
|
||
# psd_tools: kind 3 (bounding) opens a group (bottom marker),
|
||
# kind 1 (open folder) closes it (top marker, gives the name)
|
||
if group_ranges[gid][0] == i:
|
||
rendered.append((gname, 255, gvis, (0, 0), None, None,
|
||
(3, gb)))
|
||
# group open marker: 4 empty channels
|
||
ch = [(cid, b"\x00\x01") for cid in (-1, 0, 1, 2)]
|
||
cr = crop_to_canvas(*gb, width, height) if gb else None
|
||
l = t = r = b = 0
|
||
if cr:
|
||
l, t, r, b = cr
|
||
records += _layer_record(t, l, b, r, gname, 255, gvis,
|
||
ch, layer_id=layer_id, lsct=3)
|
||
for _, p in ch:
|
||
payload_f.write(p)
|
||
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
|
||
if gid and gid in group_offset:
|
||
lx = int(lx + group_offset[gid][0])
|
||
ly_ = int(ly_ + group_offset[gid][1])
|
||
visible = ly.get("visible", "1") != "0"
|
||
opacity = int(ly.get("alpha", "255") or 255)
|
||
name = f"{ly['_type']}_{ly.get('objectId', i)}"
|
||
tysh = None
|
||
if type_layer and ly["_type"] == "text" and _TYSHA:
|
||
try:
|
||
fp = _font_path_for(ly)
|
||
tysh = build_type_layer_tysh(ly, font_ps_name(fp),
|
||
text_w=img.width)
|
||
except Exception as exc:
|
||
print(f" warning: text layer {name} -> TySh failed: {exc}",
|
||
file=sys.stderr)
|
||
if visible and opacity > 0:
|
||
_opacity_paste(merged, img, (lx, ly_), opacity)
|
||
ch = channel_blocks(img, workers, ep)
|
||
records += _layer_record(ly_, lx, ly_ + img.height, lx + img.width,
|
||
name, opacity, visible,
|
||
ch, layer_id=layer_id, tysh=tysh)
|
||
for _, p in ch:
|
||
payload_f.write(p)
|
||
del ch, img
|
||
rendered.append((name, opacity, visible, (lx, ly_), None, tysh,
|
||
None))
|
||
rp.update(1)
|
||
# group close marker (top of the app group)
|
||
if gid and gid in group_ranges and group_ranges[gid][1] == i:
|
||
rendered.append((gname, 255, gvis, (0, 0), None, None,
|
||
(1, None)))
|
||
ch = [(cid, b"\x00\x01") for cid in (-1, 0, 1, 2)]
|
||
records += _layer_record(0, 0, 0, 0, gname, 255, gvis,
|
||
ch, layer_id=layer_id + 1, lsct=1)
|
||
for _, p in ch:
|
||
payload_f.write(p)
|
||
rp.close()
|
||
ep.close()
|
||
|
||
print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), "
|
||
f"画布 {width}x{height}")
|
||
build_psd(records, payload_f, merged, rendered, width, height, out_path)
|
||
payload_f.close()
|
||
print(f"PSD 已写出: {out_path}")
|
||
|
||
if preview_path:
|
||
# downscale for easy viewing, unless the project was upscaled
|
||
# (then keep the full-res composite so the 2x detail is visible)
|
||
preview = merged.convert("RGB")
|
||
if _SCALE == 1.0 and 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=max(1, (os.cpu_count() or 2) // 2),
|
||
help="parallel workers for RLE encoding (default: cores/2)")
|
||
ap.add_argument("--type-layer", action="store_true",
|
||
help="convert text layers to editable TypeLayers (TySh) "
|
||
"instead of rasterized pixels")
|
||
ap.add_argument("--scale", type=float, default=1.0,
|
||
help="upscale the whole vector project by this factor "
|
||
"(canvas, layer rects, text size, shadows) "
|
||
"(default: 1.0)")
|
||
args = ap.parse_args()
|
||
|
||
set_scale(args.scale)
|
||
out = args.output
|
||
if not out:
|
||
out = str(Path(args.input).with_suffix(".psd"))
|
||
convert(args.input, out, args.preview, args.workers, args.type_layer)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|