Add --scale upscaling and stream encode to avoid OOM
This commit is contained in:
1 parent
a08151337b
commit
6a97e68558
2 files changed
+127
-125
No files matched your search
@@ -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
@@ -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"))
|
||||||
|
|||||||
Reference in new issue
Block a user