Files
Sanctification/artifacts/cards/BP-002-david/build.py

644 lines
35 KiB
Python

#!/usr/bin/env python3
"""Build the bespoke BP-002 David Legendary card and all printing variants."""
from __future__ import annotations
from pathlib import Path
import hashlib
import json
import os
import struct
import subprocess
import sys
from xml.sax.saxutils import escape
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
ROOT = Path(__file__).resolve().parent
REPO = next(p for p in ROOT.parents if (p / "docs/card-layer-pipeline.md").is_file())
sys.path.insert(0, str(REPO / "tools/card-production"))
from card_workspace import refresh, update_index
from finish_masks import RECIPE_PATH, RECIPE_SHA256, load_recipe, prepare_illustration
CANVAS = (2000, 2800)
PRINTINGS = ("normal", "boundless", "borderless", "textless")
RESOLUTIONS = (("low", 500), ("med", 1000), ("high", 2000))
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def save_json(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2) + "\n")
def check(condition: bool, message: str) -> None:
if not condition:
raise ValueError(message)
def alpha_array(image: Image.Image) -> np.ndarray:
check("A" in image.getbands(), "Expected an alpha-bearing component")
return np.asarray(image.getchannel("A"), dtype=np.uint8)
def font_metric_box(font_path: Path, size: int) -> dict:
data = font_path.read_bytes()
tables = {}
count = struct.unpack_from(">H", data, 4)[0]
for index in range(count):
tag, _, offset, length = struct.unpack_from(">4sIII", data, 12 + 16 * index)
tables[tag.decode()] = data[offset:offset + length]
units = struct.unpack_from(">H", tables["head"], 18)[0]
ascent, descent, _ = struct.unpack_from(">hhh", tables["hhea"], 4)
ymin = struct.unpack_from(">h", tables["head"], 38)[0]
import math
return {
"unitsPerEm": units,
"hheaAscender": ascent,
"hheaDescender": descent,
"headYMin": ymin,
"size": size,
"ascent": math.ceil(ascent * size / units),
"descent": math.ceil(max(-descent, -ymin) * size / units),
"lineGapUsed": 0,
}
def luminance(rgb: np.ndarray) -> np.ndarray:
values = np.asarray(rgb, dtype=np.float64) / 255.0
values = np.where(values <= 0.04045, values / 12.92, ((values + 0.055) / 1.055) ** 2.4)
return values @ np.array([0.2126, 0.7152, 0.0722])
def contrast_ratio(a: np.ndarray, b: np.ndarray) -> np.ndarray:
la = luminance(a)
lb = luminance(b)
return (np.maximum(la, lb) + 0.05) / (np.minimum(la, lb) + 0.05)
def main() -> None:
card_path = ROOT / "card.json"
manifest_path = ROOT / "manifest.json"
card = json.loads(card_path.read_text())
manifest = json.loads(manifest_path.read_text())
art_path = ROOT / "source/art-master.png"
foreground_path = ROOT / "source/foreground/foreground-2000.png"
art = Image.open(art_path).convert("RGBA")
foreground = Image.open(foreground_path).convert("RGBA")
check(art.size == CANVAS and art.getchannel("A").getextrema() == (255, 255), "Base art must be opaque 2000x2800")
check(foreground.size == CANVAS and foreground.getchannel("A").getextrema()[1] == 255, "Foreground must be registered RGBA")
check(digest(art_path) == card["artSHA256"], "Card art hash does not match selected source")
fonts_dir = REPO / "fonts"
font_manifest_path = fonts_dir / "manifest.json"
font_manifest = json.loads(font_manifest_path.read_text())
title_font = fonts_dir / "P052-Bold.otf"
verse_font = fonts_dir / "SanctificationP052-Medium.otf"
bold_font = title_font
for font in (title_font, verse_font):
check(digest(font) == font_manifest["files"][font.name]["sha256"], f"Font hash mismatch: {font.name}")
review = ROOT / "review"
review.mkdir(exist_ok=True)
font_cache = review / "font-cache"
font_cache.mkdir(exist_ok=True)
fonts_conf = review / "fonts.conf"
fonts_conf.write_text(f"<fontconfig><dir>{fonts_dir}</dir><cachedir>{font_cache}</cachedir></fontconfig>")
env = dict(os.environ, FONTCONFIG_FILE=str(fonts_conf))
def ink(*args: object) -> str:
return subprocess.check_output(["inkscape", *map(str, args)], env=env, text=True, stderr=subprocess.PIPE).strip()
def render(source: Path, destination: Path) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
ink(source, "--export-type=png", f"--export-filename={destination}", "--export-width=2000", "--export-height=2800")
def resolved_font(family: str, style: str) -> Path:
return Path(subprocess.check_output(["fc-match", "-f", "%{file}", f"{family}:style={style}"], env=env, text=True).strip()).resolve()
check(resolved_font("P052", "Bold") == title_font.resolve(), "P052 Bold fallback detected")
check(resolved_font("Sanctification P052", "Medium") == verse_font.resolve(), "P052 Medium fallback detected")
head = '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" height="2800" viewBox="0 0 2000 2800">'
defs = """<defs>
<linearGradient id="paper" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fbf3df"/><stop offset="0.52" stop-color="#f4ead4"/><stop offset="1" stop-color="#ead8b7"/></linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f3d879"/><stop offset="0.42" stop-color="#c89732"/><stop offset="1" stop-color="#7b5317"/></linearGradient>
<pattern id="grain" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M2 9 Q9 5 16 9 T30 9 M1 24 Q9 20 17 24 T31 24" fill="none" stroke="#a58348" stroke-width="1" opacity=".08"/></pattern>
<g id="leaf"><path d="M0 0 C-17-12-29-35-21-53 C-3-45 10-26 0 0Z" fill="url(#gold)" stroke="#684713" stroke-width="3"/></g>
</defs>"""
frame = """<g id="legendary-frame">
<rect x="14" y="14" width="1972" height="2772" rx="34" fill="none" stroke="#3b2914" stroke-width="28"/>
<rect x="24" y="24" width="1952" height="2752" rx="27" fill="none" stroke="url(#gold)" stroke-width="14"/>
<rect x="42" y="42" width="1916" height="2716" rx="20" fill="none" stroke="#173e59" stroke-width="10"/>
<rect x="52" y="52" width="1896" height="2696" rx="16" fill="none" stroke="#d7b35a" stroke-width="4"/>
<g stroke="#b7882d" stroke-width="6" fill="none" stroke-linecap="round">
<path d="M64 310 C112 274 123 206 101 139 C92 111 78 88 60 72"/>
<path d="M1936 2490 C1888 2526 1877 2594 1899 2661 C1908 2689 1922 2712 1940 2728"/>
</g>
<use xlink:href="#leaf" transform="translate(105 224) rotate(56) scale(.72)"/><use xlink:href="#leaf" transform="translate(111 160) rotate(83) scale(.63)"/><use xlink:href="#leaf" transform="translate(1895 2576) rotate(236) scale(.72)"/><use xlink:href="#leaf" transform="translate(1889 2640) rotate(263) scale(.63)"/>
<g stroke="#bb8b2e" stroke-width="5" fill="none" stroke-linecap="round"><path d="M61 430 C121 470 118 539 67 580 C24 615 32 687 82 730"/><path d="M1939 585 C1879 625 1882 694 1933 735 C1976 770 1968 842 1918 885"/></g>
<use xlink:href="#leaf" transform="translate(105 505) rotate(70) scale(.52)"/><use xlink:href="#leaf" transform="translate(86 648) rotate(107) scale(.46)"/><use xlink:href="#leaf" transform="translate(1895 660) rotate(250) scale(.52)"/><use xlink:href="#leaf" transform="translate(1914 802) rotate(287) scale(.46)"/>
<circle cx="67" cy="67" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/><circle cx="1933" cy="67" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/><circle cx="67" cy="2733" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/><circle cx="1933" cy="2733" r="12" fill="url(#gold)" stroke="#533810" stroke-width="3"/>
</g>"""
title_path = "M1090 58 H1897 L1938 99 V326 L1899 365 H1090 L1048 323 V100 Z"
verse_path = "M500 2075 H1898 Q1942 2075 1942 2119 V2720 Q1942 2764 1898 2764 H500 Q466 2764 466 2730 V2109 Q466 2075 500 2075 Z"
def panel(path: str, inner: str, ident: str) -> str:
return f'<g id="{ident}"><path d="{path}" fill="url(#paper)" stroke="#654516" stroke-width="10"/><path d="{path}" fill="url(#grain)" stroke="url(#gold)" stroke-width="5"/>{inner}</g>'
title_inner = '<path d="M1105 78 H1886 L1918 110 V315 L1887 345 H1104 L1068 312 V112 Z" fill="none" stroke="#9b732b" stroke-width="3"/><path d="M1328 310 H1454 M1526 310 H1652" stroke="#98702a" stroke-width="4"/><path d="M1490 297 l13 13 -13 13 -13-13Z" fill="url(#gold)" stroke="#664716" stroke-width="2"/>'
verse_inner = '<path d="M510 2095 H1890 Q1922 2095 1922 2127 V2712 Q1922 2744 1890 2744 H510 Q486 2744 486 2720 V2119 Q486 2095 510 2095Z" fill="none" stroke="#9b732b" stroke-width="3"/><path d="M1150 2150 H1278 M1362 2150 H1490" stroke="#98702a" stroke-width="4"/><path d="M1320 2137 l13 13 -13 13 -13-13Z" fill="url(#gold)" stroke="#664716" stroke-width="2"/><path d="M1162 2705 H1282 M1358 2705 H1478" stroke="#98702a" stroke-width="4"/><path d="M1320 2693 l12 12 -12 12 -12-12Z" fill="url(#gold)" stroke="#664716" stroke-width="2"/><g stroke="#a17628" stroke-width="4" fill="none" stroke-linecap="round"><path d="M1884 2169 C1828 2190 1812 2238 1841 2290"/><path d="M1884 2670 C1828 2649 1812 2601 1841 2549"/></g><g fill="url(#gold)" stroke="#6b4918" stroke-width="2"><path d="M1840 2203 C1814 2193 1797 2206 1800 2233 C1824 2236 1841 2228 1840 2203Z"/><path d="M1827 2251 C1803 2245 1789 2262 1797 2285 C1820 2283 1834 2273 1827 2251Z"/><path d="M1840 2636 C1814 2646 1797 2633 1800 2606 C1824 2603 1841 2611 1840 2636Z"/><path d="M1827 2588 C1803 2594 1789 2577 1797 2554 C1820 2556 1834 2566 1827 2588Z"/></g>'
backing = panel(title_path, title_inner, "title-backing") + panel(verse_path, verse_inner, "verse-backing")
layers = ROOT / "source/layers"
layers.mkdir(parents=True, exist_ok=True)
frame_svg = layers / "frame.svg"
backing_svg = layers / "backing.svg"
frame_svg.write_text(head + defs + frame + "</svg>")
backing_svg.write_text(head + defs + backing + "</svg>")
render(frame_svg, layers / "frame-2000.png")
render(backing_svg, layers / "backing-2000.png")
frame_image = Image.open(layers / "frame-2000.png").convert("RGBA")
backing_image = Image.open(layers / "backing-2000.png").convert("RGBA")
# A two-pixel lead guard sits below the foreground only where the lower backing is active.
foreground_alpha_image = foreground.getchannel("A")
expanded = foreground_alpha_image.filter(ImageFilter.MaxFilter(5))
ga = np.asarray(expanded, dtype=np.uint16)
ba = np.asarray(backing_image.getchannel("A"), dtype=np.uint16)
guard_alpha = np.where((ga > 0) & (ba > 0), 255, 0).astype(np.uint8)
guard_alpha[:2060, :] = 0
guard_alpha[:, :450] = 0
guard_alpha[:, 1950:] = 0
guard_rgba = np.zeros((2800, 2000, 4), dtype=np.uint8)
guard_rgba[:, :, :3] = [29, 23, 18]
guard_rgba[:, :, 3] = guard_alpha
guard_image = Image.fromarray(guard_rgba, "RGBA")
guard_path = layers / "foreground-occlusion-guard.png"
guard_image.save(guard_path)
normal_overlay = Image.alpha_composite(Image.alpha_composite(Image.new("RGBA", CANVAS), frame_image), backing_image)
normal_overlay = Image.alpha_composite(normal_overlay, guard_image)
borderless_overlay = Image.alpha_composite(backing_image, guard_image)
normal_overlay.save(layers / "overlay-normal-2000.png")
borderless_overlay.save(layers / "backing-borderless-2000.png")
frame_image.save(layers / "frame-textless-2000.png")
(layers / "overlay-normal.svg").write_text(head + defs + frame + backing + f'<image xlink:href="foreground-occlusion-guard.png" width="2000" height="2800"/></svg>')
(layers / "backing-borderless.svg").write_text(head + defs + backing + f'<image xlink:href="foreground-occlusion-guard.png" width="2000" height="2800"/></svg>')
(layers / "frame-textless.svg").write_text(head + defs + frame + "</svg>")
text_dir = ROOT / "source/text"
text_dir.mkdir(parents=True, exist_ok=True)
rows = [
{"id": "title", "role": "title", "parts": [("DAVID", False)], "x": 1490, "baseline": 244, "size": 138, "family": "P052", "weight": 700},
{"id": "verse-0", "role": "verse", "parts": [("“", False), ("I", True), (" have found David", False)], "x": 1320, "baseline": 2264, "size": 80, "family": "Sanctification P052", "weight": 500},
{"id": "verse-1", "role": "verse", "parts": [("the son of Jesse, a man", False)], "x": 1320, "baseline": 2358, "size": 80, "family": "Sanctification P052", "weight": 500},
{"id": "verse-2", "role": "verse", "parts": [("after ", False), ("My", True), (" own heart,", False)], "x": 1320, "baseline": 2452, "size": 80, "family": "Sanctification P052", "weight": 500},
{"id": "verse-3", "role": "verse", "parts": [("who will do all ", False), ("My", True), (" will.”", False)], "x": 1320, "baseline": 2546, "size": 80, "family": "Sanctification P052", "weight": 500},
{"id": "reference", "role": "reference", "parts": [("Acts 13:22 • NKJV", False)], "x": 1320, "baseline": 2655, "size": 57, "family": "Sanctification P052", "weight": 500},
]
def text_nodes(only_emphasis: bool = False) -> str:
output = []
for row in rows:
spans = ""
for value, emphasized in row["parts"]:
if emphasized:
style = 'fill="#263f50" font-family="P052" font-weight="700"'
elif only_emphasis:
style = 'fill="#000" fill-opacity="0" stroke="none"'
else:
style = 'fill="#263f50"'
spans += f'<tspan {style}>{escape(value)}</tspan>'
output.append(
f'<text id="{row["id"]}" x="{row["x"]}" y="{row["baseline"]}" '
f'font-family="{row["family"]}" font-weight="{row["weight"]}" '
f'font-size="{row["size"]}" text-anchor="middle" xml:space="preserve">{spans}</text>'
)
return "".join(output)
text_svg = text_dir / "text.svg"
text_svg.write_text(head + text_nodes() + "</svg>")
render(text_svg, text_dir / "text-2000.png")
text_image = Image.open(text_dir / "text-2000.png").convert("RGBA")
text_alpha = alpha_array(text_image)
(text_dir / "bold-emphasis.svg").write_text(head + text_nodes(True) + "</svg>")
render(text_dir / "bold-emphasis.svg", text_dir / "bold-emphasis-2000.png")
# Validate glyph coverage, actual ink bounds, panel coverage, and foreground clearance.
def charset(font: Path) -> set[int]:
result = subprocess.check_output(["fc-query", "--format", "%{charset}", str(font)], text=True)
points: set[int] = set()
for span in result.split():
ends = [int(value, 16) for value in span.split("-")]
points.update(range(ends[0], ends[-1] + 1))
return points
medium_chars = charset(verse_font)
bold_chars = charset(bold_font)
glyph_checks = []
bounds_by_id = {}
backing_alpha = alpha_array(backing_image)
foreground_alpha = alpha_array(foreground)
for row in rows:
row_text = "".join(value for value, _ in row["parts"])
for value, emphasized in row["parts"]:
coverage = bold_chars if emphasized or row["role"] == "title" else medium_chars
check(all(ord(character) in coverage for character in value), f"Missing glyph in {row['id']}")
node = next(part for part in text_nodes().split("</text>") if f'id="{row["id"]}"' in part) + "</text>"
row_svg = text_dir / f'{row["id"]}-unclipped.svg'
row_png = text_dir / f'{row["id"]}-unclipped.png'
row_svg.write_text(head + node + "</svg>")
render(row_svg, row_png)
row_alpha = alpha_array(Image.open(row_png).convert("RGBA"))
ys, xs = np.where(row_alpha > 0)
check(len(xs) > 0, f"No rendered ink: {row['id']}")
bounds = [int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1)]
bounds_by_id[row["id"]] = bounds
safe = [1090, 58, 1940, 365] if row["role"] == "title" else [500, 2163, 1900, 2693]
check(safe[0] <= bounds[0] and safe[1] <= bounds[1] and bounds[2] <= safe[2] and bounds[3] <= safe[3], f"Text overflow: {row['id']}")
check(np.all(backing_alpha[row_alpha > 0] == 255), f"Glyph outside opaque backing: {row['id']}")
check(not np.any((row_alpha > 0) & (foreground_alpha > 0)), f"Foreground intersects lettering: {row['id']}")
glyph_checks.append({
"id": row["id"],
"role": row["role"],
"text": row_text,
"baseline": row["baseline"],
"fontFamily": row["family"],
"weight": row["weight"],
"size": row["size"],
"unclippedInkBounds": bounds,
"safeBounds": safe,
"glyphPixelsOutsideOpaqueBacking": 0,
"foregroundIntersectionPixels": 0,
"pass": True,
})
verse_text = " ".join(item["text"] for item in glyph_checks if item["role"] == "verse")
check(verse_text == "“" + card["excerpt"] + "”", "Rendered verse does not match card content")
check(glyph_checks[-1]["text"] == card["referenceDisplay"], "Rendered reference does not match card content")
verse_bounds = [bounds_by_id[f"verse-{index}"] for index in range(4)]
verse_union = [
min(box[0] for box in verse_bounds),
min(box[1] for box in verse_bounds),
max(box[2] for box in verse_bounds),
max(box[3] for box in verse_bounds),
]
upper_flourish_bottom = 2163
reference_metrics = font_metric_box(verse_font, 57)
reference_line_top = rows[-1]["baseline"] - reference_metrics["ascent"]
reference_line_bottom = rows[-1]["baseline"] + reference_metrics["descent"]
above = verse_union[1] - upper_flourish_bottom
below = reference_line_top - verse_union[3]
check(min(above, below) >= 40 and abs(above - below) <= 3, "Verse is not optically centered in its reserved band")
check(bounds_by_id["reference"][1] >= reference_line_top and bounds_by_id["reference"][3] <= reference_line_bottom, "Reference escapes its fixed line box")
check(2693 - reference_line_bottom >= 20, "Reference line box lacks lower flourish clearance")
actual_backing_rgb = np.asarray(backing_image.convert("RGB"))
ink_rgb = np.array([38, 63, 80])
minimum_text_contrast = float(np.min(contrast_ratio(ink_rgb, actual_backing_rgb[text_alpha >= 128])))
check(minimum_text_contrast >= 7.0, "Text/backing contrast is below 7:1")
# Text protection is independent of finish coverage.
protected_text = text_image.getchannel("A").filter(ImageFilter.MaxFilter(25)).filter(ImageFilter.GaussianBlur(8))
minimum_protection = int(np.min(np.asarray(protected_text)[text_alpha >= 128]))
check(minimum_protection >= 216, "Text protection does not cover rendered glyphs")
# Analyze the base and the opaque adapted-art plate exactly once each.
base_analysis = prepare_illustration(art)
adapted_plate = Image.alpha_composite(art, foreground)
check(adapted_plate.getchannel("A").getextrema() == (255, 255), "Adapted analysis plate must be opaque")
adapted_analysis = prepare_illustration(adapted_plate)
material_dir = ROOT / "source/material"
material_dir.mkdir(parents=True, exist_ok=True)
adapted_plate.save(material_dir / "foreground-analysis-plate.png")
Image.fromarray(base_analysis.raw_ridges).save(material_dir / "base-raw-ridges.png")
Image.fromarray(np.rint(base_analysis.pane_weights * 255).astype(np.uint8)).save(material_dir / "base-pane-coverage.png")
Image.fromarray(adapted_analysis.raw_ridges).save(material_dir / "foreground-raw-ridges.png")
Image.fromarray(np.rint(adapted_analysis.pane_weights * 255).astype(np.uint8)).save(material_dir / "foreground-pane-coverage.png")
def coverage(analysis) -> np.ndarray:
return analysis.pane_weights * (1.0 - analysis.raw_ridges.astype(np.float32) / 255.0)
base_coverage = coverage(base_analysis)
adapted_coverage = coverage(adapted_analysis)
fa = foreground_alpha.astype(np.float32) / 255.0
ta = text_alpha.astype(np.float32) / 255.0
overlays = {
"normal": normal_overlay,
"boundless": None,
"borderless": borderless_overlay,
"textless": frame_image,
}
foregrounds = {"normal": foreground, "borderless": foreground, "boundless": None, "textless": None}
has_text = {"normal": True, "borderless": True, "boundless": False, "textless": False}
report = {
"status": "pending",
"cardId": card["cardId"],
"revision": manifest["revision"],
"artApproval": {
"by": "user",
"note": "The selected David artwork and corrected mantle composition provide a strong basis for full card production.",
"scope": "Selected v03 illustration and adapted foreground; full assembly authorized.",
},
"finalCardApproval": None,
"inputs": {
"art": {"path": str(art_path.relative_to(REPO)), "sha256": digest(art_path)},
"foreground": {
"path": str(foreground_path.relative_to(REPO)),
"sha256": digest(foreground_path),
"contour": str((ROOT / "source/foreground/mantle-contour.json").relative_to(REPO)),
"provenance": str((ROOT / "source/foreground/art-provenance.json").relative_to(REPO)),
},
"cardSHA256BeforeBuild": digest(card_path),
"builderSHA256": digest(Path(__file__)),
},
"bespokeLayers": {
"template": "BP-002-david-legendary-v1",
"canvas": list(CANVAS),
"compositionOrder": ["base-art", "printing-overlay", "adapted-foreground-if-selected", "text-if-selected"],
"assets": {},
},
"typography": {
"sharedFontManifestSHA256": digest(font_manifest_path),
"fonts": [
{"role": "title-and-emphasis", "path": str(title_font.relative_to(REPO)), "sha256": digest(title_font), "weight": 700, "fallbackUsed": False},
{"role": "verse-and-reference", "path": str(verse_font.relative_to(REPO)), "sha256": digest(verse_font), "weight": 500, "fallbackUsed": False},
],
"glyphs": glyph_checks,
"boldTokens": ["I", "My", "My"],
"boldColor": "#263f50",
"fontMetrics": reference_metrics,
"referenceLineBox": [reference_line_top, reference_line_bottom],
"referenceLineBoxToLowerFlourish": 2693 - reference_line_bottom,
"verseInkUnion": verse_union,
"upperFlourishToVerseInk": above,
"verseInkToReferenceLineBox": below,
"centeringError": abs(above - below),
"minimumTextContrast": round(minimum_text_contrast, 2),
"textLayerSHA256": digest(text_dir / "text-2000.png"),
},
"textMask": {
"expansionRadius": 12,
"gaussianRadius": 8,
"units": "master pixels",
"minimumProtectionAtGlyphAlpha128": minimum_protection,
},
"finishRecipe": {
"id": load_recipe()["id"],
"sha256": RECIPE_SHA256,
"path": str(RECIPE_PATH.relative_to(REPO)),
"moduleSHA256": digest(REPO / "tools/card-production/finish_masks.py"),
"illustrationAnalyses": 2,
"baseRawRidgesSHA256": digest(material_dir / "base-raw-ridges.png"),
"basePaneCoverageSHA256": digest(material_dir / "base-pane-coverage.png"),
"foregroundPlateSHA256": digest(material_dir / "foreground-analysis-plate.png"),
"foregroundRawRidgesSHA256": digest(material_dir / "foreground-raw-ridges.png"),
"foregroundPaneCoverageSHA256": digest(material_dir / "foreground-pane-coverage.png"),
},
"tools": {"inkscape": ink("--version"), "pillow": Image.__version__, "numpy": np.__version__},
"printings": {},
"visualReview": {"static": "pending user review", "movingLight": "not performed", "userApproval": "pending"},
}
for name in ["frame-2000.png", "backing-2000.png", "overlay-normal-2000.png", "backing-borderless-2000.png", "frame-textless-2000.png", "foreground-occlusion-guard.png"]:
path = layers / name
report["bespokeLayers"]["assets"][name] = {"path": str(path.relative_to(REPO)), "sha256": digest(path)}
for printing in PRINTINGS:
overlay = overlays[printing]
selected_foreground = foregrounds[printing]
face = art.copy()
if overlay is not None:
face = Image.alpha_composite(face, overlay)
if selected_foreground is not None:
face = Image.alpha_composite(face, selected_foreground)
if has_text[printing]:
face = Image.alpha_composite(face, text_image)
check(face.getchannel("A").getextrema() == (255, 255), f"{printing} face is not opaque")
if printing == "boundless":
check(np.array_equal(np.asarray(face), np.asarray(art)), "Boundless must equal the base art")
if printing == "textless":
expected = Image.alpha_composite(art, frame_image)
check(np.array_equal(np.asarray(face), np.asarray(expected)), "Textless must contain only base art and frame")
overlay_alpha = np.zeros((2800, 2000), dtype=np.float32) if overlay is None else alpha_array(overlay).astype(np.float32) / 255.0
underlying = base_coverage * (1.0 - overlay_alpha)
if selected_foreground is not None:
resolved = underlying * (1.0 - fa) + adapted_coverage * fa
else:
resolved = underlying
if has_text[printing]:
resolved = resolved * (1.0 - ta)
finish_array = np.rint(np.clip(resolved, 0.0, 1.0) * 255).astype(np.uint8)
finish = Image.fromarray(finish_array, "L")
if selected_foreground is not None:
opaque_foreground = foreground_alpha == 255
expected_foreground = np.rint(adapted_coverage * 255).astype(np.uint8)
check(np.array_equal(finish_array[opaque_foreground], expected_foreground[opaque_foreground]), f"{printing} loses adapted foreground finish")
visible_overlay_only = (overlay_alpha == 1.0) & (foreground_alpha == 0 if selected_foreground is not None else True)
check(np.all(finish_array[visible_overlay_only] == 0), f"{printing} finish leaks onto visible opaque overlay")
text_mask = protected_text if has_text[printing] else Image.new("L", CANVAS, 0)
exports = []
for label, width in RESOLUTIONS:
dimensions = (width, width * 7 // 5)
folder = ROOT / label / printing
folder.mkdir(parents=True, exist_ok=True)
face_output = face if width == 2000 else face.resize(dimensions, Image.Resampling.LANCZOS)
face_output.save(folder / "card.png")
for filename, source in (("finish-mask.png", finish), ("text-mask.png", text_mask)):
output = source if width == 2000 else source.resize(dimensions, Image.Resampling.BILINEAR)
rgba = output.convert("RGBA")
rgba.putalpha(255)
rgba.save(folder / filename)
exports.append({
"resolution": label,
"dimensions": list(dimensions),
"files": {filename: digest(folder / filename) for filename in ("card.png", "finish-mask.png", "text-mask.png")},
})
editable = ROOT / "high" / printing / "card.svg"
def link(path: Path) -> str:
return f'<image width="2000" height="2800" xlink:href="{escape(os.path.relpath(path, editable.parent))}"/>'
body = link(art_path)
if overlay is not None:
overlay_path = layers / ("overlay-normal-2000.png" if printing == "normal" else "backing-borderless-2000.png" if printing == "borderless" else "frame-textless-2000.png")
body += link(overlay_path)
if selected_foreground is not None:
body += link(foreground_path)
if has_text[printing]:
body += text_nodes()
editable.write_text(head + body + "</svg>")
report["printings"][printing] = {
"overlay": None if overlay is None else ("normal-overlay" if printing == "normal" else "backing-only" if printing == "borderless" else "frame-only"),
"adaptedForeground": selected_foreground is not None,
"text": has_text[printing],
"finishCoverage": "base plus alpha-resolved adapted foreground" if selected_foreground is not None else "base illustration",
"exports": exports,
}
save_json(ROOT / "source/typography-layout.json", {
"canvas": list(CANVAS),
"policy": "Bespoke Legendary fixed coordinates; actual verse ink centered in reserved band; fixed reference line box.",
"titleAnchor": [1490, 244],
"verseAnchorX": 1320,
"verseBaselines": [2264, 2358, 2452, 2546],
"referenceBaseline": 2655,
"fontSizes": {"title": 138, "verse": 80, "reference": 57},
"boldTokens": ["I", "My", "My"],
"rows": [{**row, "parts": [{"text": text, "bold": bold} for text, bold in row["parts"]]} for row in rows],
"measured": {
"verseInkUnion": verse_union,
"upperFlourishToVerseInk": above,
"verseInkToReferenceLineBox": below,
"referenceLineBox": [reference_line_top, reference_line_bottom],
},
})
save_json(layers / "layout.json", {
"id": "BP-002-david-legendary-v1",
"canvas": list(CANVAS),
"titleBackingPath": title_path,
"verseBackingPath": verse_path,
"titleTextRegion": [1090, 58, 850, 307],
"verseTextRegion": [500, 2163, 1400, 530],
"compositionOrder": ["base-art", "active-overlay", "adapted-foreground-normal-borderless-only", "active-text"],
"occlusionGuard": {"radius": 2, "rgb": [29, 23, 18], "region": [450, 2060, 1500, 740]},
"printingComponents": {
"normal": ["base-art", "frame", "backing", "occlusion-guard", "adapted-foreground", "text"],
"borderless": ["base-art", "backing", "occlusion-guard", "adapted-foreground", "text"],
"textless": ["base-art", "frame"],
"boundless": ["base-art"],
},
})
# Review sheets.
label_font = ImageFont.truetype(str(title_font), 24)
sheet = Image.new("RGB", (1600, 610), "#11161c")
draw = ImageDraw.Draw(sheet)
for index, printing in enumerate(PRINTINGS):
draw.text((index * 400 + 14, 12), printing.title(), font=label_font, fill="#f4ead4")
image = Image.open(ROOT / "med" / printing / "card.png").convert("RGB")
image.thumbnail((370, 518), Image.Resampling.LANCZOS)
sheet.paste(image, (index * 400 + 14, 56))
sheet.save(review / "printings-comparison.png")
finish_sheet = Image.new("RGB", (1600, 610), "#11161c")
draw = ImageDraw.Draw(finish_sheet)
for index, printing in enumerate(PRINTINGS):
draw.text((index * 400 + 14, 12), printing.title(), font=label_font, fill="#f4ead4")
image = Image.open(ROOT / "med" / printing / "finish-mask.png").convert("RGB")
image.thumbnail((370, 518), Image.Resampling.BILINEAR)
finish_sheet.paste(image, (index * 400 + 14, 56))
finish_sheet.save(review / "finish-masks-comparison.png")
detail_sheet = Image.new("RGB", (1500, 760), "#11161c")
detail_draw = ImageDraw.Draw(detail_sheet)
detail_items = [
("Normal card", ROOT / "high/normal/card.png"),
("Foreground finish coverage", ROOT / "high/normal/finish-mask.png"),
("Lettering protection", ROOT / "high/normal/text-mask.png"),
]
for index, (label, path) in enumerate(detail_items):
detail_draw.text((index * 500 + 12, 12), label, font=label_font, fill="#f4ead4")
image = Image.open(path).convert("RGB").crop((250, 1900, 1250, 2800))
image.thumbnail((480, 690), Image.Resampling.LANCZOS)
detail_sheet.paste(image, (index * 500 + 10, 48))
detail_sheet.save(review / "foreground-finish-detail.png")
checker = Image.new("RGBA", CANVAS, (30, 34, 40, 255))
checker_arr = np.asarray(checker).copy()
tile = 100
for y in range(0, 2800, tile):
for x in range(0, 2000, tile):
if (x // tile + y // tile) % 2:
checker_arr[y:y + tile, x:x + tile, :3] = [56, 61, 69]
checker = Image.fromarray(checker_arr, "RGBA")
layer_items = [
("Base art", art),
("Frame", frame_image),
("Backings", backing_image),
("Adapted foreground", foreground),
("Text", text_image),
]
layer_sheet = Image.new("RGB", (1500, 500), "#11161c")
draw = ImageDraw.Draw(layer_sheet)
for index, (label, layer) in enumerate(layer_items):
draw.text((index * 300 + 10, 8), label, font=label_font, fill="#f4ead4")
composed = checker.copy()
composed.alpha_composite(layer)
composed.thumbnail((280, 392), Image.Resampling.LANCZOS)
layer_sheet.paste(composed.convert("RGB"), (index * 300 + 10, 50))
layer_sheet.save(review / "layers-comparison.png")
report["status"] = "passed"
save_json(review / "build-validation.json", report)
save_json(review / "static-review.json", {
"status": "passed-structural-user-visual-review-pending",
"cardId": card["cardId"],
"revision": manifest["revision"],
"checks": {
"fourPrintingComposition": "passed",
"layerRegistration": "passed",
"opaqueFaces": "passed",
"textOnBacking": "passed",
"foregroundTextIntersection": 0,
"foregroundFinishReplacement": "passed at all fully opaque foreground pixels",
"seamGuard": "passed structurally; selected review seam retained",
"lowMedHighExports": "passed",
},
"reviewAssets": ["printings-comparison.png", "layers-comparison.png", "finish-masks-comparison.png", "foreground-finish-detail.png"],
"staticVisualAssessment": "Pending user review of all four printings and finish masks.",
"movingLightReview": "Not performed.",
"finalCardApproval": None,
})
card.update({
"artApproval": report["artApproval"],
"artStatus": "Selected v03 illustration approved for assembly",
"sceneConstraints": ["No lineage or Jesus-related imagery."],
"layoutStatus": "Bespoke Legendary v1 production candidate assembled; four printings and low/med/high exports await user review.",
"template": "BP-002-david-legendary-v1",
"printingComponents": json.loads((layers / "layout.json").read_text())["printingComponents"],
"typographyCandidate": {
"verseFontSize": 80,
"verseBaselineGap": 94,
"referenceFontSize": 57,
"verseCenterGaps": [above, below],
"boldTokens": ["I", "My", "My"],
"emphasisTreatment": "P052 Bold 700 in standard teal #263f50; no special finish.",
},
"finishRecipe": "raw-ridges-v1",
"assemblyAuthorization": {
"by": "user",
"note": "Let's build out the other parts of the card (layers, prints, etc.)",
},
})
save_json(card_path, card)
manifest["stage"] = "assembly-review"
manifest["approval"] = None
save_json(manifest_path, manifest)
profile_path = ROOT.parents[1] / "card.json"
profile = json.loads(profile_path.read_text())
profile.update({"stage": "assembly-review", "selectedRevision": manifest["revision"], "approval": None})
save_json(profile_path, profile)
refresh(ROOT)
update_index()
print(f'{card["cardId"]} {manifest["revision"]}: bespoke Legendary layers and four printings exported at low/med/high; structural validation passed.')
if __name__ == "__main__":
main()