Files
juzi/tools/export_third_feedback_assets.py

132 lines
6.0 KiB
Python

#!/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()