Initial commit
This commit is contained in:
369
wordsearch-specs.md
Normal file
369
wordsearch-specs.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Word Search Puzzle Generator — Spec
|
||||
|
||||
## Overview
|
||||
A self-hosted web app that generates printable word search puzzles for kids
|
||||
(and grownups). Themed word lists are managed through the UI and stored as
|
||||
JSON files on disk. Every puzzle is configured explicitly per generation —
|
||||
no sticky difficulty presets.
|
||||
|
||||
## Tech Stack
|
||||
- **Python 3.14+**
|
||||
- **FastAPI** — web framework
|
||||
- **Jinja2** — server-rendered HTML templates (no JS framework, no build step)
|
||||
- **reportlab** — PDF generation
|
||||
- **uvicorn** — ASGI server
|
||||
- **Vanilla JS** — minimal, only for the theme editor (textarea + fetch)
|
||||
- **Storage:** JSON files on disk under `themes/`. No database.
|
||||
|
||||
Single language, single process, no build pipeline, no DB.
|
||||
|
||||
## Deployment
|
||||
- Dockerfile + `docker-compose.yml`
|
||||
- Single container, single port (default `8000`)
|
||||
- Mount `./themes` as a volume
|
||||
- Behind Pangolin/Traefik if exposing on a subdomain; otherwise hit the LXC IP
|
||||
|
||||
## Directory Layout
|
||||
```
|
||||
wordsearch/
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI routes
|
||||
│ ├── generator.py # Grid building + word placement
|
||||
│ ├── normaliser.py # Word normalisation + prefix stripping
|
||||
│ ├── pdf.py # PDF rendering (reportlab)
|
||||
│ ├── themes.py # Load / save / list theme JSON files
|
||||
│ └── templates/
|
||||
│ ├── base.html
|
||||
│ ├── index.html # Generate form
|
||||
│ ├── themes.html # Theme list
|
||||
│ └── theme_edit.html
|
||||
├── themes/ # JSON theme files (mounted volume)
|
||||
├── static/style.css
|
||||
├── requirements.txt
|
||||
├── Dockerfile
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
### `/` — Generate Puzzle
|
||||
```
|
||||
Theme: [ Mr Men Characters ▾ ]
|
||||
|
||||
Grid size: [ 12 ] (5 – 25)
|
||||
Words: [ 10 ] (1 – 30)
|
||||
Min length: [ 3 ]
|
||||
Max length: [ 12 ] (clamped to grid size)
|
||||
|
||||
Title: [ ] (optional, overrides theme name)
|
||||
|
||||
Directions:
|
||||
☑ Horizontal (→) — always on, locked
|
||||
☑ Vertical (↓) — always on, locked
|
||||
☐ Diagonal (↘ ↗)
|
||||
☐ Reversed (← ↑ ↖ ↙)
|
||||
|
||||
☐ Allow overlapping words
|
||||
|
||||
[ Generate ]
|
||||
```
|
||||
|
||||
- All optional toggles default **off** on every page load — no sticky state.
|
||||
- "Generate" → POSTs the form, streams the PDF straight back as a download
|
||||
(`Content-Disposition: attachment; filename="<slug>_<timestamp>.pdf"`).
|
||||
- The server keeps no copy on disk; the browser is the only place the PDF
|
||||
lives.
|
||||
|
||||
### `/themes` — Theme List
|
||||
- Table of existing themes: name, slug, word count, edit/delete buttons
|
||||
- "New theme" button → `/themes/new`
|
||||
|
||||
### `/themes/new` and `/themes/{slug}/edit` — Theme Editor
|
||||
```
|
||||
Theme name: [ ] (display name, e.g. "Sea Creatures")
|
||||
Slug: [ ] (filename; auto-generated on create, locked on edit)
|
||||
|
||||
Words (one per line):
|
||||
┌────────────────────────────────────────┐
|
||||
│ Mr Tickle │
|
||||
│ Mr Happy │
|
||||
│ Little Miss Sunshine │
|
||||
│ ... │
|
||||
└────────────────────────────────────────┘
|
||||
|
||||
Live preview:
|
||||
Mr Tickle → TICKLE
|
||||
Mr Happy → HAPPY
|
||||
Little Miss Sunshine → LITTLEMISSSUNSHINE
|
||||
|
||||
[ Save ] [ Delete ]
|
||||
```
|
||||
|
||||
- The live preview shows what each word will look like in the grid after
|
||||
normalisation. Updates on textarea change (debounced).
|
||||
- Save writes `themes/<slug>.json`. Delete confirms then removes.
|
||||
|
||||
### Auth
|
||||
None. Homelab use. Add HTTP basic auth via FastAPI middleware if exposed
|
||||
publicly later.
|
||||
|
||||
## Routes
|
||||
| Method | Path | Purpose |
|
||||
|--------|----------------------------|----------------------------------------|
|
||||
| GET | `/` | Generate form |
|
||||
| POST | `/generate` | Build puzzle, return PDF download |
|
||||
| GET | `/themes` | List themes |
|
||||
| GET | `/themes/new` | New theme form |
|
||||
| GET | `/themes/{slug}/edit` | Edit theme form |
|
||||
| POST | `/themes` | Create theme |
|
||||
| POST | `/themes/{slug}` | Update theme |
|
||||
| POST | `/themes/{slug}/delete` | Delete theme |
|
||||
| GET | `/api/themes` | JSON list of themes (dropdown source) |
|
||||
| POST | `/api/normalise` | Preview normalisation for a list of |
|
||||
| | | words (used by the editor live preview)|
|
||||
|
||||
## Word Normalisation
|
||||
|
||||
For every input word, the generator produces two forms:
|
||||
|
||||
- **Display form** — original string, untouched. Goes on the PDF word list.
|
||||
- **Grid form** — what gets placed in the grid.
|
||||
|
||||
### Grid form rules
|
||||
1. Strip any **leading prefix tokens** (case-insensitive, with or without
|
||||
trailing dot). Stripping is **token-based**, not substring — `"Misty"`
|
||||
does not match `"Miss"`.
|
||||
2. Uppercase the result.
|
||||
3. Strip all whitespace and punctuation.
|
||||
|
||||
### Stripped prefixes
|
||||
A constant in `normaliser.py`:
|
||||
```python
|
||||
PREFIXES = {
|
||||
"mr", "mrs", "ms", "miss",
|
||||
"dr", "sir", "dame", "lord", "lady", "master",
|
||||
"captain", "capt", "cpt",
|
||||
"professor", "prof",
|
||||
"saint", "st",
|
||||
}
|
||||
```
|
||||
|
||||
### Examples
|
||||
| Input | Display form | Grid form |
|
||||
|-------------------------|-----------------------|---------------------|
|
||||
| `Mr Tickle` | `Mr Tickle` | `TICKLE` |
|
||||
| `Mr. Bump` | `Mr. Bump` | `BUMP` |
|
||||
| `Little Miss Sunshine` | `Little Miss Sunshine`| `LITTLEMISSSUNSHINE`|
|
||||
| `Dr Octopus` | `Dr Octopus` | `OCTOPUS` |
|
||||
| `Sir Lancelot` | `Sir Lancelot` | `LANCELOT` |
|
||||
| `Misty` | `Misty` | `MISTY` |
|
||||
| `cucumber` | `cucumber` | `CUCUMBER` |
|
||||
| `Captain America` | `Captain America` | `AMERICA` |
|
||||
|
||||
### Prefix-only edge cases
|
||||
- Multiple consecutive prefixes get stripped: `"Mr Dr Strange"` → `STRANGE`.
|
||||
- Word that is **only** a prefix: `"Mr"` → keep as `MR`, log a warning to
|
||||
stderr (probably a theme typo).
|
||||
- After stripping, if grid form is empty, skip the word and warn.
|
||||
|
||||
## Word Selection
|
||||
1. Load theme word list.
|
||||
2. Compute grid form for each word.
|
||||
3. Filter by length: `min_length ≤ len(grid_form) ≤ min(max_length, grid_size)`.
|
||||
4. Shuffle.
|
||||
5. Place words one by one until either:
|
||||
- the requested word count is reached, or
|
||||
- the filtered list is exhausted.
|
||||
6. If fewer words placed than requested, generate the puzzle anyway and log
|
||||
a stderr warning — there is no result page, so warnings aren't surfaced
|
||||
to the user; they have to verify the dropdown's word count matches their
|
||||
target before generating.
|
||||
|
||||
### Length filter validation
|
||||
If the filter matches **fewer** words than requested, generate with what's
|
||||
available and warn (e.g. "asked for 10 words; only 4 in the theme matched
|
||||
your length filter"). Don't block — the user might genuinely want a sparse
|
||||
puzzle.
|
||||
|
||||
## Word Placement Rules
|
||||
|
||||
### Direction set
|
||||
The active directions are computed from the toggles:
|
||||
|
||||
```
|
||||
base = { → , ↓ } # always on
|
||||
|
||||
if diagonal_toggle:
|
||||
base |= { ↘ , ↗ }
|
||||
|
||||
if reversed_toggle:
|
||||
base |= { reverse(d) for d in base } # add reversal of everything in base
|
||||
```
|
||||
|
||||
So:
|
||||
| Diag | Rev | Active directions |
|
||||
|------|-----|-------------------|
|
||||
| ☐ | ☐ | → ↓ |
|
||||
| ☑ | ☐ | → ↓ ↘ ↗ |
|
||||
| ☐ | ☑ | → ↓ ← ↑ |
|
||||
| ☑ | ☑ | → ↓ ↘ ↗ ← ↑ ↖ ↙ (all 8) |
|
||||
|
||||
Direction vectors `(Δrow, Δcol)`:
|
||||
|
||||
| Symbol | Δrow | Δcol |
|
||||
|--------|------|------|
|
||||
| → | 0 | +1 |
|
||||
| ↓ | +1 | 0 |
|
||||
| ↘ | +1 | +1 |
|
||||
| ↗ | −1 | +1 |
|
||||
| ← | 0 | −1 |
|
||||
| ↑ | −1 | 0 |
|
||||
| ↖ | −1 | −1 |
|
||||
| ↙ | +1 | −1 |
|
||||
|
||||
### Placement algorithm
|
||||
For each word:
|
||||
1. Pick a random direction from the active set.
|
||||
2. Pick a random valid starting cell such that the entire word fits within
|
||||
the grid (compute bounds from word length + direction vector).
|
||||
3. Check collision against already-placed words:
|
||||
- If overlap toggle is **off**: every cell the word would occupy must be
|
||||
currently empty.
|
||||
- If overlap toggle is **on**: every cell must either be empty OR contain
|
||||
the same letter the word would place there (letter-sharing intersections).
|
||||
4. If the placement is valid, commit it. Otherwise retry up to 200 attempts
|
||||
per word (re-rolling direction + start each time), then skip with a
|
||||
warning.
|
||||
|
||||
### Grid fill
|
||||
After all words are placed, fill remaining empty cells with random uppercase
|
||||
A–Z letters. All letters are uppercase.
|
||||
|
||||
## Theme File Format
|
||||
`themes/<slug>.json`:
|
||||
```json
|
||||
{
|
||||
"name": "Mr Men Characters",
|
||||
"words": [
|
||||
"Mr Tickle",
|
||||
"Mr Happy",
|
||||
"Mr Bump",
|
||||
"Little Miss Sunshine",
|
||||
"Mr Strong",
|
||||
"Mr Tall",
|
||||
"Mr Small"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `name` — human-readable, shown in dropdown and on PDF.
|
||||
- `words` — list of strings, one per line in the editor textarea.
|
||||
|
||||
### Theme curation guidance (in README)
|
||||
- Aim for **20–30 words per theme** with a healthy spread of lengths
|
||||
(some short, some long) so length filters give useful results across
|
||||
age groups.
|
||||
- Avoid hyphens and apostrophes when possible — they get stripped.
|
||||
`"Spider-Man"` → `SPIDERMAN` is fine, but `"don't"` → `DONT` may surprise.
|
||||
|
||||
## PDF Layout
|
||||
A4 portrait, single page, plain.
|
||||
|
||||
### Top
|
||||
- Title (theme name or override), centred, large.
|
||||
|
||||
### Middle: Grid
|
||||
- Centred, monospace font, generous letter spacing.
|
||||
- No cell borders (cleaner look, easier to scan).
|
||||
- Sized so a 12×12 grid is comfortable to read; scales down for larger grids.
|
||||
|
||||
### Bottom: Word list
|
||||
- Heading: "Find these words"
|
||||
- 2–4 columns depending on word count.
|
||||
- Each entry rendered as:
|
||||
- `GRIDFORM` (bold, uppercase) followed by ` (original prefix tokens)` in
|
||||
lighter weight — only if the word had a stripped prefix.
|
||||
- Words with no prefix render bare: just `GRIDFORM` in bold uppercase.
|
||||
- Examples in the rendered list:
|
||||
```
|
||||
TICKLE (Mr) BUMP (Mr)
|
||||
HAPPY (Mr) STRONG (Mr)
|
||||
SUNSHINE (Little Miss)
|
||||
CUCUMBER TOMATO
|
||||
AMERICA (Captain)
|
||||
```
|
||||
|
||||
### Active options subtitle
|
||||
Directly under the title, in small uppercase letter-spaced grey text, list
|
||||
any *extra* directions or modes that are enabled beyond the always-on
|
||||
horizontal + vertical baseline. The labels render only when at least one
|
||||
of `diagonal`, `reversed`, or `allow_overlap` is on:
|
||||
|
||||
```
|
||||
DIAGONAL · REVERSED · OVERLAPPING
|
||||
```
|
||||
|
||||
If none of them are enabled, render nothing — no empty line, no spacing.
|
||||
|
||||
### No footer
|
||||
Page stays clean: no timestamp, no branding, no toggle state in the footer.
|
||||
(The download filename carries the timestamp.)
|
||||
|
||||
### No answer key
|
||||
v1 ships puzzle-only. Solution PDF is a future enhancement.
|
||||
|
||||
## Initial Themes to Ship
|
||||
Pre-populate `themes/` with starter files (20–30 words each, varied length):
|
||||
- `mr-men.json`
|
||||
- `sea-creatures.json`
|
||||
- `superheroes.json`
|
||||
- `farm-animals.json`
|
||||
- `villains.json`
|
||||
- `transformers.json`
|
||||
- `wild-animals.json`
|
||||
- `precious-stones.json`
|
||||
- `common-birds.json` — songbirds plus raptors (owl, eagle, hawk, falcon, etc.)
|
||||
- `science-physics.json` — forces, energy, motion, electricity
|
||||
- `science-chemistry.json` — atoms, molecules, elements, reactions
|
||||
- `science-biology.json` — cells, organs, microbes, ecology
|
||||
|
||||
## Dockerfile
|
||||
```dockerfile
|
||||
FROM python:3.14-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY app/ ./app/
|
||||
COPY static/ ./static/
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
```
|
||||
|
||||
## docker-compose.yml
|
||||
```yaml
|
||||
services:
|
||||
wordsearch:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./themes:/app/themes
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- `docker compose up` starts the app, accessible at `http://<host>:8000`.
|
||||
- Generate a 12×12 puzzle from a pre-shipped theme with default settings,
|
||||
download a valid PDF.
|
||||
- Toggling diagonal, reversed, and overlap each visibly changes the puzzle.
|
||||
- Min/max length filtering works: setting `min=8` excludes short words.
|
||||
- Theme editor: create new theme, see live normalisation preview, save,
|
||||
appear in dropdown, generate from it.
|
||||
- Edit and delete existing themes via UI.
|
||||
- Words with prefixes (Mr, Dr, etc.) show stripped form in grid, full form
|
||||
with prefix in parentheses on word list.
|
||||
- When fewer words can be placed than requested, the PDF still generates
|
||||
(warnings only go to stderr — no result page).
|
||||
- Bad input (invalid slug, empty word list, max length < min length) shows
|
||||
a clear error message, not a stack trace.
|
||||
- Downloaded PDFs are named `<slug>_<YYYY-MM-DD_HH-MM-SS>.pdf`.
|
||||
Reference in New Issue
Block a user