Files
juzi/tools/export_psd_assets.py
T

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()