190 lines
8.0 KiB
Python
190 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Export the October UI PSD without changing existing application assets.
|
|
|
|
Requires: pip install psd-tools Pillow aggdraw
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
from PIL import Image, ImageDraw
|
|
from psd_tools import PSDImage
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DESIGN = ROOT / "design/ui-20261009"
|
|
ASSETS = ROOT / "h5/assets/img/ui-20261009"
|
|
|
|
# PSD layer indices are recorded alongside descriptive filenames for traceability.
|
|
ASSET_SPECS = [
|
|
("bg-splash.jpg", "0/0", "0"),
|
|
("bg-profile.jpg", "3/0/0", "3"),
|
|
("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", "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", "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),
|
|
("skin-2-neutral.png", "3/3/1/2/2", None),
|
|
("skin-3-oily.png", "3/3/1/3/2", None),
|
|
("skin-4-mixedoily.png", "3/3/1/4/2", None),
|
|
("product-25.png", "8/1/0/1", None),
|
|
("product-35.png", "8/1/1/2", None),
|
|
("product-50.png", "8/1/2/4", None),
|
|
("product-badge.png", "8/1/0/3", None),
|
|
("score-ring-base.png", "8/12/5", None),
|
|
("score-ring-value.png", "8/12/6", None),
|
|
("sample-contour.png", "8/9/8", None),
|
|
("sample-lines.png", "8/9/10", None),
|
|
("sample-tone.png", "8/10/10", None),
|
|
("sample-redness.png", "8/11/8", None),
|
|
("camera-mask-front.png", "5/0", "5"),
|
|
("camera-mask-right.png", "6/0", "6"),
|
|
("camera-mask-left.png", "7/0", "7"),
|
|
("camera-guide-front.png", ["5/8", "5/9", "5/10"], "5"),
|
|
("camera-guide-right.png", ["6/8", "6/9", "6/10"], "6"),
|
|
("camera-guide-left.png", ["7/8", "7/9", "7/10"], "7"),
|
|
]
|
|
|
|
|
|
def layer_at(psd, indices):
|
|
layer = psd
|
|
for part in indices.split("/"):
|
|
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
|
|
|
|
|
|
def bounds(layer):
|
|
# Hidden groups can report an empty bbox; union their children explicitly.
|
|
if layer.is_group():
|
|
boxes = [bounds(child) for child in layer]
|
|
boxes = [b for b in boxes if b[2] > b[0] and b[3] > b[1]]
|
|
if boxes:
|
|
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))
|
|
return tuple(layer.bbox)
|
|
|
|
|
|
def save_image(image, target):
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
if target.suffix == ".jpg":
|
|
background = Image.new("RGB", image.size, "white")
|
|
background.paste(image, mask=image.getchannel("A") if image.mode == "RGBA" else None)
|
|
background.save(target, quality=92, optimize=True, progressive=True)
|
|
else:
|
|
image.save(target, optimize=True)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("psd", type=Path)
|
|
parser.add_argument("--assets-only", action="store_true", help="Reuse existing page exports")
|
|
args = parser.parse_args()
|
|
psd = PSDImage.open(args.psd)
|
|
DESIGN.mkdir(parents=True, exist_ok=True)
|
|
manifest = {"source": args.psd.name, "document_size": psd.size,
|
|
"pages": [], "assets": [], "layers": []}
|
|
|
|
def inventory(group, prefix=""):
|
|
for index, layer in enumerate(group):
|
|
key = f"{prefix}/{index}".strip("/")
|
|
entry = {"path": key, "name": layer.name, "kind": layer.kind,
|
|
"bbox": list(layer.bbox), "visible": layer.visible}
|
|
if layer.kind == "type":
|
|
entry["text"] = layer.text
|
|
manifest["layers"].append(entry)
|
|
if layer.is_group():
|
|
inventory(layer, key)
|
|
inventory(psd)
|
|
|
|
previews = []
|
|
for index, page in enumerate(psd):
|
|
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 and target.exists():
|
|
image = Image.open(target)
|
|
else:
|
|
image = page.composite(viewport=page.bbox)
|
|
save_image(image, target)
|
|
preview = image.copy()
|
|
preview.thumbnail((216, 1553))
|
|
previews.append((page.name, preview))
|
|
manifest["pages"].append({"file": str(target.relative_to(ROOT)),
|
|
"name": page.name, "size": image.size,
|
|
"bbox": list(page.bbox)})
|
|
|
|
thumbs = []
|
|
for filename, indices, crop_page in ASSET_SPECS:
|
|
source_indices = indices if isinstance(indices, list) else [indices]
|
|
layers = [layer_at(psd, key) for key in source_indices]
|
|
layer = layers[0]
|
|
viewport = layer_at(psd, crop_page).bbox if crop_page else bounds(layer)
|
|
print(f"Asset: {filename}", flush=True)
|
|
# A Photoshop clipping layer composites to empty if its base is omitted.
|
|
# Disable only this root layer's clipping while exporting it independently.
|
|
image = Image.new("RGBA", (viewport[2]-viewport[0], viewport[3]-viewport[1]))
|
|
for source in layers:
|
|
was_clipping = source.clipping
|
|
if was_clipping:
|
|
source.clipping = False
|
|
part = source.composite(viewport=viewport, layer_filter=lambda _: True)
|
|
if was_clipping:
|
|
source.clipping = True
|
|
if part is not None:
|
|
image = Image.alpha_composite(image, part.convert("RGBA"))
|
|
if image is None:
|
|
raise RuntimeError(f"No image for {indices}: {layer.name}")
|
|
if image.mode == "RGBA" and image.getchannel("A").getextrema()[1] == 0:
|
|
raise RuntimeError(f"Empty transparent export for {indices}: {filename}")
|
|
target = ASSETS / filename
|
|
save_image(image, target)
|
|
manifest["assets"].append({"file": str(target.relative_to(ROOT)),
|
|
"source_layer": indices, "layer_name": layer.name,
|
|
"size": image.size, "bbox": list(viewport),
|
|
"bytes": target.stat().st_size,
|
|
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
|
"original_visible": layer.is_visible(),
|
|
"usage": "reference-only" if filename.startswith("sample-")
|
|
else "frontend-asset"})
|
|
thumb = image.copy()
|
|
thumb.thumbnail((180, 190))
|
|
thumbs.append((filename, thumb))
|
|
|
|
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
|
|
draw.text((x, y - 25), f"Page {i+1:02d}", fill="black")
|
|
sheet.paste(preview, (x, y), preview if preview.mode == "RGBA" else None)
|
|
save_image(sheet, DESIGN / "pages-overview.jpg")
|
|
|
|
sheet = Image.new("RGB", (6 * 205, ((len(thumbs)+5)//6)*230), "#e3e3e3")
|
|
draw = ImageDraw.Draw(sheet)
|
|
for i, (name, thumb) in enumerate(thumbs):
|
|
x, y = (i % 6)*205 + 10, (i // 6)*230 + 30
|
|
draw.text((x, y - 22), name, fill="black")
|
|
sheet.paste(thumb, (x, y), thumb if thumb.mode == "RGBA" else None)
|
|
save_image(sheet, DESIGN / "assets-overview.jpg")
|
|
(DESIGN / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+"\n")
|
|
print(f"Exported {len(previews)} pages and {len(thumbs)} assets", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|