447 lines
15 KiB
Python
447 lines
15 KiB
Python
"""
|
||
Maths Crossword Generator
|
||
=========================
|
||
Usage:
|
||
python3 crossword_builder.py EIGHT THREE NINE SEVEN TEN FOUR
|
||
python3 crossword_builder.py --clues "EIGHT:4+4,THREE:9-6,..." NINE SEVEN TEN FOUR
|
||
python3 crossword_builder.py --output my_puzzle.pdf EIGHT THREE NINE SEVEN TEN FOUR
|
||
|
||
The script will:
|
||
1. Find a valid crossword layout for the given words
|
||
2. Generate a printable PDF
|
||
"""
|
||
|
||
import sys
|
||
import random
|
||
import argparse
|
||
from copy import deepcopy
|
||
|
||
|
||
# ============================================================
|
||
# STEP 1: CROSSWORD SOLVER
|
||
# ============================================================
|
||
|
||
def find_intersections(w1, w2):
|
||
"""Return list of (i, j, char) where w1[i] == w2[j]."""
|
||
return [(i, j, c) for i, c in enumerate(w1) for j, c2 in enumerate(w2) if c == c2]
|
||
|
||
|
||
def build_grid(placements):
|
||
"""Build a dict {(row,col): char} from placements."""
|
||
grid = {}
|
||
for word, is_across, r, c in placements:
|
||
for i, ch in enumerate(word):
|
||
pos = (r, c + i) if is_across else (r + i, c)
|
||
grid[pos] = ch
|
||
return grid
|
||
|
||
|
||
def conflicts(placements, word, is_across, r, c):
|
||
"""Return True if placing word here conflicts with existing placements."""
|
||
grid = build_grid(placements)
|
||
word_len = len(word)
|
||
|
||
# Cell immediately before start must be empty
|
||
before = (r, c - 1) if is_across else (r - 1, c)
|
||
if before in grid:
|
||
return True
|
||
|
||
# Cell immediately after end must be empty
|
||
after = (r, c + word_len) if is_across else (r + word_len, c)
|
||
if after in grid:
|
||
return True
|
||
|
||
# Track which direction(s) occupy each cell
|
||
cell_dirs = {}
|
||
for ew, eia, er, ec in placements:
|
||
d = 'across' if eia else 'down'
|
||
for k in range(len(ew)):
|
||
pos = (er, ec + k) if eia else (er + k, ec)
|
||
cell_dirs.setdefault(pos, set()).add(d)
|
||
|
||
new_dir = 'across' if is_across else 'down'
|
||
|
||
for i, ch in enumerate(word):
|
||
pos = (r, c + i) if is_across else (r + i, c)
|
||
|
||
if pos in grid:
|
||
if grid[pos] != ch:
|
||
return True # character mismatch
|
||
if new_dir in cell_dirs.get(pos, set()):
|
||
return True # same-direction word already here
|
||
else:
|
||
# Empty cell: perpendicular neighbors must also be empty
|
||
if is_across:
|
||
n1, n2 = (r - 1, c + i), (r + 1, c + i)
|
||
else:
|
||
n1, n2 = (r + i, c - 1), (r + i, c + 1)
|
||
if n1 in grid or n2 in grid:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def is_connected(placements):
|
||
"""Check that all placed words form a single connected component (BFS)."""
|
||
if len(placements) <= 1:
|
||
return True
|
||
n = len(placements)
|
||
adj = [set() for _ in range(n)]
|
||
for i in range(n):
|
||
w1, ia1, r1, c1 = placements[i]
|
||
pos1 = {(r1, c1+k) if ia1 else (r1+k, c1) for k in range(len(w1))}
|
||
for j in range(i + 1, n):
|
||
w2, ia2, r2, c2 = placements[j]
|
||
pos2 = {(r2, c2+k) if ia2 else (r2+k, c2) for k in range(len(w2))}
|
||
if pos1 & pos2:
|
||
adj[i].add(j)
|
||
adj[j].add(i)
|
||
visited = {0}
|
||
queue = [0]
|
||
while queue:
|
||
curr = queue.pop()
|
||
for nb in adj[curr]:
|
||
if nb not in visited:
|
||
visited.add(nb)
|
||
queue.append(nb)
|
||
return len(visited) == n
|
||
|
||
|
||
def count_intersections(placements):
|
||
"""Count total number of cell-sharing intersections between words."""
|
||
total = 0
|
||
for i, (w1, ia1, r1, c1) in enumerate(placements):
|
||
pos1 = {(r1, c1 + k) if ia1 else (r1 + k, c1) for k in range(len(w1))}
|
||
for w2, ia2, r2, c2 in placements[i+1:]:
|
||
pos2 = {(r2, c2 + k) if ia2 else (r2 + k, c2) for k in range(len(w2))}
|
||
total += len(pos1 & pos2)
|
||
return total
|
||
|
||
|
||
def grid_bounds(placements):
|
||
"""Return (min_r, min_c, max_r, max_c) of all placements."""
|
||
cells = []
|
||
for word, is_across, r, c in placements:
|
||
for i in range(len(word)):
|
||
cells.append((r, c + i) if is_across else (r + i, c))
|
||
rows = [p[0] for p in cells]
|
||
cols = [p[1] for p in cells]
|
||
return min(rows), min(cols), max(rows), max(cols)
|
||
|
||
|
||
def normalize(placements):
|
||
"""Shift placements so grid starts at (0,0)."""
|
||
if not placements:
|
||
return placements
|
||
min_r, min_c, _, _ = grid_bounds(placements)
|
||
return [(w, ia, r - min_r, c - min_c) for w, ia, r, c in placements]
|
||
|
||
|
||
def solve(words, max_attempts=5000):
|
||
"""
|
||
Try to find a valid crossword layout for the given words.
|
||
Returns list of (word, is_across, row, col) or None.
|
||
"""
|
||
words = [w.upper() for w in words]
|
||
best = None
|
||
best_score = (-1, -1) # (words_placed, intersections)
|
||
|
||
for attempt in range(max_attempts):
|
||
random.shuffle(words)
|
||
placements = []
|
||
|
||
# Place first word horizontally at origin
|
||
first = words[0]
|
||
placements.append((first, True, 0, 0))
|
||
|
||
for word in words[1:]:
|
||
placed = False
|
||
# Try to intersect with each already-placed word
|
||
candidates = []
|
||
for existing_word, is_across, er, ec in placements:
|
||
for ei, ej, ch in find_intersections(existing_word, word):
|
||
# existing_word[ei] == word[ej] == ch
|
||
# Place new word perpendicular to existing
|
||
new_across = not is_across
|
||
if is_across:
|
||
# existing is horizontal, new word is vertical
|
||
# existing cell at (er, ec+ei), new word col = ec+ei, row = er - ej
|
||
nr, nc = er - ej, ec + ei
|
||
else:
|
||
# existing is vertical, new word is horizontal
|
||
# existing cell at (er+ei, ec), new word row = er+ei, col = ec - ej
|
||
nr, nc = er + ei, ec - ej
|
||
|
||
if not conflicts(placements, word, new_across, nr, nc):
|
||
candidates.append((word, new_across, nr, nc))
|
||
|
||
if candidates:
|
||
choice = random.choice(candidates)
|
||
placements.append(choice)
|
||
placed = True
|
||
|
||
if not placed:
|
||
# Try placing with adjacency search (wider search)
|
||
break
|
||
|
||
placements = normalize(placements)
|
||
score = (len(placements), count_intersections(placements))
|
||
|
||
if score > best_score:
|
||
best_score = score
|
||
best = placements
|
||
|
||
# Perfect solution: all words placed and connected
|
||
if len(placements) == len(words) and is_connected(placements):
|
||
return normalize(placements)
|
||
|
||
return best
|
||
|
||
|
||
# ============================================================
|
||
# STEP 2: PDF GENERATOR
|
||
# ============================================================
|
||
|
||
def generate_pdf(placements, clues_map, output_path):
|
||
try:
|
||
from reportlab.lib.pagesizes import A4
|
||
from reportlab.lib import colors
|
||
from reportlab.pdfgen import canvas as rl_canvas
|
||
from reportlab.lib.units import cm
|
||
except ImportError:
|
||
print("ERROR: reportlab not installed. Run: pip install reportlab")
|
||
sys.exit(1)
|
||
|
||
DARK_BLUE = colors.Color(0.18, 0.18, 0.55)
|
||
RED = colors.Color(0.85, 0.07, 0.07)
|
||
|
||
placements = normalize(placements)
|
||
min_r, min_c, max_r, max_c = grid_bounds(placements)
|
||
rows = max_r + 1
|
||
cols = max_c + 1
|
||
|
||
# Build letter grid
|
||
grid = [[None]*cols for _ in range(rows)]
|
||
for word, is_across, r, c in placements:
|
||
for i, ch in enumerate(word):
|
||
if is_across:
|
||
grid[r][c+i] = ch
|
||
else:
|
||
grid[r+i][c] = ch
|
||
|
||
# Number clue starts
|
||
starts = {}
|
||
for word, is_across, r, c in placements:
|
||
starts.setdefault((r, c), [])
|
||
clue_nums = {}
|
||
n = 1
|
||
for r in range(rows):
|
||
for c in range(cols):
|
||
if (r, c) in starts:
|
||
clue_nums[(r, c)] = str(n)
|
||
n += 1
|
||
|
||
# Sort words into across/down with their numbers
|
||
across_words = sorted([(w, r, c) for w, ia, r, c in placements if ia], key=lambda x: (x[1], x[2]))
|
||
down_words = sorted([(w, r, c) for w, ia, r, c in placements if not ia], key=lambda x: (x[1], x[2]))
|
||
|
||
page_w, page_h = A4
|
||
margin = 2.2 * cm
|
||
cv = rl_canvas.Canvas(output_path, pagesize=A4)
|
||
|
||
# Border
|
||
cv.setStrokeColor(DARK_BLUE)
|
||
cv.setLineWidth(3)
|
||
cv.rect(1.2*cm, 1.2*cm, page_w - 2.4*cm, page_h - 2.4*cm, fill=0, stroke=1)
|
||
|
||
# Title
|
||
cv.setFont("Helvetica-Bold", 28)
|
||
cv.setFillColor(RED)
|
||
cv.drawString(margin, page_h - 2.8*cm, "Crossword")
|
||
|
||
# Subtitle
|
||
cv.setFont("Helvetica-Bold", 14)
|
||
cv.setFillColor(DARK_BLUE)
|
||
cv.drawString(margin, page_h - 4.2*cm, "Write the answers to this puzzle in words:")
|
||
cv.drawString(margin, page_h - 5.0*cm, "ONE, TWO, THREE, ...")
|
||
|
||
# Grid sizing
|
||
max_grid_w = page_w - 2 * margin - 1*cm
|
||
max_grid_h = page_h * 0.42
|
||
cell_size = min(int(max_grid_w / cols), int(max_grid_h / rows), 38)
|
||
grid_w = cols * cell_size
|
||
grid_x = (page_w - grid_w) / 2
|
||
grid_top_y = page_h - 6.0*cm
|
||
|
||
# Draw grid
|
||
for r in range(rows):
|
||
for col in range(cols):
|
||
cx = grid_x + col * cell_size
|
||
cy = grid_top_y - r * cell_size
|
||
if grid[r][col] is not None:
|
||
cv.setFillColor(colors.white)
|
||
cv.setStrokeColor(DARK_BLUE)
|
||
cv.setLineWidth(1.2)
|
||
cv.rect(cx, cy - cell_size, cell_size, cell_size, fill=1, stroke=1)
|
||
if (r, col) in clue_nums:
|
||
cv.setFillColor(DARK_BLUE)
|
||
cv.setFont("Helvetica-Bold", 10)
|
||
cv.drawString(cx + 2, cy - 11, clue_nums[(r, col)])
|
||
|
||
# Outer grid border
|
||
cv.setStrokeColor(DARK_BLUE)
|
||
cv.setLineWidth(2.5)
|
||
cv.rect(grid_x, grid_top_y - rows * cell_size, grid_w, rows * cell_size, fill=0, stroke=1)
|
||
|
||
grid_bottom_y = grid_top_y - rows * cell_size
|
||
|
||
# Clues
|
||
clue_y = grid_bottom_y - 0.7*cm
|
||
col1_x = margin
|
||
col2_x = page_w / 2 + 0.3*cm
|
||
|
||
cv.setFont("Helvetica-Bold", 15)
|
||
cv.setFillColor(DARK_BLUE)
|
||
cv.drawString(col1_x, clue_y, "Across")
|
||
cv.drawString(col2_x, clue_y, "Down")
|
||
clue_y -= 0.55*cm
|
||
|
||
line_h = 0.62*cm
|
||
|
||
for i, (w, r, c) in enumerate(across_words):
|
||
num = clue_nums.get((r, c), "?")
|
||
eq = clues_map.get(w, "_ + _ = ?")
|
||
y = clue_y - i * line_h
|
||
cv.setFont("Helvetica-Bold", 13); cv.setFillColor(RED)
|
||
cv.drawString(col1_x, y, f"{num}.")
|
||
cv.setFillColor(DARK_BLUE)
|
||
cv.drawString(col1_x + 0.9*cm, y, eq)
|
||
|
||
for i, (w, r, c) in enumerate(down_words):
|
||
num = clue_nums.get((r, c), "?")
|
||
eq = clues_map.get(w, "_ + _ = ?")
|
||
y = clue_y - i * line_h
|
||
cv.setFont("Helvetica-Bold", 13); cv.setFillColor(RED)
|
||
cv.drawString(col2_x, y, f"{num}.")
|
||
cv.setFillColor(DARK_BLUE)
|
||
cv.drawString(col2_x + 0.9*cm, y, eq)
|
||
|
||
cv.save()
|
||
print(f"PDF saved: {output_path}")
|
||
|
||
|
||
# ============================================================
|
||
# STEP 3: MAIN
|
||
# ============================================================
|
||
|
||
NUMBER_WORDS = {
|
||
'ONE': 1, 'TWO': 2, 'THREE': 3, 'FOUR': 4, 'FIVE': 5,
|
||
'SIX': 6, 'SEVEN': 7, 'EIGHT': 8, 'NINE': 9, 'TEN': 10,
|
||
}
|
||
|
||
|
||
def generate_equation(word):
|
||
"""Generate a +/- equation with 2 or 3 operations for a number word (for ages ~6)."""
|
||
n = NUMBER_WORDS.get(word.upper())
|
||
if n is None:
|
||
return "_ + _ = ?"
|
||
|
||
num_ops = random.choice([2, 3])
|
||
|
||
for _ in range(500):
|
||
# Build operators ensuring at least one + and one -
|
||
ops = [random.choice(['+', '-']) for _ in range(num_ops)]
|
||
if len(set(ops)) < 2:
|
||
ops[random.randrange(num_ops)] = '-' if ops[0] == '+' else '+'
|
||
|
||
# Pick first num_ops numbers randomly (1–9), derive the last
|
||
nums = [random.randint(1, 9) for _ in range(num_ops)]
|
||
total = nums[0]
|
||
valid = True
|
||
for k in range(1, num_ops):
|
||
total = total + nums[k] if ops[k - 1] == '+' else total - nums[k]
|
||
if total < 0:
|
||
valid = False
|
||
break
|
||
if not valid:
|
||
continue
|
||
|
||
# Last number must satisfy: total OP last = n
|
||
last = (n - total) if ops[-1] == '+' else (total - n)
|
||
if 1 <= last <= 9:
|
||
nums.append(last)
|
||
parts = [str(nums[0])]
|
||
for op, num in zip(ops, nums[1:]):
|
||
parts.append(f"{op} {num}")
|
||
return " ".join(parts) + " = ?"
|
||
|
||
# Fallback to simple addition
|
||
for a in range(1, 10):
|
||
b = n - a
|
||
if 1 <= b <= 9:
|
||
return f"{a} + {b} = ?"
|
||
return f"= {n}"
|
||
|
||
|
||
def parse_clues(clues_str):
|
||
"""Parse 'EIGHT:4+4,THREE:9-6' into {'EIGHT': '4+4', 'THREE': '9-6'}"""
|
||
result = {}
|
||
for part in clues_str.split(","):
|
||
if ":" in part:
|
||
word, eq = part.split(":", 1)
|
||
result[word.strip().upper()] = eq.strip()
|
||
return result
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Generate a maths crossword PDF from a list of words.")
|
||
parser.add_argument("words", nargs="+", help="Words to place in the crossword (e.g. EIGHT THREE NINE)")
|
||
parser.add_argument("--clues", default="", help="Equations per word: 'EIGHT:4+4,THREE:9-6,...'")
|
||
parser.add_argument("--output", default="crossword.pdf", help="Output PDF filename")
|
||
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducible layouts")
|
||
args = parser.parse_args()
|
||
|
||
if args.seed is not None:
|
||
random.seed(args.seed)
|
||
|
||
# If a single integer is given, pick that many random number words
|
||
if len(args.words) == 1 and args.words[0].isdigit():
|
||
count = int(args.words[0])
|
||
all_words = list(NUMBER_WORDS.keys())
|
||
if count > len(all_words):
|
||
print(f"ERROR: Only {len(all_words)} number words available (ONE–TEN).")
|
||
sys.exit(1)
|
||
words = random.sample(all_words, count)
|
||
else:
|
||
words = [w.upper() for w in args.words]
|
||
clues_map = parse_clues(args.clues)
|
||
|
||
# Fill in auto-generated clues for any word without one
|
||
for w in words:
|
||
if w not in clues_map:
|
||
clues_map[w] = generate_equation(w)
|
||
|
||
print(f"Solving layout for: {', '.join(words)}")
|
||
placements = solve(words)
|
||
|
||
if not placements:
|
||
print("ERROR: Could not find a valid layout. Try different words or fewer words.")
|
||
sys.exit(1)
|
||
|
||
placed_words = [w for w, _, _, _ in placements]
|
||
missing = [w for w in words if w not in placed_words]
|
||
if missing:
|
||
print(f"WARNING: Could not place: {', '.join(missing)}")
|
||
|
||
print(f"Placed {len(placements)}/{len(words)} words")
|
||
print(f"Layout:")
|
||
for word, is_across, r, c in sorted(placements, key=lambda x: (x[2], x[3])):
|
||
direction = "across" if is_across else "down"
|
||
print(f" {word:12} {direction:6} row={r}, col={c}")
|
||
|
||
generate_pdf(placements, clues_map, args.output)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|