feat(h5): align third feedback with latest PSD and scanning screen

This commit is contained in:
xsl
2026-10-12 16:33:10 +08:00
parent f3e7eac909
commit c7b085e795
30 changed files with 3343 additions and 987 deletions
+13 -7
View File
@@ -19,18 +19,18 @@ ASSETS = ROOT / "h5/assets/img/ui-20261009"
ASSET_SPECS = [
("bg-splash.jpg", "0/0", "0"),
("bg-profile.jpg", "3/0/0", "3"),
("bg-login.jpg", "9/0/0", "9"),
("bg-login.jpg", "login/0/0", "login"),
("bg-report.jpg", "8/0", "8"),
("bg-history.jpg", "2/0", "2"),
("brand-logo.png", "0/1/0", None),
("brand-login.png", "9/7/1", None),
("brand-login.png", "login/7/1", None),
("icon-close.png", "1/6", None),
("icon-dropdown.png", "3/1/2", None),
("icon-back.png", "3/6/0", None),
("icon-album.png", "5/1/1", None),
("icon-switch-camera.png", "5/1/0", None),
("icon-wechat.png", "9/7/6", None),
("icon-guest.png", "9/7/7", None),
("icon-wechat.png", "login/7/6", None),
("icon-guest.png", "login/7/7", None),
("icon-shutter.png", "5/1/2", None),
("skin-0-dry.png", "3/3/1/0/2", None),
("skin-1-mixeddry.png", "3/3/1/1/2", None),
@@ -59,7 +59,13 @@ ASSET_SPECS = [
def layer_at(psd, indices):
layer = psd
for part in indices.split("/"):
layer = layer[int(part)]
if part == "login":
matches = [page for page in psd if page.name == "登录"]
if len(matches) != 1:
raise ValueError("Expected exactly one 登录 page; refusing incorrect asset mapping")
layer = matches[0]
else:
layer = layer[int(part)]
return layer
@@ -111,7 +117,7 @@ def main():
print(f"Page {index}: {page.name}", flush=True)
# Cached layer pixels preserve Photoshop text appearance without local fonts.
target = DESIGN / "pages" / f"{index + 1:02d}-{page.name}.png"
if args.assets_only:
if args.assets_only and target.exists():
image = Image.open(target)
else:
image = page.composite(viewport=page.bbox)
@@ -160,7 +166,7 @@ def main():
thumb.thumbnail((180, 190))
thumbs.append((filename, thumb))
sheet = Image.new("RGB", (5 * 236, 2 * 1620), "#eeeeee")
sheet = Image.new("RGB", (5 * 236, ((len(previews)+4)//5) * 1620), "#eeeeee")
draw = ImageDraw.Draw(sheet)
for i, (_, preview) in enumerate(previews):
x, y = (i % 5) * 236 + 10, (i // 5) * 1620 + 35
+131
View File
@@ -0,0 +1,131 @@
#!/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()
+35
View File
@@ -0,0 +1,35 @@
"""The third PSD inserts 扫描 before 登录; never silently use its index."""
import unittest
from export_psd_assets import layer_at, ASSET_SPECS
class Page(list):
def __init__(self, name):
super().__init__([["background"], [], [], [], [], [], [], [None, "brand", None, None, None, None, "wechat", "guest"]])
self.name = name
class LoginMappingTest(unittest.TestCase):
def test_old_and_new_layout(self):
for index in (9, 10):
pages = [Page("other") for _ in range(index)] + [Page("登录")]
if index == 10:
pages[9].name = "扫描"
self.assertEqual(layer_at(pages, "login/0/0"), "background")
self.assertEqual(layer_at(pages, "login/7/1"), "brand")
self.assertEqual(layer_at(pages, "login/7/6"), "wechat")
def test_missing_or_ambiguous_login(self):
for pages in ([], [Page("扫描")], [Page("登录"), Page("登录")]):
with self.assertRaises(ValueError):
layer_at(pages, "login/0/0")
def test_every_login_asset_uses_semantic_mapping(self):
names = {"bg-login.jpg", "brand-login.png", "icon-wechat.png", "icon-guest.png"}
for filename, key, _ in ASSET_SPECS:
if filename in names:
self.assertTrue(key.startswith("login/"))
if __name__ == "__main__":
unittest.main()