564 lines
23 KiB
Python
564 lines
23 KiB
Python
#!/usr/bin/env python
|
|
"""Assemble a usable custom deck from raster courts plus SVG card geometry.
|
|
|
|
Court illustrations remain PNGs. The SVG wrapper supplies dependable rank
|
|
indices and suit marks; number cards are fully deterministic SVG. This is the
|
|
practical game-asset pipeline—there is no hand tracing of generated figures.
|
|
|
|
Usage:
|
|
./.venv/bin/python build_deck.py
|
|
"""
|
|
|
|
import base64
|
|
from collections import deque
|
|
import json
|
|
import os
|
|
from functools import lru_cache
|
|
from xml.sax.saxutils import escape
|
|
from xml.etree import ElementTree
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from fontTools.pens.boundsPen import BoundsPen
|
|
from fontTools.pens.svgPathPen import SVGPathPen
|
|
from fontTools.ttLib import TTFont
|
|
from PIL import Image
|
|
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT = os.path.dirname(HERE)
|
|
EXPORTS = os.path.join(ROOT, "exports")
|
|
CARD_DIR = os.path.join(EXPORTS, "cards")
|
|
ART_DIR = os.path.join(EXPORTS, "art")
|
|
COURT_ART_DIR = os.path.join(ART_DIR, "courts")
|
|
RANK_FONT_PATH = os.path.join(ROOT, "assets", "fonts", "RobotoSlab-SemiBold.ttf")
|
|
|
|
RANKS = ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")
|
|
SUITS = ("S", "H", "D", "C")
|
|
COURTS = ("J", "Q", "K")
|
|
|
|
CARD_WIDTH = 500
|
|
CARD_HEIGHT = 700
|
|
COURT_FRAME_X = 86
|
|
COURT_FRAME_Y = 76
|
|
# Derive the far edges from the approved top/left inset. Hard-coding the old
|
|
# width and height shifted the frame's centre and made otherwise mirrored
|
|
# indices and suit marks look uneven at the lower end of every court card.
|
|
COURT_FRAME_WIDTH = CARD_WIDTH - 2 * COURT_FRAME_X
|
|
COURT_FRAME_HEIGHT = CARD_HEIGHT - 2 * COURT_FRAME_Y
|
|
COURT_SUIT_TOP = {
|
|
# Club and Spade silhouettes need a small optical shift away from the
|
|
# figure. Their left edges remain inside the frame at this position.
|
|
"S": (132, 137),
|
|
"H": (137, 137),
|
|
"D": (137, 137),
|
|
"C": (132, 137),
|
|
}
|
|
COURT_SUIT_BOTTOM = {
|
|
suit: (CARD_WIDTH - point[0], CARD_HEIGHT - point[1])
|
|
for suit, point in COURT_SUIT_TOP.items()
|
|
}
|
|
|
|
# Uniform, centred scales preserve the original figure proportions. Measured
|
|
# painted area—not bounding-box width—is the comparison metric: after these
|
|
# small reductions all twelve courts sit within roughly five percent.
|
|
COURT_ART_SCALE = {
|
|
"KC": 0.95,
|
|
"KD": 0.95,
|
|
"KH": 0.97,
|
|
"KS": 0.97,
|
|
}
|
|
|
|
RANK_BASELINE = 85.0
|
|
RANK_TARGET_HEIGHT = 64.0
|
|
RANK_MAX_WIDTH = 63.0
|
|
TEN_MAX_WIDTH = 76.0
|
|
INDEX_SUIT_X = 48
|
|
INDEX_SUIT_Y = 127
|
|
# Suit silhouettes have very different native widths and painted areas. These
|
|
# role-specific optical scales make them read as peers without distorting any
|
|
# individual mark. In particular, the fuller heart needs less geometric scale
|
|
# while the narrow diamond needs a little more.
|
|
INDEX_SUIT_SCALE = {"S": 0.78, "H": 0.71, "D": 0.92, "C": 0.78}
|
|
NUMBER_PIP_SCALE = {"S": 1.50, "H": 1.28, "D": 1.75, "C": 1.43}
|
|
ACE_PIP_SCALE = {"S": 3.20, "H": 2.75, "D": 3.72, "C": 2.92}
|
|
COURT_PIP_SCALE = {"S": 1.45, "H": 1.24, "D": 1.71, "C": 1.31}
|
|
|
|
HEART_COURTS = {
|
|
"JH": os.path.join(ROOT, "concepts", "dt-court-jh-v3.png"),
|
|
"QH": os.path.join(ROOT, "concepts", "dt-court-qh-v3.png"),
|
|
"KH": os.path.join(ROOT, "concepts", "dt-court-kh-v3.png"),
|
|
}
|
|
|
|
SUIT_NAME = {"S": "spades", "H": "hearts", "D": "diamonds", "C": "clubs"}
|
|
SUIT_COLOR = {"S": "black", "H": "red", "D": "red", "C": "black"}
|
|
|
|
|
|
def ensure_dirs():
|
|
for path in (CARD_DIR, ART_DIR, COURT_ART_DIR):
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
def validate_geometry():
|
|
"""Fail the build if two-way court geometry stops being centred."""
|
|
checks = {
|
|
"court frame horizontal centre":
|
|
2 * COURT_FRAME_X + COURT_FRAME_WIDTH == CARD_WIDTH,
|
|
"court frame vertical centre":
|
|
2 * COURT_FRAME_Y + COURT_FRAME_HEIGHT == CARD_HEIGHT,
|
|
}
|
|
for suit in SUITS:
|
|
checks["%s suit horizontal mirror" % suit] = (
|
|
COURT_SUIT_TOP[suit][0] + COURT_SUIT_BOTTOM[suit][0] == CARD_WIDTH)
|
|
checks["%s suit vertical mirror" % suit] = (
|
|
COURT_SUIT_TOP[suit][1] + COURT_SUIT_BOTTOM[suit][1] == CARD_HEIGHT)
|
|
failed = [name for name, passed in checks.items() if not passed]
|
|
if failed:
|
|
raise ValueError("asymmetric card geometry: " + ", ".join(failed))
|
|
|
|
|
|
def court_source(code):
|
|
if code in HEART_COURTS:
|
|
return HEART_COURTS[code]
|
|
return os.path.join(ROOT, "art", "courts", "raw", code.lower() + ".png")
|
|
|
|
|
|
def prepare_court_art(code):
|
|
"""Extract one clean upper court half for exact SVG mirroring.
|
|
|
|
Draw Things often stops or restarts the rectangular frame for no visual
|
|
reason. Long neutral runs in the known frame bands are removed and repaired
|
|
from the figure-facing side where they crossed clothing. Edge-connected
|
|
card stock is then made transparent. Only the upper half is retained; the
|
|
SVG rotates that exact artwork for the bottom half.
|
|
"""
|
|
source = court_source(code)
|
|
if not os.path.exists(source):
|
|
raise FileNotFoundError("missing court render for %s: %s" % (code, source))
|
|
image = Image.open(source).convert("RGB")
|
|
pixels = np.asarray(image).copy()
|
|
|
|
# The approved Heart sources contain generated red pips. Remove the upper
|
|
# pip now; the lower source half is discarded, and SVG supplies both exact
|
|
# suit marks later. Flooding a connected red component avoids a rectangular
|
|
# knockout and preserves nearby crown/garment details.
|
|
if code.endswith("H"):
|
|
r, g, b = pixels[..., 0], pixels[..., 1], pixels[..., 2]
|
|
red = (r > 85) & (r > g * 1.05) & (r > b * 1.05)
|
|
component = np.zeros(red.shape, dtype=bool)
|
|
queue = deque([(177, 155)])
|
|
while queue:
|
|
x, y = queue.popleft()
|
|
if not (0 <= x < 640 and 0 <= y < 896):
|
|
continue
|
|
if component[y, x] or not red[y, x]:
|
|
continue
|
|
component[y, x] = True
|
|
queue.extend(((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)))
|
|
# Two-pixel dilation catches the anti-aliased red edge.
|
|
expanded = component.copy()
|
|
for dy in range(-2, 3):
|
|
for dx in range(-2, 3):
|
|
expanded[max(0, dy):896 + min(0, dy), max(0, dx):640 + min(0, dx)] |= \
|
|
component[max(0, -dy):896 - max(0, dy), max(0, -dx):640 - max(0, dx)]
|
|
pixels[expanded] = (252, 252, 250)
|
|
|
|
dark = (pixels.max(axis=2) - pixels.min(axis=2) < 34) & (pixels.mean(axis=2) < 125)
|
|
|
|
left = np.zeros(dark.shape, dtype=bool)
|
|
right = np.zeros(dark.shape, dtype=bool)
|
|
top = np.zeros(dark.shape, dtype=bool)
|
|
bottom = np.zeros(dark.shape, dtype=bool)
|
|
|
|
def mark_long_runs(target, view, offset_y, offset_x, vertical, minimum=14):
|
|
"""Mark straight dark runs while ignoring curved figure keylines."""
|
|
major = view.shape[1] if vertical else view.shape[0]
|
|
minor = view.shape[0] if vertical else view.shape[1]
|
|
for fixed in range(major):
|
|
line = view[:, fixed] if vertical else view[fixed, :]
|
|
start = None
|
|
for pos in range(minor + 1):
|
|
on = pos < minor and bool(line[pos])
|
|
if on and start is None:
|
|
start = pos
|
|
elif not on and start is not None:
|
|
if pos - start >= minimum:
|
|
if vertical:
|
|
y0, y1 = offset_y + start, offset_y + pos
|
|
x0 = offset_x + fixed
|
|
target[y0:y1, max(0, x0 - 2):x0 + 3] = True
|
|
else:
|
|
y0 = offset_y + fixed
|
|
x0, x1 = offset_x + start, offset_x + pos
|
|
target[max(0, y0 - 2):y0 + 3, x0:x1] = True
|
|
start = None
|
|
|
|
# Raw 640x896 coordinates. The bands are narrow enough to exclude props
|
|
# while accommodating the small frame drift across generated cards.
|
|
mark_long_runs(left, dark[70:835, 96:126], 70, 96, vertical=True)
|
|
mark_long_runs(right, dark[70:835, 524:554], 70, 524, vertical=True)
|
|
mark_long_runs(top, dark[82:116, 90:555], 82, 90, vertical=False)
|
|
mark_long_runs(bottom, dark[802:838, 90:555], 802, 90, vertical=False)
|
|
|
|
# Reconstruct what lies beneath the generated frame. Inpainting is
|
|
# deliberately limited to the detected straight-line mask: on blank stock
|
|
# it restores stock; over a robe, sword, or hand it extends the surrounding
|
|
# artwork through the narrow band. A one-pixel dilation also removes the
|
|
# stroke's anti-aliased fringe without disturbing nearby figure keylines.
|
|
frame_mask = (left | right | top | bottom).astype("uint8")
|
|
frame_mask = cv2.dilate(frame_mask, np.ones((3, 3), dtype="uint8"), iterations=1)
|
|
pixels = cv2.inpaint(pixels, frame_mask * 255, 3, cv2.INPAINT_TELEA)
|
|
|
|
# Flood edge-connected neutral stock. White garment areas are enclosed by
|
|
# keylines, so they are intentionally not reached by this flood.
|
|
neutral = (pixels.max(axis=2) - pixels.min(axis=2) < 42) & (pixels.min(axis=2) > 90)
|
|
background = np.zeros(neutral.shape, dtype=bool)
|
|
queue = deque()
|
|
height, width = neutral.shape
|
|
for x in range(width):
|
|
queue.append((x, 0)); queue.append((x, height - 1))
|
|
for y in range(height):
|
|
queue.append((0, y)); queue.append((width - 1, y))
|
|
while queue:
|
|
x, y = queue.popleft()
|
|
if not (0 <= x < width and 0 <= y < height):
|
|
continue
|
|
if background[y, x] or not neutral[y, x]:
|
|
continue
|
|
background[y, x] = True
|
|
queue.extend(((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)))
|
|
|
|
# Eliminate any residual outer-card shadow/perimeter.
|
|
background[:24, :] = True
|
|
background[-24:, :] = True
|
|
background[:, :24] = True
|
|
background[:, -24:] = True
|
|
alpha = np.where(background, 0, 255).astype("uint8")
|
|
|
|
# Build an exact two-way figure from the approved upper character. A
|
|
# per-card dynamic-programming seam follows areas where the upright art and
|
|
# its 180° rotation already agree (usually a sash or garment fold), then a
|
|
# narrow crossfade removes the hard splice. The seam is constrained to be
|
|
# rotationally symmetric, so the finished court is exact in both directions.
|
|
upright = np.dstack((pixels, alpha))
|
|
rotated = np.rot90(upright, 2)
|
|
y_start, y_stop = 330, 566
|
|
upright_rgb = upright[y_start:y_stop, :, :3].astype("int16")
|
|
rotated_rgb = rotated[y_start:y_stop, :, :3].astype("int16")
|
|
upright_alpha = upright[y_start:y_stop, :, 3] > 0
|
|
rotated_alpha = rotated[y_start:y_stop, :, 3] > 0
|
|
cost = np.abs(upright_rgb - rotated_rgb).mean(axis=2).astype("float64")
|
|
cost += (upright_alpha != rotated_alpha) * 600
|
|
cost[(~upright_alpha) & (~rotated_alpha)] = 0
|
|
|
|
half = 320
|
|
choices = y_stop - y_start
|
|
paired_cost = np.empty((half, choices), dtype="float64")
|
|
for x in range(half):
|
|
paired_cost[x] = cost[:, x] + cost[::-1, 639 - x]
|
|
|
|
infinity = 1e18
|
|
dp = np.full_like(paired_cost, infinity)
|
|
previous = np.full(paired_cost.shape, -1, dtype="int16")
|
|
centre = 447 - y_start
|
|
dp[0] = paired_cost[0] + np.abs(np.arange(choices) - centre) * 0.5
|
|
for x in range(1, half):
|
|
for candidate in range(choices):
|
|
lo, hi = max(0, candidate - 4), min(choices, candidate + 5)
|
|
prior = dp[x - 1, lo:hi] + np.abs(np.arange(lo, hi) - candidate) * 2.5
|
|
best = lo + int(prior.argmin())
|
|
dp[x, candidate] = paired_cost[x, candidate] + prior[best - lo]
|
|
previous[x, candidate] = best
|
|
|
|
candidate = int(np.argmin(dp[-1] + np.abs(np.arange(choices) - centre) * 20))
|
|
seam = np.empty(640, dtype="int16")
|
|
for x in range(half - 1, -1, -1):
|
|
seam[x] = y_start + candidate
|
|
if x:
|
|
candidate = int(previous[x, candidate])
|
|
for x in range(half):
|
|
seam[639 - x] = 895 - seam[x]
|
|
|
|
yy = np.arange(896, dtype="float32")[:, None]
|
|
blend = 10.0
|
|
upright_weight = np.clip((blend - (yy - seam[None, :])) / (2 * blend), 0, 1)
|
|
rotated_weight = 1.0 - upright_weight
|
|
ua = upright[..., 3].astype("float32") / 255.0
|
|
ra = rotated[..., 3].astype("float32") / 255.0
|
|
out_alpha = upright_weight * ua + rotated_weight * ra
|
|
premultiplied = (
|
|
upright_weight[..., None] * upright[..., :3].astype("float32") * ua[..., None]
|
|
+ rotated_weight[..., None] * rotated[..., :3].astype("float32") * ra[..., None]
|
|
)
|
|
out_rgb = np.zeros_like(premultiplied, dtype="uint8")
|
|
visible = out_alpha > 0.001
|
|
out_rgb[visible] = np.clip(
|
|
premultiplied[visible] / out_alpha[visible, None], 0, 255
|
|
).astype("uint8")
|
|
rgba = np.dstack((out_rgb, np.clip(out_alpha * 255, 0, 255).astype("uint8")))
|
|
|
|
# Frame removal and background flooding can leave a handful of detached
|
|
# opaque pixels behind. Every approved figure is one connected painted
|
|
# component; reject any unexpectedly substantial detached artwork, then
|
|
# discard the tiny islands so they cannot appear as dust in final exports.
|
|
count, labels, stats, _ = cv2.connectedComponentsWithStats(
|
|
(rgba[..., 3] > 0).astype("uint8"), 8)
|
|
if count <= 1:
|
|
raise ValueError("court extraction produced no figure for " + code)
|
|
largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
|
|
detached_areas = [int(stats[label, cv2.CC_STAT_AREA])
|
|
for label in range(1, count) if label != largest]
|
|
if detached_areas and max(detached_areas) > 100:
|
|
raise ValueError("court extraction detached substantial artwork for %s: %s"
|
|
% (code, detached_areas))
|
|
rgba[labels != largest] = 0
|
|
|
|
output = os.path.join(COURT_ART_DIR, code.lower() + ".png")
|
|
Image.fromarray(rgba).save(output, optimize=True)
|
|
return output
|
|
|
|
|
|
def data_uri(path, mime="image/png"):
|
|
with open(path, "rb") as f:
|
|
encoded = base64.b64encode(f.read()).decode("ascii")
|
|
return "data:%s;base64,%s" % (mime, encoded)
|
|
|
|
|
|
def court_art_scale(code, art_path):
|
|
"""Return a uniform centred display scale for one court illustration."""
|
|
scale = COURT_ART_SCALE.get(code, 1.0)
|
|
return scale, scale
|
|
|
|
|
|
def svg_defs():
|
|
return r'''
|
|
<defs>
|
|
<clipPath id="card-clip"><rect width="500" height="700" rx="24"/></clipPath>
|
|
<g id="suit-H"><path d="M0 36C-7 27-32 10-32-9C-32-27-11-38 0-19C11-38 32-27 32-9C32 10 7 27 0 36Z"/></g>
|
|
<g id="suit-D"><path d="M0-32L23 0L0 32L-23 0Z"/></g>
|
|
<g id="suit-C">
|
|
<circle cx="0" cy="-15" r="15"/>
|
|
<circle cx="-16.5" cy="6" r="15"/>
|
|
<circle cx="16.5" cy="6" r="15"/>
|
|
<circle cx="0" cy="-2" r="12"/>
|
|
<path d="M-7 8C-6 19-11 25-17 30H17C11 25 6 19 7 8Z"/>
|
|
</g>
|
|
<g id="suit-S">
|
|
<path d="M0-32C-8-18-31-7-27 10C-24 23-9 22-2 13C-2 24-8 29-14 34H14C8 29 2 24 2 13C9 22 24 23 27 10C31-7 8-18 0-32Z"/>
|
|
</g>
|
|
</defs>'''
|
|
|
|
|
|
def svg_start(title):
|
|
return '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
viewBox="0 0 500 700" width="500" height="700" role="img">
|
|
<title>%s</title>
|
|
<style>
|
|
.stock{fill:#fcfcfa}.black{fill:#111;color:#111}.red{fill:#b51620;color:#b51620}
|
|
</style>
|
|
%s
|
|
''' % (escape(title), svg_defs())
|
|
|
|
|
|
def suit_use(suit, x, y, scale=1.0, rotate=0):
|
|
color = SUIT_COLOR[suit]
|
|
return ('<use href="#suit-%s" xlink:href="#suit-%s" class="%s" '
|
|
'transform="translate(%s %s) rotate(%s) scale(%s)"/>') % (
|
|
suit, suit, color, x, y, rotate, scale)
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def rank_paths(rank):
|
|
"""Draw one rank as self-contained Roboto Slab vector outlines."""
|
|
font = TTFont(RANK_FONT_PATH)
|
|
glyph_set = font.getGlyphSet()
|
|
cmap = font.getBestCmap()
|
|
metrics = font["hmtx"].metrics
|
|
cursor = 0
|
|
pieces = []
|
|
bounds = []
|
|
for position, character in enumerate(rank):
|
|
glyph_name = cmap[ord(character)]
|
|
glyph = glyph_set[glyph_name]
|
|
path_pen = SVGPathPen(glyph_set)
|
|
glyph.draw(path_pen)
|
|
bounds_pen = BoundsPen(glyph_set)
|
|
glyph.draw(bounds_pen)
|
|
if bounds_pen.bounds is None:
|
|
continue
|
|
x_min, y_min, x_max, y_max = bounds_pen.bounds
|
|
bounds.append((cursor + x_min, y_min, cursor + x_max, y_max))
|
|
pieces.append('<path d="%s" transform="translate(%s 0)"/>' % (
|
|
path_pen.getCommands(), cursor))
|
|
cursor += metrics[glyph_name][0]
|
|
if position < len(rank) - 1:
|
|
cursor -= 35
|
|
if not bounds:
|
|
raise ValueError("rank has no drawable glyphs: " + rank)
|
|
x_min = min(item[0] for item in bounds)
|
|
y_min = min(item[1] for item in bounds)
|
|
x_max = max(item[2] for item in bounds)
|
|
y_max = max(item[3] for item in bounds)
|
|
target_height = RANK_TARGET_HEIGHT
|
|
max_width = TEN_MAX_WIDTH if rank == "10" else RANK_MAX_WIDTH
|
|
# Size against the cap height above the font baseline. Descenders (most
|
|
# visibly Q's tail) must not make an otherwise matching capital smaller.
|
|
scale = min(target_height / y_max, max_width / (x_max - x_min))
|
|
translate_x = 48 - (x_min + x_max) * scale / 2
|
|
translate_y = RANK_BASELINE
|
|
return ('<g class="rank-outline" transform="translate(%.4f %.4f) '
|
|
'scale(%.6f -%.6f)">%s</g>') % (
|
|
translate_x, translate_y, scale, scale, ''.join(pieces))
|
|
|
|
|
|
def indices(rank, suit):
|
|
# Roboto Slab SemiBold outlines keep exports independent of installed fonts
|
|
# while providing large, clear slab-serif indices.
|
|
top = '''
|
|
<g id="index-top" class="%s">
|
|
%s
|
|
%s
|
|
</g>''' % (
|
|
SUIT_COLOR[suit], rank_paths(rank),
|
|
suit_use(suit, INDEX_SUIT_X, INDEX_SUIT_Y, INDEX_SUIT_SCALE[suit])
|
|
)
|
|
bottom = '''
|
|
<g id="index-bottom" transform="rotate(180 250 350)">%s</g>''' % top.replace(
|
|
'id="index-top"', 'id="index-mirrored"'
|
|
)
|
|
return top + bottom
|
|
|
|
|
|
def write_svg(code, body):
|
|
path = os.path.join(CARD_DIR, code.lower() + ".svg")
|
|
title = "%s of %s" % (code[0:-1], SUIT_NAME[code[-1]])
|
|
text = svg_start(title)
|
|
text += ' <rect class="stock" width="500" height="700" rx="24"/>\n'
|
|
text += body
|
|
text += indices(code[:-1], code[-1])
|
|
text += '\n <rect x="2" y="2" width="496" height="696" rx="22" fill="none" stroke="#b7b2aa" stroke-width="4"/>\n</svg>\n'
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
return path
|
|
|
|
|
|
def court_svg(code, art_path):
|
|
uri = data_uri(art_path)
|
|
suit = code[-1]
|
|
scale_x, scale_y = court_art_scale(code, art_path)
|
|
body = '''
|
|
<g id="court-frame" fill="none" stroke="#111" stroke-width="3"
|
|
stroke-linejoin="round">
|
|
<rect x="%d" y="%d" width="%d" height="%d" rx="4"/>
|
|
</g>
|
|
<g clip-path="url(#card-clip)">
|
|
<image href="%s" xlink:href="%s" x="0" y="0" width="500" height="700"
|
|
preserveAspectRatio="none"
|
|
transform="translate(250 350) scale(%.6f %.6f) translate(-250 -350)"/>
|
|
</g>
|
|
<g id="court-suit-marks">
|
|
%s
|
|
%s
|
|
</g>
|
|
''' % (
|
|
COURT_FRAME_X,
|
|
COURT_FRAME_Y,
|
|
COURT_FRAME_WIDTH,
|
|
COURT_FRAME_HEIGHT,
|
|
uri,
|
|
uri,
|
|
scale_x,
|
|
scale_y,
|
|
suit_use(suit, COURT_SUIT_TOP[suit][0], COURT_SUIT_TOP[suit][1],
|
|
COURT_PIP_SCALE[suit]),
|
|
suit_use(suit, COURT_SUIT_BOTTOM[suit][0], COURT_SUIT_BOTTOM[suit][1],
|
|
COURT_PIP_SCALE[suit], 180),
|
|
)
|
|
return write_svg(code, body)
|
|
|
|
|
|
# Standard, readable pip layouts in 500x700 card coordinates. A third value
|
|
# of True turns the pip upside down for the lower half of the card.
|
|
PIP_LAYOUTS = {
|
|
"2": ((250, 175, False), (250, 525, True)),
|
|
"3": ((250, 155, False), (250, 350, False), (250, 545, True)),
|
|
"4": ((165, 175, False), (335, 175, False), (165, 525, True), (335, 525, True)),
|
|
"5": ((165, 165, False), (335, 165, False), (250, 350, False), (165, 535, True), (335, 535, True)),
|
|
"6": ((165, 145, False), (335, 145, False), (165, 350, False), (335, 350, False), (165, 555, True), (335, 555, True)),
|
|
"7": ((165, 135, False), (335, 135, False), (250, 245, False), (165, 350, False), (335, 350, False), (165, 565, True), (335, 565, True)),
|
|
"8": ((165, 125, False), (335, 125, False), (250, 235, False), (165, 350, False), (335, 350, False), (250, 465, True), (165, 575, True), (335, 575, True)),
|
|
"9": ((165, 115, False), (335, 115, False), (165, 275, False), (335, 275, False), (250, 350, False), (165, 425, True), (335, 425, True), (165, 585, True), (335, 585, True)),
|
|
"10": ((165, 105, False), (335, 105, False), (250, 200, False), (165, 270, False), (335, 270, False), (165, 430, True), (335, 430, True), (250, 500, True), (165, 595, True), (335, 595, True)),
|
|
}
|
|
|
|
|
|
def number_svg(code):
|
|
rank, suit = code[:-1], code[-1]
|
|
if rank == "A":
|
|
body = '<g id="pips">%s</g>\n' % suit_use(
|
|
suit, 250, 350, ACE_PIP_SCALE[suit])
|
|
else:
|
|
pips = []
|
|
for x, y, inverted in PIP_LAYOUTS[rank]:
|
|
pips.append(suit_use(
|
|
suit, x, y, NUMBER_PIP_SCALE[suit], 180 if inverted else 0))
|
|
body = '<g id="pips">\n %s\n </g>\n' % '\n '.join(pips)
|
|
return write_svg(code, body)
|
|
|
|
|
|
def build_back():
|
|
source = os.path.join(ROOT, "concepts", "calibration-v1.png")
|
|
# Crop to the card itself. The sheet's two rows are not the same height --
|
|
# the top row of cards is 460px tall but the bottom row (which holds the
|
|
# backs) is only 431px. Using the top row's height here ran 28px past the
|
|
# card and baked a band of sheet background into the bottom of every back,
|
|
# which then read as a shadow under the border stroke. Bounds below are
|
|
# measured from the sheet's background gutters.
|
|
image = Image.open(source).convert("RGB").crop((780, 524, 1109, 955))
|
|
art_path = os.path.join(ART_DIR, "back.png")
|
|
image.save(art_path, optimize=True)
|
|
uri = data_uri(art_path)
|
|
path = os.path.join(CARD_DIR, "back.svg")
|
|
text = svg_start("Card back")
|
|
text += (' <g clip-path="url(#card-clip)"><image href="%s" xlink:href="%s" '
|
|
'width="500" height="700" preserveAspectRatio="none"/></g>\n' % (uri, uri))
|
|
text += ' <rect x="2" y="2" width="496" height="696" rx="22" fill="none" stroke="#b7b2aa" stroke-width="4"/>\n</svg>\n'
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
f.write(text)
|
|
return path
|
|
|
|
|
|
def main():
|
|
validate_geometry()
|
|
ensure_dirs()
|
|
generated = []
|
|
for suit in SUITS:
|
|
for rank in RANKS:
|
|
code = rank + suit
|
|
if rank in COURTS:
|
|
art = prepare_court_art(code)
|
|
generated.append(court_svg(code, art))
|
|
else:
|
|
generated.append(number_svg(code))
|
|
generated.append(build_back())
|
|
|
|
manifest = {
|
|
"cards": [os.path.basename(path) for path in generated if os.path.basename(path) != "back.svg"],
|
|
"back": "back.svg",
|
|
"format": "standalone SVG; court SVGs contain embedded PNG artwork",
|
|
"count": 52,
|
|
}
|
|
with open(os.path.join(EXPORTS, "manifest.json"), "w", encoding="utf-8") as f:
|
|
json.dump(manifest, f, indent=2)
|
|
|
|
# Fail immediately on malformed exports rather than discovering a broken
|
|
# card in the browser later.
|
|
for path in generated:
|
|
ElementTree.parse(path)
|
|
print("built %d cards and one back in %s" % (52, os.path.relpath(CARD_DIR, ROOT)))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|