Add --scale upscaling and stream encode to avoid OOM

This commit is contained in:
AskaEth committed 2026-08-29 00:37:08 +08:00
1 parent a08151337b
commit 6a97e68558
2 files changed
+127 -125

No files matched your search

+5 -1
View File
@@ -46,15 +46,19 @@ python3 ppl2psd.py input.ppl -o out.psd --preview out.png
# PPL → PSD(文字转可编辑 TypeLayer) # PPL → PSD(文字转可编辑 TypeLayer)
python3 ppl2psd.py input.ppl -o out.psd --type-layer python3 ppl2psd.py input.ppl -o out.psd --type-layer
# PPL → PSD(整个矢量项目等比放大 2 倍,画布/图层/文字/阴影 ×2)
python3 ppl2psd.py input.ppl -o out_2x.psd --scale 2
# PSD → PPL(反向) # PSD → PPL(反向)
python3 psd2ppl.py input.psd -o out.ppl python3 psd2ppl.py input.psd -o out.ppl
``` ```
参数: 参数:
- `-o` / `--output`:输出路径(默认与输入同名) - `-o` / `--output`:输出路径(默认与输入同名)
- `--preview`:同时输出合成预览 PNG - `--preview`:同时输出合成预览 PNG(`--scale` 非 1 时输出全分辨率预览)
- `-j` / `--workers`:并行编码线程数(默认 4) - `-j` / `--workers`:并行编码线程数(默认 4)
- `--type-layer`:文字转 TypeLayer - `--type-layer`:文字转 TypeLayer
- `--scale`:整个矢量项目等比放大倍数(默认 1.0;≥2 时自动降为单进程编码防爆内存)
--- ---
+122 -124
View File
@@ -28,6 +28,9 @@ from pathlib import Path
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from PIL import Image, ImageDraw, ImageFont 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 from tqdm import tqdm
try: try:
@@ -44,6 +47,15 @@ try:
except ImportError: # pragma: no cover except ImportError: # pragma: no cover
_TYSHA = False _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 # Fonts
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -164,9 +176,9 @@ def parse_gradient(spec: str) -> dict:
# Layer rendering # Layer rendering
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def bounding_rect(ly: dict) -> Tuple[float, float, float, float]: def bounding_rect(ly: dict) -> Tuple[float, float, float, float]:
"""boundingRect is (left, top, right, bottom), not (x, y, w, h).""" """boundingRect is (left, top, right, bottom), scaled by _SCALE."""
l, t, r, b = (float(v) for v in ly["boundingRect"].split(",")) l, t, r, b = (float(v) for v in ly["boundingRect"].split(","))
return l, t, r, b 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 def crop_to_canvas(l: float, t: float, r: float, b: float, cw: int, ch: int
@@ -254,7 +266,7 @@ def render_shape(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tupl
img.paste(grad_img, (0, 0), mask) img.paste(grad_img, (0, 0), mask)
# stroke # stroke
thickness = int(float(ly.get("shapeStrokeThickness", "0") or 0)) thickness = int(float(ly.get("shapeStrokeThickness", "0") or 0) * _SCALE)
if thickness > 0: if thickness > 0:
stroke = parse_gradient(ly.get("shapeStrokeColor", "")) stroke = parse_gradient(ly.get("shapeStrokeColor", ""))
if stroke["type"] == "solid": if stroke["type"] == "solid":
@@ -269,8 +281,8 @@ def render_shape(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tupl
img.paste(stroke_img, (0, 0), ring) img.paste(stroke_img, (0, 0), ring)
# shadow (ppl renders a soft offset shadow per layer) # shadow (ppl renders a soft offset shadow per layer)
sd = float(ly.get("shadowDistance", "0") or 0) sd = float(ly.get("shadowDistance", "0") or 0) * _SCALE
sb = float(ly.get("shadowBlur", "0") or 0) sb = float(ly.get("shadowBlur", "0") or 0) * _SCALE
if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1": if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1":
img, ox, oy = _add_shadow(img, sd, sb, img, ox, oy = _add_shadow(img, sd, sb,
float(ly.get("shadowAngle", "0") or 0), float(ly.get("shadowAngle", "0") or 0),
@@ -325,7 +337,7 @@ def render_text(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tuple
size_raw = float(ly.get("textSize", "12") or 12) size_raw = float(ly.get("textSize", "12") or 12)
font_path = _font_path_for(ly) font_path = _font_path_for(ly)
prof = font_profile(font_path) prof = font_profile(font_path)
size = size_raw * prof["k"] size = size_raw * prof["k"] * _SCALE
if font_path: if font_path:
font = ImageFont.truetype(font_path, int(size)) font = ImageFont.truetype(font_path, int(size))
else: else:
@@ -437,8 +449,8 @@ def render_text(ly: dict, cw: int, ch: int) -> Optional[Tuple[Image.Image, Tuple
img.paste(gimg, (0, 0), img.getchannel("A")) img.paste(gimg, (0, 0), img.getchannel("A"))
# soft shadow (used by rasterized output / previews) # soft shadow (used by rasterized output / previews)
sd = float(ly.get("shadowDistance", "0") or 0) sd = float(ly.get("shadowDistance", "0") or 0) * _SCALE
sb = float(ly.get("shadowBlur", "0") or 0) sb = float(ly.get("shadowBlur", "0") or 0) * _SCALE
if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1": if (sd > 0 or sb > 0) and ly.get("_no_shadow") != "1":
img, ox, oy = _add_shadow(img, sd, sb, img, ox, oy = _add_shadow(img, sd, sb,
float(ly.get("shadowAngle", "0") or 0), float(ly.get("shadowAngle", "0") or 0),
@@ -514,42 +526,32 @@ def channel_blocks(img: Image.Image, workers: int,
progress: Optional[tqdm] = None) -> List[Tuple[int, bytes]]: progress: Optional[tqdm] = None) -> List[Tuple[int, bytes]]:
"""RLE-encode the planes of a cropped RGB(A) layer (parallel). """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].""" 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 Bands are encoded one at a time and their raw bytes released right away,
if arr is not None and arr.ndim == 3: so at most two full-image buffers (the raw plane + its RLE result) exist
nch = arr.shape[2] at once. On 2x canvases the old copy-all-planes approach multiplied the
if nch == 4: layer size by ~5 and blew up RAM/ZRAM/SWAP, freezing the phone."""
# zero RGB where alpha==0 for better compression
arr = arr.copy()
arr[arr[..., 3] == 0] = 0
# PSD layer channel ids: -1=alpha mask, 0=R, 1=G, 2=B
planes = [arr[..., 3].tobytes()] + [arr[..., i].tobytes() for i in range(3)]
else:
planes = [arr[..., i].tobytes() for i in range(nch)]
else:
bands = img.getbands()
if bands == ("R", "G", "B", "A"):
planes = [img.getchannel("A").tobytes()] + \
[img.getchannel(c).tobytes() for c in "RGB"]
else:
planes = [img.getchannel(c).tobytes() for c in bands]
w = img.width w = img.width
jobs = [(plane, w) for plane in planes] ids = [-1, 0, 1, 2] if img.mode == "RGBA" else [0, 1, 2]
if workers > 1 and len(jobs) > 1: order = ["A", "R", "G", "B"] if img.mode == "RGBA" else ["R", "G", "B"]
if workers > 1 and len(order) > 1:
from multiprocessing import Pool from multiprocessing import Pool
with Pool(workers) as pool: with Pool(workers) as pool:
enc = [] enc = []
for res in pool.imap(_pack_channel_worker, jobs, chunksize=1): for band in order:
enc.append(res) plane = img.getchannel(band).tobytes()
enc.append(pool.apply(_pack_channel_worker, ((plane, w),)))
del plane
if progress: if progress:
progress.update(1) progress.update(1)
else: else:
enc = [] enc = []
for j in jobs: for band in order:
enc.append(_pack_channel_worker(j)) plane = img.getchannel(band).tobytes()
enc.append(_pack_channel_worker((plane, w)))
del plane
if progress: if progress:
progress.update(1) progress.update(1)
ids = [-1, 0, 1, 2] if img.mode == "RGBA" else [0, 1, 2]
return list(zip(ids, enc)) return list(zip(ids, enc))
@@ -594,7 +596,7 @@ def build_type_layer_tysh(ly: dict, font_ps: str, text_w: Optional[float] = None
pass pass
prof = font_profile(_font_path_for(ly)) prof = font_profile(_font_path_for(ly))
size_px = float(ly.get("textSize", "12") or 12) * prof["k"] size_px = float(ly.get("textSize", "12") or 12) * prof["k"] * _SCALE
ssd = eng["StyleRun"]["RunArray"][0]["StyleSheet"]["StyleSheetData"] ssd = eng["StyleRun"]["RunArray"][0]["StyleSheet"]["StyleSheetData"]
ssd["FontSize"] = Float(size_px) ssd["FontSize"] = Float(size_px)
@@ -710,71 +712,20 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
return bytes(rec) return bytes(rec)
def build_psd(layers, width: int, height: int, out_path: str, workers: int, def build_psd(records: bytearray, payload_f, merged: Image.Image, layers: list,
progress: Optional[tqdm] = None) -> None: width: int, height: int, out_path: str) -> None:
"""layers: list of (name, opacity, visible, offset, Image[, tysh[, lsct]]) """Assemble the PSD file from pre-encoded pieces.
bottom->top. img=None marks a group divider layer (lsct present); it gets
empty channels (2-byte compression markers only)."""
# encode all channels (parallel)
chan_data = []
for layer in layers:
img = layer[4]
if img is None:
chan_data.append(None)
else:
ch = channel_blocks(img, workers, progress)
chan_data.append(ch)
# layer records + channel payloads The renderer already streamed every layer's RLE payloads into `payload_f`
records = bytearray() (temp file) and composited the visible layers onto `merged`, so this only
payload = bytearray() writes header + records + streamed payloads + merged image. Nothing is
for li, layer in enumerate(layers): loaded back into RAM."""
name, opacity, visible, (lx, ly), img = layer[:5] import shutil
tysh = layer[5] if len(layer) > 5 else None payload_f.seek(0, 2)
lsct = layer[6] if len(layer) > 6 else None payload_len = payload_f.tell()
if img is None: payload_f.seek(0)
# group divider: 4 empty channels (RLE marker only, len 2) # Header: channels=4 (document has transparency, like PS writes when
ch = [(cid, b"\x00\x01") for cid in (-1, 0, 1, 2)] # layers have alpha)
l = t = r = b = 0
if lsct and lsct[1]:
cr = crop_to_canvas(*lsct[1], width, height)
if cr:
l, t, r, b = cr
records += _layer_record(t, l, b, r, name, opacity, visible, ch,
layer_id=li + 1,
lsct=lsct[0] if lsct else None)
for _, p in ch:
payload += p
continue
ch = chan_data[li]
l, t = lx, ly
r = l + img.width
b = t + img.height
records += _layer_record(t, l, b, r, name, opacity, visible, ch,
layer_id=li + 1, tysh=tysh, lsct=lsct)
for _, p in ch:
payload += p
# merged preview: composite visible layers over transparent
merged = Image.new("RGBA", (width, height), (0, 0, 0, 0))
for layer in layers:
name, opacity, visible, (lx, ly), img = layer[:5]
if img is not None and visible and opacity > 0:
_opacity_paste(merged, img, (lx, ly), opacity)
merged_rgb = None # keep the RGBA merged image for the 4-channel output
# Merged image data is written RAW with a single compression field and
# all planes in planar order (R G B A). 4 channels because the document
# has transparency (negative layer count references the merged alpha).
if np is not None:
arr = np.asarray(merged)
merged_block = b"\x00\x00" + b"".join(
arr[..., i].tobytes() for i in range(4))
else:
merged_block = b"\x00\x00" + b"".join(
merged.getchannel(c).tobytes() for c in "RGBA")
# assemble file. Header channels = 4 (document has transparency, like PS
# writes when layers have alpha).
fh = open(out_path, "wb") 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(">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)) # color mode data
@@ -782,17 +733,23 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int,
# Layer info section: [Length(4)] + [layer count(2) + records + channel data] # Layer info section: [Length(4)] + [layer count(2) + records + channel data]
# Negative layer count: abs = layer count, first alpha channel of the # Negative layer count: abs = layer count, first alpha channel of the
# merged image holds the merged transparency (PS writes this when the # merged image holds the merged transparency.
# document has transparent layers). body_len = 2 + len(records) + payload_len
layer_info_body = struct.pack(">h", -len(layers)) + bytes(records) + bytes(payload) # layer & mask section: length = [4-byte inner length] + body +
layer_info = struct.pack(">I", len(layer_info_body)) + layer_info_body # global layer mask info (4); the outer length does NOT include its own
# layer & mask section: length = layer info + global layer mask info # 4-byte length field
# (the outer length does NOT include its own 4-byte length field) fh.write(struct.pack(">I", 4 + body_len + 4))
lam = layer_info + struct.pack(">I", 0) # global layer mask info fh.write(struct.pack(">I", body_len))
fh.write(struct.pack(">I", len(lam)) + lam) 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) # merged image data (RGB, RAW), one plane at a time
fh.write(merged_block) fh.write(b"\x00\x00")
for c in "RGBA":
fh.write(merged.getchannel(c).tobytes())
del merged
fh.close() fh.close()
@@ -810,6 +767,10 @@ def _safe_workers(workers: int) -> int:
def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None, def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
workers: int = 4, type_layer: bool = False) -> None: workers: int = 4, type_layer: bool = False) -> None:
workers = _safe_workers(workers) 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 import tempfile
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
with zipfile.ZipFile(ppl_path) as zf: with zipfile.ZipFile(ppl_path) as zf:
@@ -819,8 +780,8 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
layers, groups = parse_layers(text) layers, groups = parse_layers(text)
mw = re.search(r"^width=(\d+)", text, re.M) mw = re.search(r"^width=(\d+)", text, re.M)
mh = re.search(r"^height=(\d+)", text, re.M) mh = re.search(r"^height=(\d+)", text, re.M)
width = int(mw.group(1)) if mw else 0 width = int(float(mw.group(1)) * _SCALE) if mw else 0
height = int(mh.group(1)) if mh else 0 height = int(float(mh.group(1)) * _SCALE) if mh else 0
# --- group mapping --- # --- group mapping ---
group_map = {} group_map = {}
@@ -866,10 +827,21 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
rendered = [] rendered = []
skipped = 0 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) 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): for i, ly in enumerate(layers):
gid = ly.get("groupId") gid = ly.get("groupId")
gname = gvis = gb = None gname = gvis = gb = None
layer_id = len(rendered) + 1
if gid and gid in group_ranges: if gid and gid in group_ranges:
g = group_map.get(gid, {}) g = group_map.get(gid, {})
gname = g.get("name", "") or f"group_{gid}" gname = g.get("name", "") or f"group_{gid}"
@@ -880,6 +852,16 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
if group_ranges[gid][0] == i: if group_ranges[gid][0] == i:
rendered.append((gname, 255, gvis, (0, 0), None, None, rendered.append((gname, 255, gvis, (0, 0), None, None,
(3, gb))) (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 img_path = None
if ly["_type"] == "bitmap": if ly["_type"] == "bitmap":
uri = ly.get("uri", "") uri = ly.get("uri", "")
@@ -916,30 +898,41 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
except Exception as exc: except Exception as exc:
print(f" warning: text layer {name} -> TySh failed: {exc}", print(f" warning: text layer {name} -> TySh failed: {exc}",
file=sys.stderr) file=sys.stderr)
rendered.append((name, opacity, visible, (lx, ly_), img, tysh)) 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) rp.update(1)
# group close marker (top of the app group)
if gid and gid in group_ranges and group_ranges[gid][1] == i: if gid and gid in group_ranges and group_ranges[gid][1] == i:
rendered.append((gname, 255, gvis, (0, 0), None, None, rendered.append((gname, 255, gvis, (0, 0), None, None,
(1, 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() rp.close()
ep.close()
print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), " print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), "
f"画布 {width}x{height}") f"画布 {width}x{height}")
ep = tqdm(total=len(rendered) * 4 + 3, desc="编码通道", unit="通道", build_psd(records, payload_f, merged, rendered, width, height, out_path)
file=sys.stderr) payload_f.close()
build_psd(rendered, width, height, out_path, workers, ep)
ep.close()
print(f"PSD 已写出: {out_path}") print(f"PSD 已写出: {out_path}")
if preview_path: if preview_path:
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0)) # downscale for easy viewing, unless the project was upscaled
for layer in rendered: # (then keep the full-res composite so the 2x detail is visible)
name, opacity, visible, (lx, ly), img = layer[:5] preview = merged.convert("RGB")
if img is not None and visible and opacity > 0: if _SCALE == 1.0 and preview.width > 2000:
_opacity_paste(canvas, img, (lx, ly), opacity)
# downscale for easy viewing
preview = canvas.convert("RGB")
if preview.width > 2000:
scale = 2000 / preview.width scale = 2000 / preview.width
preview = preview.resize( preview = preview.resize(
(int(preview.width * scale), int(preview.height * scale)), (int(preview.width * scale), int(preview.height * scale)),
@@ -959,8 +952,13 @@ def main():
ap.add_argument("--type-layer", action="store_true", ap.add_argument("--type-layer", action="store_true",
help="convert text layers to editable TypeLayers (TySh) " help="convert text layers to editable TypeLayers (TySh) "
"instead of rasterized pixels") "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() args = ap.parse_args()
set_scale(args.scale)
out = args.output out = args.output
if not out: if not out:
out = str(Path(args.input).with_suffix(".psd")) out = str(Path(args.input).with_suffix(".psd"))