644 lines
35 KiB
Python
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()
|