first commit
This commit is contained in:
@@ -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~~ ✅
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 © {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 — 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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -0,0 +1,2 @@
|
||||
reportlab>=4.1
|
||||
click>=8.1
|
||||
@@ -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
|
||||
|
Reference in New Issue
Block a user