first commit

This commit is contained in:
Stefanescu Mihai
2026-03-24 19:36:04 +02:00
commit 5493e927de
15 changed files with 1845 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
# Crossword Puzzle Generator for Amazon KDP
## Overview
A Python CLI tool that reads words and clues from CSV files, generates crossword puzzle grids, and outputs print-ready PDF books formatted for Amazon KDP upload.
---
## How It Works (High Level)
1. **Read** a CSV file containing words and their clues
2. **Generate** crossword grids by placing words on a grid so they interlock at shared letters
3. **Render** each puzzle and its solution as pages in a print-ready PDF
4. **Format** the PDF to meet KDP's trim size, margin, and bleed requirements
---
## Input Format
A CSV file with at minimum two columns:
```csv
word,clue
PYTHON,"A large snake, or a programming language"
GRID,"A network of lines that cross each other"
PUZZLE,"A game or problem that tests ingenuity"
```
Optional columns:
- `difficulty` — to group words into easy/medium/hard puzzles
- `category` — to generate themed puzzles (e.g., "Animals", "Science")
---
## Core Components
### 1. CSV Parser (`csv_reader.py`)
- Read and validate the CSV (reject duplicates, too-short words, non-alpha characters)
- Normalize words to uppercase
- Group words by category/difficulty if those columns exist
- Return a list of `(word, clue)` tuples ready for the generator
### 2. Crossword Generator (`generator.py`)
This is the core algorithm. It places words onto a 2D grid so they intersect at shared letters.
**Algorithm (backtracking with heuristics):**
1. Sort words longest-first (longer words are harder to place, so do them early)
2. Place the first word horizontally in the center of the grid
3. For each remaining word:
a. Find all candidate positions where it intersects an already-placed word at a shared letter
b. Score each candidate (prefer positions that keep the grid compact and balanced)
c. Place the word at the best valid position (no collisions, no unintended adjacencies)
d. If no valid position exists, skip the word (or backtrack)
4. Number the cells (across and down) in reading order (left-to-right, top-to-bottom)
5. Return the grid, placed words with their positions/directions, and numbered clue lists
**Grid rules to enforce:**
- Words cannot run parallel and adjacent (would create unintended mini-words)
- Every letter in the grid must belong to a placed word
- Minimum grid density target to avoid sparse-looking puzzles
- Configurable grid size (e.g., 15x15 for standard, 10x10 for easy)
**Output of this step:** a `Crossword` object containing:
- The letter grid (2D array)
- List of placed words with start position, direction, and clue number
- Across clues list (numbered)
- Down clues list (numbered)
### 3. PDF Renderer (`renderer.py`)
Uses **ReportLab** to generate print-ready PDFs.
**Each puzzle produces two pages:**
1. **Puzzle page:**
- Title (e.g., "Puzzle 1" or a category name)
- The empty grid with numbered cells and black squares
- Across clues listed below or beside the grid
- Down clues listed below or beside the grid
2. **Solution page:**
- The filled grid with all letters visible
- Labeled "Solution to Puzzle N"
**Page layout considerations:**
- Grid cells should be large enough to write in (~0.3–0.4 inches per cell)
- Clue text should be readable (10–12pt font)
- Clean, professional look (black borders, light grid lines)
### 4. KDP Formatter (`kdp_format.py`)
Handles KDP-specific requirements:
- **Trim sizes:** Support common KDP sizes (8.5"x11", 6"x9", 8"x10")
- **Margins:** KDP requires minimum margins that vary by page count (typically 0.5" outside, 0.75"+ gutter/inside)
- **Page count:** Must be between 24–828 pages
- **Bleed:** Not needed for puzzle books (no content to edge)
- **Front matter:** Generate a title page, copyright page, and optional instructions page
- **Page numbering:** Footer with page numbers
- **Table of contents:** Optional, mapping puzzle numbers to pages
### 5. CLI Interface (`main.py`)
```
python main.py --input words.csv \
--output crossword_book.pdf \
--puzzles 50 \
--grid-size 15 \
--trim-size 8.5x11 \
--title "The Ultimate Crossword Book" \
--author "Your Name"
```
**Arguments:**
| Flag | Description | Default |
|------|-------------|---------|
| `--input` | Path to CSV file | required |
| `--output` | Output PDF path | `output.pdf` |
| `--puzzles` | Number of puzzles to generate | `20` |
| `--grid-size` | Grid dimensions (e.g., 15 for 15x15) | `13` |
| `--trim-size` | KDP page size | `8.5x11` |
| `--title` | Book title for the cover page | `"Crossword Puzzles"` |
| `--author` | Author name | `""` |
| `--words-per-puzzle` | Target words per puzzle | `20` |
| `--solutions-at-end` | Group all solutions at the back | `false` |
| `--seed` | Random seed for reproducibility | none |
---
## Project Structure
```
crossword-kdp-generator/
├── main.py # CLI entry point
├── csv_reader.py # CSV parsing and validation
├── generator.py # Crossword grid generation algorithm
├── renderer.py # PDF rendering (grids, clues, layout)
├── kdp_format.py # KDP trim sizes, margins, front matter
├── models.py # Data classes (Crossword, PlacedWord, Cell, etc.)
├── requirements.txt # Dependencies
├── sample_words.csv # Example input file
└── README.md # Usage instructions
```
---
## Dependencies
| Package | Purpose |
|---------|---------|
| `reportlab` | PDF generation |
| `click` | CLI argument parsing |
No heavy dependencies needed — the crossword algorithm is pure Python, and ReportLab handles all the PDF work.
---
## Generation Strategy for a Full Book
A single CSV might have hundreds of words, but each puzzle only uses ~15–25 of them. The tool will:
1. Shuffle the word list (using the optional seed for reproducibility)
2. Pull a batch of N words for each puzzle
3. Attempt to generate a crossword grid from that batch
4. If the grid doesn't meet a minimum quality threshold (too few intersections, too sparse), retry with a different subset
5. Repeat until the requested number of puzzles is generated
6. Assemble all puzzles into the final PDF with front matter and optional solutions section at the back
---
## Edge Cases to Handle
- **Too few words in CSV** — error out with a clear message
- **Words that can't interlock** — skip them, log a warning
- **Grid overflow** — if words don't fit the requested grid size, increase grid or reduce words per puzzle
- **Duplicate words across puzzles** — allow (different puzzles can reuse words) but avoid within a single puzzle
- **Non-ASCII characters** — strip accents or reject, since crosswords are typically A-Z only
- **Very long words** — warn if a word exceeds grid size
---
## Stretch Goals (Post-MVP)
- **Cover page PDF** — generate a separate KDP-compliant cover
- **Multiple difficulty levels** in one book (easy section, medium, hard)
- **Themed sections** based on CSV category column
- **Word search mode** — reuse the same CSV to generate word search puzzles too
- **Web UI** — a simple local web interface instead of CLI
- **ISBN barcode** placement on back cover
---
## Implementation Order
1. ~~`models.py` — define data structures~~ ✅
2. ~~`csv_reader.py` — parse and validate input~~ ✅
3. ~~`generator.py` — crossword algorithm (the hard part)~~ ✅
4. ~~`renderer.py` — PDF grid and clue rendering~~ ✅
5. ~~`kdp_format.py` — page layout and front matter~~ ✅
6. ~~`main.py` — wire it all together with CLI~~ ✅
7. ~~Test with sample data and iterate on grid quality~~ ✅
+143
View File
@@ -0,0 +1,143 @@
# Crossword Puzzle Generator for Amazon KDP
A Python CLI tool that reads words and clues from a CSV file, generates crossword puzzle grids, and outputs a print-ready PDF book formatted for Amazon KDP upload.
## Quick Start
```bash
# Set up a virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Generate a crossword book from the sample data
python main.py --input sample_words.csv \
--output my_book.pdf \
--puzzles 20 \
--title "My Crossword Book" \
--author "Your Name"
```
## Input Format
A CSV file with at minimum two columns — `word` and `clue`:
```csv
word,clue
PYTHON,"A large snake, or a programming language"
GRID,"A network of lines that cross each other"
PUZZLE,"A game or problem that tests ingenuity"
```
Optional columns:
| Column | Purpose |
|--------|---------|
| `difficulty` | Group words into easy/medium/hard puzzles |
| `category` | Generate themed puzzles (e.g. "Animals", "Science") |
Words are automatically normalised to uppercase. Accented characters are stripped (e.g. `café` → `CAFE`). Duplicates, words shorter than 3 letters, and words containing non-alpha characters are rejected with a warning.
## CLI Reference
```
Usage: main.py [OPTIONS]
Generate a KDP-ready crossword puzzle book from a CSV word list.
```
### Required
| Flag | Description |
|------|-------------|
| `--input FILE` | Path to a CSV file with `word,clue` columns |
### Optional
| Flag | Default | Description |
|------|---------|-------------|
| `--output TEXT` | `output.pdf` | Output PDF file path |
| `--puzzles INTEGER` | `20` | Number of crossword puzzles to generate |
| `--grid-size INTEGER` | `13` | Grid dimensions (e.g. `15` for a 15×15 grid) |
| `--trim-size CHOICE` | `8.5x11` | KDP page trim size — one of `8.5x11`, `6x9`, `8x10` |
| `--title TEXT` | `Crossword Puzzles` | Book title for the cover page |
| `--author TEXT` | *(empty)* | Author name |
| `--words-per-puzzle INTEGER` | `20` | Target number of words per puzzle |
| `--solutions-at-end` | off | Group all solution pages at the back of the book |
| `--seed INTEGER` | *(random)* | Random seed for reproducible output |
| `--no-toc` | off | Omit the table of contents page |
| `--no-instructions` | off | Omit the how-to-solve instructions page |
| `-v, --verbose` | off | Enable verbose/debug logging |
| `--help` | | Show help and exit |
### Examples
Generate a 50-puzzle book in 6×9 format with solutions at the back:
```bash
python main.py --input words.csv \
--output crossword_book.pdf \
--puzzles 50 \
--grid-size 15 \
--trim-size 6x9 \
--title "The Ultimate Crossword Book" \
--author "Jane Doe" \
--solutions-at-end \
--seed 42
```
Quick 10-puzzle book with a smaller grid and no front matter extras:
```bash
python main.py --input words.csv \
--output quick_puzzles.pdf \
--puzzles 10 \
--grid-size 10 \
--no-toc \
--no-instructions
```
## Book Structure
Each generated PDF contains:
1. **Title page** — book title and author
2. **Copyright page** — auto-generated with the current year
3. **Table of contents** — puzzle numbers mapped to page numbers (optional)
4. **Instructions page** — "How to Solve" guide (optional)
5. **Puzzle pages** — empty grid with numbered cells, black squares, and clues in two columns
6. **Solution pages** — filled grid with all letters visible
Page numbering starts after the front matter. Gutter (inside) margins are automatically calculated based on total page count per KDP specifications.
## Project Structure
```
crossword-kdp-generator/
├── main.py # CLI entry point
├── csv_reader.py # CSV parsing and validation
├── generator.py # Crossword grid generation algorithm
├── renderer.py # PDF grid and clue rendering
├── kdp_format.py # KDP page layout and front matter
├── models.py # Data classes
├── requirements.txt # Dependencies
├── sample_words.csv # Example input (50 words, 5 categories)
├── PLAN.md # Implementation plan
└── README.md # This file
```
## Dependencies
| Package | Purpose |
|---------|---------|
| [ReportLab](https://www.reportlab.com/) | PDF generation |
| [Click](https://click.palletsprojects.com/) | CLI argument parsing |
## KDP Notes
- **Trim sizes:** 8.5"×11", 6"×9", 8"×10"
- **Margins:** Outside 0.5", gutter auto-calculated (0.375"–0.875" based on page count)
- **Page count:** KDP requires 24–828 pages; the tool warns if below minimum
- **Bleed:** Not needed for puzzle books (no content extends to the page edge)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+166
View File
@@ -0,0 +1,166 @@
import csv
import logging
import unicodedata
from pathlib import Path
from typing import Optional
from models import WordClue
logger = logging.getLogger(__name__)
MIN_WORD_LENGTH = 3
REQUIRED_COLUMNS = {"word", "clue"}
OPTIONAL_COLUMNS = {"difficulty", "category"}
class CSVValidationError(Exception):
"""Raised when the CSV file fails validation."""
def _strip_accents(text: str) -> str:
"""Replace accented characters with their ASCII equivalents."""
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c))
def _normalize_word(raw: str) -> Optional[str]:
"""Normalize a word to uppercase A-Z only. Returns None if invalid."""
word = _strip_accents(raw.strip()).upper()
word = word.replace(" ", "").replace("-", "")
if not word.isalpha():
return None
return word
def read_csv(
filepath: str | Path,
min_words: int = 1,
max_word_length: Optional[int] = None,
) -> list[WordClue]:
"""Read and validate a CSV file of words and clues.
Args:
filepath: Path to the CSV file.
min_words: Minimum number of valid words required.
max_word_length: If set, words longer than this trigger a warning.
Returns:
A deduplicated, validated list of WordClue objects.
Raises:
CSVValidationError: If the file is missing required columns or
contains fewer than min_words valid entries.
FileNotFoundError: If the file does not exist.
"""
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"CSV file not found: {path}")
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
if reader.fieldnames is None:
raise CSVValidationError("CSV file is empty or has no header row.")
headers = {h.strip().lower() for h in reader.fieldnames}
missing = REQUIRED_COLUMNS - headers
if missing:
raise CSVValidationError(
f"CSV is missing required columns: {', '.join(sorted(missing))}. "
f"Found: {', '.join(sorted(headers))}"
)
has_difficulty = "difficulty" in headers
has_category = "category" in headers
words_seen: set[str] = set()
results: list[WordClue] = []
skipped = 0
for line_num, row in enumerate(reader, start=2):
raw_word = row.get("word", "").strip()
clue = row.get("clue", "").strip()
if not raw_word or not clue:
logger.warning("Line %d: empty word or clue, skipping.", line_num)
skipped += 1
continue
word = _normalize_word(raw_word)
if word is None:
logger.warning(
"Line %d: word %r contains non-alpha characters, skipping.",
line_num,
raw_word,
)
skipped += 1
continue
if len(word) < MIN_WORD_LENGTH:
logger.warning(
"Line %d: word %r is too short (min %d letters), skipping.",
line_num,
word,
MIN_WORD_LENGTH,
)
skipped += 1
continue
if max_word_length and len(word) > max_word_length:
logger.warning(
"Line %d: word %r has %d letters, exceeds grid size of %d.",
line_num,
word,
len(word),
max_word_length,
)
if word in words_seen:
logger.warning(
"Line %d: duplicate word %r, skipping.", line_num, word
)
skipped += 1
continue
words_seen.add(word)
difficulty = row.get("difficulty", "").strip().lower() or None if has_difficulty else None
category = row.get("category", "").strip() or None if has_category else None
results.append(WordClue(
word=word,
clue=clue,
difficulty=difficulty,
category=category,
))
if len(results) < min_words:
raise CSVValidationError(
f"CSV contains only {len(results)} valid word(s), "
f"but at least {min_words} are required."
)
if skipped:
logger.info("Skipped %d invalid row(s). Loaded %d word(s).", skipped, len(results))
else:
logger.info("Loaded %d word(s) from %s.", len(results), path.name)
return results
def group_by_category(words: list[WordClue]) -> dict[str, list[WordClue]]:
"""Group words by their category. Uncategorized words go under 'General'."""
groups: dict[str, list[WordClue]] = {}
for wc in words:
key = wc.category or "General"
groups.setdefault(key, []).append(wc)
return groups
def group_by_difficulty(words: list[WordClue]) -> dict[str, list[WordClue]]:
"""Group words by difficulty. Words without difficulty go under 'medium'."""
groups: dict[str, list[WordClue]] = {}
for wc in words:
key = wc.difficulty or "medium"
groups.setdefault(key, []).append(wc)
return groups
+393
View File
@@ -0,0 +1,393 @@
"""Crossword grid generation algorithm.
Uses a greedy placement strategy with two passes:
1. Sort words longest-first (harder to place, so do them early).
2. Place the first word horizontally in the centre of the grid.
3. For each remaining word, find all positions where it intersects an
already-placed word at a shared letter, score them, and pick the best.
4. Run a second pass for words that failed the first time (the grid has
grown, so new opportunities may exist).
5. Number the cells in reading order and build the Crossword object.
"""
import logging
import random
from typing import Optional
from models import Cell, Crossword, Direction, NumberedClue, PlacedWord, WordClue
logger = logging.getLogger(__name__)
class _GridBuilder:
"""Sparse, mutable grid used during crossword construction.
Internally stores letters in a dict keyed by (row, col) so we never
worry about array resizing. Converts to a dense Cell grid only when
`build()` is called.
"""
def __init__(self, size: int):
self.size = size
self._letters: dict[tuple[int, int], str] = {}
self._dirs: dict[tuple[int, int], set[Direction]] = {}
self.placed: list[PlacedWord] = []
# -- low-level helpers ---------------------------------------------------
def get(self, row: int, col: int) -> Optional[str]:
return self._letters.get((row, col))
def is_occupied(self, row: int, col: int) -> bool:
return (row, col) in self._letters
def in_bounds(self, row: int, col: int) -> bool:
return 0 <= row < self.size and 0 <= col < self.size
# -- mutation ------------------------------------------------------------
def place(self, word: str, clue: str, row: int, col: int,
direction: Direction) -> None:
pw = PlacedWord(word=word, clue=clue, row=row, col=col,
direction=direction)
self.placed.append(pw)
for i, (r, c) in enumerate(pw.cells()):
self._letters[(r, c)] = word[i]
self._dirs.setdefault((r, c), set()).add(direction)
# -- validation ----------------------------------------------------------
def is_valid(self, word: str, row: int, col: int,
direction: Direction) -> bool:
"""Return True if *word* can legally be placed at (row, col)."""
length = len(word)
if direction == Direction.ACROSS:
end_col = col + length - 1
if col < 0 or row < 0 or end_col >= self.size or row >= self.size:
return False
# Nothing immediately before / after the word
if self.is_occupied(row, col - 1):
return False
if self.is_occupied(row, end_col + 1):
return False
has_cross = False
for i in range(length):
c = col + i
existing = self.get(row, c)
if existing is not None:
if existing != word[i]:
return False
# Must cross a DOWN word — overlapping same direction is illegal
if Direction.ACROSS in self._dirs.get((row, c), set()):
return False
has_cross = True
else:
# Parallel-adjacency guard: no letters directly above/below
if self.is_occupied(row - 1, c) or self.is_occupied(row + 1, c):
return False
return has_cross or not self.placed
else: # DOWN
end_row = row + length - 1
if row < 0 or col < 0 or end_row >= self.size or col >= self.size:
return False
if self.is_occupied(row - 1, col):
return False
if self.is_occupied(end_row + 1, col):
return False
has_cross = False
for i in range(length):
r = row + i
existing = self.get(r, col)
if existing is not None:
if existing != word[i]:
return False
if Direction.DOWN in self._dirs.get((r, col), set()):
return False
has_cross = True
else:
if self.is_occupied(r, col - 1) or self.is_occupied(r, col + 1):
return False
return has_cross or not self.placed
# -- scoring -------------------------------------------------------------
def score(self, word: str, row: int, col: int,
direction: Direction) -> float:
"""Score a valid placement — higher is better."""
s = 0.0
length = len(word)
if direction == Direction.ACROSS:
for i in range(length):
if self.is_occupied(row, col + i):
s += 10.0
else:
for i in range(length):
if self.is_occupied(row + i, col):
s += 10.0
# Prefer compact placement near the grid centre
centre = self.size / 2.0
if direction == Direction.ACROSS:
mid_r, mid_c = float(row), col + length / 2.0
else:
mid_r, mid_c = row + length / 2.0, float(col)
s -= (abs(mid_r - centre) + abs(mid_c - centre)) * 0.5
return s
# -- candidate search ----------------------------------------------------
def find_best(self, word: str) -> Optional[tuple[int, int, Direction]]:
"""Find the highest-scoring valid position for *word*, or None."""
best_pos: Optional[tuple[int, int, Direction]] = None
best_score = float("-inf")
for pw in self.placed:
for i, ch_new in enumerate(word):
for j, ch_old in enumerate(pw.word):
if ch_new != ch_old:
continue
# Try placing perpendicular to the existing word
if pw.direction == Direction.ACROSS:
nr, nc, nd = pw.row - i, pw.col + j, Direction.DOWN
else:
nr, nc, nd = pw.row + j, pw.col - i, Direction.ACROSS
if self.is_valid(word, nr, nc, nd):
sc = self.score(word, nr, nc, nd)
if sc > best_score:
best_score = sc
best_pos = (nr, nc, nd)
return best_pos
# -- finalization --------------------------------------------------------
def build(self) -> Crossword:
"""Trim to bounding box, number cells, and return a Crossword."""
if not self.placed:
return Crossword(grid=[], placed_words=[], width=0, height=0)
min_r = min(r for r, _ in self._letters)
max_r = max(r for r, _ in self._letters)
min_c = min(c for _, c in self._letters)
max_c = max(c for _, c in self._letters)
height = max_r - min_r + 1
width = max_c - min_c + 1
# Re-base coordinates to (0, 0)
shifted = [
PlacedWord(
word=pw.word, clue=pw.clue,
row=pw.row - min_r, col=pw.col - min_c,
direction=pw.direction,
)
for pw in self.placed
]
# Dense cell grid
grid: list[list[Cell]] = [
[Cell() for _ in range(width)] for _ in range(height)
]
for pw in shifted:
for i, (r, c) in enumerate(pw.cells()):
grid[r][c].letter = pw.word[i]
# Assign clue numbers in reading order
starts: dict[tuple[int, int], list[PlacedWord]] = {}
for pw in shifted:
starts.setdefault((pw.row, pw.col), []).append(pw)
across_clues: list[NumberedClue] = []
down_clues: list[NumberedClue] = []
num = 1
for r in range(height):
for c in range(width):
if (r, c) not in starts:
continue
grid[r][c].number = num
for pw in starts[(r, c)]:
pw.number = num
nc = NumberedClue(num, pw.clue, pw.word, pw.direction)
if pw.direction == Direction.ACROSS:
across_clues.append(nc)
else:
down_clues.append(nc)
num += 1
return Crossword(
grid=grid,
placed_words=shifted,
across_clues=across_clues,
down_clues=down_clues,
width=width,
height=height,
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
_PLACEMENT_PASSES = 4
def _build_once(valid: list[WordClue], grid_size: int) -> _GridBuilder:
"""Run one placement attempt and return the builder."""
builder = _GridBuilder(grid_size)
first = valid[0]
start_row = grid_size // 2
start_col = (grid_size - len(first.word)) // 2
builder.place(first.word, first.clue, start_row, start_col,
Direction.ACROSS)
# Multiple passes — each pass the grid has grown, opening new slots
remaining = list(valid[1:])
for _ in range(_PLACEMENT_PASSES):
still: list[WordClue] = []
for wc in remaining:
pos = builder.find_best(wc.word)
if pos:
builder.place(wc.word, wc.clue, *pos)
else:
still.append(wc)
if not still:
break
remaining = still
return builder
def generate_crossword(
word_clues: list[WordClue],
grid_size: int = 15,
rng: Optional[random.Random] = None,
) -> Optional[Crossword]:
"""Generate a single crossword puzzle from *word_clues*.
Words longer than *grid_size* are silently skipped. When *rng* is
provided, multiple orderings are tried and the best result is kept.
Returns None if fewer than two words could be placed.
"""
if not word_clues:
return None
valid: list[WordClue] = []
for wc in word_clues:
if len(wc.word) > grid_size:
logger.warning("Word %r (%d chars) exceeds grid size %d, skipping.",
wc.word, len(wc.word), grid_size)
else:
valid.append(wc)
if not valid:
return None
attempts = 4 if rng else 1
best_builder: Optional[_GridBuilder] = None
for _ in range(attempts):
# Sort longest-first, then shuffle within same-length groups
valid.sort(key=lambda wc: (-len(wc.word), rng.random() if rng else 0))
builder = _build_once(valid, grid_size)
if best_builder is None or len(builder.placed) > len(best_builder.placed):
best_builder = builder
if len(builder.placed) == len(valid):
break # perfect — no need to try again
assert best_builder is not None
unplaced = set(wc.word for wc in valid) - {pw.word for pw in best_builder.placed}
if unplaced:
logger.info("Could not place %d word(s): %s",
len(unplaced), ", ".join(sorted(unplaced)))
crossword = best_builder.build()
if crossword.word_count < 2:
logger.warning("Only %d word(s) placed — puzzle too small.",
crossword.word_count)
return None
return crossword
def generate_batch(
word_clues: list[WordClue],
count: int = 20,
words_per_puzzle: int = 20,
grid_size: int = 15,
seed: Optional[int] = None,
min_density: float = 0.10,
max_retries: int = 5,
) -> list[Crossword]:
"""Generate *count* crossword puzzles by sampling from *word_clues*.
For each puzzle the word pool is shuffled (using *seed* for
reproducibility), a batch of *words_per_puzzle* is drawn, and
`generate_crossword` is called. If the result doesn't meet quality
thresholds the batch is re-shuffled and retried up to *max_retries*
times.
"""
if len(word_clues) < 3:
logger.error("Need at least 3 words to generate puzzles, got %d.",
len(word_clues))
return []
rng = random.Random(seed)
words = list(word_clues)
puzzles: list[Crossword] = []
min_placed = max(2, int(words_per_puzzle * 0.4))
for puzzle_idx in range(count):
best: Optional[Crossword] = None
for attempt in range(max_retries):
rng.shuffle(words)
batch = words[:words_per_puzzle]
candidate = generate_crossword(batch, grid_size, rng)
if candidate and (best is None
or candidate.word_count > best.word_count):
best = candidate
if best and best.word_count >= min_placed and best.density >= min_density:
puzzles.append(best)
logger.info("Puzzle %d: %d words placed, density %.1f%%.",
puzzle_idx + 1, best.word_count,
best.density * 100)
else:
logger.warning(
"Failed to generate puzzle %d after %d attempts.",
puzzle_idx + 1, max_retries)
return puzzles
# ---------------------------------------------------------------------------
# Debug helper
# ---------------------------------------------------------------------------
def format_grid(crossword: Crossword) -> str:
"""Return a human-readable text rendering of the grid."""
lines: list[str] = []
for row in crossword.grid:
parts: list[str] = []
for cell in row:
if cell.is_empty:
parts.append("██")
else:
parts.append(f" {cell.letter}")
lines.append("".join(parts))
return "\n".join(lines)
+384
View File
@@ -0,0 +1,384 @@
"""KDP-specific formatting: margins, front matter, page numbering, TOC.
Assembles a complete print-ready PDF book that meets Amazon KDP
requirements for trim size, margins (including gutter), page count,
and includes professional front matter.
"""
import datetime
import logging
from typing import Optional
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import inch
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Frame, Paragraph
from models import Crossword, PuzzleBook, TrimSize
from renderer import render_puzzle_page, render_solution_page
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# KDP constraints
# ---------------------------------------------------------------------------
KDP_MIN_PAGES = 24
KDP_MAX_PAGES = 828
_OUTSIDE_MARGIN = 0.5 # inches — top, bottom, outside edge
_TOP_MARGIN = 0.5
_BOTTOM_MARGIN = 0.6 # slightly more room for the page number
_PAGE_NUM_FONT = "Helvetica"
_PAGE_NUM_SIZE = 9
# ---------------------------------------------------------------------------
# Margin helpers
# ---------------------------------------------------------------------------
def _gutter_margin(page_count: int) -> float:
"""KDP minimum inside (gutter) margin in inches, based on page count."""
if page_count <= 150:
return 0.375
if page_count <= 300:
return 0.5
if page_count <= 500:
return 0.625
if page_count <= 700:
return 0.75
return 0.875
def _content_rect(trim: TrimSize, abs_page: int,
gutter: float) -> tuple[float, float, float, float]:
"""Return (content_x, content_top_y, content_w, content_h) in points.
Odd pages (recto) have the gutter on the *left*;
even pages (verso) have the gutter on the *right*.
"""
pw = trim.width * inch
ph = trim.height * inch
outside = _OUTSIDE_MARGIN * inch
gut = gutter * inch
top = _TOP_MARGIN * inch
bot = _BOTTOM_MARGIN * inch
if abs_page % 2 == 1: # recto — gutter left
content_x = gut
else: # verso — gutter right
content_x = outside
content_w = pw - gut - outside
content_top_y = ph - top
content_h = ph - top - bot
return content_x, content_top_y, content_w, content_h
# ---------------------------------------------------------------------------
# Page-count estimation
# ---------------------------------------------------------------------------
def _estimate_page_count(puzzle_count: int,
solutions_at_end: bool,
include_toc: bool,
include_instructions: bool) -> int:
"""Pre-calculate total pages so the gutter margin can be determined."""
front = 2 # title + copyright
if include_toc:
front += 1
if include_instructions:
front += 1
if front % 2 != 0: # pad so puzzles start recto
front += 1
content = puzzle_count * 2 # puzzle page + solution page
return front + content
# ---------------------------------------------------------------------------
# Front-matter pages
# ---------------------------------------------------------------------------
def _draw_title_page(cv: Canvas, title: str, author: str,
pw: float, ph: float) -> None:
y_title = ph * 0.58
cv.setFont("Helvetica-Bold", 30)
cv.setFillColorRGB(0, 0, 0)
tw = cv.stringWidth(title, "Helvetica-Bold", 30)
cv.drawString((pw - tw) / 2, y_title, title)
# Decorative rule
rule_w = min(pw * 0.35, 180)
cv.setStrokeColorRGB(0.35, 0.35, 0.35)
cv.setLineWidth(0.75)
rule_y = y_title - 18
cv.line((pw - rule_w) / 2, rule_y, (pw + rule_w) / 2, rule_y)
if author:
cv.setFont("Helvetica", 16)
cv.setFillColorRGB(0.25, 0.25, 0.25)
aw = cv.stringWidth(author, "Helvetica", 16)
cv.drawString((pw - aw) / 2, rule_y - 28, author)
def _draw_copyright_page(cv: Canvas, title: str, author: str,
pw: float, ph: float) -> None:
year = datetime.date.today().year
style = ParagraphStyle(
"Copyright", fontName="Helvetica", fontSize=9, leading=13,
spaceBefore=3, spaceAfter=3,
)
items: list[Paragraph] = [
Paragraph(f"<i>{title}</i>", style),
]
if author:
items.append(Paragraph(f"Copyright &copy; {year} {author}", style))
items.extend([
Paragraph("All rights reserved.", style),
Paragraph(
"No part of this publication may be reproduced, distributed, "
"or transmitted in any form without prior written permission.",
style),
Paragraph(f"First published {year}.", style),
])
margin = 0.75 * inch
Frame(margin, margin, pw - 2 * margin, ph * 0.25,
leftPadding=0, rightPadding=0,
topPadding=0, bottomPadding=0).addFromList(items, cv)
def _draw_instructions_page(cv: Canvas, pw: float, ph: float,
solutions_at_end: bool) -> None:
margin = 0.75 * inch
content_w = pw - 2 * margin
cv.setFont("Helvetica-Bold", 20)
cv.setFillColorRGB(0, 0, 0)
heading = "How to Solve"
hw = cv.stringWidth(heading, "Helvetica-Bold", 20)
heading_y = ph - margin - 20
cv.drawString((pw - hw) / 2, heading_y, heading)
style = ParagraphStyle(
"Instructions", fontName="Helvetica", fontSize=11, leading=16,
spaceBefore=6, spaceAfter=6,
)
if solutions_at_end:
sol_note = ("Solutions for every puzzle can be found at the back "
"of this book.")
else:
sol_note = ("The solution for each puzzle appears on the page "
"immediately following it.")
paras = [
Paragraph(
"Each puzzle consists of a grid of white and black squares "
"and a set of numbered clues. Solve the clues and write one "
"letter per white square.", style),
Paragraph(
"<b>Across</b> clues are answered by writing letters "
"from left to right.", style),
Paragraph(
"<b>Down</b> clues are answered by writing letters "
"from top to bottom.", style),
Paragraph(
"Where an across answer and a down answer share a square, "
"the letter must be the same for both words &mdash; these "
"intersections are your best starting points!", style),
Paragraph(f"{sol_note} Good luck and have fun!", style),
]
frame_top = heading_y - 16
Frame(margin, margin, content_w, frame_top - margin,
leftPadding=0, rightPadding=0,
topPadding=0, bottomPadding=0).addFromList(paras, cv)
# ---------------------------------------------------------------------------
# Table of contents
# ---------------------------------------------------------------------------
def _draw_toc_page(cv: Canvas, entries: list[tuple[str, int]],
pw: float, ph: float) -> None:
margin = 0.75 * inch
content_w = pw - 2 * margin
cv.setFont("Helvetica-Bold", 20)
cv.setFillColorRGB(0, 0, 0)
heading = "Contents"
hw = cv.stringWidth(heading, "Helvetica-Bold", 20)
cv.drawString((pw - hw) / 2, ph - margin - 20, heading)
y = ph - margin - 54
entry_font = "Helvetica"
entry_size = 11
dot_spacing = 4.5
for label, page_num in entries:
if y < margin + 20:
break
cv.setFont(entry_font, entry_size)
cv.setFillColorRGB(0, 0, 0)
cv.drawString(margin, y, label)
pn_text = str(page_num)
pn_w = cv.stringWidth(pn_text, entry_font, entry_size)
cv.drawRightString(pw - margin, y, pn_text)
# Dotted leader
label_end = margin + cv.stringWidth(label, entry_font, entry_size) + 8
dots_end = pw - margin - pn_w - 8
cv.setFillColorRGB(0.5, 0.5, 0.5)
x = label_end
while x < dots_end:
cv.drawString(x, y, ".")
x += dot_spacing
y -= 22
cv.setFillColorRGB(0, 0, 0)
# ---------------------------------------------------------------------------
# Page number
# ---------------------------------------------------------------------------
def _draw_page_number(cv: Canvas, display_num: int,
pw: float, ph: float) -> None:
cv.setFont(_PAGE_NUM_FONT, _PAGE_NUM_SIZE)
cv.setFillColorRGB(0.4, 0.4, 0.4)
text = str(display_num)
tw = cv.stringWidth(text, _PAGE_NUM_FONT, _PAGE_NUM_SIZE)
cv.drawString((pw - tw) / 2, 0.32 * inch, text)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def render_kdp_book(
book: PuzzleBook,
output_path: str,
include_toc: bool = True,
include_instructions: bool = True,
) -> int:
"""Render a complete KDP-ready PDF book.
Returns the total page count.
Raises ValueError if the resulting page count falls outside KDP's
allowed range (24–828).
"""
trim = book.trim_size
pw = trim.width * inch
ph = trim.height * inch
n = len(book.puzzles)
if n == 0:
raise ValueError("No puzzles to render.")
# --- page layout --------------------------------------------------------
total_pages = _estimate_page_count(
n, book.solutions_at_end, include_toc, include_instructions)
if total_pages < KDP_MIN_PAGES:
logger.warning(
"Book has %d pages, below KDP minimum of %d. "
"Consider adding more puzzles.", total_pages, KDP_MIN_PAGES)
if total_pages > KDP_MAX_PAGES:
raise ValueError(
f"Book has {total_pages} pages, exceeding KDP maximum of "
f"{KDP_MAX_PAGES}.")
gutter = _gutter_margin(total_pages)
logger.info("Book layout: %d pages, trim %s, gutter %.3f\".",
total_pages, trim, gutter)
# --- TOC entries (display page numbers) ---------------------------------
toc_entries: list[tuple[str, int]] = []
if include_toc:
if book.solutions_at_end:
for i in range(n):
toc_entries.append((f"Puzzle {i + 1}", i + 1))
toc_entries.append(("Solutions", n + 1))
else:
for i in range(n):
toc_entries.append((f"Puzzle {i + 1}", i * 2 + 1))
# --- render -------------------------------------------------------------
cv = Canvas(output_path, pagesize=(pw, ph))
abs_page = 0
# -- front matter (no page numbers) --
abs_page += 1
_draw_title_page(cv, book.title, book.author, pw, ph)
cv.showPage()
abs_page += 1
_draw_copyright_page(cv, book.title, book.author, pw, ph)
cv.showPage()
if include_toc:
abs_page += 1
_draw_toc_page(cv, toc_entries, pw, ph)
cv.showPage()
if include_instructions:
abs_page += 1
_draw_instructions_page(cv, pw, ph, book.solutions_at_end)
cv.showPage()
# Pad so puzzles start on a recto (odd absolute page)
while abs_page % 2 != 0:
abs_page += 1
cv.showPage()
# -- content pages -------------------------------------------------------
display = 0
if book.solutions_at_end:
for i, puzzle in enumerate(book.puzzles, 1):
abs_page += 1
display += 1
cx, cy, cw, ch = _content_rect(trim, abs_page, gutter)
render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch)
_draw_page_number(cv, display, pw, ph)
cv.showPage()
for i, puzzle in enumerate(book.puzzles, 1):
abs_page += 1
display += 1
cx, cy, cw, ch = _content_rect(trim, abs_page, gutter)
render_solution_page(cv, puzzle, i, cx, cy, cw, ch)
_draw_page_number(cv, display, pw, ph)
cv.showPage()
else:
for i, puzzle in enumerate(book.puzzles, 1):
abs_page += 1
display += 1
cx, cy, cw, ch = _content_rect(trim, abs_page, gutter)
render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch)
_draw_page_number(cv, display, pw, ph)
cv.showPage()
abs_page += 1
display += 1
cx, cy, cw, ch = _content_rect(trim, abs_page, gutter)
render_solution_page(cv, puzzle, i, cx, cy, cw, ch)
_draw_page_number(cv, display, pw, ph)
cv.showPage()
cv.save()
logger.info("Wrote %d-page KDP book to %s.", abs_page, output_path)
return abs_page
+110
View File
@@ -0,0 +1,110 @@
"""CLI entry point for the Crossword KDP Book Generator."""
import logging
import sys
import click
from csv_reader import CSVValidationError, read_csv
from generator import generate_batch
from kdp_format import render_kdp_book
from models import TRIM_SIZES, PuzzleBook
@click.command()
@click.option("--input", "input_path", required=True,
type=click.Path(exists=True, dir_okay=False),
help="Path to a CSV file with word,clue columns.")
@click.option("--output", "output_path", default="output.pdf",
show_default=True,
help="Output PDF file path.")
@click.option("--puzzles", default=20, show_default=True,
help="Number of crossword puzzles to generate.")
@click.option("--grid-size", default=13, show_default=True,
help="Grid dimensions (e.g. 15 for a 15×15 grid).")
@click.option("--trim-size", default="8.5x11", show_default=True,
type=click.Choice(list(TRIM_SIZES.keys())),
help="KDP page trim size.")
@click.option("--title", default="Crossword Puzzles", show_default=True,
help="Book title for the cover page.")
@click.option("--author", default="", help="Author name.")
@click.option("--words-per-puzzle", default=20, show_default=True,
help="Target number of words per puzzle.")
@click.option("--solutions-at-end", is_flag=True, default=False,
help="Group all solution pages at the back of the book.")
@click.option("--seed", default=None, type=int,
help="Random seed for reproducible output.")
@click.option("--no-toc", is_flag=True, default=False,
help="Omit the table of contents page.")
@click.option("--no-instructions", is_flag=True, default=False,
help="Omit the how-to-solve instructions page.")
@click.option("-v", "--verbose", is_flag=True, default=False,
help="Enable verbose logging.")
def main(input_path: str, output_path: str, puzzles: int, grid_size: int,
trim_size: str, title: str, author: str, words_per_puzzle: int,
solutions_at_end: bool, seed: int | None, no_toc: bool,
no_instructions: bool, verbose: bool) -> None:
"""Generate a KDP-ready crossword puzzle book from a CSV word list."""
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(levelname)s: %(message)s",
)
# -- read & validate CSV -------------------------------------------------
try:
word_clues = read_csv(input_path, min_words=3,
max_word_length=grid_size)
except (CSVValidationError, FileNotFoundError) as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
click.echo(f"Loaded {len(word_clues)} words from {input_path}.")
# -- generate puzzles ----------------------------------------------------
click.echo(f"Generating {puzzles} puzzle(s) "
f"({words_per_puzzle} words each, {grid_size}×{grid_size} grid)…")
generated = generate_batch(
word_clues,
count=puzzles,
words_per_puzzle=words_per_puzzle,
grid_size=grid_size,
seed=seed,
)
if not generated:
click.echo("Error: could not generate any puzzles. "
"Try a larger word list or smaller grid.", err=True)
sys.exit(1)
if len(generated) < puzzles:
click.echo(f"Warning: only {len(generated)}/{puzzles} puzzles met "
f"quality thresholds.", err=True)
click.echo(f"Generated {len(generated)} puzzle(s).")
# -- assemble KDP book ---------------------------------------------------
book = PuzzleBook(
title=title,
author=author,
puzzles=generated,
trim_size=TRIM_SIZES[trim_size],
solutions_at_end=solutions_at_end,
)
try:
total_pages = render_kdp_book(
book, output_path,
include_toc=not no_toc,
include_instructions=not no_instructions,
)
except ValueError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
click.echo(f"Wrote {total_pages}-page KDP book to {output_path}.")
if __name__ == "__main__":
main()
+131
View File
@@ -0,0 +1,131 @@
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class Direction(Enum):
ACROSS = "across"
DOWN = "down"
@dataclass
class WordClue:
"""A word-clue pair parsed from the input CSV."""
word: str
clue: str
difficulty: Optional[str] = None
category: Optional[str] = None
@dataclass
class Cell:
"""A single cell in the crossword grid."""
letter: Optional[str] = None
number: Optional[int] = None
@property
def is_empty(self) -> bool:
return self.letter is None
@property
def is_numbered(self) -> bool:
return self.number is not None
@dataclass
class PlacedWord:
"""A word placed on the crossword grid with its position and direction."""
word: str
clue: str
row: int
col: int
direction: Direction
number: Optional[int] = None
@property
def length(self) -> int:
return len(self.word)
def cells(self) -> list[tuple[int, int]]:
"""Return the (row, col) positions this word occupies."""
if self.direction == Direction.ACROSS:
return [(self.row, self.col + i) for i in range(self.length)]
return [(self.row + i, self.col) for i in range(self.length)]
def end_row(self) -> int:
if self.direction == Direction.DOWN:
return self.row + self.length - 1
return self.row
def end_col(self) -> int:
if self.direction == Direction.ACROSS:
return self.col + self.length - 1
return self.col
@dataclass
class NumberedClue:
"""A clue with its assigned number, ready for display."""
number: int
clue: str
word: str
direction: Direction
@dataclass
class Crossword:
"""A complete crossword puzzle: grid, placed words, and numbered clues."""
grid: list[list[Cell]]
placed_words: list[PlacedWord]
across_clues: list[NumberedClue] = field(default_factory=list)
down_clues: list[NumberedClue] = field(default_factory=list)
width: int = 0
height: int = 0
@property
def word_count(self) -> int:
return len(self.placed_words)
@property
def density(self) -> float:
"""Fraction of grid cells that contain letters."""
total = self.width * self.height
if total == 0:
return 0.0
filled = sum(1 for row in self.grid for cell in row if not cell.is_empty)
return filled / total
@dataclass
class TrimSize:
"""KDP trim size specification in inches."""
width: float
height: float
label: str
def __str__(self) -> str:
return self.label
TRIM_SIZES: dict[str, TrimSize] = {
"8.5x11": TrimSize(8.5, 11.0, "8.5\" x 11\""),
"6x9": TrimSize(6.0, 9.0, "6\" x 9\""),
"8x10": TrimSize(8.0, 10.0, "8\" x 10\""),
}
@dataclass
class PuzzleBook:
"""A collection of crossword puzzles forming a book."""
title: str
author: str
puzzles: list[Crossword] = field(default_factory=list)
trim_size: TrimSize = field(default_factory=lambda: TRIM_SIZES["8.5x11"])
solutions_at_end: bool = False
+261
View File
@@ -0,0 +1,261 @@
"""PDF rendering for crossword puzzle grids and clues.
Draws crossword grids (empty for puzzle pages, filled for solution pages)
and lays out numbered clues in a two-column format. All drawing happens
within caller-supplied content rectangles so the KDP formatter can
control margins, page size, and front matter independently.
"""
from typing import Optional
from xml.sax.saxutils import escape as xml_escape
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.units import inch
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Frame, Paragraph
from models import Crossword
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_TITLE_FONT = "Helvetica-Bold"
_TITLE_SIZE = 18
_SOLUTION_TITLE_SIZE = 14
_NUMBER_FONT = "Helvetica"
_LETTER_FONT = "Helvetica-Bold"
# Cell sizes (points). ~0.39" for puzzle pages (room to write),
# ~0.28" for solution pages (just needs to be legible).
_MAX_PUZZLE_CELL = 28.0
_MAX_SOLUTION_CELL = 20.0
# Max fraction of the content height the grid is allowed to consume,
# leaving the rest for clues (puzzle) or whitespace (solution).
_PUZZLE_GRID_RATIO = 0.55
_SOLUTION_GRID_RATIO = 0.75
# Colours (RGB 0-1)
_BLACK_FILL = (0.12, 0.12, 0.12)
_GRID_LINE = (0.45, 0.45, 0.45)
_OUTER_BORDER = (0, 0, 0)
_SOLUTION_TITLE_CLR = (0.35, 0.35, 0.35)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _calc_cell_size(cols: int, rows: int,
avail_w: float, avail_h: float,
max_cell: float, h_ratio: float) -> float:
"""Largest cell size that fits *cols*×*rows* inside the available area."""
by_w = avail_w / cols
by_h = (avail_h * h_ratio) / rows
return min(by_w, by_h, max_cell)
def _draw_grid(cv: Canvas, crossword: Crossword,
grid_x: float, grid_top_y: float,
cell_size: float, show_letters: bool) -> float:
"""Render the crossword grid. Returns the y-coordinate of the bottom."""
for ri, row in enumerate(crossword.grid):
for ci, cell in enumerate(row):
x = grid_x + ci * cell_size
y = grid_top_y - (ri + 1) * cell_size # bottom-left of cell
if cell.is_empty:
cv.setFillColorRGB(*_BLACK_FILL)
cv.setStrokeColorRGB(*_BLACK_FILL)
cv.setLineWidth(0.5)
cv.rect(x, y, cell_size, cell_size, fill=1, stroke=1)
continue
# White cell
cv.setFillColorRGB(1, 1, 1)
cv.setStrokeColorRGB(*_GRID_LINE)
cv.setLineWidth(0.5)
cv.rect(x, y, cell_size, cell_size, fill=1, stroke=1)
# Clue number (small, top-left corner)
if cell.is_numbered:
n_size = max(5.0, cell_size * 0.25)
cv.setFont(_NUMBER_FONT, n_size)
cv.setFillColorRGB(0, 0, 0)
cv.drawString(x + 1.5, y + cell_size - n_size - 0.5,
str(cell.number))
# Letter (solution pages only)
if show_letters and cell.letter:
l_size = cell_size * 0.50
cv.setFont(_LETTER_FONT, l_size)
cv.setFillColorRGB(0, 0, 0)
lw = cv.stringWidth(cell.letter, _LETTER_FONT, l_size)
# Shift down a bit when a number is present
ly = y + cell_size * 0.08 if cell.is_numbered else y + cell_size * 0.18
cv.drawString(x + (cell_size - lw) / 2, ly, cell.letter)
# Thick outer border
grid_w = crossword.width * cell_size
grid_h = crossword.height * cell_size
cv.setStrokeColorRGB(*_OUTER_BORDER)
cv.setLineWidth(1.5)
cv.rect(grid_x, grid_top_y - grid_h, grid_w, grid_h, fill=0, stroke=1)
cv.setLineWidth(0.5) # reset
return grid_top_y - grid_h
def _draw_clues(cv: Canvas, crossword: Crossword,
x: float, y_bottom: float,
width: float, height: float) -> None:
"""Draw ACROSS / DOWN clues in two columns with automatic text wrap."""
clue_style = ParagraphStyle(
"Clue", fontName="Helvetica", fontSize=10, leading=12,
spaceBefore=1, spaceAfter=1,
)
header_style = ParagraphStyle(
"ClueHeader", fontName="Helvetica-Bold", fontSize=12,
leading=16, spaceBefore=0, spaceAfter=4,
)
gap = 18
col_w = (width - gap) / 2
# Across (left column)
across_items: list[Paragraph] = [Paragraph("ACROSS", header_style)]
for cl in crossword.across_clues:
across_items.append(
Paragraph(f"<b>{cl.number}.</b> {xml_escape(cl.clue)}",
clue_style))
Frame(x, y_bottom, col_w, height,
leftPadding=0, rightPadding=4,
topPadding=0, bottomPadding=0).addFromList(across_items, cv)
# Down (right column)
down_items: list[Paragraph] = [Paragraph("DOWN", header_style)]
for cl in crossword.down_clues:
down_items.append(
Paragraph(f"<b>{cl.number}.</b> {xml_escape(cl.clue)}",
clue_style))
Frame(x + col_w + gap, y_bottom, col_w, height,
leftPadding=0, rightPadding=4,
topPadding=0, bottomPadding=0).addFromList(down_items, cv)
# ---------------------------------------------------------------------------
# Public page-level functions
# ---------------------------------------------------------------------------
def render_puzzle_page(cv: Canvas, crossword: Crossword,
puzzle_num: int,
content_x: float, content_top_y: float,
content_w: float, content_h: float,
title: Optional[str] = None) -> None:
"""Draw a puzzle page (empty grid + clues) inside the content rectangle.
*content_top_y* is the top edge of the content area (ReportLab's
y-axis points upward).
"""
# -- title ---------------------------------------------------------------
label = title or f"Puzzle {puzzle_num}"
cv.setFont(_TITLE_FONT, _TITLE_SIZE)
cv.setFillColorRGB(0, 0, 0)
tw = cv.stringWidth(label, _TITLE_FONT, _TITLE_SIZE)
cv.drawString(content_x + (content_w - tw) / 2,
content_top_y - _TITLE_SIZE, label)
title_block = _TITLE_SIZE + 12 # title height + gap
# -- grid ----------------------------------------------------------------
cs = _calc_cell_size(crossword.width, crossword.height,
content_w, content_h - title_block,
_MAX_PUZZLE_CELL, _PUZZLE_GRID_RATIO)
grid_w = crossword.width * cs
grid_x = content_x + (content_w - grid_w) / 2
grid_top = content_top_y - title_block
grid_bottom = _draw_grid(cv, crossword, grid_x, grid_top, cs,
show_letters=False)
# -- clues ---------------------------------------------------------------
clue_gap = 14
clue_area_top = grid_bottom - clue_gap
clue_area_bottom = content_top_y - content_h # = page bottom margin
clue_h = clue_area_top - clue_area_bottom
if clue_h > 30:
_draw_clues(cv, crossword, content_x, clue_area_bottom,
content_w, clue_h)
def render_solution_page(cv: Canvas, crossword: Crossword,
puzzle_num: int,
content_x: float, content_top_y: float,
content_w: float, content_h: float) -> None:
"""Draw a solution page (filled grid, no clues)."""
label = f"Solution to Puzzle {puzzle_num}"
cv.setFont(_TITLE_FONT, _SOLUTION_TITLE_SIZE)
cv.setFillColorRGB(*_SOLUTION_TITLE_CLR)
tw = cv.stringWidth(label, _TITLE_FONT, _SOLUTION_TITLE_SIZE)
cv.drawString(content_x + (content_w - tw) / 2,
content_top_y - _SOLUTION_TITLE_SIZE, label)
title_block = _SOLUTION_TITLE_SIZE + 10
cs = _calc_cell_size(crossword.width, crossword.height,
content_w, content_h - title_block,
_MAX_SOLUTION_CELL, _SOLUTION_GRID_RATIO)
grid_w = crossword.width * cs
grid_x = content_x + (content_w - grid_w) / 2
_draw_grid(cv, crossword, grid_x, content_top_y - title_block, cs,
show_letters=True)
# ---------------------------------------------------------------------------
# Convenience: standalone PDF with basic margins
# ---------------------------------------------------------------------------
def render_puzzles_to_pdf(
puzzles: list[Crossword],
output_path: str,
page_width: float = 8.5 * inch,
page_height: float = 11.0 * inch,
margin: float = 0.75 * inch,
solutions_at_end: bool = False,
) -> None:
"""Render a list of puzzles to a PDF file.
Each puzzle gets a puzzle page followed by its solution page.
If *solutions_at_end* is True, all solution pages are grouped
at the back of the document instead.
This is a convenience wrapper; the KDP formatter provides full
control over layout, front matter, and page numbering.
"""
cv = Canvas(output_path, pagesize=(page_width, page_height))
cx = margin
cy = page_height - margin
cw = page_width - 2 * margin
ch = page_height - 2 * margin
if solutions_at_end:
for i, puzzle in enumerate(puzzles, 1):
render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
for i, puzzle in enumerate(puzzles, 1):
render_solution_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
else:
for i, puzzle in enumerate(puzzles, 1):
render_puzzle_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
render_solution_page(cv, puzzle, i, cx, cy, cw, ch)
cv.showPage()
cv.save()
+2
View File
@@ -0,0 +1,2 @@
reportlab>=4.1
click>=8.1
+51
View File
@@ -0,0 +1,51 @@
word,clue,difficulty,category
PYTHON,"A large snake, or a popular programming language",medium,Technology
ALGORITHM,"A step-by-step procedure for solving a problem",hard,Technology
KEYBOARD,"Input device with keys for typing",easy,Technology
MONITOR,"A screen used to display computer output",easy,Technology
DATABASE,"An organized collection of structured information",medium,Technology
NETWORK,"A group of interconnected computers",medium,Technology
SOFTWARE,"Programs and operating information used by a computer",medium,Technology
BROWSER,"Application used to access websites",easy,Technology
COMPILER,"Translates source code into machine code",hard,Technology
FUNCTION,"A reusable block of code that performs a task",medium,Technology
VARIABLE,"A named container for storing data values",easy,Technology
BOOLEAN,"A data type with only true or false values",medium,Technology
INTEGER,"A whole number without a fractional component",easy,Technology
LIBRARY,"A collection of prewritten code modules",medium,Technology
SYNTAX,"The set of rules that defines correct code structure",medium,Technology
PLANET,"A celestial body orbiting a star",easy,Science
GRAVITY,"The force that attracts objects toward each other",medium,Science
MOLECULE,"The smallest unit of a chemical compound",medium,Science
ELEMENT,"A substance made of only one type of atom",easy,Science
NEUTRON,"A subatomic particle with no electric charge",hard,Science
VOLCANO,"A mountain that can erupt with lava and ash",easy,Science
GLACIER,"A slow-moving mass of dense ice",medium,Science
ECLIPSE,"When one celestial body blocks light from another",medium,Science
FOSSIL,"Preserved remains of ancient organisms",easy,Science
NUCLEUS,"The central part of an atom or cell",medium,Science
OCEAN,"A vast body of salt water covering the earth",easy,Geography
ISLAND,"A piece of land surrounded by water",easy,Geography
DESERT,"A barren area with very little precipitation",easy,Geography
CANYON,"A deep gorge typically with a river flowing through",medium,Geography
MOUNTAIN,"A large natural elevation of the earth's surface",easy,Geography
CAPITAL,"The city serving as the seat of government",easy,Geography
EQUATOR,"An imaginary line around the middle of the earth",medium,Geography
HARBOR,"A sheltered body of water for anchoring ships",medium,Geography
PLATEAU,"A flat elevated area of land",medium,Geography
COMPASS,"A device that shows cardinal directions",easy,Geography
RHYTHM,"A strong regular repeated pattern of sound",medium,Language
SYNONYM,"A word having the same meaning as another",easy,Language
METAPHOR,"A figure of speech comparing unlike things directly",medium,Language
PRONOUN,"A word that substitutes for a noun",easy,Language
ADVERB,"A word that modifies a verb or adjective",easy,Language
SONNET,"A fourteen-line poem with a specific rhyme scheme",hard,Language
DIALECT,"A regional variety of a language",medium,Language
HYPHEN,"A punctuation mark used to join words",easy,Language
PHRASE,"A small group of words forming a conceptual unit",easy,Language
VOWEL,"A speech sound made without closing the mouth",easy,Language
RECIPE,"A set of instructions for preparing a dish",easy,Food
CUISINE,"A style of cooking from a particular region",medium,Food
HARVEST,"The process of gathering ripe crops",easy,Food
PROTEIN,"A nutrient essential for building body tissue",medium,Food
VITAMIN,"An organic compound essential in small quantities",medium,Food
1 word clue difficulty category
2 PYTHON A large snake, or a popular programming language medium Technology
3 ALGORITHM A step-by-step procedure for solving a problem hard Technology
4 KEYBOARD Input device with keys for typing easy Technology
5 MONITOR A screen used to display computer output easy Technology
6 DATABASE An organized collection of structured information medium Technology
7 NETWORK A group of interconnected computers medium Technology
8 SOFTWARE Programs and operating information used by a computer medium Technology
9 BROWSER Application used to access websites easy Technology
10 COMPILER Translates source code into machine code hard Technology
11 FUNCTION A reusable block of code that performs a task medium Technology
12 VARIABLE A named container for storing data values easy Technology
13 BOOLEAN A data type with only true or false values medium Technology
14 INTEGER A whole number without a fractional component easy Technology
15 LIBRARY A collection of prewritten code modules medium Technology
16 SYNTAX The set of rules that defines correct code structure medium Technology
17 PLANET A celestial body orbiting a star easy Science
18 GRAVITY The force that attracts objects toward each other medium Science
19 MOLECULE The smallest unit of a chemical compound medium Science
20 ELEMENT A substance made of only one type of atom easy Science
21 NEUTRON A subatomic particle with no electric charge hard Science
22 VOLCANO A mountain that can erupt with lava and ash easy Science
23 GLACIER A slow-moving mass of dense ice medium Science
24 ECLIPSE When one celestial body blocks light from another medium Science
25 FOSSIL Preserved remains of ancient organisms easy Science
26 NUCLEUS The central part of an atom or cell medium Science
27 OCEAN A vast body of salt water covering the earth easy Geography
28 ISLAND A piece of land surrounded by water easy Geography
29 DESERT A barren area with very little precipitation easy Geography
30 CANYON A deep gorge typically with a river flowing through medium Geography
31 MOUNTAIN A large natural elevation of the earth's surface easy Geography
32 CAPITAL The city serving as the seat of government easy Geography
33 EQUATOR An imaginary line around the middle of the earth medium Geography
34 HARBOR A sheltered body of water for anchoring ships medium Geography
35 PLATEAU A flat elevated area of land medium Geography
36 COMPASS A device that shows cardinal directions easy Geography
37 RHYTHM A strong regular repeated pattern of sound medium Language
38 SYNONYM A word having the same meaning as another easy Language
39 METAPHOR A figure of speech comparing unlike things directly medium Language
40 PRONOUN A word that substitutes for a noun easy Language
41 ADVERB A word that modifies a verb or adjective easy Language
42 SONNET A fourteen-line poem with a specific rhyme scheme hard Language
43 DIALECT A regional variety of a language medium Language
44 HYPHEN A punctuation mark used to join words easy Language
45 PHRASE A small group of words forming a conceptual unit easy Language
46 VOWEL A speech sound made without closing the mouth easy Language
47 RECIPE A set of instructions for preparing a dish easy Food
48 CUISINE A style of cooking from a particular region medium Food
49 HARVEST The process of gathering ripe crops easy Food
50 PROTEIN A nutrient essential for building body tissue medium Food
51 VITAMIN An organic compound essential in small quantities medium Food