#!/usr/bin/env python """Audit finished Side Cards assets for structural and two-way consistency.""" import hashlib import json import math import re import tempfile from pathlib import Path from xml.etree import ElementTree import cv2 import numpy as np from PIL import Image from build_deck import ( ACE_PIP_SCALE, COURT_PIP_SCALE, INDEX_SUIT_SCALE, NUMBER_PIP_SCALE, RANK_BASELINE, RANK_FONT_PATH, rank_paths, ) from size_test import render_svg HERE = Path(__file__).resolve().parent ROOT = HERE.parent CARD_DIR = ROOT / "exports" / "cards" COURT_ART_DIR = ROOT / "exports" / "art" / "courts" RANKS = ("a", "2", "3", "4", "5", "6", "7", "8", "9", "10", "j", "q", "k") SUITS = ("s", "h", "d", "c") COURTS = ("j", "q", "k") SVG_NS = "http://www.w3.org/2000/svg" RANK_FONT_SHA256 = "e6f84f63a76e65bb43c4fe6445e59167a0298a30f172833e5d6e6e08adcf1c8b" def local_name(element): return element.tag.rsplit("}", 1)[-1] def by_id(root, wanted): return next((element for element in root.iter() if element.attrib.get("id") == wanted), None) def fail(message): raise SystemExit("AUDIT FAILED: " + message) def outline_signature(element): return ( element.attrib.get("class"), element.attrib.get("transform"), [(child.attrib.get("d"), child.attrib.get("transform")) for child in element if local_name(child) == "path"], ) def audit_club_geometry(root, path): """Require the traditional bilateral Club construction.""" club = by_id(root, "suit-C") if club is None: fail("missing Club definition in " + path.name) circles = [element for element in club if local_name(element) == "circle"] stems = [element for element in club if local_name(element) == "path"] if len(circles) != 4 or len(stems) != 1: fail("Club is not three lobes, a centre boss, and one stem in " + path.name) geometry = sorted((float(circle.attrib["cx"]), float(circle.attrib["cy"]), float(circle.attrib["r"])) for circle in circles) expected = sorted(((0.0, -15.0, 15.0), (-16.5, 6.0, 15.0), (16.5, 6.0, 15.0), (0.0, -2.0, 12.0))) if geometry != expected: fail("Club has drifted from its bilateral traditional geometry in " + path.name) if stems[0].attrib.get("d") != \ "M-7 8C-6 19-11 25-17 30H17C11 25 6 19 7 8Z": fail("Club stem is no longer short, centred, and flared in " + path.name) left, right = (-16.5, 6.0), (16.5, 6.0) lower_gap = math.dist(left, right) - 30.0 boss = (0.0, -2.0) if lower_gap <= 0 or any(math.dist(boss, point) >= 27.0 for point in (left, right)): fail("Club centre boss no longer bridges the lower lobes in " + path.name) def audit_card_corner_geometry(root, path): """Keep the SVG card edge circular and concentric at poker-card scale.""" clip = by_id(root, "card-clip") clip_rect = None if clip is None else next( (element for element in clip if local_name(element) == "rect"), None) if clip_rect is None or float(clip_rect.attrib.get("rx", 0)) != 24.0: fail("outer card clip is not radius 24 in " + path.name) outline = next((element for element in root.iter() if local_name(element) == "rect" and element.attrib.get("x") == "2" and element.attrib.get("y") == "2" and element.attrib.get("width") == "496" and element.attrib.get("height") == "696"), None) if outline is None or float(outline.attrib.get("rx", 0)) != 22.0: fail("inset card outline is not concentric radius 22 in " + path.name) def audit_svg_set(): expected = {rank + suit + ".svg" for suit in SUITS for rank in RANKS} expected.add("back.svg") actual = {path.name for path in CARD_DIR.glob("*.svg")} if actual != expected: fail("wrong SVG set; missing=%s extra=%s" % ( sorted(expected - actual), sorted(actual - expected))) for path in sorted(CARD_DIR.glob("*.svg")): try: root = ElementTree.parse(path).getroot() except ElementTree.ParseError as exc: fail("malformed %s: %s" % (path.name, exc)) audit_club_geometry(root, path) audit_card_corner_geometry(root, path) for element in root.iter(): if local_name(element) != "image": continue hrefs = [value for key, value in element.attrib.items() if key.rsplit("}", 1)[-1] == "href"] if not hrefs or any(not value.startswith("data:image/") for value in hrefs): fail("external or missing image reference in " + path.name) if path.stem[-1:] not in SUITS: continue rank = path.stem[:-1] top_index = by_id(root, "index-top") bottom_index = by_id(root, "index-bottom") if top_index is None or bottom_index is None: fail("missing index pair in " + path.name) if bottom_index.attrib.get("transform") != "rotate(180 250 350)": fail("bottom index is not an exact card-centre rotation in " + path.name) if any(local_name(element) == "text" for element in root.iter()): fail("font-dependent text remains in " + path.name) outline = next((element for element in top_index.iter() if "rank-outline" in element.attrib.get("class", "").split()), None) if outline is None: fail("vector rank outline missing in " + path.name) transform = outline.attrib.get("transform", "") baseline_match = re.match( r"translate\([\d.-]+ ([\d.-]+)\) scale\(", transform) if not baseline_match or abs(float(baseline_match.group(1)) - RANK_BASELINE) > 0.001: fail("rank is not aligned to the shared baseline in " + path.name) if "rank-q-weight" in outline.attrib.get("class", "").split(): fail("synthetic Q weight remains in " + path.name) expected_outline = ElementTree.fromstring(rank_paths(rank.upper())) if outline_signature(outline) != outline_signature(expected_outline): fail("rank outline does not match Roboto Slab source in " + path.name) index_suits = [element for element in top_index.iter() if local_name(element) == "use"] expected_index_transform = "translate(48 127) rotate(0) scale(%s)" % \ INDEX_SUIT_SCALE[path.stem[-1].upper()] if len(index_suits) != 1 or index_suits[0].attrib.get("transform") != \ expected_index_transform: fail("mini index suit size or position drifted in " + path.name) if rank in COURTS: audit_court_svg(path, root) else: pips = by_id(root, "pips") count = sum(local_name(element) == "use" for element in pips.iter()) expected_count = 1 if rank == "a" else int(rank) if count != expected_count: fail("%s has %d pips, expected %d" % ( path.name, count, expected_count)) scale = (ACE_PIP_SCALE if rank == "a" else NUMBER_PIP_SCALE)[ path.stem[-1].upper()] expected_scale = "scale(%s)" % scale if any(expected_scale not in element.attrib.get("transform", "") for element in pips.iter() if local_name(element) == "use"): fail("field-pip scale drifted in " + path.name) def audit_court_svg(path, root): frame = by_id(root, "court-frame") if frame is None: fail("missing court frame in " + path.name) rect = next((element for element in frame if local_name(element) == "rect"), None) if rect is None: fail("missing court frame rectangle in " + path.name) x = float(rect.attrib["x"]) y = float(rect.attrib["y"]) width = float(rect.attrib["width"]) height = float(rect.attrib["height"]) if 2 * x + width != 500 or 2 * y + height != 700: fail("off-centre court frame in " + path.name) children = list(root) frame_position = children.index(frame) image_position = next((i for i, child in enumerate(children) if any(local_name(element) == "image" for element in child.iter())), None) if image_position is None or frame_position >= image_position: fail("court figure is not layered in front of frame in " + path.name) image = next(element for element in root.iter() if local_name(element) == "image") transform = image.attrib.get("transform", "") match = re.fullmatch( r"translate\(250 350\) scale\(([\d.]+) ([\d.]+)\) " r"translate\(-250 -350\)", transform) if not match or any(not 0 < float(value) <= 1 for value in match.groups()): fail("court art is not scaled about the card centre in " + path.name) scale_x, scale_y = (float(value) for value in match.groups()) if abs(scale_x - scale_y) > 0.000001: fail("court art is non-uniformly scaled in " + path.name) marks = by_id(root, "court-suit-marks") transforms = [element.attrib.get("transform", "") for element in marks if local_name(element) == "use"] suit = path.stem[-1] top_x = 132 if suit in ("s", "c") else 137 bottom_x = 500 - top_x expected_scale = "scale(%s)" % COURT_PIP_SCALE[suit.upper()] if len(transforms) != 2 or "translate(%d 137)" % top_x not in transforms[0] \ or "translate(%d 563) rotate(180)" % bottom_x not in transforms[1] \ or expected_scale not in transforms[0] \ or expected_scale not in transforms[1]: fail("court suit marks are not a mirrored pair in " + path.name) def audit_court_art(): expected = {rank + suit + ".png" for suit in SUITS for rank in COURTS} actual = {path.name for path in COURT_ART_DIR.glob("*.png")} if actual != expected: fail("wrong processed court-art set") for path in sorted(COURT_ART_DIR.glob("*.png")): rgba = np.asarray(Image.open(path).convert("RGBA")) delta = np.abs(rgba.astype(np.int16) - np.rot90(rgba, 2).astype(np.int16)) if int(delta.max()) > 1: fail("court art is not rotationally exact: " + path.name) alpha = rgba[..., 3] count, _, stats, _ = cv2.connectedComponentsWithStats( (alpha > 0).astype("uint8"), 8) if count != 2: islands = sorted(int(value) for value in stats[1:, cv2.CC_STAT_AREA]) fail("detached court-art specks remain in %s: %s" % (path.name, islands)) edge = np.concatenate((alpha[0], alpha[-1], alpha[:, 0], alpha[:, -1])) if np.any(edge): fail("opaque perimeter residue in " + path.name) def audit_manifest_and_gallery(): manifest = json.loads((ROOT / "exports" / "manifest.json").read_text()) if manifest.get("count") != 52 or len(manifest.get("cards", [])) != 52: fail("manifest does not describe 52 card faces") gallery = (ROOT / "gallery.html").read_text() if "exports/cards/back.svg" not in gallery: fail("gallery does not include back.svg") if "border-radius: 4.8% / 3.4286%" not in gallery: fail("gallery radius does not match the circular SVG card edge") font_path = Path(RANK_FONT_PATH) if hashlib.sha256(font_path.read_bytes()).hexdigest() != RANK_FONT_SHA256: fail("Roboto Slab source font fingerprint changed") if not (ROOT / "assets" / "fonts" / "RobotoSlab-Apache-2.0.txt").exists(): fail("Roboto Slab Apache-2.0 license is missing") def isolated_layer_mask(path, layer): """Render one SVG layer against green and return its painted silhouette.""" tree = ElementTree.parse(path) root = tree.getroot() children = list(root) definitions = next(element for element in children if local_name(element) == "defs") if layer == "suit": target = next(element for element in children if element.attrib.get("id") == "court-suit-marks") elif layer == "index-suit": index = next(element for element in children if element.attrib.get("id") == "index-top") target = next(element for element in index.iter() if local_name(element) == "use") elif layer == "index-rank": index = next(element for element in children if element.attrib.get("id") == "index-top") target = next(element for element in index.iter() if "rank-outline" in element.attrib.get("class", "").split()) elif layer == "pips": target = next(element for element in children if element.attrib.get("id") == "pips") else: target = next(element for element in children if any(local_name(child) == "image" for child in element.iter())) for element in list(root): root.remove(element) root.append(definitions) root.append(ElementTree.Element("{%s}rect" % SVG_NS, { "width": "500", "height": "700", "fill": "#00ff00"})) root.append(target) with tempfile.NamedTemporaryFile(suffix=".svg", delete=False) as handle: temporary = Path(handle.name) try: tree.write(temporary, encoding="utf-8", xml_declaration=True) rendered = np.asarray(render_svg(str(temporary), 500)).astype("int16") finally: temporary.unlink(missing_ok=True) difference = np.max(np.abs(rendered - np.array([0, 255, 0])), axis=2) mask = (difference > 20).astype("uint8") count, labels, stats, _ = cv2.connectedComponentsWithStats(mask, 8) cleaned = np.zeros_like(mask, dtype=bool) for label in range(1, count): if stats[label, cv2.CC_STAT_AREA] >= 20: cleaned |= labels == label return cleaned def mask_metrics(mask): """Return painted area, width and height for one rendered silhouette.""" y, x = np.nonzero(mask) if not len(x): fail("empty rendered pip silhouette") return { "area": int(len(x)), "width": int(x.max() - x.min() + 1), "height": int(y.max() - y.min() + 1), } def audit_rendered_pips(): """Guard optical suit balance, not merely source transform values.""" field = {} aces = {} for suit in SUITS: field_mask = isolated_layer_mask( CARD_DIR / ("2" + suit + ".svg"), "pips") field_mask[350:] = False field[suit] = mask_metrics(field_mask) aces[suit] = mask_metrics(isolated_layer_mask( CARD_DIR / ("a" + suit + ".svg"), "pips")) heart_aspect = field["h"]["width"] / field["h"]["height"] heart_to_spade = field["h"]["area"] / field["s"]["area"] diamond_to_spade = field["d"]["area"] / field["s"]["area"] field_areas = [value["area"] for value in field.values()] field_spread = max(field_areas) / min(field_areas) ace_areas = [value["area"] for value in aces.values()] ace_spread = max(ace_areas) / min(ace_areas) if not 0.90 <= heart_aspect <= 1.05: fail("Heart field pip is not approximately as tall as it is wide") if not 0.95 <= heart_to_spade <= 1.05: fail("Heart field-pip ink is not balanced against Spade") if not 0.90 <= diamond_to_spade <= 1.05: fail("Diamond field-pip ink is not balanced against Spade") if field_spread > 1.12: fail("number-card field-pip ink varies by more than twelve percent") if ace_spread > 1.08: fail("Ace pip ink varies by more than eight percent") print("rendered pips: Heart %dx%d (area %.1f%% of Spade), " "Diamond area %.1f%% of Spade, field spread %.1f%%, " "Ace spread %.1f%%" % ( field["h"]["width"], field["h"]["height"], heart_to_spade * 100, diamond_to_spade * 100, (field_spread - 1) * 100, (ace_spread - 1) * 100)) def audit_rendered_indices(): """Check the mini suit marks that repeat in every card corner.""" metrics = {} for suit in SUITS: metrics[suit] = mask_metrics(isolated_layer_mask( CARD_DIR / ("a" + suit + ".svg"), "index-suit")) areas = [value["area"] for value in metrics.values()] widths = [value["width"] for value in metrics.values()] heights = [value["height"] for value in metrics.values()] ink_spread = max(areas) / min(areas) width_spread = max(widths) / min(widths) height_spread = max(heights) / min(heights) # Dense silhouettes such as Clubs should not be shrunk merely to equalise # ink. Apparent footprint is the readability constraint; Diamond remains # naturally taller while all four marks retain comparable width. if ink_spread > 1.25 or width_spread > 1.20 or height_spread > 1.30: fail("corner suit marks no longer have comparable apparent footprints") rank_heights = {} for rank in ("a", "10", "j", "q", "k"): mask = isolated_layer_mask( CARD_DIR / (rank + "s.svg"), "index-rank") mask[int(RANK_BASELINE):] = False rank_heights[rank] = mask_metrics(mask)["height"] cap_spread = max(rank_heights.values()) / min(rank_heights.values()) if cap_spread > 1.08: fail("representative corner-rank cap heights vary by more than eight percent") print("rendered corner suits: ink spread %.1f%%, width %d-%dpx, " "height %d-%dpx (S/H/D/C areas %d/%d/%d/%d); " "cap heights A/10/J/Q/K %d/%d/%d/%d/%d" % ( (ink_spread - 1) * 100, min(widths), max(widths), min(heights), max(heights), *(metrics[suit]["area"] for suit in SUITS), *(rank_heights[rank] for rank in ("a", "10", "j", "q", "k")))) def audit_rendered_layout(): """Check the visual relationships that source-coordinate checks miss.""" suit_masks = {} for suit in SUITS: mask = isolated_layer_mask(CARD_DIR / ("j" + suit + ".svg"), "suit") mask[350:] = False mask[:, 250:] = False suit_masks[suit] = mask court_suit_areas = [int(suit_masks[suit].sum()) for suit in SUITS] court_suit_spread = max(court_suit_areas) / min(court_suit_areas) if court_suit_spread > 1.08: fail("court suit-mark ink varies by more than eight percent") metrics = {} for suit in SUITS: for rank in COURTS: code = rank + suit figure = isolated_layer_mask(CARD_DIR / (code + ".svg"), "figure") upper_y, upper_x = np.nonzero(figure[:350]) if not len(upper_x): fail("empty rendered court figure: " + code) distance = cv2.distanceTransform( (~figure).astype("uint8"), cv2.DIST_L2, 5) clearance = max(0.0, float(distance[suit_masks[suit]].min()) - 1.0) metrics[code] = { "clearance": clearance, "width": int(upper_x.max() - upper_x.min() + 1), "area": int(len(upper_x)), } all_values = list(metrics.values()) areas = [value["area"] for value in all_values] widths = [value["width"] for value in all_values] if max(areas) / min(areas) > 1.06: fail("rendered court painted areas vary by more than six percent") width_ratio = max(widths) / min(widths) if width_ratio > 1.08: fail("rendered court widths vary by more than eight percent") if min(value["clearance"] for value in all_values) < 2.5: fail("a court figure crowds its suit emblem") king_clearances = sorted(metrics["k" + suit]["clearance"] for suit in SUITS) king_median = (king_clearances[1] + king_clearances[2]) / 2 if min(king_clearances) < 5 or king_median < 7: fail("King suit clearance is still inconsistent") print("rendered layout: court-suit spread %.1f%%, " "figure width %d-%dpx (%.1f%% spread), " "painted-area spread %.1f%%, " "King clearance %.1f-%.1fpx (median %.1fpx)" % ( (court_suit_spread - 1) * 100, min(widths), max(widths), (width_ratio - 1) * 100, (max(areas) / min(areas) - 1) * 100, min(king_clearances), max(king_clearances), king_median)) def main(): audit_svg_set() audit_court_art() audit_manifest_and_gallery() audit_rendered_pips() audit_rendered_indices() audit_rendered_layout() print("audit passed: 52 faces + back, centred two-way geometry, correct pips,") print("embedded assets, mirrored court art, clean perimeters, and gallery back") if __name__ == "__main__": main()