#!/usr/bin/env python3 """ Dobble / Spot It card generator. Generates a set of Dobble cards from a directory of images and outputs a printable PDF. The mathematical foundation is based on finite projective planes of prime order. Usage: dobble [-o output.pdf] [-n ORDER] [--card-diameter MM] [--cards-per-page N] [--page-size A4|LETTER] """ import argparse import math import random import sys from pathlib import Path from reportlab.lib.pagesizes import A4, LETTER from reportlab.lib.units import mm from 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()