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:
AskaEth committed 2026-08-27 11:01:58 +08:00
1 parent 39c27504d4
commit bcb3ad0c47
1 file changed
+78 -15
+78 -15
View File
@@ -109,9 +109,10 @@ def resolve_font(spec: str) -> str:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Parsing # Parsing
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def parse_layers(text: str) -> List[dict]: def parse_layers(text: str) -> Tuple[List[dict], List[dict]]:
"""Parse layers.ini into an ordered list of layer dicts (bottom -> top).""" """Parse layers.ini -> (layers, groups), each ordered bottom -> top.
layers = [] Groups are [group] sections; layers carry a groupId field."""
layers, groups = [], []
for block in re.split(r"\n(?=\[)", text): for block in re.split(r"\n(?=\[)", text):
if not block.startswith("["): if not block.startswith("["):
continue continue
@@ -121,8 +122,11 @@ def parse_layers(text: str) -> List[dict]:
ly = {"_type": m.group(1)} ly = {"_type": m.group(1)}
for kv in re.finditer(r"^(\w+)=(.*)$", block, re.M): for kv in re.finditer(r"^(\w+)=(.*)$", block, re.M):
ly[kv.group(1)] = kv.group(2) ly[kv.group(1)] = kv.group(2)
layers.append(ly) if m.group(1) == "group":
return layers groups.append(ly)
else:
layers.append(ly)
return layers, groups
def argb_to_rgba(v: int) -> Tuple[int, int, int, int]: 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, 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 = bytearray()
rec += struct.pack(">4i", top, left, bottom, right) rec += struct.pack(">4i", top, left, bottom, right)
rec += struct.pack(">H", len(channels)) 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 # TySh: makes this an editable text layer in Photoshop
if tysh: if tysh:
extra += b"8BIMTySh" + struct.pack(">I", len(tysh)) + 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) # standard blocks Photoshop expects (see _standard_blocks)
extra += _standard_blocks(layer_id) extra += _standard_blocks(layer_id)
rec += struct.pack(">I", len(extra)) rec += struct.pack(">I", len(extra))
@@ -595,15 +610,18 @@ def _layer_record(top, left, bottom, right, name, opacity, visible, channels,
def build_psd(layers, width: int, height: int, out_path: str, workers: int, def build_psd(layers, width: int, height: int, out_path: str, workers: int,
progress: Optional[tqdm] = None) -> None: progress: Optional[tqdm] = None) -> None:
"""layers: list of (name, opacity, visible, offset, Image[, tysh]) """layers: list of (name, opacity, visible, offset, Image[, tysh[, lsct]])
bottom->top. When tysh (TySh block) is present the layer is an editable bottom->top. img=None marks a group divider layer (lsct present); it gets
text layer; the image is kept as the rasterized channel data.""" empty channels (2-byte compression markers only)."""
# encode all channels (parallel) # encode all channels (parallel)
chan_data = [] chan_data = []
for layer in layers: for layer in layers:
img = layer[4] img = layer[4]
ch = channel_blocks(img, workers, progress) if img is None:
chan_data.append(ch) chan_data.append(None)
else:
ch = channel_blocks(img, workers, progress)
chan_data.append(ch)
# layer records + channel payloads # layer records + channel payloads
records = bytearray() records = bytearray()
@@ -611,12 +629,27 @@ def build_psd(layers, width: int, height: int, out_path: str, workers: int,
for li, layer in enumerate(layers): for li, layer in enumerate(layers):
name, opacity, visible, (lx, ly), img = layer[:5] name, opacity, visible, (lx, ly), img = layer[:5]
tysh = layer[5] if len(layer) > 5 else None 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] ch = chan_data[li]
l, t = lx, ly l, t = lx, ly
r = l + img.width r = l + img.width
b = t + img.height b = t + img.height
records += _layer_record(t, l, b, r, name, opacity, visible, ch, 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: for _, p in ch:
payload += p 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)) merged = Image.new("RGBA", (width, height), (0, 0, 0, 0))
for layer in layers: for layer in layers:
name, opacity, visible, (lx, ly), img = layer[:5] 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.paste(img, (lx, ly), img)
merged_rgb = None # keep the RGBA merged image for the 4-channel output 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 # 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) zf.extractall(tmp)
ini = Path(tmp) / "layers.ini" ini = Path(tmp) / "layers.ini"
text = ini.read_text(encoding="utf-8", errors="replace") 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) 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(mw.group(1)) if mw else 0
height = int(mh.group(1)) if mh 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 = [] rendered = []
skipped = 0 skipped = 0
rp = tqdm(total=len(layers), desc="渲染图层", unit="层", file=sys.stderr) rp = tqdm(total=len(layers), desc="渲染图层", unit="层", file=sys.stderr)
for i, ly in enumerate(layers): 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 img_path = None
if ly["_type"] == "bitmap": if ly["_type"] == "bitmap":
uri = ly.get("uri", "") uri = ly.get("uri", "")
@@ -716,6 +776,9 @@ def convert(ppl_path: str, out_path: str, preview_path: Optional[str] = None,
file=sys.stderr) file=sys.stderr)
rendered.append((name, opacity, visible, (lx, ly_), img, tysh)) rendered.append((name, opacity, visible, (lx, ly_), img, tysh))
rp.update(1) 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() rp.close()
print(f"渲染完成: {len(rendered)}/{len(layers)} 层 (跳过 {skipped}), " 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)) canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
for layer in rendered: for layer in rendered:
name, opacity, visible, (lx, ly), img = layer[:5] 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) canvas.paste(img, (lx, ly), img)
# downscale for easy viewing # downscale for easy viewing
preview = canvas.convert("RGB") preview = canvas.convert("RGB")