commit 5493e927dee4ed1383724a775912685c022a94f1 Author: Stefanescu Mihai Date: Tue Mar 24 19:36:04 2026 +0200 first commit diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..7cd9f56 --- /dev/null +++ b/PLAN.md @@ -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~~ ✅ diff --git a/README.md b/README.md new file mode 100644 index 0000000..1d751eb --- /dev/null +++ b/README.md @@ -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) diff --git a/__pycache__/csv_reader.cpython-313.pyc b/__pycache__/csv_reader.cpython-313.pyc new file mode 100644 index 0000000..a88079b Binary files /dev/null and b/__pycache__/csv_reader.cpython-313.pyc differ diff --git a/__pycache__/generator.cpython-313.pyc b/__pycache__/generator.cpython-313.pyc new file mode 100644 index 0000000..9b663a4 Binary files /dev/null and b/__pycache__/generator.cpython-313.pyc differ diff --git a/__pycache__/kdp_format.cpython-313.pyc b/__pycache__/kdp_format.cpython-313.pyc new file mode 100644 index 0000000..bbbe643 Binary files /dev/null and b/__pycache__/kdp_format.cpython-313.pyc differ diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000..e09309c Binary files /dev/null and b/__pycache__/models.cpython-313.pyc differ diff --git a/__pycache__/renderer.cpython-313.pyc b/__pycache__/renderer.cpython-313.pyc new file mode 100644 index 0000000..f6f9e57 Binary files /dev/null and b/__pycache__/renderer.cpython-313.pyc differ diff --git a/csv_reader.py b/csv_reader.py new file mode 100644 index 0000000..8ae472f --- /dev/null +++ b/csv_reader.py @@ -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 diff --git a/generator.py b/generator.py new file mode 100644 index 0000000..110ceb2 --- /dev/null +++ b/generator.py @@ -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) diff --git a/kdp_format.py b/kdp_format.py new file mode 100644 index 0000000..c02a885 --- /dev/null +++ b/kdp_format.py @@ -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"{title}", style), + ] + if author: + items.append(Paragraph(f"Copyright © {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( + "Across clues are answered by writing letters " + "from left to right.", style), + Paragraph( + "Down 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 — 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 diff --git a/main.py b/main.py new file mode 100644 index 0000000..c3a3eea --- /dev/null +++ b/main.py @@ -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() diff --git a/models.py b/models.py new file mode 100644 index 0000000..5e18a28 --- /dev/null +++ b/models.py @@ -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 diff --git a/renderer.py b/renderer.py new file mode 100644 index 0000000..8465035 --- /dev/null +++ b/renderer.py @@ -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"{cl.number}. {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"{cl.number}. {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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..efee9de --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +reportlab>=4.1 +click>=8.1 diff --git a/sample_words.csv b/sample_words.csv new file mode 100644 index 0000000..725ba08 --- /dev/null +++ b/sample_words.csv @@ -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