From 5493e927dee4ed1383724a775912685c022a94f1 Mon Sep 17 00:00:00 2001 From: Stefanescu Mihai Date: Tue, 24 Mar 2026 19:36:04 +0200 Subject: [PATCH] first commit --- PLAN.md | 204 +++++++++++++ README.md | 143 +++++++++ __pycache__/csv_reader.cpython-313.pyc | Bin 0 -> 7359 bytes __pycache__/generator.cpython-313.pyc | Bin 0 -> 17549 bytes __pycache__/kdp_format.cpython-313.pyc | Bin 0 -> 13653 bytes __pycache__/models.cpython-313.pyc | Bin 0 -> 7285 bytes __pycache__/renderer.cpython-313.pyc | Bin 0 -> 10147 bytes csv_reader.py | 166 +++++++++++ generator.py | 393 +++++++++++++++++++++++++ kdp_format.py | 384 ++++++++++++++++++++++++ main.py | 110 +++++++ models.py | 131 +++++++++ renderer.py | 261 ++++++++++++++++ requirements.txt | 2 + sample_words.csv | 51 ++++ 15 files changed, 1845 insertions(+) create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 __pycache__/csv_reader.cpython-313.pyc create mode 100644 __pycache__/generator.cpython-313.pyc create mode 100644 __pycache__/kdp_format.cpython-313.pyc create mode 100644 __pycache__/models.cpython-313.pyc create mode 100644 __pycache__/renderer.cpython-313.pyc create mode 100644 csv_reader.py create mode 100644 generator.py create mode 100644 kdp_format.py create mode 100644 main.py create mode 100644 models.py create mode 100644 renderer.py create mode 100644 requirements.txt create mode 100644 sample_words.csv 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 0000000000000000000000000000000000000000..a88079bfbb1b1c5df2a9f1c43d735fa1df476b30 GIT binary patch literal 7359 zcmdTpTWlNIb$9rFiBDM+Wm~?MCEN5$lppfSPuX6{57`#A%h_2cl^SMD4yCcF8SR~+ zWf4KEwm=~vAGVVkrjry=vD-ia^5LQd>a_WAf&eYh2PktBvSTMOPUBn`m zxr9iyX9%Nzn}`W|(piLWb zvsU6^ZLIx(i8Qee)(O406?Gui)rMI2xbLvJ2I*%#;{mp5JkV!hy=~~4g#=ljK4KdW zSOEye{rY&Q9knC2l?{vs#w|T|gxKJCGaDMWjW<&`ps{7#p<_7MW{Po*Aua3{V-)7Y zfN8*(YP4H)+O2Pd3+r%^2__nOL0OMS8O`!8uTDbm7@bx{S>m(w{fbP|gV~($g6>eW z%)qB+Nx^{TN{g9{n960_V#41hQzW(Az##jJvF)RAI8 zge}-KM=~k#Q$jMSxsu5#Ii1VWcTY0;VUEw%W_-zHMkI=w6(vEEVcMQdrsWj8Y)K{+ z6?jM`c~vFi)to9Q$t3XtD=+Duw9#Hp5s1<|B~J2UOi_glF9}L2*ZVG!uL&tt>79mY zD!SejkrjnXtLJ)px@SU=1j4H_=}jp&l0@Ls0*Ot}5)V+-A9TA4)#0C_rCyH9~M-#$ymQM+oPo)G&1+}CmdBUeu*fO3G)k&%$k>E4z;P5aOKFo=r z9spEgnk9bj(vXH;VgC@UqqDyYVb}tPLg+##LK5?)&2Wst+TI$gJZx=^UC{4hODYjo@QVJCh#-s&0&3S?< z?dCKY&~}1Z5OF$L-MA7vk(E3}8!SAFCfgin9-goNpzEh8w z^1gTO9k_GgJL%sk4uJXN9WmxFoiQeACfoIuB$-TsCFcdra!nScsEJT|nnlW7Pp{+2 zyPgpOdqm(FkrIW+9D43Vjk1eh*cTqrs0&PctyZxdVUYfQg$|m6Z-j5a9oxI zEM~AM>AoeVF_>(HMKz`;9<}NM*Q~lVXjYw$W+&BSq1i=+&rVPBpuRnTLFj(hY0sCe zD>_MeA-w?6MxidjgQ_o5*!BJB{pfe!D!F%;-QCOX?wS^Tw@xgaD3}Y0k{g%ZdzamN zmqJTdOYQ?y#rou}frWvJF9ct6m#PYOP)9ipE?sj@iITpeqWPxyoBC@Kj^>0qnOdt^ zH?W~wfDL$GZvn<}lgym?b=FYV0yxdFx`zSZWA>s$CWDH=jMc4y10Azw)^gNrLXu^S z9uU1Hj4R$4fW8B97K4Ul<*W(nKR2`#>|)Gk46-%@USKwd51|iiis=BT3Y-r{NX{ez z26m%2EIAQsj2UmnoM;;9s5%|VF4@ND!PFaP?}5EY4$fgnhd;B9M?33C&=bF*jYW(B z&SC7r=)H(_z7flAKpQ>ipe{dwVO^a4I4mJKS$FNtHAanHZ^o`9cQ2AWtVe1Zquxqy zoRw{2y+=V}=a~!tick(5=k$Maz65oF8yf3toZ;6=R`P!VzW+<&2RMISzF=Kk!1zi7 z-(%QdE3wOXYxHb@^BR5zBd&=J_B4T9nnpeZ%q|0t^BZ!Mf?P1w{zj@H&I>rT6Sj`- zp9AimI^Cc_cg$cJ9fOdR2TtF>cQdRIY+xO>RB5hTs|2zz8HD)*o3G57j{O;|2=@Di z6}RXsZo)ffI$S&NuzHYlQ_JGP9^OE&h0@zhM(3H4{lK|9@s=T{Mh_UZ+X>ck*7w#A z@SEG9>&w~D>rVGc##ko})($Czb(CSPQiyXI<2>BLU$DOW%dP(fYczA7L{DS)#@iUZ zKlBFj4sao^nGGMUpKlCjb7Z;dk60`H*Rb%0?OpL0lH?fO=HUFV@t&;>NUjbEz9+&l z@N7F|HLwNdN>g19>#YbZZewG&a!s32USkKI&b5}a8I5h5QGna&;KtP;MrfQtpsScL z{3Kfj?L^(k;F`t_8JO7ty2U8|t&`>*2m;6Ld=VxR5V2!kN*fsm1i_75M9%1;g`xw{ z)JTcBTxZCHa?FLH8Q1VJO!F%mfUM1AVmeIyfB`OLmjg76~ zl$^~?NeY&UE(}IZ#*;jyjwkiJ4ikAMmfuMMU$fg|IB)94d1nmkJj3ENwk0&^A>d!o3;KWr9D+@r zsCqC-959sMeHmE7UBGU2c|EEH9;o4UW97OyJxvw+0<0G0kJf>8ur2^IBrC~Mj~=hC zN#oYwZ-PneFA0-UzQ0KyN_Jnw8-wX#T|6_qgH2*`eg%=w)ss z&XP`j6$O+cq%|`ff6Xa?^Pu$xK{HRlw=FBvW3Sn3r(RDyG&5uXnr~z{p1d-e7)o9~ z8$bX41~_J%UNt5M-?KjOKu} zwVFNA(@-^o`pHqYz!-H0ue54T@1RU3LEdBx0C&Mp*#k(l04 zt4=Fmc-CTk>TCb_(4zX$(WO5u?HYI$CtR?6d$^nk;))l4 z99q2c(Y8gzwUly`3L7uou4=^K63}>;=lW^2@?vPVLopH zHh$qe@IMM^uV)l0)G#V{w;-r;(J5;XNucmk?N68LDm=e%Cg&F+b5_S!3y^XToZ zxxfXdp)w3^j9i3)A6m#b9k)%;^%TuClhVxBg;|9rYt&IyeN#Z6PF|g@J4TWQfP3&$ z&cI_1J=?o)?%We^Xx>!u`fpudxL)?|E_`*~Tyc2Ij>xhDsz(J;;3C@k^RiXcvnrYk|F+KjQmYZd`$Qv_jR5K-{#oQEV##Vg;#ppLg5~QeW zgnXR}b-nmQb@4Z7k_muYq_CSQU}|Bi?A=qi`F~RYR0od$#W;iqC!p8t^lpZLty&7U z)9W-VUaf@=>MV`r2;~$u1C{HnQoWO%aIIg~FLgpoUF0l%T%eEB@X-9D@4i1g8b5Q{ z2of~sS%|pwKkMir1;hy|(W{xYSU{9i%|c^*{btlGX$VJj`_pBh{|Th}M+v<)?l@8X z+sXh@U^xbUiUXeDf*9rray>!bCn)$U)cPy5^*_+oXHI|F+4k7kRya~}cFx(J1w(Vb zN^@J`Trsq(i2RjiytuCz>Vel)8^atfL{<@WD-JU=#1ul{&Y)imt?K=X$Hw^SAaskZ zOS}GJ`zn21X+bUFN>}tV`-cn@s(1sphZe4_nCZteUvR}rU*H_Hv=>r$et$&=0yk)L z&F}rAul>fU&rX_cOtA3&;@BT_?B_q(JK8h5LU{ b1?qgDJREw|`r~-9|Kjq&OTVE&x}^RW>hN&0 literal 0 HcmV?d00001 diff --git a/__pycache__/generator.cpython-313.pyc b/__pycache__/generator.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b663a4a81c1fefa9824fc6d547ae6a2cc1dfc56 GIT binary patch literal 17549 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 literal 0 HcmV?d00001 diff --git a/__pycache__/kdp_format.cpython-313.pyc b/__pycache__/kdp_format.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bbbe643b93048202edd8b5a4b67a4afdac708754 GIT binary patch literal 13653 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 literal 0 HcmV?d00001 diff --git a/__pycache__/models.cpython-313.pyc b/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e09309ccd5a9b939d99a2e653a5b44cd96634652 GIT binary patch literal 7285 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 literal 0 HcmV?d00001 diff --git a/__pycache__/renderer.cpython-313.pyc b/__pycache__/renderer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f6f9e5768ad3977cd084c8b57dea6652e99180fd GIT binary patch literal 10147 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 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