132 lines
6.0 KiB
Python
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()
|