feat(h5): align third feedback with latest PSD and scanning screen
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user