#!/usr/bin/env python3 """Deterministic, selective exports from the 2026-10-12 third-feedback PSD. Does not overwrite the original 10.09 exports, modify the PSD, substitute fonts, or reveal hidden Photoshop layers. Requires psd-tools, Pillow, aggdraw. """ import argparse import hashlib import json from pathlib import Path from PIL import Image from psd_tools import PSDImage from export_psd_assets import ROOT, layer_at ASSETS = ROOT / "h5/assets/img/ui-20261012" DESIGN = ROOT / "design/ui-20261012" def union(layers): boxes = [layer.bbox for layer in layers] return (min(b[0] for b in boxes), min(b[1] for b in boxes), max(b[2] for b in boxes), max(b[3] for b in boxes)) def sha256(path): digest = hashlib.sha256() with path.open("rb") as source: for chunk in iter(lambda: source.read(8 * 1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def composite(layers, viewport): image = Image.new("RGBA", (viewport[2] - viewport[0], viewport[3] - viewport[1])) for layer in layers: # Cached Photoshop text pixels, with original internal visibility intact. part = layer.composite(viewport=viewport) if part is None: raise ValueError(f"Empty layer: {layer.name}") image = Image.alpha_composite(image, part.convert("RGBA")) return image def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("psd", type=Path) args = parser.parse_args() psd = PSDImage.open(args.psd) expected = {0: "启动页", 1: "启动页弹窗", 4: "拍照弹窗", 8: "报告页", 9: "扫描", 10: "登录"} for index, name in expected.items(): if len(psd) <= index or psd[index].name != name: raise ValueError(f"Unexpected PSD layout at {index}: expected {name}; no files written") ASSETS.mkdir(parents=True, exist_ok=True) DESIGN.mkdir(parents=True, exist_ok=True) manifest = {"source": args.psd.name, "source_sha256": sha256(args.psd), "document_size": list(psd.size), "native_header_px": 248, "design_scale": 2.88, "assets": []} def export(name, paths, viewport=None): layers = [layer_at(psd, key) for key in paths] viewport = viewport or union(layers) image = composite(layers, viewport) if image.getchannel("A").getbbox() is None: raise ValueError(f"Empty transparent export: {name}") target = ASSETS / name if target.suffix == ".webp": image.save(target, lossless=True, method=6) else: image.save(target, optimize=True) manifest["assets"].append({"file": str(target.relative_to(ROOT)), "source_layers": paths, "bbox": list(viewport), "size": list(image.size), "bytes": target.stat().st_size, "sha256": sha256(target)}) print(f"Exported {name}: {image.size}", flush=True) return image export("splash-slogan.png", ["0/5", "0/4"]) export("brand-lockup.png", ["0/1"]) export("button-start-label.png", ["0/3/1"]) export("button-history-label.png", ["0/2/2/2"]) # Preserve the exact clipped right half of each card (not a new center crop). for tier, key in [(25, "8/1/0"), (35, "8/1/1"), (50, "8/1/2")]: card = layer_at(psd, key) left, top, right, bottom = card.bbox export(f"product-{tier}.webp", [key], (left + 480, top, right, bottom)) page = psd[9] left, top, right, bottom = page.bbox # Composite the whole page: the face uses cross-layer clipping dependencies. # Exporting the icon group alone silently loses the face. The viewport crops # out the native header/back; default visibility keeps hidden controls hidden. scan = export("scan-waiting.webp", ["9"], (left, top + 248, right, bottom)) if min(scan.getpixel((540, 720))[:3]) < 245: raise ValueError("Scanning face missing from composite") yellow = scan.getpixel((540, 1200)) if not (yellow[0] > 220 and yellow[1] > 220 and yellow[2] < 150): raise ValueError("Scanning yellow face region missing from composite") manifest["text_styles"] = [] for key in ["0/4", "0/5", "0/1/1", "1/3/0", "1/3/1", "4/7", "4/8", "8/1/0/5", "8/1/0/6"]: layer = layer_at(psd, key) fonts = [font.get("Name").value for font in layer.resource_dict["FontSet"]] runs = [] for run in layer.engine_dict["StyleRun"]["RunArray"]: style = run["StyleSheet"]["StyleSheetData"] font = fonts[int(style["Font"])] runs.append({"font": font, "font_size": float(style["FontSize"]), "leading": float(style["Leading"]) if "Leading" in style else None}) manifest["text_styles"].append({"path": key, "text": layer.text, "bbox": list(layer.bbox), "transform": list(layer.transform), "runs": runs}) manifest["shape_metrics"] = [] for key in ["0/3/0", "0/2/0", "1/2/0", "4/2/0", "8/1/0/0", "1/1", "4/1"]: layer = layer_at(psd, key) anchors = [(knot.anchor[1] * psd.width, knot.anchor[0] * psd.height) for knot in layer.vector_mask.paths[0]] # These PSD rounded rectangles start on the top edge, one radius in. radius = anchors[0][0] - min(x for x, _ in anchors) manifest["shape_metrics"].append({"path": key, "bbox": list(layer.bbox), "corner_radius_psd_px": round(radius, 6)}) # Review-only page previews; never use them as full-page H5 replacements. for index in [0, 1, 4, 9]: page = psd[index] left, top, right, bottom = page.bbox preview = page.composite(viewport=(left, top + 248, right, bottom)) preview.thumbnail((540, 1400)) preview.save(DESIGN / f"page-{index}.webp", quality=94, method=6) (DESIGN / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") if __name__ == "__main__": main()