commit 18b7e116578c744416edf4b187628593f5e4df09 Author: Ovidiu U Date: Mon May 4 09:45:17 2026 +0100 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f413769 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +*.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a967d6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..df20088 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,97 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project status + +The repo currently contains only `wordsearch-specs.md` — the implementation has not been written yet. The spec is the source of truth; read it before any change. When implementing, follow the directory layout, routes, and behaviours it prescribes rather than improvising. + +## Local development + +Iterate on the Mac with a venv + `uvicorn --reload`; only use Docker when verifying the deploy. The Docker bind-mount (`./themes`) points at the same directory, so local and container runs share theme state. + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +`--reload` restarts on `.py` changes; Jinja2 templates reload without a restart. `.venv/` must be in `.gitignore`. + +### Dependency management + +**Use `requirements.txt` with pinned versions. Do not introduce Poetry, uv, Pipenv, or `pyproject.toml`.** This is a homelab app — plain `pip` works identically in the venv and inside the Docker container with no extra tooling. + +Initial pin set (don't drop any of these without checking why they're here): + +``` +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +jinja2==3.1.4 +reportlab==4.2.5 +python-multipart==0.0.12 +``` + +`python-multipart` is required for the form-encoded `POST /generate` and theme-editor submits — easy to omit until the form 500s. + +### What not to add + +No test framework, no linter, no formatter, no build step is specified. Don't introduce one without asking. The project is deliberately minimal: single language, single process, no build pipeline, no DB. + +### Docker + +Production runs the same code in a container — `docker compose up` mounts `./themes` as a volume. The container has no venv (the container *is* the isolation); the Dockerfile `pip install`s into the system Python. Don't try to share a venv between host and container. + +## Architecture + +### Two-form word model (central design idea) + +Every input word produces two strings, and they are not interchangeable: + +- **Display form** — original string, untouched. Goes on the PDF word list. +- **Grid form** — stripped of leading prefix tokens, uppercased, whitespace/punctuation removed. Goes in the grid and is what length filters and placement operate on. + +Prefix stripping (`Mr`, `Mrs`, `Miss`, `Dr`, `Sir`, `Captain`, `Saint`, etc. — full list in `app/normaliser.py::PREFIXES`) is **token-based, case-insensitive, with optional trailing dot**. `"Misty"` does NOT match `"Miss"`. Multiple consecutive prefixes strip (`"Mr Dr Strange"` → `STRANGE`). A word that is *only* a prefix (`"Mr"`) keeps as `MR` and logs a stderr warning. Empty grid form after stripping → skip and warn. + +The PDF word list shows `GRIDFORM (original prefix tokens)` only when something was stripped, e.g. `TICKLE (Mr)`. Bare `GRIDFORM` otherwise. + +### Direction set is computed, not picked + +Don't expose every direction as an independent toggle. Horizontal (→) and vertical (↓) are always on. The two user toggles compose: + +``` +base = { →, ↓ } +if diagonal: base |= { ↘, ↗ } +if reversed: base |= { reverse(d) for d in base } +``` + +So `reversed` without `diagonal` gives `→ ↓ ← ↑` (no diagonals reversed because none were in `base`). `diagonal + reversed` gives all 8. + +### Placement: retry-then-skip, not backtrack + +Per word: random direction + random start, validate collision (empty cells, or matching letters when overlap is on), commit or retry up to 200 times. Skip after 200 and warn — never block, never backtrack already-placed words. If fewer words placed than requested, generate the puzzle anyway and surface a warning to the result page. + +### No sticky state + +All optional toggles (diagonal, reversed, overlap) default **off** on every `/` page load. Don't add cookies, localStorage, or session state to remember the last config. Each puzzle is configured explicitly. + +### Storage + +Flat JSON files under `themes/.json` with `{ name, words: [...] }`. No DB. The slug is the filename and is locked after creation. `themes/` is the only persistence — generated PDFs are streamed straight to the browser as `_.pdf` and never written to disk on the server. + +### Server-rendered, almost no JS + +Jinja2 templates only. The single piece of vanilla JS lives in the theme editor: a debounced `fetch` to `POST /api/normalise` to render the live preview of grid forms. No bundler, no framework, no build artifacts in the repo. + +### Auth + +None by design (homelab use). If exposing publicly, add HTTP basic auth as FastAPI middleware — don't introduce a user model. + +## Conventions worth preserving + +- Validation errors (invalid slug, empty word list, `max_length < min_length`) must render a clear message, never a stack trace. +- `max_length` is clamped to `grid_size` before filtering. +- All grid letters are uppercase A–Z, including the random fill. +- The PDF is plain: no footer, no timestamp, no branding, no toggle state printed. The filename carries the timestamp. +- v1 ships puzzle-only — no answer key PDF. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc41248 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9753c54 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Wordsmith — Puzzle Workshop + +A small self-hosted web app that generates printable word search PDFs from +themed word lists. Single Python process, no database, no build step. + +## Stack + +- Python 3.14+ +- FastAPI + Jinja2 (server-rendered HTML) +- reportlab (PDF) +- A small piece of vanilla JS for the live theme-editor preview + +## Local development + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +`--reload` restarts on `.py` changes; Jinja templates reload without a restart. + +## Docker + +```bash +docker compose up --build +``` + +Maps host port `3801` → container `8000`. The bind-mounts `./themes:/app/themes`, +so any theme JSON files in the repo's `themes/` folder are picked up at boot +and any UI-edited themes are written back to the same place. + +## Themes + +Each theme is a flat JSON file under `themes/.json`: + +```json +{ + "name": "Mr Men Characters", + "words": ["Mr Tickle", "Mr Happy", "Little Miss Sunshine"] +} +``` + +Words can include leading honorific tokens (`Mr`, `Mrs`, `Miss`, `Dr`, +`Captain`, etc.) — they're stripped from the grid form but kept on the PDF +word list in parentheses, e.g. `TICKLE (Mr)`. + +## Deployment + +The repo is intended to be deployed via [Komodo](https://komo.do)'s git-stack +mode: point a stack at this repo, set the compose path to `docker-compose.yml`, +and redeploy. Themes added via the deployed UI live in Komodo's per-stack +checkout — they survive normal redeploys but a Komodo "Destroy" wipes them. +For lasting changes, edit themes locally and `git push`. + +## Generated PDFs + +Generated puzzles stream straight to the browser as a download +(`_.pdf`); the server keeps no copy on disk. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/generator.py b/app/generator.py new file mode 100644 index 0000000..a95ba7b --- /dev/null +++ b/app/generator.py @@ -0,0 +1,198 @@ +"""Grid building and word placement.""" + +from __future__ import annotations + +import random +import string +from dataclasses import dataclass, field + +from .normaliser import Normalised, normalise_all + +# Direction vectors: (drow, dcol) +RIGHT = (0, 1) +DOWN = (1, 0) +DOWN_RIGHT = (1, 1) +UP_RIGHT = (-1, 1) +LEFT = (0, -1) +UP = (-1, 0) +UP_LEFT = (-1, -1) +DOWN_LEFT = (1, -1) + +PLACEMENT_RETRIES = 200 + + +@dataclass +class Placement: + word: Normalised + row: int + col: int + direction: tuple[int, int] + + +@dataclass +class Puzzle: + grid: list[list[str]] + placements: list[Placement] + skipped: list[Normalised] = field(default_factory=list) + filtered_out: list[Normalised] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + diagonal: bool = False + reversed_: bool = False + allow_overlap: bool = False + + @property + def size(self) -> int: + return len(self.grid) + + @property + def option_labels(self) -> list[str]: + labels = [] + if self.diagonal: + labels.append("Diagonal") + if self.reversed_: + labels.append("Reversed") + if self.allow_overlap: + labels.append("Overlapping") + return labels + + +def active_directions(diagonal: bool, reversed_: bool) -> list[tuple[int, int]]: + base = [RIGHT, DOWN] + if diagonal: + base += [DOWN_RIGHT, UP_RIGHT] + if reversed_: + base = base + [(-dr, -dc) for (dr, dc) in base] + return base + + +def _can_place(grid: list[list[str]], word: str, row: int, col: int, + direction: tuple[int, int], allow_overlap: bool) -> bool: + size = len(grid) + dr, dc = direction + for i, ch in enumerate(word): + r, c = row + dr * i, col + dc * i + if not (0 <= r < size and 0 <= c < size): + return False + existing = grid[r][c] + if existing == "": + continue + if allow_overlap and existing == ch: + continue + return False + return True + + +def _commit(grid: list[list[str]], word: str, row: int, col: int, + direction: tuple[int, int]) -> None: + dr, dc = direction + for i, ch in enumerate(word): + grid[row + dr * i][col + dc * i] = ch + + +def _start_bounds(size: int, length: int, + direction: tuple[int, int]) -> tuple[range, range]: + """Valid starting (row, col) ranges so the whole word fits.""" + dr, dc = direction + if dr >= 0: + rows = range(0, size - (length - 1) * dr) + else: + rows = range((length - 1) * (-dr), size) + if dc >= 0: + cols = range(0, size - (length - 1) * dc) + else: + cols = range((length - 1) * (-dc), size) + return rows, cols + + +def generate( + *, + words: list[str], + grid_size: int, + word_count: int, + min_length: int, + max_length: int, + diagonal: bool, + reversed_: bool, + allow_overlap: bool, + rng: random.Random | None = None, +) -> Puzzle: + if rng is None: + rng = random.Random() + + if grid_size < 5 or grid_size > 25: + raise ValueError("grid size must be between 5 and 25") + if word_count < 1 or word_count > 30: + raise ValueError("word count must be between 1 and 30") + if min_length < 1: + raise ValueError("min length must be at least 1") + if max_length < min_length: + raise ValueError("max length must be greater than or equal to min length") + + effective_max = min(max_length, grid_size) + + normalised = normalise_all(words) + eligible: list[Normalised] = [] + filtered_out: list[Normalised] = [] + skipped: list[Normalised] = [] + for n in normalised: + if n.skipped: + skipped.append(n) + continue + length = len(n.grid) + if min_length <= length <= effective_max: + eligible.append(n) + else: + filtered_out.append(n) + + rng.shuffle(eligible) + directions = active_directions(diagonal, reversed_) + + grid: list[list[str]] = [["" for _ in range(grid_size)] + for _ in range(grid_size)] + placements: list[Placement] = [] + warnings: list[str] = [] + + for word in eligible: + if len(placements) >= word_count: + break + placed = False + for _ in range(PLACEMENT_RETRIES): + direction = rng.choice(directions) + rows, cols = _start_bounds(grid_size, len(word.grid), direction) + if not rows or not cols: + continue + row = rng.choice(rows) + col = rng.choice(cols) + if _can_place(grid, word.grid, row, col, direction, allow_overlap): + _commit(grid, word.grid, row, col, direction) + placements.append(Placement(word=word, row=row, col=col, + direction=direction)) + placed = True + break + if not placed: + warnings.append( + f"could not place word {word.grid!r} after " + f"{PLACEMENT_RETRIES} attempts; skipping" + ) + + if filtered_out: + warnings.append( + f"{len(filtered_out)} word(s) excluded by length filter " + f"(min={min_length}, max={effective_max})" + ) + + if len(placements) < word_count: + warnings.append( + f"asked for {word_count} word(s); placed {len(placements)}" + ) + + # Fill remaining empty cells with random uppercase letters. + for r in range(grid_size): + for c in range(grid_size): + if grid[r][c] == "": + grid[r][c] = rng.choice(string.ascii_uppercase) + + return Puzzle(grid=grid, placements=placements, skipped=skipped, + filtered_out=filtered_out, warnings=warnings, + diagonal=diagonal, reversed_=reversed_, + allow_overlap=allow_overlap) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..8de8bc8 --- /dev/null +++ b/app/main.py @@ -0,0 +1,251 @@ +"""FastAPI app: routes for puzzle generation and theme management.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +from fastapi import FastAPI, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from . import themes as theme_store +from .generator import generate +from .normaliser import normalise_all +from .pdf import render_puzzle + +ROOT = Path(__file__).resolve().parent.parent +STATIC_DIR = ROOT / "static" +TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" + +app = FastAPI(title="Word Search Puzzle Generator") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + +def _checkbox(value: str | None) -> bool: + return value is not None and value != "" and value.lower() not in {"0", "false", "off"} + + +@app.get("/", response_class=HTMLResponse) +def index(request: Request): + available = theme_store.list_themes() + return templates.TemplateResponse( + "index.html", + {"request": request, "themes": available, "errors": [], "warnings": []}, + ) + + +@app.post("/generate") +async def generate_puzzle( + request: Request, + theme: str = Form(...), + grid_size: int = Form(...), + word_count: int = Form(...), + min_length: int = Form(...), + max_length: int = Form(...), + title: str = Form(""), + diagonal: str | None = Form(None), + reversed_: str | None = Form(None, alias="reversed"), + overlap: str | None = Form(None), +): + available = theme_store.list_themes() + errors: list[str] = [] + + try: + loaded = theme_store.load_theme(theme) + except theme_store.ThemeError as e: + errors.append(str(e)) + return templates.TemplateResponse( + "index.html", + {"request": request, "themes": available, "errors": errors, + "warnings": []}, + status_code=400, + ) + + if max_length < min_length: + errors.append("max length must be greater than or equal to min length") + + if errors: + return templates.TemplateResponse( + "index.html", + {"request": request, "themes": available, "errors": errors, + "warnings": []}, + status_code=400, + ) + + try: + puzzle = generate( + words=loaded.words, + grid_size=grid_size, + word_count=word_count, + min_length=min_length, + max_length=max_length, + diagonal=_checkbox(diagonal), + reversed_=_checkbox(reversed_), + allow_overlap=_checkbox(overlap), + ) + except ValueError as e: + return templates.TemplateResponse( + "index.html", + {"request": request, "themes": available, "errors": [str(e)], + "warnings": []}, + status_code=400, + ) + + display_title = title.strip() or loaded.name + pdf_bytes = render_puzzle(puzzle, display_title) + + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + filename = f"{loaded.slug}_{timestamp}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@app.get("/themes", response_class=HTMLResponse) +def themes_index(request: Request): + return templates.TemplateResponse( + "themes.html", + {"request": request, "themes": theme_store.list_themes()}, + ) + + +@app.get("/themes/new", response_class=HTMLResponse) +def themes_new(request: Request): + return templates.TemplateResponse( + "theme_edit.html", + { + "request": request, + "is_new": True, + "name": "", + "slug": "", + "words_text": "", + "errors": [], + }, + ) + + +@app.get("/themes/{slug}/edit", response_class=HTMLResponse) +def themes_edit(request: Request, slug: str): + try: + t = theme_store.load_theme(slug) + except theme_store.ThemeError as e: + raise HTTPException(status_code=404, detail=str(e)) + return templates.TemplateResponse( + "theme_edit.html", + { + "request": request, + "is_new": False, + "name": t.name, + "slug": t.slug, + "words_text": "\n".join(t.words), + "errors": [], + }, + ) + + +@app.post("/themes") +def themes_create( + request: Request, + name: str = Form(...), + slug: str = Form(""), + words: str = Form(""), +): + proposed_slug = (slug.strip() or theme_store.slugify(name)) + parsed = theme_store.parse_words_textarea(words) + errors: list[str] = [] + if not theme_store.is_valid_slug(proposed_slug): + errors.append("slug must be lowercase letters, digits, and hyphens only") + if errors: + return templates.TemplateResponse( + "theme_edit.html", + { + "request": request, + "is_new": True, + "name": name, + "slug": proposed_slug, + "words_text": words, + "errors": errors, + }, + status_code=400, + ) + try: + theme_store.save_theme(proposed_slug, name, parsed) + except theme_store.ThemeError as e: + return templates.TemplateResponse( + "theme_edit.html", + { + "request": request, + "is_new": True, + "name": name, + "slug": proposed_slug, + "words_text": words, + "errors": [str(e)], + }, + status_code=400, + ) + return RedirectResponse(url="/themes", status_code=303) + + +@app.post("/themes/{slug}") +def themes_update( + request: Request, + slug: str, + name: str = Form(...), + words: str = Form(""), +): + parsed = theme_store.parse_words_textarea(words) + try: + theme_store.save_theme(slug, name, parsed) + except theme_store.ThemeError as e: + return templates.TemplateResponse( + "theme_edit.html", + { + "request": request, + "is_new": False, + "name": name, + "slug": slug, + "words_text": words, + "errors": [str(e)], + }, + status_code=400, + ) + return RedirectResponse(url="/themes", status_code=303) + + +@app.post("/themes/{slug}/delete") +def themes_delete(slug: str): + try: + theme_store.delete_theme(slug) + except theme_store.ThemeError as e: + raise HTTPException(status_code=400, detail=str(e)) + return RedirectResponse(url="/themes", status_code=303) + + +@app.get("/api/themes") +def api_themes(): + return [{"slug": t.slug, "name": t.name, "word_count": len(t.words)} + for t in theme_store.list_themes()] + + +@app.post("/api/normalise") +async def api_normalise(request: Request): + payload = await request.json() + words = payload.get("words", []) if isinstance(payload, dict) else [] + if not isinstance(words, list): + return JSONResponse({"error": "words must be a list"}, status_code=400) + results = normalise_all([str(w) for w in words]) + return [ + { + "display": n.display, + "grid": n.grid, + "stripped": n.stripped_prefixes, + "skipped": n.skipped, + "warning": n.warning, + } + for n in results + ] diff --git a/app/normaliser.py b/app/normaliser.py new file mode 100644 index 0000000..09cc029 --- /dev/null +++ b/app/normaliser.py @@ -0,0 +1,72 @@ +"""Word normalisation: produce display + grid forms with prefix stripping.""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass + +PREFIXES = { + "mr", "mrs", "ms", "miss", + "dr", "sir", "dame", "lord", "lady", "master", + "captain", "capt", "cpt", + "professor", "prof", + "saint", "st", +} + + +@dataclass +class Normalised: + display: str + grid: str + stripped_prefixes: list[str] + skipped: bool = False + warning: str | None = None + + +def _strip_prefix_tokens(tokens: list[str]) -> tuple[list[str], list[str]]: + """Remove leading prefix tokens. Returns (stripped_prefixes, remaining_tokens).""" + stripped: list[str] = [] + i = 0 + while i < len(tokens): + bare = tokens[i].rstrip(".").lower() + if bare in PREFIXES: + stripped.append(tokens[i]) + i += 1 + else: + break + return stripped, tokens[i:] + + +def normalise(word: str) -> Normalised: + display = word + tokens = word.split() + if not tokens: + return Normalised(display=display, grid="", stripped_prefixes=[], + skipped=True, warning="empty word") + + stripped, remainder = _strip_prefix_tokens(tokens) + + if not remainder: + # Word is *only* prefix tokens — keep raw form, warn. + joined = "".join(tokens) + grid = re.sub(r"[^A-Za-z0-9]", "", joined).upper() + warning = f"word {word!r} is only prefix tokens; keeping as {grid}" + print(f"[normaliser] warning: {warning}", file=sys.stderr) + return Normalised(display=display, grid=grid, stripped_prefixes=[], + warning=warning) + + joined = "".join(remainder) + grid = re.sub(r"[^A-Za-z0-9]", "", joined).upper() + + if not grid: + warning = f"word {word!r} normalises to empty after stripping; skipping" + print(f"[normaliser] warning: {warning}", file=sys.stderr) + return Normalised(display=display, grid="", stripped_prefixes=stripped, + skipped=True, warning=warning) + + return Normalised(display=display, grid=grid, stripped_prefixes=stripped) + + +def normalise_all(words: list[str]) -> list[Normalised]: + return [normalise(w) for w in words] diff --git a/app/pdf.py b/app/pdf.py new file mode 100644 index 0000000..14410c3 --- /dev/null +++ b/app/pdf.py @@ -0,0 +1,143 @@ +"""PDF rendering of a generated puzzle (A4 portrait, single page).""" + +from __future__ import annotations + +import io +from typing import Iterable + +from reportlab.lib.pagesizes import A4 +from reportlab.lib.units import mm +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.pdfgen import canvas + +from .generator import Puzzle + +PAGE_W, PAGE_H = A4 +MARGIN = 18 * mm + +TITLE_FONT = "Helvetica-Bold" +TITLE_SIZE = 24 + +OPTIONS_FONT = "Helvetica" +OPTIONS_SIZE = 10 + +GRID_FONT = "Courier-Bold" + +LIST_HEADING_FONT = "Helvetica-Bold" +LIST_HEADING_SIZE = 14 +LIST_ENTRY_FONT_BOLD = "Helvetica-Bold" +LIST_ENTRY_FONT_LIGHT = "Helvetica" +LIST_ENTRY_SIZE = 11 + + +def _column_count(n: int) -> int: + if n <= 8: + return 2 + if n <= 18: + return 3 + return 4 + + +def _wrap_columns(items: list[tuple], cols: int) -> list[list[tuple]]: + """Lay items out top-to-bottom, then left-to-right, in `cols` columns.""" + rows = (len(items) + cols - 1) // cols + columns: list[list[tuple]] = [[] for _ in range(cols)] + for i, item in enumerate(items): + columns[i // rows].append(item) + return columns + + +def _grid_geometry(grid_size: int, available_w: float, available_h: float + ) -> tuple[float, float]: + """Choose (font_size, cell_size) so a `grid_size` x `grid_size` grid + fits comfortably in the available box.""" + # Cell size driven by whichever dimension is tighter, with padding. + target_cell = min(available_w, available_h) / grid_size + cell = min(target_cell, 14 * mm) # cap so 5x5 isn't comically huge + cell = max(cell, 5 * mm) + font_size = cell * 0.7 + return font_size, cell + + +def render_puzzle(puzzle: Puzzle, title: str) -> bytes: + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + + # Title + c.setFont(TITLE_FONT, TITLE_SIZE) + title_y = PAGE_H - MARGIN - TITLE_SIZE + c.drawCentredString(PAGE_W / 2, title_y, title) + + # Active option labels under the title (only when extras are enabled). + options_y = title_y + labels = puzzle.option_labels + if labels: + options_y = title_y - TITLE_SIZE * 0.7 + c.setFont(OPTIONS_FONT, OPTIONS_SIZE) + c.setFillGray(0.35) + c.drawCentredString(PAGE_W / 2, options_y, + " · ".join(s.upper() for s in labels)) + c.setFillGray(0) + + # Reserve space for word list at the bottom; estimate from entry count. + placed = puzzle.placements + cols = _column_count(len(placed)) if placed else 2 + rows_per_col = (len(placed) + cols - 1) // cols if placed else 0 + list_line_height = LIST_ENTRY_SIZE * 1.5 + list_block_h = LIST_HEADING_SIZE * 2 + rows_per_col * list_line_height + list_block_h = max(list_block_h, 30 * mm) + + grid_top = (options_y if labels else title_y) - 8 * mm + grid_bottom = MARGIN + list_block_h + 6 * mm + available_h = grid_top - grid_bottom + available_w = PAGE_W - 2 * MARGIN + + grid_size = puzzle.size + font_size, cell = _grid_geometry(grid_size, available_w, available_h) + grid_w = grid_size * cell + grid_h = grid_size * cell + origin_x = (PAGE_W - grid_w) / 2 + origin_y = grid_top - grid_h # top of grid sits at grid_top + + c.setFont(GRID_FONT, font_size) + for r in range(grid_size): + for col_idx in range(grid_size): + ch = puzzle.grid[r][col_idx] + x = origin_x + col_idx * cell + cell / 2 + # Top row in the grid array should print at the top of the grid box. + y = origin_y + (grid_size - 1 - r) * cell + (cell - font_size) / 2 + c.drawCentredString(x, y, ch) + + # Word list + list_top = grid_bottom - 2 * mm + c.setFont(LIST_HEADING_FONT, LIST_HEADING_SIZE) + c.drawCentredString(PAGE_W / 2, list_top, "Find these words") + + entries: list[tuple[str, str]] = [] + for p in placed: + prefix_text = "" + if p.word.stripped_prefixes: + prefix_text = " (" + " ".join(p.word.stripped_prefixes) + ")" + entries.append((p.word.grid, prefix_text)) + + if entries: + column_lists = _wrap_columns(entries, cols) + col_w = available_w / cols + list_y_start = list_top - LIST_HEADING_SIZE - 4 * mm + for col_idx, items in enumerate(column_lists): + x = MARGIN + col_idx * col_w + for i, (grid_form, prefix) in enumerate(items): + y = list_y_start - i * list_line_height + c.setFont(LIST_ENTRY_FONT_BOLD, LIST_ENTRY_SIZE) + c.drawString(x, y, grid_form) + if prefix: + bold_w = stringWidth(grid_form, LIST_ENTRY_FONT_BOLD, + LIST_ENTRY_SIZE) + c.setFont(LIST_ENTRY_FONT_LIGHT, LIST_ENTRY_SIZE) + c.setFillGray(0.4) + c.drawString(x + bold_w, y, prefix) + c.setFillGray(0) + + c.showPage() + c.save() + return buf.getvalue() diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..0e6539e --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,41 @@ + + + + + + {% block title %}Wordsmith — Puzzle Workshop{% endblock %} + + + + + + +{% set path = request.url.path %} +
+ + +
+ {% block content %}{% endblock %} +
+ +
+ ✦   WORDSMITH   ·   PUZZLE WORKSHOP   ·   EST. 2026   ✦ +
+
+{% block scripts %}{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..57192b3 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} +{% block title %}Generate · Wordsmith{% endblock %} +{% block content %} +
+
+
+
№ 01 · Studio
+

Generate a puzzle

+
+
{{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} on file
+
+
+ + {% if errors %} +
+ Hold on +
    + {% for e in errors %}
  • {{ e }}
  • {% endfor %} +
+
+ {% endif %} + + {% if not themes %} +
+

No themes yet. Create one to get started.

+
+ {% else %} + +
+
+ + +
+ + + + +
+ + + +
+ Directions +
+ + + + +
+
+ +
+
+ +
+ + +
+
+ + +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/theme_edit.html b/app/templates/theme_edit.html new file mode 100644 index 0000000..9d73596 --- /dev/null +++ b/app/templates/theme_edit.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} +{% block title %}{% if is_new %}New theme{% else %}Edit {{ name }}{% endif %} · Wordsmith{% endblock %} +{% block content %} +
+
+ themes + / + {% if is_new %}new{% else %}{{ slug }}{% endif %} +
+ +
+
+
№ 03 · Workbench
+

{% if is_new %}New theme{% else %}Edit theme{% endif %}

+
+ {% if not is_new %} +
slug · {{ slug }}
+ {% endif %} +
+
+ + {% if errors %} +
+ Hold on +
    + {% for e in errors %}
  • {{ e }}
  • {% endfor %} +
+
+ {% endif %} + +
+ +
+ + +
+ +
+ + + +
+ +
+ + Cancel + + {% if not is_new %} + + + + {% endif %} +
+ +
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/themes.html b/app/templates/themes.html new file mode 100644 index 0000000..80804b6 --- /dev/null +++ b/app/templates/themes.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Themes · Wordsmith{% endblock %} +{% block content %} +{% set total_words = themes|map(attribute='words')|map('length')|sum %} +
+
+
+
№ 02 · Ledger
+

Themes

+
+ {{ themes|length }} theme{{ '' if themes|length == 1 else 's' }} · {{ total_words }} word{{ '' if total_words == 1 else 's' }} on file +
+
+ +
+
+ + {% if themes %} +
+ + + + + + + + + + + {% for t in themes %} + + + + + + + {% endfor %} + +
NameSlugWords
{{ t.name }}{{ t.slug }}{{ t.words|length }} + + edit +
+ +
+
+
+
+ {% else %} +
+

No themes yet. Create the first one.

+
+ {% endif %} +
+{% endblock %} diff --git a/app/themes.py b/app/themes.py new file mode 100644 index 0000000..61428c7 --- /dev/null +++ b/app/themes.py @@ -0,0 +1,96 @@ +"""Theme storage: flat JSON files under themes/.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +THEMES_DIR = Path(__file__).resolve().parent.parent / "themes" + +_SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +@dataclass +class Theme: + slug: str + name: str + words: list[str] + + +class ThemeError(ValueError): + """Raised for invalid theme input.""" + + +def slugify(name: str) -> str: + """Lowercase, replace non-alphanumeric runs with single hyphen, trim.""" + s = name.lower().strip() + s = re.sub(r"[^a-z0-9]+", "-", s) + s = s.strip("-") + return s + + +def is_valid_slug(slug: str) -> bool: + return bool(_SLUG_RE.match(slug)) + + +def _path(slug: str) -> Path: + if not is_valid_slug(slug): + raise ThemeError(f"invalid slug: {slug!r}") + return THEMES_DIR / f"{slug}.json" + + +def list_themes() -> list[Theme]: + THEMES_DIR.mkdir(parents=True, exist_ok=True) + out: list[Theme] = [] + for path in sorted(THEMES_DIR.glob("*.json")): + slug = path.stem + if not is_valid_slug(slug): + continue + try: + data = json.loads(path.read_text(encoding="utf-8")) + out.append(Theme(slug=slug, name=data.get("name", slug), + words=list(data.get("words", [])))) + except (json.JSONDecodeError, OSError): + continue + return out + + +def load_theme(slug: str) -> Theme: + path = _path(slug) + if not path.exists(): + raise ThemeError(f"theme not found: {slug!r}") + data = json.loads(path.read_text(encoding="utf-8")) + return Theme(slug=slug, name=data.get("name", slug), + words=list(data.get("words", []))) + + +def save_theme(slug: str, name: str, words: list[str]) -> Theme: + if not is_valid_slug(slug): + raise ThemeError( + "slug must be lowercase letters, digits, and hyphens only" + ) + name = name.strip() + if not name: + raise ThemeError("theme name is required") + cleaned = [w.strip() for w in words if w.strip()] + if not cleaned: + raise ThemeError("theme must contain at least one word") + THEMES_DIR.mkdir(parents=True, exist_ok=True) + path = THEMES_DIR / f"{slug}.json" + payload = {"name": name, "words": cleaned} + path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8") + return Theme(slug=slug, name=name, words=cleaned) + + +def delete_theme(slug: str) -> None: + path = _path(slug) + if path.exists(): + path.unlink() + + +def parse_words_textarea(blob: str) -> list[str]: + """One word per line; ignore blank lines and surrounding whitespace.""" + return [line.strip() for line in blob.splitlines() if line.strip()] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8b9a924 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +name: wordsearch +services: + wordsearch: + build: . + networks: + default: null + ports: + - mode: ingress + target: 8000 + published: "3801" + protocol: tcp + restart: unless-stopped + volumes: + - type: bind + source: ./themes + target: /app/themes + bind: + create_host_path: true +networks: + default: + name: wordsearch_default diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..267812e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.32.0 +jinja2==3.1.4 +reportlab==4.2.5 +python-multipart==0.0.12 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b31003a --- /dev/null +++ b/static/style.css @@ -0,0 +1,701 @@ +/* ============================================================ + Word Search — polished stylesheet + Plain CSS, no framework. + ============================================================ */ + +:root { + /* Paper palette — newsprint */ + --paper-bg: #ece4d3; + --paper: #f6efde; + --paper-2: #fbf6e8; + --paper-3: #f0e8d3; + + /* Ink */ + --ink: #1c1a16; + --ink-soft: #6b6356; + --ink-faint: rgba(28, 26, 22, 0.14); + --rule-dashed: rgba(28, 26, 22, 0.32); + + /* Accent (red ink) */ + --accent: #b3321a; + --accent-soft: rgba(179, 50, 26, 0.08); + + /* Type */ + --display: 'Playfair Display', 'Times New Roman', serif; + --body: 'DM Mono', ui-monospace, Menlo, monospace; + --mono: 'DM Mono', ui-monospace, Menlo, monospace; + + /* Geometry */ + --rule: 1.5px solid var(--ink); + --rule-2: 1px dashed var(--rule-dashed); + --double: 3px double var(--ink); + + /* Spacing scale */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 24px; + --s-6: 32px; + --s-7: 48px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--paper-bg); + color: var(--ink); + font-family: var(--body); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* Subtle paper grain — faint speckle */ +body { + background-image: + radial-gradient(rgba(28,26,22,0.025) 1px, transparent 1px), + radial-gradient(rgba(28,26,22,0.018) 1px, transparent 1px); + background-size: 3px 3px, 7px 7px; + background-position: 0 0, 1px 2px; +} + +/* ---------- Typography ---------- */ +h1, h2, h3 { font-family: var(--display); font-weight: 900; letter-spacing: -0.01em; margin: 0; } +h1 { font-size: 30px; line-height: 1.05; } +h2 { font-size: 26px; line-height: 1.1; } +h3 { font-size: 18px; font-weight: 800; line-height: 1.2; } + +a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; } +a:hover { text-decoration-thickness: 2px; } + +.eyebrow { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.muted { color: var(--ink-soft); } +.mono { font-family: var(--mono); } + +/* ---------- Layout ---------- */ +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.container { + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 0 var(--s-6); +} + +main { + padding: var(--s-7) 0; + flex: 1; +} + +/* ---------- Header (Stamp + Tabs) ---------- */ +.site-header { + background: var(--paper); + border-bottom: var(--rule); + position: sticky; + top: 0; + z-index: 10; +} + +.site-header__inner { + max-width: 1100px; + margin: 0 auto; + padding: var(--s-3) var(--s-6); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s-4); +} + +.brand { + display: flex; + align-items: center; + gap: var(--s-3); + text-decoration: none; + color: var(--ink); +} + +.brand__mark { + width: 38px; + height: 38px; + border: 2px solid var(--ink); + background: var(--ink); + color: var(--paper); + display: grid; + place-items: center; + font-family: var(--display); + font-weight: 900; + font-size: 22px; + flex: 0 0 auto; +} + +.brand__name { + display: block; + font-family: var(--display); + font-weight: 800; + font-size: 19px; + line-height: 1; +} + +.brand__tagline { + display: block; + font-family: var(--mono); + font-size: 10px; + color: var(--ink-soft); + letter-spacing: 0.08em; + margin-top: 3px; +} + +.tabs { + display: flex; + gap: 0; + font-family: var(--mono); + font-size: 13px; +} + +.tabs a { + padding: 8px 18px; + border: var(--rule); + color: var(--ink); + background: var(--paper); + text-decoration: none; + font-weight: 500; +} + +.tabs a + a { border-left: none; } + +.tabs a:hover { background: var(--paper-3); } + +.tabs a[aria-current="page"] { + background: var(--ink); + color: var(--paper); +} + +/* ---------- Page heading ---------- */ +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: var(--s-4); + flex-wrap: wrap; + margin-bottom: var(--s-4); +} + +.page-head__title h1 { margin-top: 4px; } +.page-head__meta { + font-family: var(--mono); + font-size: 11px; + color: var(--ink-soft); +} + +.section-rule { + border: 0; + border-top: var(--double); + margin: 0 0 var(--s-6); +} + +/* ---------- Cards / panels ---------- */ +.panel { + background: var(--paper); + border: var(--rule); + padding: var(--s-5); +} + +.panel--inset { + background: var(--paper-2); +} + +/* ---------- Notices (errors / warnings) ---------- */ +.notice { + background: var(--paper); + border: var(--rule); + border-left: 4px solid var(--accent); + padding: var(--s-3) var(--s-4); + margin-bottom: var(--s-4); + font-family: var(--mono); + font-size: 13px; +} +.notice strong { + display: block; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + margin-bottom: 4px; +} +.notice ul { margin: 0; padding-left: 18px; } +.notice--warn { border-left-color: #c08a00; } + +/* ---------- Form controls ---------- */ +.form-grid { + display: grid; + gap: var(--s-4); +} + +.field { + display: block; +} + +.field__label { + display: block; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ink-soft); + margin-bottom: 6px; + font-weight: 500; +} + +.field__hint { + display: block; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-soft); + margin-top: 6px; +} + +input[type="text"], +input[type="number"], +input[type="email"], +select, +textarea { + width: 100%; + border: var(--rule); + background: var(--paper-2); + color: var(--ink); + padding: 10px 12px; + font-family: var(--mono); + font-size: 14px; + line-height: 1.4; + border-radius: 0; + appearance: none; + -webkit-appearance: none; + outline: none; + transition: background 0.12s, box-shadow 0.12s; +} + +input[type="text"]:focus, +input[type="number"]:focus, +select:focus, +textarea:focus { + background: var(--paper); + box-shadow: 0 0 0 3px var(--ink); + outline-offset: 2px; +} + +input[readonly] { background: var(--paper-3); cursor: not-allowed; } + +textarea { + min-height: 240px; + resize: vertical; + line-height: 1.7; +} + +select { + background-image: url("data:image/svg+xml;utf8,"); + background-repeat: no-repeat; + background-position: right 14px center; + background-size: 10px; + padding-right: 36px; + cursor: pointer; +} + +.row { display: flex; gap: var(--s-3); } +.row > * { flex: 1; min-width: 0; } + +/* Checkboxes — custom square ink boxes */ +.check { + display: inline-flex; + align-items: center; + gap: 10px; + cursor: pointer; + font-family: var(--mono); + font-size: 13px; + user-select: none; +} + +.check input { + appearance: none; + -webkit-appearance: none; + width: 16px; + height: 16px; + border: 1.5px solid var(--ink); + background: var(--paper-2); + display: inline-block; + position: relative; + cursor: pointer; + margin: 0; + flex: 0 0 auto; +} + +.check input:checked { + background: var(--ink); +} + +.check input:checked::after { + content: "✓"; + position: absolute; + inset: 0; + color: var(--paper); + font-size: 12px; + font-family: var(--display); + font-weight: 900; + line-height: 14px; + text-align: center; +} + +.check input:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.check input:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +.check--locked { opacity: 0.7; } +.check--locked .lock { + font-family: var(--mono); + font-size: 10px; + color: var(--ink-soft); + letter-spacing: 0.08em; + margin-left: auto; +} + +/* ---------- Buttons ---------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + border: var(--rule); + background: var(--ink); + color: var(--paper); + cursor: pointer; + text-decoration: none; + transition: transform 0.05s ease, background 0.12s; +} + +.btn:hover { background: #000; } +.btn:active { transform: translateY(1px); } +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +.btn--ghost { + background: var(--paper); + color: var(--ink); +} +.btn--ghost:hover { background: var(--paper-3); } + +.btn--danger { + background: var(--paper); + color: var(--accent); + border-color: var(--accent); +} +.btn--danger:hover { background: var(--accent); color: var(--paper); } + +.btn--sm { padding: 6px 12px; font-size: 12px; } + +/* Inline link-style action (for table rows) */ +.row-actions { + display: inline-flex; + gap: 14px; + font-family: var(--mono); + font-size: 12px; +} +.row-actions a, +.row-actions button { + color: var(--ink); + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} +.row-actions a.del, +.row-actions button.del { color: var(--accent); } +.row-actions form { display: inline; margin: 0; } + +/* ---------- Tables (Themes ledger) ---------- */ +.ledger { + width: 100%; + border-collapse: collapse; + font-family: var(--mono); + font-size: 13px; +} + +.ledger thead th { + text-align: left; + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + font-weight: 600; + padding: 12px 10px; + border-bottom: 1.5px solid var(--ink); + background: var(--paper-2); +} + +.ledger tbody tr { + border-bottom: var(--rule-2); + transition: background 0.1s; +} + +.ledger tbody tr:hover { + background: var(--paper-2); +} + +.ledger tbody td { + padding: 14px 10px; + vertical-align: middle; +} + +.ledger .col-name { + font-family: var(--display); + font-size: 16px; + font-weight: 700; +} + +.ledger .col-slug { + color: var(--ink-soft); +} + +.ledger .col-words, +.ledger .col-num { + text-align: right; + font-feature-settings: "tnum" 1; +} + +.ledger .col-actions { + text-align: right; + width: 1%; + white-space: nowrap; +} + +/* ---------- Studio (Generate puzzle) split layout ---------- */ +.studio { + display: grid; + grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); + gap: var(--s-6); + align-items: start; +} + +@media (max-width: 880px) { + .studio { grid-template-columns: 1fr; } +} + +.fieldset { + border: var(--rule); + background: var(--paper); + padding: var(--s-4) var(--s-5) var(--s-5); + margin: 0; +} + +.fieldset > legend { + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--ink-soft); + padding: 0 8px; + background: var(--paper-bg); + margin-left: -4px; +} + +.directions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px var(--s-4); +} + +.fieldset__divider { + margin-top: 14px; + padding-top: 12px; + border-top: 1px dashed var(--ink-faint); +} + +.button-row { + display: flex; + gap: 10px; + margin-top: 4px; +} + +.preview-card { + background: var(--paper); + border: var(--rule); + padding: var(--s-4); + position: sticky; + top: 96px; +} + +.preview-card__head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: var(--s-3); + padding-bottom: var(--s-3); + border-bottom: var(--rule-2); +} + +.preview-card__title { + font-family: var(--display); + font-weight: 800; + font-size: 16px; +} + +/* Word search grid (decorative on Generate, real on Result) */ +.ws-grid { + display: grid; + gap: 1px; + background: var(--ink); + border: 1.5px solid var(--ink); + aspect-ratio: 1 / 1; +} + +.ws-grid > span { + background: var(--paper-2); + display: grid; + place-items: center; + font-family: var(--mono); + font-weight: 700; + font-size: clamp(10px, 1.4vw, 14px); + color: var(--ink); +} + +.ws-words { + margin-top: var(--s-3); + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-soft); +} + +.ws-words b { + color: var(--ink); + font-weight: 600; +} + +.ws-words .sep { color: var(--ink-faint); } + +/* ---------- Result page ---------- */ +.result-grid { + margin: 0 auto; + max-width: 640px; +} +.result-grid .ws-grid > span { + font-size: clamp(11px, 1.6vw, 18px); +} + +.word-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + gap: 4px 16px; + list-style: none; + padding: 0; + margin: var(--s-4) 0 0; + font-family: var(--mono); + font-size: 13px; +} +.word-list li b { font-weight: 600; } +.word-list li .pref { color: var(--ink-soft); margin-left: 4px; font-size: 11px; } + +/* ---------- Edit theme workbench ---------- */ +.crumbs { + font-family: var(--mono); + font-size: 12px; + color: var(--ink-soft); + margin-bottom: 4px; +} + +.crumbs a { color: var(--ink-soft); text-decoration: none; } +.crumbs a:hover { color: var(--ink); } +.crumbs .sep { margin: 0 6px; } +.crumbs .here { color: var(--ink); } + +.workbench { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + gap: var(--s-5); +} + +@media (max-width: 880px) { + .workbench { grid-template-columns: 1fr; } +} + +.preview-list { + font-family: var(--mono); + font-size: 13px; + line-height: 1.85; + max-height: 22rem; + overflow: auto; +} + +.preview-list .row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 2px 0; + border-bottom: 1px dashed var(--ink-faint); +} + +.preview-list .src { color: var(--ink-soft); } +.preview-list .dst { color: var(--ink); font-weight: 600; } +.preview-list .row.skipped .dst { color: var(--accent); font-weight: 500; font-style: italic; } + +.stat-row { + display: flex; + justify-content: space-between; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-soft); + margin-top: var(--s-3); + padding-top: var(--s-3); + border-top: 1px dashed var(--ink-faint); +} + +.bottom-bar { + display: flex; + align-items: center; + gap: var(--s-3); + margin-top: var(--s-5); + padding-top: var(--s-4); + border-top: var(--rule-2); +} + +.bottom-bar .spacer { flex: 1; } +.bottom-bar form { display: inline; margin: 0; } + +/* ---------- Footer ---------- */ +.site-footer { + border-top: var(--double); + padding: var(--s-4) 0; + font-family: var(--mono); + font-size: 11px; + color: var(--ink-soft); + text-align: center; + letter-spacing: 0.08em; +} diff --git a/themes/common-birds.json b/themes/common-birds.json new file mode 100644 index 0000000..a4f610e --- /dev/null +++ b/themes/common-birds.json @@ -0,0 +1,33 @@ +{ + "name": "Common Birds", + "words": [ + "Robin", + "Sparrow", + "Crow", + "Pigeon", + "Dove", + "Magpie", + "Starling", + "Finch", + "Swallow", + "Wren", + "Blackbird", + "Jay", + "Owl", + "Eagle", + "Hawk", + "Falcon", + "Kestrel", + "Buzzard", + "Vulture", + "Heron", + "Stork", + "Swan", + "Goose", + "Duck", + "Woodpecker", + "Kingfisher", + "Raven", + "Osprey" + ] +} diff --git a/themes/farm-animals.json b/themes/farm-animals.json new file mode 100644 index 0000000..839d96e --- /dev/null +++ b/themes/farm-animals.json @@ -0,0 +1,31 @@ +{ + "name": "Farm Animals", + "words": [ + "Cow", + "Pig", + "Hen", + "Cat", + "Dog", + "Goat", + "Duck", + "Goose", + "Sheep", + "Horse", + "Donkey", + "Rabbit", + "Turkey", + "Rooster", + "Lamb", + "Calf", + "Piglet", + "Chicken", + "Foal", + "Kid", + "Pony", + "Llama", + "Alpaca", + "Mule", + "Bull", + "Stallion" + ] +} diff --git a/themes/mr-men.json b/themes/mr-men.json new file mode 100644 index 0000000..c2a8769 --- /dev/null +++ b/themes/mr-men.json @@ -0,0 +1,31 @@ +{ + "name": "Mr Men Characters", + "words": [ + "Mr Tickle", + "Mr Happy", + "Mr Bump", + "Mr Strong", + "Mr Tall", + "Mr Small", + "Mr Greedy", + "Mr Lazy", + "Mr Funny", + "Mr Noisy", + "Mr Quiet", + "Mr Fussy", + "Mr Grumpy", + "Mr Silly", + "Mr Sneeze", + "Mr Forgetful", + "Mr Worry", + "Mr Daydream", + "Little Miss Sunshine", + "Little Miss Naughty", + "Little Miss Tiny", + "Little Miss Magic", + "Little Miss Splendid", + "Little Miss Chatterbox", + "Little Miss Trouble", + "Little Miss Curious" + ] +} diff --git a/themes/precious-stones.json b/themes/precious-stones.json new file mode 100644 index 0000000..f8f7fa2 --- /dev/null +++ b/themes/precious-stones.json @@ -0,0 +1,32 @@ +{ + "name": "Precious Stones", + "words": [ + "Diamond", + "Ruby", + "Sapphire", + "Emerald", + "Topaz", + "Opal", + "Pearl", + "Amethyst", + "Garnet", + "Jade", + "Onyx", + "Quartz", + "Citrine", + "Turquoise", + "Aquamarine", + "Peridot", + "Tanzanite", + "Lapis", + "Moonstone", + "Tourmaline", + "Coral", + "Amber", + "Obsidian", + "Agate", + "Bloodstone", + "Zircon", + "Spinel" + ] +} diff --git a/themes/science-biology.json b/themes/science-biology.json new file mode 100644 index 0000000..3a03f23 --- /dev/null +++ b/themes/science-biology.json @@ -0,0 +1,33 @@ +{ + "name": "Science: Biology", + "words": [ + "Cell", + "Nucleus", + "Membrane", + "Bacteria", + "Virus", + "Fungus", + "Algae", + "Tissue", + "Organ", + "Skeleton", + "Muscle", + "Neuron", + "Blood", + "Heart", + "Lung", + "Brain", + "Kidney", + "Liver", + "Photosynthesis", + "Respiration", + "Digestion", + "Evolution", + "Genetics", + "Chromosome", + "Protein", + "Enzyme", + "Ecosystem", + "Habitat" + ] +} diff --git a/themes/science-chemistry.json b/themes/science-chemistry.json new file mode 100644 index 0000000..df3ee15 --- /dev/null +++ b/themes/science-chemistry.json @@ -0,0 +1,33 @@ +{ + "name": "Science: Chemistry", + "words": [ + "Atom", + "Molecule", + "Element", + "Compound", + "Mixture", + "Solution", + "Solvent", + "Reaction", + "Acid", + "Base", + "Alkali", + "Crystal", + "Liquid", + "Solid", + "Plasma", + "Hydrogen", + "Oxygen", + "Carbon", + "Nitrogen", + "Helium", + "Sulphur", + "Iron", + "Copper", + "Sodium", + "Calcium", + "Catalyst", + "Bond", + "Water" + ] +} diff --git a/themes/science-physics.json b/themes/science-physics.json new file mode 100644 index 0000000..5adc48c --- /dev/null +++ b/themes/science-physics.json @@ -0,0 +1,34 @@ +{ + "name": "Science: Physics", + "words": [ + "Force", + "Motion", + "Speed", + "Velocity", + "Friction", + "Gravity", + "Magnetism", + "Electricity", + "Energy", + "Heat", + "Light", + "Sound", + "Wave", + "Pressure", + "Mass", + "Weight", + "Inertia", + "Momentum", + "Acceleration", + "Temperature", + "Voltage", + "Current", + "Circuit", + "Magnet", + "Power", + "Frequency", + "Reflection", + "Refraction", + "Fire" + ] +} diff --git a/themes/sea-creatures.json b/themes/sea-creatures.json new file mode 100644 index 0000000..ecfd11e --- /dev/null +++ b/themes/sea-creatures.json @@ -0,0 +1,31 @@ +{ + "name": "Sea Creatures", + "words": [ + "Crab", + "Eel", + "Squid", + "Shark", + "Whale", + "Dolphin", + "Octopus", + "Lobster", + "Starfish", + "Jellyfish", + "Seahorse", + "Stingray", + "Turtle", + "Clownfish", + "Anglerfish", + "Barracuda", + "Cuttlefish", + "Manta", + "Manatee", + "Narwhal", + "Orca", + "Pufferfish", + "Seal", + "Walrus", + "Coral", + "Plankton" + ] +} diff --git a/themes/superheroes.json b/themes/superheroes.json new file mode 100644 index 0000000..a1c4615 --- /dev/null +++ b/themes/superheroes.json @@ -0,0 +1,32 @@ +{ + "name": "Superheroes", + "words": [ + "Spider-Man", + "Iron Man", + "Captain America", + "Thor", + "Hulk", + "Black Widow", + "Hawkeye", + "Black Panther", + "Doctor Strange", + "Ant-Man", + "Wasp", + "Falcon", + "Vision", + "Scarlet Witch", + "Wolverine", + "Storm", + "Cyclops", + "Rogue", + "Nightcrawler", + "Daredevil", + "Punisher", + "Batman", + "Superman", + "Wonder Woman", + "Flash", + "Aquaman", + "Green Lantern" + ] +} diff --git a/themes/transformers.json b/themes/transformers.json new file mode 100644 index 0000000..8261b80 --- /dev/null +++ b/themes/transformers.json @@ -0,0 +1,31 @@ +{ + "name": "Transformers", + "words": [ + "Optimus Prime", + "Bumblebee", + "Megatron", + "Starscream", + "Soundwave", + "Ironhide", + "Ratchet", + "Jazz", + "Wheeljack", + "Bulkhead", + "Arcee", + "Sideswipe", + "Sunstreaker", + "Hot Rod", + "Ultra Magnus", + "Grimlock", + "Shockwave", + "Devastator", + "Galvatron", + "Thundercracker", + "Skywarp", + "Blitzwing", + "Laserbeak", + "Cliffjumper", + "Prowl", + "Blaster" + ] +} diff --git a/themes/villains.json b/themes/villains.json new file mode 100644 index 0000000..7a7df5e --- /dev/null +++ b/themes/villains.json @@ -0,0 +1,31 @@ +{ + "name": "Villains", + "words": [ + "Joker", + "Penguin", + "Riddler", + "Bane", + "Scarecrow", + "Catwoman", + "Two-Face", + "Loki", + "Thanos", + "Ultron", + "Magneto", + "Mystique", + "Sabretooth", + "Venom", + "Carnage", + "Green Goblin", + "Doc Ock", + "Vulture", + "Mysterio", + "Kingpin", + "Doctor Doom", + "Galactus", + "Red Skull", + "Sandman", + "Electro", + "Lizard" + ] +} diff --git a/themes/wild-animals.json b/themes/wild-animals.json new file mode 100644 index 0000000..38364bf --- /dev/null +++ b/themes/wild-animals.json @@ -0,0 +1,33 @@ +{ + "name": "Wild Animals", + "words": [ + "Tiger", + "Lion", + "Elephant", + "Giraffe", + "Zebra", + "Cheetah", + "Leopard", + "Hippo", + "Rhino", + "Gorilla", + "Chimpanzee", + "Kangaroo", + "Koala", + "Panda", + "Wolf", + "Bear", + "Fox", + "Deer", + "Moose", + "Bison", + "Buffalo", + "Hyena", + "Jaguar", + "Crocodile", + "Anaconda", + "Sloth", + "Lemur", + "Otter" + ] +} diff --git a/wordsearch-specs.md b/wordsearch-specs.md new file mode 100644 index 0000000..74da25a --- /dev/null +++ b/wordsearch-specs.md @@ -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="_.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/.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/.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://: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 `_.pdf`.