Something went wrong. Try again.
Spot it
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353#!/usr/bin/env python3"""Dobble / Spot It card generator.
Generates a set of Dobble cards from a directory of images and outputs aprintable PDF. The mathematical foundation is based on finite projectiveplanes of prime order.
Usage: dobble <images-dir> [-o output.pdf] [-n ORDER] [--card-diameter MM] [--cards-per-page N] [--page-size A4|LETTER]"""
import argparseimport mathimport randomimport sysfrom pathlib import Path
from reportlab.lib.pagesizes import A4, LETTERfrom reportlab.lib.units import mmfrom reportlab.pdfgen import canvas
# ---------------------------------------------------------------------------# Projective-plane card generation# ---------------------------------------------------------------------------
def is_prime(n: int) -> bool: if n < 2: return False if n < 4: return True if n % 2 == 0 or n % 3 == 0: return False i = 5 while i * i <= n: if n % i == 0 or n % (i + 2) == 0: return False i += 6 return True
def generate_cards(order: int) -> list[list[int]]: """ Generate a full set of Dobble cards for a projective plane of prime *order*. Returns order^2 + order + 1 cards, each with order + 1 symbols. Every pair of cards shares exactly one symbol. """ if not is_prime(order): raise ValueError(f"Order must be a prime number, got {order}")
n = order cards: list[list[int]] = []
# First card: symbols 0 .. n cards.append(list(range(n + 1)))
# Next n cards for i in range(n): cards.append([0] + [n + 1 + n * i + j for j in range(n)])
# Remaining n*n cards for i in range(n): for j in range(n): card = [i + 1] for k in range(n): card.append(n + 1 + n * k + (i * k + j) % n) cards.append(card)
return cards
# ---------------------------------------------------------------------------# Card layout helpers# ---------------------------------------------------------------------------
def _ring_split(remaining: int) -> tuple[int, int]: """Decide how many symbols go on the inner vs outer ring.""" if remaining <= 4: return remaining, 0 inner = round(remaining / 3) inner = max(2, inner) return inner, remaining - inner
def _place_positions(count: int, card_cx: float, card_cy: float, card_radius: float, rng: random.Random, ) -> list[tuple[float, float, float]]: """ Return raw (x, y, angle_from_centre) positions for *count* symbols. One centre + one or two rings with angular offsets so that inner and outer ring symbols never sit on the same radial line. """ positions: list[tuple[float, float, float]] = []
# Centre symbol (angle 0, doesn't matter) positions.append((card_cx, card_cy, 0.0))
remaining = count - 1 if remaining == 0: return positions
inner_n, outer_n = _ring_split(remaining)
angle_offset = rng.uniform(0, 2 * math.pi)
if outer_n == 0: # Single ring ring_r = card_radius * 0.55 for i in range(inner_n): angle = 2 * math.pi * i / inner_n + angle_offset sx = card_cx + ring_r * math.cos(angle) sy = card_cy + ring_r * math.sin(angle) positions.append((sx, sy, angle)) else: # Two rings — outer is offset by half a step so symbols interleave inner_r = card_radius * 0.35 outer_r = card_radius * 0.68
for i in range(inner_n): angle = 2 * math.pi * i / inner_n + angle_offset sx = card_cx + inner_r * math.cos(angle) sy = card_cy + inner_r * math.sin(angle) positions.append((sx, sy, angle))
# Offset outer ring by half an outer step outer_offset = angle_offset + math.pi / outer_n for i in range(outer_n): angle = 2 * math.pi * i / outer_n + outer_offset sx = card_cx + outer_r * math.cos(angle) sy = card_cy + outer_r * math.sin(angle) positions.append((sx, sy, angle))
return positions
def _max_symbol_size(idx: int, positions: list[tuple[float, float, float]], card_cx: float, card_cy: float, card_radius: float) -> float: """Compute the max half-size for symbol *idx* so it doesn't overlap.""" x, y, _ = positions[idx] # Distance to card edge dist_to_edge = card_radius - math.hypot(x - card_cx, y - card_cy) min_dist = dist_to_edge
# Half the distance to every other symbol for j, (ox, oy, _) in enumerate(positions): if j == idx: continue d = math.hypot(x - ox, y - oy) / 2 min_dist = min(min_dist, d)
return min_dist
def symbol_positions(count: int, card_cx: float, card_cy: float, card_radius: float, rng: random.Random, ) -> list[tuple[float, float, float, float]]: """ Return (cx, cy, half_size, rotation_degrees) for each symbol on a card. Layout: 1 centre + 1 or 2 rings (interleaved to avoid radial alignment). Sizes are capped so symbols never overlap, then randomized within that cap. """ raw = _place_positions(count, card_cx, card_cy, card_radius, rng)
# Compute max allowed size per symbol, apply padding padding = 0.85 max_sizes = [ _max_symbol_size(i, raw, card_cx, card_cy, card_radius) * padding for i in range(len(raw)) ]
results: list[tuple[float, float, float, float]] = [] for i, (sx, sy, angle) in enumerate(raw): scale = rng.uniform(0.65, 1.0) half = max_sizes[i] * scale # Centre symbol: no rotation; ring symbols: top points toward centre if i == 0: rot = 0.0 else: rot = math.degrees(angle) + 90.0 results.append((sx, sy, half, rot))
return results
# ---------------------------------------------------------------------------# PDF generation# ---------------------------------------------------------------------------
PAGE_SIZES = {"a4": A4, "letter": LETTER}
def draw_card(c: canvas.Canvas, images: list[str], symbols: list[int], cx: float, cy: float, radius: float, card_index: int) -> None: """Draw a single card on the canvas.""" rng = random.Random(card_index)
# Card circle outline c.setStrokeColorRGB(0.3, 0.3, 0.3) c.setLineWidth(0.5 * mm) c.circle(cx, cy, radius)
# Clip to circle so images don't overflow c.saveState() p = c.beginPath() p.circle(cx, cy, radius - 0.3 * mm) c.clipPath(p, stroke=0, fill=0)
positions = symbol_positions(len(symbols), cx, cy, radius, rng) for idx, (sx, sy, sz, rot) in enumerate(positions): sym = symbols[idx] img_path = images[sym % len(images)] half = sz try: c.saveState() c.translate(sx, sy) c.rotate(rot) c.drawImage(img_path, -half, -half, width=half * 2, height=half * 2, preserveAspectRatio=True, mask='auto') c.restoreState() except Exception: c.restoreState() # Fallback: draw a labelled circle c.setFillColorRGB(0.9, 0.9, 0.9) c.circle(sx, sy, half * 0.8, fill=1) c.setFillColorRGB(0, 0, 0) c.setFont("Helvetica", half * 0.6) c.drawCentredString(sx, sy - half * 0.2, str(sym))
c.restoreState()
def generate_pdf(images: list[str], cards: list[list[int]], output: str, card_diameter_mm: float, cards_per_page: int, page_size: tuple[float, float]) -> None: page_w, page_h = page_size card_r = (card_diameter_mm * mm) / 2
# Compute grid layout cols = max(1, int(page_w / (card_diameter_mm * mm))) rows = max(1, int(page_h / (card_diameter_mm * mm))) per_page = min(cards_per_page, cols * rows)
# Re-derive cols/rows to fit per_page cards nicely cols = min(cols, per_page) rows = math.ceil(per_page / cols)
x_spacing = page_w / cols y_spacing = page_h / rows
c = canvas.Canvas(output, pagesize=page_size)
for i, card_symbols in enumerate(cards): slot = i % per_page if slot == 0 and i > 0: c.showPage()
col = slot % cols row = slot // cols
cx = x_spacing * (col + 0.5) cy = page_h - y_spacing * (row + 0.5)
draw_card(c, images, card_symbols, cx, cy, card_r, i)
c.save() print(f"Generated {output} with {len(cards)} cards " f"({math.ceil(len(cards) / per_page)} pages)")
# ---------------------------------------------------------------------------# CLI# ---------------------------------------------------------------------------
def collect_images(directory: str) -> list[str]: exts = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.webp'} imgs = sorted( str(p) for p in Path(directory).iterdir() if p.suffix.lower() in exts and p.is_file() ) return imgs
def main() -> None: parser = argparse.ArgumentParser( description="Generate Dobble / Spot It cards as a printable PDF." ) parser.add_argument("images_dir", help="Directory containing symbol images") parser.add_argument("-o", "--output", default="dobble.pdf", help="Output PDF path (default: dobble.pdf)") parser.add_argument("-n", "--order", type=int, default=0, help="Prime order for projective plane " "(default: auto from image count)") parser.add_argument("--card-diameter", type=float, default=80.0, help="Card diameter in mm (default: 80)") parser.add_argument("--cards-per-page", type=int, default=6, help="Max cards per page (default: 6)") parser.add_argument("--page-size", choices=["a4", "letter"], default="a4", help="Page size (default: a4)")
args = parser.parse_args()
images = collect_images(args.images_dir) if not images: sys.exit(f"No images found in {args.images_dir}")
num_images = len(images)
# Determine order if args.order: order = args.order if not is_prime(order): sys.exit(f"Order {order} is not prime.") else: # Find the largest prime order whose symbol count <= num_images # symbols needed = order^2 + order + 1 order = 2 for p in [2, 3, 5, 7, 11, 13]: needed = p * p + p + 1 if needed <= num_images: order = p print(f"Auto-selected order {order} " f"({order**2 + order + 1} symbols needed, " f"{num_images} images available)")
total_symbols = order * order + order + 1 total_cards = total_symbols symbols_per_card = order + 1
if num_images < total_symbols: sys.exit(f"Need {total_symbols} images for order {order}, " f"but only found {num_images}.")
print(f"Order: {order}") print(f"Symbols per card: {symbols_per_card}") print(f"Total cards: {total_cards}") print(f"Total symbols: {total_symbols}")
cards = generate_cards(order) page_size = PAGE_SIZES[args.page_size]
generate_pdf(images, cards, args.output, args.card_diameter, args.cards_per_page, page_size)
if __name__ == "__main__": main()