diff --git a/ppl2psd.py b/ppl2psd.py index 8aaa79f..d2fdf3a 100644 --- a/ppl2psd.py +++ b/ppl2psd.py @@ -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) - layers.append(ly) - return layers + if m.group(1) == "group": + groups.append(ly) + else: + layers.append(ly) + 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,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, 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] - ch = channel_blocks(img, workers, progress) - chan_data.append(ch) + if img is None: + chan_data.append(None) + else: + ch = channel_blocks(img, workers, progress) + chan_data.append(ch) # layer records + channel payloads 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): 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")