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
+78
-15
+78
-15
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user