Map ppl layer groups to PSD groups via lsct section dividers
Groups ([group] sections + layer groupId) become PSD groups: a bounding divider (lsct kind 3) opens the group at its bottom, an open-folder divider (kind 1) closes it at the top (giving the group its name), per psd_tools' parsing order. Group divider layers get empty channels. Verified: psd_tools reads the group with its 11 child layers.
This commit is contained in:
1 parent
39c27504d4
commit
bcb3ad0c47
1 file changed
+75
-12
+75
-12
@@ -109,9 +109,10 @@ def resolve_font(spec: str) -> str:
|
||||
# --------------------------------------------------------------------------
|
||||
# Parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_layers(text: str) -> List[dict]:
|
||||
"""Parse layers.ini into an ordered list of layer dicts (bottom -> top)."""
|
||||
layers = []
|
||||
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
|
||||
@@ -121,8 +122,11 @@ def parse_layers(text: str) -> List[dict]:
|
||||
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
|
||||
return layers, groups
|
||||
|
||||
|
||||
def argb_to_rgba(v: int) -> Tuple[int, int, int, int]:
|
||||
@@ -551,7 +555,8 @@ def _standard_blocks(layer_id: int) -> bytes:
|
||||
|
||||
|
||||
def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
|
||||
layer_id=1, tysh: Optional[bytes] = None) -> bytes:
|
||||
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))
|
||||
@@ -586,6 +591,16 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
|
||||
# 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))
|
||||
@@ -595,13 +610,16 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
|
||||
|
||||
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[, tysh])
|
||||
bottom->top. When tysh (TySh block) is present the layer is an editable
|
||||
text layer; the image is kept as the rasterized channel data."""
|
||||
"""layers: list of (name, opacity, visible, offset, Image[, tysh[, lsct]])
|
||||
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)
|
||||
|
||||
@@ -611,12 +629,27 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int,
|
||||
for li, layer in enumerate(layers):
|
||||
name, opacity, visible, (lx, ly), img = layer[:5]
|
||||
tysh = layer[5] if len(layer) > 5 else None
|
||||
lsct = layer[6] if len(layer) > 6 else None
|
||||
if img is None:
|
||||
# group divider: 4 empty channels (RLE marker only, len 2)
|
||||
ch = [(cid, b"\x00\x01") for cid in (-1, 0, 1, 2)]
|
||||
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)
|
||||
layer_id=li + 1, tysh=tysh, lsct=lsct)
|
||||
for _, p in ch:
|
||||
payload += p
|
||||
|
||||
@@ -624,7 +657,7 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int,
|
||||
merged = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
for layer in layers:
|
||||
name, opacity, visible, (lx, ly), img = layer[:5]
|
||||
if visible and opacity > 0:
|
||||
if img is not None and visible and opacity > 0:
|
||||
merged.paste(img, (lx, ly), img)
|
||||
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
|
||||
@@ -672,16 +705,43 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
||||
zf.extractall(tmp)
|
||||
ini = Path(tmp) / "layers.ini"
|
||||
text = ini.read_text(encoding="utf-8", errors="replace")
|
||||
layers = parse_layers(text)
|
||||
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(mw.group(1)) if mw else 0
|
||||
height = int(mh.group(1)) 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
|
||||
|
||||
rendered = []
|
||||
skipped = 0
|
||||
rp = tqdm(total=len(layers), desc="渲染图层", unit="层", file=sys.stderr)
|
||||
for i, ly in enumerate(layers):
|
||||
gid = ly.get("groupId")
|
||||
gname = gvis = gb = None
|
||||
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)))
|
||||
img_path = None
|
||||
if ly["_type"] == "bitmap":
|
||||
uri = ly.get("uri", "")
|
||||
@@ -716,6 +776,9 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
||||
file=sys.stderr)
|
||||
rendered.append((name, opacity, visible, (lx, ly_), img, tysh))
|
||||
rp.update(1)
|
||||
if gid and gid in group_ranges and group_ranges[gid][1] == i:
|
||||
rendered.append((gname, 255, gvis, (0, 0), None, None,
|
||||
(1, None)))
|
||||
rp.close()
|
||||
|
||||
print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), "
|
||||
@@ -730,7 +793,7 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
|
||||
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
for layer in rendered:
|
||||
name, opacity, visible, (lx, ly), img = layer[:5]
|
||||
if visible and opacity > 0:
|
||||
if img is not None and visible and opacity > 0:
|
||||
canvas.paste(img, (lx, ly), img)
|
||||
# downscale for easy viewing
|
||||
preview = canvas.convert("RGB")
|
||||
|
||||
Reference in new issue
Block a user