Skill: odrobnik/sudoku (version 2.6.0)

--- name: sudoku description: Fetch Sudoku puzzles and store them as JSON in the workspace; render images on demand; reveal solutions later. version: 2.6.0 homepage: https://github.com/odrobnik/sudoku-skill metadata: openclaw: emoji: "🧩" requires: bins: ["python3"] python: ["requests", "Pillow", "lzstring"] --- # Sudoku ## Overview Fetch, render, and reveal Sudoku puzzles. Use `sudoku.py` to get new puzzles from `sudokuonline.io`, generate printable PDFs or images, and reveal solutions. For details on the saved JSON format, see [DATA_FORMAT.md](references/DATA_FORMAT.md). ## Available Puzzle Types * `kids4n`: Kids 4x4 * `kids4l`: Kids 4x4 with Letters * `kids6`: Kids 6x6 * `kids6l`: Kids 6x6 with Letters * `easy9`: Classic 9x9 (Easy) * `medium9`: Classic 9x9 (Medium) * `hard9`: Classic 9x9 (Hard) * `evil9`: Classic 9x9 (Evil) ## Setup / Requirements - Binaries: `python3` - Python libs: ```bash python3 -m pip install requests Pillow lzstring ``` ## Get a Puzzle Fetches a new puzzle and stores it as JSON. Output is JSON by default (use `--text` for human-readable output). Use `--count N` to fetch/store multiple puzzles in one call. If a batch does not contain enough unseen puzzles, the command will fetch additional batches until it has enough. Use `--id <fragment>` to select a specific source puzzle by matching any unique part of its UUID. If multiple IDs match, the command errors and lists candidates. **Get a Classic Easy puzzle:** ```bash ./scripts/sudoku.py get easy9 ``` **Get multiple new puzzles at once (e.g. 4 easy 9x9):** ```bash ./scripts/sudoku.py get easy9 --count 4 ``` **Get a Kids 6x6 puzzle:** ```bash ./scripts/sudoku.py get kids6 ``` ## Render Puzzle Render a puzzle as an image, PDF, or minimal HTML. **Render latest puzzle as A4 PDF (for printing):** ```bash ./scripts/sudoku.py render --pdf ``` **Render latest puzzle as clean PNG (for viewing):** ```bash ./scripts/sudoku.py render ``` **Render a specific previous puzzle by short ID:** ```bash ./scripts/sudoku.py render --id a09f3680 ``` **Render minimal HTML (square cells + bold box lines):** ```bash ./scripts/sudoku.py html --id a09f3680 ``` ## Reveal Solution Reveal the solution for the latest or specific puzzle. Use `--id <short_id>` (e.g., `a09f3680`) to target a specific puzzle. **Reveal full solution as printable PDF:** ```bash ./scripts/sudoku.py reveal --pdf ``` **Reveal full solution for a specific ID:** ```bash ./scripts/sudoku.py reveal --id a09f3680 --image ``` **Reveal full solution as PNG image:** ```bash ./scripts/sudoku.py reveal ``` **Reveal a single cell (row 3, column 7):** ```bash ./scripts/sudoku.py reveal --cell 3 7 ``` **Reveal a specific 3x3 box (index 5):** ```bash ./scripts/sudoku.py reveal --box 5 ``` ## Share Link Generate a share link for a stored puzzle. Targets the latest puzzle by default; use `--id <short_id>` for a specific one. **Generate a SudokuPad share link (default):** ```bash ./scripts/sudoku.py share ``` **Generate link for specific ID:** ```bash ./scripts/sudoku.py share --id a09f3680 ``` **Generate an SCL share link:** ```bash ./scripts/sudoku.py share --type scl ``` **Telegram Formatting Tip:** Format links as a short button-style link and hide the full URL: `[Easy Classic \[<id>\]](<url>)`.

path: references/DATA_FORMAT.md
content: # Sudoku Data Format

The Sudoku skill persists puzzle state in JSON files stored in `sudoku/puzzles/`.

## File Naming
Format: `YYYY-MM-DD_HHMMSSZ_<preset>_<size>x<size>_<short_id>.json`
Example: `2026-01-25_124059Z_easy9_9x9_2b8d9c35.json`

## Schema

The root object contains:

| Field | Type | Description |
| :--- | :--- | :--- |
| **`version`** | `int` | Schema version (currently `2`). |
| **`created_utc`** | `string` | ISO timestamp of creation (e.g., `2026-01-25_124059Z`). |
| **`preset`** | `object` | Details about the puzzle source preset. |
| **`picked`** | `object` | Details about the specific puzzle selection. |
| **`size`** | `int` | Grid size (e.g., `9` or `6`). |
| **`block`** | `object` | Block dimensions (`bw` width, `bh` height). |
| **`clues`** | `int[][]` | The initial grid (0 = empty). |
| **`solution`** | `int[][]` | The fully solved grid. |
| **`share`** | `object` | Pre-generated share link details. |

### `preset` Object
*   **`key`** *(string)*: Preset ID (e.g., `easy9`, `kids6`).
*   **`desc`** *(string)*: Human-readable name.
*   **`url`** *(string)*: Source URL.
*   **`letters`** *(bool)*: `true` if using letters (A-I) instead of numbers.

### `picked` Object
Stores provenance data about the specific puzzle chosen from the source batch.
*   **`id`** *(string)*: Unique puzzle UUID from the source.
*   **`index`** *(int)*: The zero-based index of this puzzle in the downloaded batch.
*   **`total`** *(int)*: Total number of puzzles available in the source batch at fetch time.

### `clues` / `solution` Arrays
A 2D array (list of lists) representing the grid rows.
*   `0`: Empty cell (clues only).
*   `1-9`: Cell value (or 1-N for other sizes).

### `share` Object
*   **`kind`** *(string)*: Type of share link (`native`, `scl`, or `none`).
*   **`link`** *(string|null)*: The generated share URL.


path: scripts/sudoku.py
content: #!/usr/bin/env python3
"""skills/sudoku/sudoku.py

Sudoku skill CLI.

Key design (per Oliver preference):
- **Puzzles are stored as individual JSON files** under the workspace `sudoku/puzzles/` folder.
- **Images are generated from JSON on demand** (no persistent state.json / history.jsonl).
- “Latest puzzle” = newest JSON in `sudoku/puzzles/`.

Data source: https://www.sudokuonline.io (pages embed a `preloadedPuzzles` array).

Commands:
  - list
  - get <preset> [--count N] [--id ID] [--render] [--json]
  - render [--latest|--id ID] [--pdf|--printable] [--json]
  - html [--latest|--id ID] [--json]
  - reveal [--latest|--id ID] [--full|--box ...|--cell r c] [--image] [--json]

Notes:
- Only **Classic 9×9** puzzles produce a reliable SudokuPad share link.
- Reveal images render **givens in black** and **filled-in values in blue**.
"""

from __future__ import annotations

import argparse
import json
import math
import os
import random
import re
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import requests
from PIL import Image

# Import the existing render + link utilities.
REPO_ROOT = Path.cwd()
# sys.path.insert(0, str(REPO_ROOT))

from sudoku_fetcher import (  # type: ignore
    RENDER_CELL_SIZE,
    RENDER_INSET,
    decode_puzzle,
    generate_native_link,
    generate_scl_link,
    generate_fpuzzles_link,
    get_block_dims,
    render_sudoku,
)

from sudoku_print_render import render_sudoku_a4_pdf  # type: ignore


# Storage (workspace-local)
# Walk up from CWD to find the workspace root (parent of "skills/").
# Falls back to SUDOKU_WORKSPACE env var, then cwd.
def _find_workspace_root() -> Path:
    # Use $PWD (shell's logical path) which preserves symlinks,
    # unlike Path.cwd() / os.getcwd() which resolve them.
    # This handles: `cd ~/clawd/skills/sudoku && python3 scripts/sudoku.py ...`
    # where ~/clawd/skills/sudoku is a symlink — we want ~/clawd, not the resolved target.
    pwd_env = os.environ.get("PWD")
    cwd = Path(pwd_env) if pwd_env else Path.cwd()

    d = cwd
    for _ in range(6):
        if (d / "skills").is_dir() and d != d.parent:
            return d
        parent = d.parent
        if parent == d:
            break
        d = parent

    # Fallback: walk up from script location (resolved, for direct invocations).
    d = Path(__file__).resolve().parent
    for _ in range(6):
        if (d / "skills").is_dir() and d != d.parent:
            return d
        d = d.parent
    return cwd

WORKSPACE_ROOT = _find_workspace_root()
PUZZLES_DIR = WORKSPACE_ROOT / "sudoku" / "puzzles"
RENDERS_DIR = WORKSPACE_ROOT / "sudoku" / "renders"


@dataclass(frozen=True)
class Preset:
    key: str
    desc: str
    url: str
    letters: bool = False


PRESETS: Dict[str, Preset] = {
    # Kids
    "kids4n": Preset(
        key="kids4n",
        desc="Kids 4x4",
        url="https://www.sudokuonline.io/kids/numbers-4-4",
        letters=False,
    ),
    "kids4l": Preset(
        key="kids4l",
        desc="Kids 4x4 with Letters",
        url="https://www.sudokuonline.io/kids/letters-4-4",
        letters=True,
    ),
    "kids6": Preset(
        key="kids6",
        desc="Kids 6x6",
        url="https://www.sudokuonline.io/kids/numbers-6-6",
        letters=False,
    ),
    "kids6l": Preset(
        key="kids6l",
        desc="Kids 6x6 with Letters",
        url="https://www.sudokuonline.io/kids/letters-6-6",
        letters=True,
    ),

    # Classic 9x9
    "easy9": Preset(
        key="easy9",
        desc="Classic 9x9 (Easy)",
        url="https://www.sudokuonline.io/easy",
        letters=False,
    ),
    "medium9": Preset(
        key="medium9",
        desc="Classic 9x9 (Medium)",
        url="https://www.sudokuonline.io/medium",
        letters=False,
    ),
    "hard9": Preset(
        key="hard9",
        desc="Classic 9x9 (Hard)",
        url="https://www.sudokuonline.io/hard",
        letters=False,
    ),
    "evil9": Preset(
        key="evil9",
        desc="Classic 9x9 (Evil)",
        url="https://www.sudokuonline.io/evil",
        letters=False,
    ),
}


def utc_stamp() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%d_%H%M%SZ")


def ensure_dirs() -> None:
    PUZZLES_DIR.mkdir(parents=True, exist_ok=True)
    RENDERS_DIR.mkdir(parents=True, exist_ok=True)


def _extract_js_array_contents(html: str, var_name: str) -> str:
    marker = f"const {var_name} = ["
    marker_pos = html.find(marker)
    if marker_pos < 0:
        raise ValueError(f"Could not find {var_name} in HTML")

    # Position at the opening '['
    start = marker_pos + len(marker) - 1

    depth = 0
    in_string = False
    string_quote = ""
    escape = False

    for i in range(start, len(html)):
        ch = html[i]

        if in_string:
            if escape:
                escape = False
                continue
            if ch == "\\":
                escape = True
                continue
            if ch == string_quote:
                in_string = False
            continue

        if ch in ("'", '"'):
            in_string = True
            string_quote = ch
            continue

        if ch == "[":
            depth += 1
            continue

        if ch == "]":
            depth -= 1
            if depth == 0:
                return html[start + 1 : i]

    raise ValueError(f"Could not parse {var_name} array from HTML")


def parse_preloaded_puzzles(html: str) -> List[Dict[str, Any]]:
    blob = _extract_js_array_contents(html, "preloadedPuzzles")
    puzzles: List[Dict[str, Any]] = []

    # Entries are JS/Python-ish object literals — convert to valid JSON and parse safely.
    for pm in re.finditer(r"\{[^}]+\}", blob):
        s = pm.group(0)
        # Fix JS keywords → JSON
        s = re.sub(r"\btrue\b", "true", s)
        s = re.sub(r"\bfalse\b", "false", s)
        s = re.sub(r"\bnull\b", "null", s)
        # Replace single quotes with double quotes (site uses Python-style 'key': 'value')
        s = s.replace("'", '"')
        try:
            obj = json.loads(s)
            if isinstance(obj, dict) and "id" in obj and "data" in obj:
                puzzles.append(obj)
        except (json.JSONDecodeError, ValueError):
            continue

    return puzzles


def fetch_puzzles(url: str) -> List[Dict[str, Any]]:
    r = requests.get(url, timeout=30)
    r.raise_for_status()
    return parse_preloaded_puzzles(r.text)


def _previously_used_puzzle_ids() -> set[str]:
    """Return the set of puzzle IDs (full UUID) already stored on disk."""
    used: set[str] = set()
    for path in list_puzzle_jsons():
        try:
            doc = json.loads(path.read_text(encoding="utf-8"))
            pid = doc.get("picked", {}).get("id")
            if pid:
                used.add(str(pid))
        except (json.JSONDecodeError, OSError):
            continue
    return used


def pick_puzzle(
    puzzles: List[Dict[str, Any]],
    *,
    index: Optional[int] = None,
    puzzle_id: Optional[str] = None,
    seed: Optional[str] = None,
) -> Tuple[Dict[str, Any], int]:
    if not puzzles:
        raise ValueError("No puzzles found")

    if puzzle_id is not None:
        needle = _normalize_id_fragment(puzzle_id).lower()
        matches: List[Tuple[int, Dict[str, Any]]] = []
        for i, p in enumerate(puzzles):
            pid = str(p.get("id", ""))
            if needle and needle in pid.lower():
                matches.append((i, p))

        if not matches:
            raise ValueError(f"Puzzle id fragment not found: {puzzle_id}")
        if len(matches) > 1:
            ids = [str(p.get("id")) for _, p in matches[:5]]
            extra = "" if len(matches) <= 5 else f" (+{len(matches) - 5} more)"
            raise ValueError(
                f"Puzzle id fragment is ambiguous ({len(matches)} matches): {', '.join(ids)}{extra}"
            )

        i, p = matches[0]
        return p, i

    if index is not None:
        # 1-based index by default (friendlier). Allow 0 as explicit first element.
        i = 0 if index == 0 else (index - 1)
        if i < 0 or i >= len(puzzles):
            raise ValueError(f"index out of range: {index} (have {len(puzzles)} puzzles)")
        return puzzles[i], i

    # Random selection — prefer puzzles not yet fetched.
    used_ids = _previously_used_puzzle_ids()
    fresh = [(i, p) for i, p in enumerate(puzzles) if str(p.get("id")) not in used_ids]
    pool = fresh if fresh else list(enumerate(puzzles))  # fall back to all if every puzzle was used

    rng = random.Random(seed)
    i, puzzle = pool[rng.randrange(len(pool))]
    return puzzle, i


def format_cell_value(val: int, letters_mode: bool) -> str:
    if val == 0:
        return ""
    if letters_mode:
        return chr(ord("A") + val - 1)
    return str(val)


def puzzle_json_filename(stamp: str, preset_key: str, size: int, puzzle_id: str) -> str:
    short = puzzle_id.split("-")[0]
    return f"{stamp}_{preset_key}_{size}x{size}_{short}.json"


def write_puzzle_json(doc: Dict[str, Any]) -> Path:
    ensure_dirs()
    stamp = doc.get("created_utc") or utc_stamp()
    preset_key = doc.get("preset", {}).get("key", "preset")
    size = int(doc.get("size", 0) or 0)
    puzzle_id = str(doc.get("picked", {}).get("id", "unknown"))
    path = PUZZLES_DIR / puzzle_json_filename(str(stamp), str(preset_key), size, puzzle_id)
    path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
    return path


def list_puzzle_jsons() -> List[Path]:
    ensure_dirs()
    return sorted(PUZZLES_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime)


def latest_puzzle_json() -> Path:
    files = list_puzzle_jsons()
    if not files:
        raise FileNotFoundError(f"No puzzles stored yet in {PUZZLES_DIR}")
    return files[-1]


def _normalize_id_fragment(value: str) -> str:
    s = str(value or "").strip()
    if not s:
        raise ValueError("Puzzle id cannot be empty")
    if not re.fullmatch(r"[0-9A-Fa-f-]{1,64}", s):
        raise ValueError("Puzzle id may only contain hex characters and '-'")
    return s


def find_puzzle_json_by_short_id(short_id: str) -> Path:
    """Fast-path lookup by the short ID used in filenames (first UUID segment)."""
    ensure_dirs()
    sid = _normalize_id_fragment(short_id).split("-")[0].lower()
    matches = [p for p in list_puzzle_jsons() if p.name.lower().endswith(f"_{sid}.json")]
    if not matches:
        raise FileNotFoundError(f"No stored puzzle JSON found for short id={short_id}")
    return matches[-1]


def find_puzzle_json_by_id(puzzle_id: str) -> Path:
    """Find a stored puzzle by UUID.

    Accepts either:
    - full UUID (e.g. 324306f5-034d-4089-8723-56a8087fde14)
    - short ID (first segment, e.g. 324306f5) which is embedded in the filename

    Fast path: try filename suffix match first; fallback: scan JSON contents.
    """

    normalized = _normalize_id_fragment(puzzle_id)
    sid = normalized.split("-")[0].lower()

    # Fast path: suffix match on known puzzle JSON filenames (no glob with user input).
    candidates = [p for p in list_puzzle_jsons() if p.name.lower().endswith(f"_{sid}.json")]
    if candidates:
        # If user gave only short id, just return latest match.
        if "-" not in normalized:
            return candidates[-1]

        # If user gave full UUID, verify candidate content before returning.
        for p in reversed(candidates):
            try:
                d = json.loads(p.read_text(encoding="utf-8"))
                if str(d.get("picked", {}).get("id", "")).lower() == normalized.lower():
                    return p
            except Exception:
                continue

    # Slow path: scan all stored docs.
    for p in reversed(list_puzzle_jsons()):
        try:
            d = json.loads(p.read_text(encoding="utf-8"))
            if str(d.get("picked", {}).get("id", "")).lower() == normalized.lower():
                return p
        except Exception:
            continue

    raise FileNotFoundError(f"No stored puzzle JSON found for id={puzzle_id}")


def load_puzzle_doc(*, puzzle_id: Optional[str] = None, latest: bool = False) -> Tuple[Dict[str, Any], Path]:
    if puzzle_id is not None and latest:
        raise SystemExit("Use only one of --id / --latest")

    try:
        if puzzle_id is not None:
            p = find_puzzle_json_by_id(puzzle_id)
        else:
            p = latest_puzzle_json()
    except (ValueError, FileNotFoundError) as e:
        raise SystemExit(str(e))

    doc = json.loads(p.read_text(encoding="utf-8"))
    return doc, p


def render_paths(doc: Dict[str, Any], *, kind: str, ext: str = "png") -> Path:
    ensure_dirs()
    stamp = utc_stamp()
    preset_key = doc.get("preset", {}).get("key", "preset")
    size = int(doc.get("size", 0) or 0)
    puzzle_id_short = str(doc.get("picked", {}).get("id", "unknown")).split("-")[0]
    return RENDERS_DIR / f"{stamp}_{preset_key}_{size}x{size}_{puzzle_id_short}_{kind}.{ext}"


def _print_header(doc: Dict[str, Any]) -> Tuple[str, List[str]]:
    puzzle_id = str(doc.get("picked", {}).get("id", "unknown"))
    short_id = puzzle_id.split("-")[0]

    preset_key = str(doc.get("preset", {}).get("key", ""))
    size = int(doc.get("size", 0) or 0)
    letters_mode = bool(doc.get("preset", {}).get("letters", False))

    # Title conventions
    # - Kids: "Kids 6x6" (and optionally add Letters when needed)
    # - Classic: "Easy Classic" / "Medium Classic" / ...
    if preset_key.startswith("kids"):
        title = f"Kids {size}x{size}"
        if letters_mode:
            title += " Letters"
    else:
        difficulty = preset_key.replace("9", "").capitalize() or "Easy"
        title = f"{difficulty} Classic"

    right_lines = [short_id]
    return title, right_lines


def render_puzzle_image(doc: Dict[str, Any], *, printable: bool = False) -> Path:
    kind = "puzzle_print" if printable else "puzzle"
    out = render_paths(doc, kind=kind, ext="png")
    clues = doc["clues"]
    size = int(doc["size"])
    letters_mode = bool(doc.get("preset", {}).get("letters", False))

    if printable:
        title, right_lines = _print_header(doc)
        render_sudoku(
            clues,
            size,
            str(out),
            title=title,
            extra_lines=None,
            extra_lines_right=right_lines,
            letters_mode=letters_mode,
        )
    else:
        render_sudoku(clues, size, str(out), title=None, extra_lines=None, extra_lines_right=None, letters_mode=letters_mode)

    return out


def render_puzzle_pdf(doc: Dict[str, Any], *, printable: bool = True, dpi: int = 300) -> Path:
    kind = "puzzle_print" if printable else "puzzle"
    out = render_paths(doc, kind=kind, ext="pdf")
    clues = doc["clues"]
    size = int(doc["size"])
    letters_mode = bool(doc.get("preset", {}).get("letters", False))
    bw, bh = get_block_dims(size)

    title_left = None
    right_lines: List[str] = []
    if printable:
        title_left, right_lines = _print_header(doc)

    render_sudoku_a4_pdf(
        grid=clues,
        size=size,
        out_pdf=out,
        bw=bw,
        bh=bh,
        title_left=title_left,
        right_lines=right_lines,
        original_clues=None,
        letters_mode=letters_mode,
        dpi=dpi,
    )
    return out


def render_puzzle_html(doc: Dict[str, Any]) -> Path:
    out = render_paths(doc, kind="puzzle", ext="html")

    clues = doc["clues"]
    size = int(doc["size"])
    letters_mode = bool(doc.get("preset", {}).get("letters", False))
    bw, bh = get_block_dims(size)

    def cell_text(v: int) -> str:
        return format_cell_value(int(v), letters_mode)

    rows: List[str] = []
    for r in range(size):
        cells: List[str] = []
        for c in range(size):
            v = int(clues[r][c])
            classes: List[str] = []
            if c == 0:
                classes.append("edge-left")
            if r == 0:
                classes.append("edge-top")
            if (c + 1) % bw == 0:
                classes.append("edge-right-thick")
            else:
                classes.append("edge-right")
            if (r + 1) % bh == 0:
                classes.append("edge-bottom-thick")
            else:
                classes.append("edge-bottom")

            classes_s = " ".join(classes)
            text = cell_text(v)
            cells.append(f'<td class="{classes_s}"><span class="cell-value">{text}</span></td>')
        rows.append("<tr>" + "".join(cells) + "</tr>")

    html = f"""<!doctype html>
<html lang=\"en\">
<head>
  <meta charset=\"utf-8\" />
  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
  <title>Sudoku</title>
  <style>
    :root {{
      --cell: 56px;
      --thin: 1px;
      --thick: 3px;
      --line: #444;
      --thick-line: #000;
    }}
    body {{
      margin: 0;
      min-height: 100vh;
      display: grid;
      place-items: center;
      background: #fff;
      font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;
    }}
    table.sudoku {{
      border-collapse: collapse;
      border-spacing: 0;
    }}
    table.sudoku td {{
      width: var(--cell);
      height: var(--cell);
      min-width: var(--cell);
      min-height: var(--cell);
      text-align: center;
      vertical-align: middle;
      box-sizing: border-box;
      padding: 0;
    }}
    .cell-value {{
      width: 100%;
      height: 100%;
      display: grid;
      place-items: center;
      font-size: calc(var(--cell) * 0.52);
      line-height: 1;
      font-weight: 600;
      color: #111;
    }}
    .edge-left {{ border-left: var(--thick) solid var(--thick-line); }}
    .edge-top {{ border-top: var(--thick) solid var(--thick-line); }}
    .edge-right {{ border-right: var(--thin) solid var(--line); }}
    .edge-bottom {{ border-bottom: var(--thin) solid var(--line); }}
    .edge-right-thick {{ border-right: var(--thick) solid var(--thick-line); }}
    .edge-bottom-thick {{ border-bottom: var(--thick) solid var(--thick-line); }}
  </style>
</head>
<body>
  <table class=\"sudoku\" aria-label=\"Sudoku grid\">{''.join(rows)}</table>
</body>
</html>
"""

    out.write_text(html, encoding="utf-8")
    return out


def render_reveal_image(doc: Dict[str, Any], *, printable: bool = False) -> Path:
    """Render a styled solution image: givens black, filled-in values blue."""
    kind = "reveal_print" if printable else "reveal"
    out = render_paths(doc, kind=kind, ext="png")
    clues = doc["clues"]
    solution = doc["solution"]
    size = int(doc["size"])
    letters_mode = bool(doc.get("preset", {}).get("letters", False))

    if printable:
        title, right_lines = _print_header(doc)
        title = f"Solution: {title}"
        render_sudoku(
            solution,
            size,
            str(out),
            title=title,
            extra_lines=None,
            extra_lines_right=right_lines,
            original_clues=clues,
            letters_mode=letters_mode,
        )
    else:
        render_sudoku(solution, size, str(out), title=None, extra_lines=None, extra_lines_right=None, original_clues=clues, letters_mode=letters_mode)

    return out


def render_reveal_pdf(doc: Dict[str, Any], *, printable: bool = True, dpi: int = 300) -> Path:
    kind = "reveal_print" if printable else "reveal"
    out = render_paths(doc, kind=kind, ext="pdf")
    clues = doc["clues"]
    solution = doc["solution"]
    size = int(doc["size"])
    letters_mode = bool(doc.get("preset", {}).get("letters", False))
    bw, bh = get_block_dims(size)

    title_left = None
    right_lines: List[str] = []
    if printable:
        t, right_lines = _print_header(doc)
        title_left = f"Solution: {t}"

    render_sudoku_a4_pdf(
        grid=solution,
        size=size,
        out_pdf=out,
        bw=bw,
        bh=bh,
        title_left=title_left,
        right_lines=right_lines,
        original_clues=clues,
        letters_mode=letters_mode,
        dpi=dpi,
    )
    return out


def crop_box_image(
    image_path: Path,
    *,
    size: int,
    box_r: int,
    box_c: int,
    out_path: Path,
    margin: int = RENDER_INSET,
    cell_size: int = RENDER_CELL_SIZE,
) -> Path:
    bw, bh = get_block_dims(size)

    br0 = box_r - 1
    bc0 = box_c - 1

    left = margin
    top = margin

    x0 = left + (bc0 * bw) * cell_size
    y0 = top + (br0 * bh) * cell_size
    x1 = x0 + (bw * cell_size)
    y1 = y0 + (bh * cell_size)

    pad = 6

    img = Image.open(image_path)
    crop = img.crop((max(0, x0 - pad), max(0, y0 - pad), min(img.width, x1 + pad), min(img.height, y1 + pad)))
    crop.save(out_path)
    return out_path


def crop_cell_image(
    image_path: Path,
    *,
    r: int,
    c: int,
    out_path: Path,
    margin: int = RENDER_INSET,
    cell_size: int = RENDER_CELL_SIZE,
) -> Path:
    rr0 = r - 1
    cc0 = c - 1

    left = margin
    top = margin

    x0 = left + cc0 * cell_size
    y0 = top + rr0 * cell_size
    x1 = x0 + cell_size
    y1 = y0 + cell_size

    pad = 6

    img = Image.open(image_path)
    crop = img.crop((max(0, x0 - pad), max(0, y0 - pad), min(img.width, x1 + pad), min(img.height, y1 + pad)))
    crop.save(out_path)
    return out_path


def cmd_list(args: argparse.Namespace) -> int:
    items = []
    for key in sorted(PRESETS.keys()):
        p = PRESETS[key]
        items.append({"preset": p.key, "desc": p.desc, "letters": p.letters, "url": p.url})

    if args.json:
        print(json.dumps({"presets": items}, ensure_ascii=False))
    else:
        for it in items:
            print(f"- {it['preset']}: {it['desc']}\n  {it['url']}")

    return 0


def cmd_get(args: argparse.Namespace) -> int:
    if args.preset not in PRESETS:
        raise SystemExit(f"Unknown preset '{args.preset}'. Run: sudoku.py list")

    has_id_selector = args.id is not None

    count = int(getattr(args, "count", 1) or 1)
    if count < 1:
        raise SystemExit("--count must be >= 1")

    if count > 1 and has_id_selector:
        raise SystemExit("--count > 1 cannot be combined with --id")

    preset = PRESETS[args.preset]

    selected: List[Tuple[Dict[str, Any], int, int]] = []  # (puzzle, picked_idx, batch_total)

    if count == 1:
        puzzles = fetch_puzzles(preset.url)
        try:
            puzzle, picked_idx = pick_puzzle(puzzles, puzzle_id=args.id)
        except ValueError as e:
            raise SystemExit(str(e))
        selected.append((puzzle, picked_idx, len(puzzles)))
    else:
        used_ids = _previously_used_puzzle_ids()
        seen_ids: set[str] = set()
        attempts = 0
        max_attempts = max(5, count * 4)

        while len(selected) < count and attempts < max_attempts:
            attempts += 1
            puzzles = fetch_puzzles(preset.url)

            fresh = [
                (p, i, len(puzzles))
                for i, p in enumerate(puzzles)
                if str(p.get("id")) not in used_ids and str(p.get("id")) not in seen_ids
            ]

            if not fresh:
                continue

            random.shuffle(fresh)
            needed = count - len(selected)
            for p, i, total in fresh[:needed]:
                pid = str(p.get("id"))
                seen_ids.add(pid)
                selected.append((p, i, total))

        if len(selected) < count:
            raise SystemExit(
                f"Could only fetch {len(selected)} unique new puzzle(s) after {attempts} batch fetches (requested {count})."
            )

    items: List[Dict[str, Any]] = []

    for puzzle, picked_idx, batch_total in selected:
        size, clues, solution = decode_puzzle(puzzle["data"])

        stamp = utc_stamp()
        puzzle_id = str(puzzle["id"])

        # Share link: classic 9x9 only.
        share_kind = "none"
        share_link = None
        if size == 9:
            short_id = puzzle_id.split("-")[0]
            # Oliver preference: embedded SudokuPad metadata title format
            # "Easy Classic [ID]"
            difficulty = preset.key.replace("9", "").capitalize()  # easy/medium/hard/evil
            share_title = f"{difficulty} Classic [{short_id}]"

            # Use SudokuPad /puzzle/ links (required for SudokuPad app).
            # The payload is generated URL-safe so chat systems don't break it.
            share_link = generate_native_link(clues, size, title=share_title)
            if isinstance(share_link, str) and share_link.startswith("http"):
                share_kind = "native"
            else:
                share_kind = "none"
                share_link = None

        doc: Dict[str, Any] = {
            "version": 2,
            "created_utc": stamp,
            "preset": {"key": preset.key, "desc": preset.desc, "url": preset.url, "letters": preset.letters},
            "picked": {"id": puzzle_id, "index": picked_idx, "total": batch_total},
            "size": size,
            "block": {"bw": get_block_dims(size)[0], "bh": get_block_dims(size)[1]},
            "clues": clues,
            "solution": solution,
            "share": {"kind": share_kind, "link": share_link},
        }

        json_path = write_puzzle_json(doc)

        item: Dict[str, Any] = {
            "preset": preset.key,
            "desc": preset.desc,
            "puzzle_id": puzzle_id,
            "picked_index": picked_idx,
            "puzzle_count": batch_total,
            "size": size,
            "letters_mode": preset.letters,
            "puzzle_json": str(json_path),
            "share_kind": share_kind,
            "share_link": share_link,
        }

        if args.render:
            item["puzzle_image"] = str(render_puzzle_image(doc, printable=False))

        items.append(item)

    if count == 1:
        payload: Dict[str, Any] = items[0]
        if args.json:
            print(json.dumps(payload, ensure_ascii=False))
        else:
            print(f"Stored: {payload['puzzle_json']}")
            if payload.get("puzzle_image"):
                print(f"Puzzle image: {payload['puzzle_image']}")
            if payload.get("share_kind") != "none":
                print(f"Share link ({payload['share_kind']}): {payload['share_link']}")
        return 0

    payload_multi: Dict[str, Any] = {
        "preset": preset.key,
        "desc": preset.desc,
        "count_requested": count,
        "count_fetched": len(items),
        "puzzle_ids": [it["puzzle_id"] for it in items],
        "items": items,
    }

    if args.json:
        print(json.dumps(payload_multi, ensure_ascii=False))
    else:
        print(f"Stored {len(items)} puzzle(s):")
        for it in items:
            print(f"- {it['puzzle_id']} -> {it['puzzle_json']}")

    return 0


def cmd_render(args: argparse.Namespace) -> int:
    doc, json_path = load_puzzle_doc(puzzle_id=args.id, latest=args.latest)

    if args.pdf:
        # PDF is primarily for printing; we include the small header by default.
        out_path = render_puzzle_pdf(doc, printable=True)
        out = {"puzzle_json": str(json_path), "puzzle_pdf": str(out_path)}
    else:
        img = render_puzzle_image(doc, printable=args.printable)
        out = {"puzzle_json": str(json_path), "puzzle_image": str(img)}

    if args.json:
        print(json.dumps(out, ensure_ascii=False))
    else:
        print(str(list(out.values())[-1]))
    return 0


def cmd_html(args: argparse.Namespace) -> int:
    doc, json_path = load_puzzle_doc(puzzle_id=args.id, latest=args.latest)
    html = render_puzzle_html(doc)
    out = {"puzzle_json": str(json_path), "puzzle_html": str(html)}

    if args.json:
        print(json.dumps(out, ensure_ascii=False))
    else:
        print(str(html))
    return 0


def cmd_share(args: argparse.Namespace) -> int:
    doc, json_path = load_puzzle_doc(puzzle_id=args.id, latest=args.latest)
    
    clues = doc["clues"]
    size = int(doc["size"])
    puzzle_id = str(doc.get("picked", {}).get("id", "unknown"))
    short_id = puzzle_id.split("-")[0]
    
    preset_key = str(doc.get("preset", {}).get("key", ""))
    difficulty = preset_key.replace("9", "").capitalize() or "Easy"
    
    title = f"{difficulty} Classic [{short_id}]"
    
    link = None
    if args.type == "scl":
        link = generate_scl_link(clues, size, title=title)
    elif args.type == "fpuzzle":
        link = generate_fpuzzles_link(clues, size, title=title)
    else: # sudokupad
        if size == 9:
            link = generate_native_link(clues, size, title=title)
        else:
            link = generate_native_link(clues, size, title=title)
            if not link.startswith("http"):
                 link = generate_scl_link(clues, size, title=title)

    out = {"puzzle_json": str(json_path), "share_link": link, "type": args.type}
    
    if args.json:
        print(json.dumps(out, ensure_ascii=False))
    else:
        print(link)
    return 0


def cmd_reveal(args: argparse.Namespace) -> int:
    doc, json_path = load_puzzle_doc(puzzle_id=args.id, latest=args.latest)

    size = int(doc["size"])
    letters_mode = bool(doc.get("preset", {}).get("letters", False))

    # If nothing selected, default to full reveal image.
    want_full = bool(args.full) or (args.box is None and args.cell is None)

    if args.pdf and want_full:
        pdf = render_reveal_pdf(doc, printable=True)
        out = {"puzzle_json": str(json_path), "solution_pdf": str(pdf)}
        if args.json:
            print(json.dumps(out, ensure_ascii=False))
        else:
            print(str(pdf))
        return 0

    reveal_img = render_reveal_image(doc, printable=args.printable)

    if want_full:
        out = {"puzzle_json": str(json_path), "solution_image": str(reveal_img)}
        if args.json:
            print(json.dumps(out, ensure_ascii=False))
        else:
            print(str(reveal_img))
        return 0

    if args.box is not None:
        bw, bh = get_block_dims(size)
        boxes_per_row = size // bw
        boxes_per_col = size // bh
        total_boxes = boxes_per_row * boxes_per_col

        vals = args.box
        if len(vals) == 1:
            idx = vals[0]
            if not (1 <= idx <= total_boxes):
                raise ValueError(f"box index out of range: {idx} (1..{total_boxes})")
            box_r = (idx - 1) // boxes_per_row + 1
            box_c = (idx - 1) % boxes_per_row + 1
        elif len(vals) == 2:
            box_r, box_c = vals
            if not (1 <= box_r <= boxes_per_col and 1 <= box_c <= boxes_per_row):
                raise ValueError(
                    f"box row/col out of range: ({box_r},{box_c}); rows 1..{boxes_per_col}, cols 1..{boxes_per_row}"
                )
            idx = (box_r - 1) * boxes_per_row + box_c
        else:
            raise ValueError("--box expects either 1 value (index) or 2 values (row col)")

        out_path = render_paths(doc, kind=f"box_{idx}_r{box_r}_c{box_c}")
        crop_box_image(reveal_img, size=size, box_r=box_r, box_c=box_c, out_path=out_path)

        out = {"puzzle_json": str(json_path), "box": {"index": idx, "r": box_r, "c": box_c}, "image": str(out_path)}
        if args.json:
            print(json.dumps(out, ensure_ascii=False))
        else:
            print(str(out_path))
        return 0

    if args.cell is not None:
        r, c = args.cell
        if not (1 <= r <= size and 1 <= c <= size):
            raise ValueError(f"cell out of range: ({r},{c}) for size {size}")

        val = int(doc["solution"][r - 1][c - 1])
        text = format_cell_value(val, letters_mode)

        cell_img_path: Optional[Path] = None
        if args.image:
            cell_img_path = render_paths(doc, kind=f"cell_r{r}_c{c}")
            crop_cell_image(reveal_img, r=r, c=c, out_path=cell_img_path)

        if args.json:
            out: Dict[str, Any] = {"puzzle_json": str(json_path), "cell": {"r": r, "c": c}, "value": val, "text": text}
            if cell_img_path:
                out["image"] = str(cell_img_path)
            print(json.dumps(out, ensure_ascii=False))
        else:
            # requirement: output just the digit/letter
            print(text)
            if cell_img_path:
                print(str(cell_img_path))
        return 0

    raise SystemExit("Nothing to reveal")


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="sudoku.py")
    sub = p.add_subparsers(dest="cmd", required=True)

    p_list = sub.add_parser("list", help="List available presets")
    p_list.add_argument("--text", dest="json", action="store_false", help="Output text instead of JSON")
    p_list.set_defaults(json=True)
    p_list.set_defaults(func=cmd_list)

    p_get = sub.add_parser("get", help="Fetch a puzzle from a preset and store as JSON")
    p_get.add_argument("preset", help="Preset name (see: list)")
    p_get.add_argument("--count", type=int, default=1, help="Fetch and store N puzzles (default: 1)")
    p_get.add_argument("--id", help="Select puzzle by unique ID fragment (matches any part of source UUID)")
    p_get.add_argument("--render", action="store_true", help="Also render the puzzle image now")
    p_get.add_argument("--text", dest="json", action="store_false", help="Output text instead of JSON")
    p_get.set_defaults(json=True)
    p_get.set_defaults(func=cmd_get)

    p_ren = sub.add_parser("render", help="Render puzzle image from stored JSON")
    g = p_ren.add_mutually_exclusive_group(required=False)
    g.add_argument("--latest", action="store_true", help="Use latest stored puzzle (default)")
    g.add_argument("--id", help="Puzzle ID (full UUID or short 8-char ID from filename)")
    p_ren.add_argument("--printable", action="store_true", help="Include small header (difficulty + short ID) for printout")
    p_ren.add_argument("--pdf", action="store_true", help="Render as A4 PDF (recommended for printing)")
    p_ren.add_argument("--text", dest="json", action="store_false", help="Output text instead of JSON")
    p_ren.set_defaults(json=True)
    p_ren.set_defaults(func=cmd_render)

    p_html = sub.add_parser("html", help="Render puzzle as minimal HTML")
    g_html = p_html.add_mutually_exclusive_group(required=False)
    g_html.add_argument("--latest", action="store_true", help="Use latest stored puzzle (default)")
    g_html.add_argument("--id", help="Puzzle ID (full UUID or short 8-char ID from filename)")
    p_html.add_argument("--text", dest="json", action="store_false", help="Output text instead of JSON")
    p_html.set_defaults(json=True)
    p_html.set_defaults(func=cmd_html)

    p_share = sub.add_parser("share", help="Generate share link")
    g_share = p_share.add_mutually_exclusive_group(required=False)
    g_share.add_argument("--latest", action="store_true", help="Use latest stored puzzle (default)")
    g_share.add_argument("--id", help="Puzzle ID (full UUID or short 8-char ID from filename)")
    p_share.add_argument("--type", choices=["sudokupad", "fpuzzle", "scl"], default="sudokupad", help="Link type")
    p_share.add_argument("--text", dest="json", action="store_false", help="Output text instead of JSON")
    p_share.set_defaults(json=True)
    p_share.set_defaults(func=cmd_share)

    p_rev = sub.add_parser("reveal", help="Reveal solution from stored JSON (full/box/cell)")
    g2 = p_rev.add_mutually_exclusive_group(required=False)
    g2.add_argument("--latest", action="store_true", help="Use latest stored puzzle (default)")
    g2.add_argument("--id", help="Puzzle ID (full UUID or short 8-char ID from filename)")
    p_rev.add_argument("--printable", action="store_true", help="Include small header (difficulty + short ID) for printout")
    p_rev.add_argument("--pdf", action="store_true", help="Render as A4 PDF (recommended for printing)")

    sel = p_rev.add_mutually_exclusive_group(required=False)
    sel.add_argument("--full", action="store_true", help="Full solution image (default)")
    sel.add_argument("--box", type=int, nargs="+", help="Reveal a single box: '--box <idx>' or '--box <row> <col>' (1-based)")
    sel.add_argument("--cell", type=int, nargs=2, metavar=("ROW", "COL"), help="Reveal a single cell value: '--cell <row> <col>' (1-based)")

    p_rev.add_argument("--image", action="store_true", help="With --cell: also write a tiny 1-cell image")
    p_rev.add_argument("--text", dest="json", action="store_false", help="Output text instead of JSON")
    p_rev.set_defaults(json=True)
    p_rev.set_defaults(func=cmd_reveal)

    return p


def main(argv: Optional[List[str]] = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/sudoku_fetcher.py
content: #!/usr/bin/env python3
"""
Fetch and render Sudoku puzzles from sudokuonline.io.
Supports 4x4, 6x6, and 9x9 grids, including letters mode.

Usage:
    ./sudoku_fetcher.py [url] [--letters] [--solution]
    
Examples:
    ./sudoku_fetcher.py                                       # Default (6x6)
    ./sudoku_fetcher.py https://www.sudokuonline.io/easy      # 9x9 Easy
    ./sudoku_fetcher.py https://www.sudokuonline.io/kids/letters-4-4 --letters
"""

import requests
import re
import random
import sys
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont

# Default URL if none provided
DEFAULT_URL = "https://www.sudokuonline.io/kids/numbers-6-6"

# Repo-local helpers (avoid absolute paths; makes this portable)
REPO_ROOT = Path(__file__).resolve().parent

def _extract_js_array_contents(html, var_name):
    marker = f"const {var_name} = ["
    marker_pos = html.find(marker)
    if marker_pos < 0:
        return None

    start = marker_pos + len(marker) - 1  # points to '['

    depth = 0
    in_string = False
    string_quote = ""
    escape = False

    for i in range(start, len(html)):
        ch = html[i]

        if in_string:
            if escape:
                escape = False
                continue
            if ch == "\\":
                escape = True
                continue
            if ch == string_quote:
                in_string = False
            continue

        if ch in ("'", '"'):
            in_string = True
            string_quote = ch
            continue

        if ch == "[":
            depth += 1
            continue

        if ch == "]":
            depth -= 1
            if depth == 0:
                return html[start + 1 : i]

    return None


def fetch_puzzles(url):
    """Fetch preloaded puzzles from the given URL."""
    try:
        response = requests.get(url)
        response.raise_for_status()
        html = response.text
    except Exception as e:
        print(f"Error fetching URL: {e}")
        sys.exit(1)

    puzzles_str = _extract_js_array_contents(html, "preloadedPuzzles")
    if puzzles_str is None:
        print("Could not find preloadedPuzzles in HTML. Is this a valid sudokuonline.io page?")
        sys.exit(1)

    # Extract individual puzzle objects
    puzzles = []
    for puzzle_match in re.finditer(r"\{[^}]+\}", puzzles_str):
        puzzle_str = puzzle_match.group(0)
        # Convert JS object to JSON
        # Convert JS object literal to valid JSON, then parse safely.
        s = puzzle_str
        # Replace single quotes with double quotes
        s = s.replace("'", '"')
        # Fix JS keywords → JSON
        s = re.sub(r"\btrue\b", "true", s)
        s = re.sub(r"\bfalse\b", "false", s)
        s = re.sub(r"\bnull\b", "null", s)
        # Quote unquoted JS keys: {foo: "bar"} -> {"foo": "bar"}
        s = re.sub(r'([\{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:', r'\1"\2":', s)
        try:
            puzzle = json.loads(s)
            if isinstance(puzzle, dict):
                puzzles.append(puzzle)
        except (json.JSONDecodeError, ValueError):
            continue

    return puzzles

def decode_puzzle(data):
    """
    Decode puzzle data into a grid.
    Returns: (size, clues_grid, solution_grid)
    """
    total_cells = len(data)
    size = int(math.sqrt(total_cells))
    
    if size * size != total_cells:
        raise ValueError(f"Invalid data length: {total_cells} (not a perfect square)")
        
    clues = [[0]*size for _ in range(size)]
    solution = [[0]*size for _ in range(size)]
    
    for i, val in enumerate(data):
        row, col = i // size, i % size
        if val > 100:
            digit = val - 100
            clues[row][col] = digit
            solution[row][col] = digit
        else:
            clues[row][col] = 0
            solution[row][col] = val
            
    return size, clues, solution

def get_block_dims(size):
    """Return (block_width, block_height) for a given grid size."""
    if size == 4:
        return 2, 2
    elif size == 6:
        return 3, 2  # 3 cols x 2 rows (standard for 6x6 "landscape" blocks)
    elif size == 9:
        return 3, 3
    else:
        # Fallback for weird sizes: assume sqrt
        root = int(math.sqrt(size))
        return root, size // root

def format_val(val, letters_mode):
    """Convert integer value to display string (Number or Letter)."""
    if val == 0:
        return ""
    if letters_mode:
        return chr(ord('A') + val - 1)
    return str(val)

import zlib
import base64
import json

def generate_scl_link(grid, size, title="Sudoku", author="Sudoku Skill"):
    """
    Generate a SudokuPad SCL link (native format).
    Pipeline: JSON -> Deflate -> Base64URL -> Strip Padding
    """
    # 1. Build SCL JSON object
    # Convert grid to "givens" string
    givens_str = ""
    for r in range(size):
        for c in range(size):
            val = grid[r][c]
            # Use dot for empty, as per standard SCL examples
            givens_str += str(val) if val != 0 else "."
            
    scl_obj = {
        "size": size,
        "givens": givens_str,
        "constraints": [], # Ensure constraints array exists
        "metadata": {
            "title": title,
            "author": author,
            "rules": "Normal Sudoku rules apply."
        }
    }

    # 2. Serialize to JSON (minimal separators)
    json_str = json.dumps(scl_obj, separators=(',', ':'))

    # 3. Compress (Raw Deflate)
    compressor = zlib.compressobj(level=9, wbits=-15)
    compressed = compressor.compress(json_str.encode('utf-8')) + compressor.flush()

    # 4. Base64 URL-safe encode
    b64 = base64.urlsafe_b64encode(compressed).decode('utf-8')

    # 5. Strip padding
    b64 = b64.rstrip("=")
    
    link = f"https://sudokupad.app/scl{b64}"
    return link

import lzstring as _lzstring
import urllib.parse

_lz = _lzstring.LZString()

# SudokuPad classic compact codec (zipClassicSudoku2)
_BLANK_ENCODES = [REDACTED_SECRET]

def _zip_classic_sudoku2(puzzle: str) -> str:
    """Compress an 81-char puzzle string using SudokuPad's compact classic codec."""
    if not puzzle:
        return ""
    is_digit = lambda ch: ch.isdigit() and ch != "0"
    digit = puzzle[0] if is_digit(puzzle[0]) else "0"
    res = []
    blanks = 0
    for i in range(1, len(puzzle)):
        nxt = puzzle[i] if is_digit(puzzle[i]) else "0"
        if blanks == 5 or nxt != "0":
            res.append(_BLANK_ENCODES[int(digit) + blanks * 10])
            digit = nxt
            blanks = 0
        else:
            blanks += 1
    res.append(_BLANK_ENCODES[int(digit) + blanks * 10])
    return "".join(res)


def generate_puzzle_link(grid, size, title="Sudoku", author="Sudoku Skill"):
    """Generate a SudokuPad web player link (CTC format, LZString compressed)."""
    givens_str = ""
    for r in range(size):
        for c in range(size):
            val = grid[r][c]
            givens_str += str(val) if val != 0 else "."

    scl_obj = {
        "size": size,
        "givens": givens_str,
        "metadata": {"title": title, "author": author},
    }
    json_str = json.dumps(scl_obj, separators=(',', ':'))

    try:
        encoded = _lz.compressToEncodedURIComponent(json_str)
        return f"https://sudokupad.svencodes.com/puzzle/{encoded}"
    except Exception as e:
        return f"Error generating puzzle link: {e}"


def generate_native_link(grid, size, title="Sudoku"):
    """Generate a SudokuPad web player link for classic 9x9 sudoku.
    
    Uses CTC format (ctc prefix + LZString base64) on sudokupad.app.
    """
    if size != 9:
        return "Native /puzzle/ classic format currently implemented for 9x9 only."

    puzzle81 = "".join(
        str(grid[r][c]) if grid[r][c] else "0" for r in range(9) for c in range(9)
    )
    p = _zip_classic_sudoku2(puzzle81)
    wrapper = {
        "p": p,
        "n": title,
        "s": "",
        "m": "",
    }

    _BASE = "https://sudokupad.svencodes.com/puzzle/"
    _MAX_URL = 251  # iOS SudokuPad universal link length limit

    try:
        payload = json.dumps(wrapper, separators=(',', ':'), ensure_ascii=False)
        blob = _lz.compressToBase64(payload)
        # Strip '=' padding — iOS import path rejects it.
        blob = blob.rstrip('=')
        # Encode '/' to keep payload as single path segment.
        blob = blob.replace('/', '%2F')
        url = f"{_BASE}{blob}"
        if len(url) > _MAX_URL:
            # Fallback: drop message entirely to shorten
            payload = json.dumps({"p": p, "n": title}, separators=(',', ':'), ensure_ascii=False)
            blob = _lz.compressToBase64(payload).rstrip('=').replace('/', '%2F')
            url = f"{_BASE}{blob}"
        return url
    except Exception as e:
        return f"Error generating native link: {e}"

def generate_fpuzzles_link(grid, size, title="Sudoku", author="Sudoku Skill"):
    """
    Generate a SudokuPad link using F-Puzzles format (fallback).
    """
    # Create F-Puzzles JSON structure
    fpuzzles_obj = {
        "size": size,
        "grid": [],
        "info": {
            "title": title,
            "author": author
        }
    }

    # Populate grid
    for r in range(size):
        for c in range(size):
            val = grid[r][c]
            cell = {"col": c, "row": r}
            if val != 0:
                cell["value"] = val
                cell["given"] = True
            fpuzzles_obj["grid"].append(cell)

    # Convert to JSON string (compact, like JSON.stringify)
    json_str = json.dumps(fpuzzles_obj, separators=(',', ':'))

    # Compress (Raw Deflate)
    compressor = zlib.compressobj(level=9, wbits=-15)
    compressed = compressor.compress(json_str.encode('utf-8')) + compressor.flush()

    # Base64 encode (standard, but SudokuPad accepts it)
    b64 = base64.b64encode(compressed).decode('utf-8')
    
    link = f"https://sudokupad.app/fpuzzles{b64}"
    return link

# Render constants (used by both rendering and image cropping helpers)
RENDER_CELL_SIZE = 80
RENDER_OUTER_LINE_WIDTH = 4
# The grid starts at the image edge (no outer padding beyond the outer border).
RENDER_INSET = 0

# Print/PDF constants
A4_INCH_W = 8.27
A4_INCH_H = 11.69
A4_DPI_DEFAULT = 300


def render_sudoku(
    grid,
    size,
    filename="sudoku.png",
    title=None,
    extra_lines=None,
    extra_lines_right=None,
    original_clues=None,
    letters_mode=False,
):
    """Render the Sudoku grid.

    Rendering goals:
    - No outer padding outside the outermost grid lines.
    - The outer border should look like a single continuous rectangle (clean corners).

    Note: If a title or extra_lines are provided, they are rendered in an area *above* the grid.
    """

    cell_size = RENDER_CELL_SIZE
    outer_w = RENDER_OUTER_LINE_WIDTH

    # Calculate header height (supports left + right header blocks)
    left_h = 0
    if title:
        left_h += 30
    if extra_lines:
        left_h += len(extra_lines) * 20

    right_h = 0
    if extra_lines_right:
        right_h += len(extra_lines_right) * 20

    header_content_height = max(left_h, right_h)

    # Add some padding if we have a header
    header_height = header_content_height + 20 if header_content_height > 0 else 0

    board_size = cell_size * size

    # No padding outside the grid: the outer border sits at the image edge.
    # +1 because PIL coordinates are inclusive at the right/bottom edge.
    img_width = board_size + 1
    img_height = header_height + board_size + 1

    bg_color = (255, 255, 255)
    line_color = (0, 0, 0)
    text_color = (0, 0, 0)  # true black for givens/clues
    solution_color = (0, 100, 200)  # blue for filled-in (non-given) values

    img = Image.new("RGB", (img_width, img_height), bg_color)
    draw = ImageDraw.Draw(img)

    # Load fonts
    try:
        font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 40)
        title_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 24)
        meta_font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 16)
    except Exception:
        font = ImageFont.load_default()
        title_font = font
        meta_font = font

    grid_left = RENDER_INSET
    grid_top = header_height + RENDER_INSET
    grid_right = grid_left + board_size
    grid_bottom = grid_top + board_size

    # Draw Header
    header_top = 15

    # Left block (title + optional extra lines)
    current_y = header_top
    if title:
        draw.text((grid_left, current_y), title, font=title_font, fill=(50, 50, 50))
        current_y += 30

    if extra_lines:
        for line in extra_lines:
            draw.text((grid_left, current_y), line, font=meta_font, fill=(100, 100, 100))
            current_y += 20

    # Right block (right-aligned)
    if extra_lines_right:
        ry = header_top
        for line in extra_lines_right:
            bbox = draw.textbbox((0, 0), line, font=meta_font)
            w = bbox[2] - bbox[0]
            x = grid_right - w
            draw.text((x, ry), line, font=meta_font, fill=(100, 100, 100))
            ry += 20

    # Outer border: draw as one rectangle so the corners look like a single stroke.
    draw.rectangle([grid_left, grid_top, grid_right, grid_bottom], outline=line_color, width=outer_w)

    # Get block dimensions
    bw, bh = get_block_dims(size)

    # Internal grid lines (skip outer border lines; those are handled by the rectangle above)
    for i in range(1, size):
        is_block_boundary = (i % bh == 0)
        width = 4 if is_block_boundary else 1
        y = grid_top + i * cell_size
        draw.line([(grid_left, y), (grid_right, y)], fill=line_color, width=width)

    for j in range(1, size):
        is_block_boundary = (j % bw == 0)
        width = 4 if is_block_boundary else 1
        x = grid_left + j * cell_size
        draw.line([(x, grid_top), (x, grid_bottom)], fill=line_color, width=width)

    # Draw content
    for row in range(size):
        for col in range(size):
            val = grid[row][col]
            if val != 0:
                text = format_val(val, letters_mode)

                # Center text within the cell
                x = grid_left + col * cell_size + cell_size // 2
                y = grid_top + row * cell_size + cell_size // 2

                bbox = draw.textbbox((0, 0), text, font=font)
                w = bbox[2] - bbox[0]
                h = bbox[3] - bbox[1]

                fill = text_color
                if original_clues and original_clues[row][col] == 0:
                    fill = solution_color

                draw.text((x - w / 2, y - h / 2 - 2), text, font=font, fill=fill)

    img.save(filename)
    return filename

def main():
    args = sys.argv[1:]
    url = DEFAULT_URL
    letters_mode = False
    show_solution = False
    
    # Parse simple args
    for arg in args:
        if arg == "--letters":
            letters_mode = True
        elif arg == "--solution":
            show_solution = True
        elif arg.startswith("http"):
            url = arg
            # Auto-detect letters mode from URL if not explicitly set
            if "letters" in url and "--letters" not in args:
                letters_mode = True

    print(f"Fetching from: {url}")
    puzzles = fetch_puzzles(url)
    print(f"Found {len(puzzles)} puzzles.")
    
    if not puzzles:
        print("No puzzles found.")
        return

    # Pick random puzzle
    puzzle = random.choice(puzzles)
    size, clues, solution = decode_puzzle(puzzle['data'])
    
    print(f"Selected Puzzle ID: {puzzle['id']}")
    print(f"Grid Size: {size}x{size}")
    if letters_mode:
        print("Mode: Letters")
        
    # Render
    filename = f"sudoku_{size}x{size}.png"
    render_sudoku(clues, size, filename, 
                  title=f"Sudoku {size}x{size} ({'Letters' if letters_mode else 'Numbers'})", 
                  letters_mode=letters_mode)
    print(f"Saved puzzle to {filename}")
    
    # Generate SudokuPad SCL link
    try:
        # Note: SCL handles custom sizes (like 4x4) gracefully usually
        if letters_mode:
            print("\nNote: For SudokuPad link, letters are mapped to numbers (A=1, B=2...).")
            
        link_scl = generate_scl_link(clues, size, title=f"Sudoku {size}x{size}")
        print(f"\n🔗 SudokuPad Link (SCL):\n{link_scl}")
        
        link_fp = generate_fpuzzles_link(clues, size, title=f"Sudoku {size}x{size}")
        print(f"\n🔗 SudokuPad Link (F-Puzzles Fallback):\n{link_fp}\n")
        
        short_id = str(puzzle['id']).split('-')[0]
        link_native = generate_native_link(clues, size, title=f"Sudoku {size}x{size} [{short_id}]")
        print(f"\n🔗 SudokuPad Link (Native Short Share):\n{link_native}\n")
    except Exception as e:
        print(f"Error generating link: {e}")
    
    if show_solution:
        sol_filename = f"sudoku_{size}x{size}_solution.png"
        render_sudoku(solution, size, sol_filename, 
                      title=f"Solution {size}x{size}", 
                      original_clues=clues,
                      letters_mode=letters_mode)
        print(f"Saved solution to {sol_filename}")


path: scripts/sudoku_print_render.py
content: """A4 print rendering helpers for Sudoku.

Goal: create a print-friendly A4 PDF with safe margins (so printer non-printable edges
won't clip the outer border) and high DPI (so digits aren't pixelated).

Deliberately printer-agnostic.
"""

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional

from PIL import Image, ImageDraw, ImageFont


@dataclass(frozen=True)
class A4Spec:
    width_px: int
    height_px: int
    dpi: int
    margin_px: int
    header_top_px: int


def a4_spec(dpi: int = 300, margin_mm: float = 15.0, header_mm: float = 22.0) -> A4Spec:
    # A4 inches: 8.27 × 11.69
    width_px = int(round(8.27 * dpi))
    height_px = int(round(11.69 * dpi))

    mm_to_in = 1.0 / 25.4
    margin_px = int(round(margin_mm * mm_to_in * dpi))
    header_top_px = int(round(header_mm * mm_to_in * dpi))

    return A4Spec(width_px=width_px, height_px=height_px, dpi=dpi, margin_px=margin_px, header_top_px=header_top_px)


def _load_fonts(cell_size: int):
    # Digit font ~ 60% of cell
    digit_px = max(18, int(cell_size * 0.62))
    title_px = max(14, int(cell_size * 0.30))
    meta_px = max(12, int(cell_size * 0.22))

    try:
        digit = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", digit_px)
        title = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", title_px)
        meta = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", meta_px)
    except Exception:
        digit = ImageFont.load_default()
        title = digit
        meta = digit

    return digit, title, meta


def render_sudoku_a4_pdf(
    *,
    grid: List[List[int]],
    size: int,
    out_pdf: Path,
    bw: int,
    bh: int,
    title_left: Optional[str] = None,
    right_lines: Optional[List[str]] = None,
    original_clues: Optional[List[List[int]]] = None,
    letters_mode: bool = False,
    dpi: int = 300,
) -> Path:
    spec = a4_spec(dpi=dpi)

    bg = (255, 255, 255)
    black = (0, 0, 0)
    gray = (100, 100, 100)
    blue = (0, 100, 200)

    img = Image.new("RGB", (spec.width_px, spec.height_px), bg)
    draw = ImageDraw.Draw(img)

    right_lines = right_lines or []

    # Consistent header block
    header_h = spec.header_top_px

    # Available area for the board (keep safe margins)
    avail_w = spec.width_px - 2 * spec.margin_px
    avail_h = spec.height_px - (spec.margin_px + header_h + spec.margin_px)

    cell_size = min(avail_w // size, avail_h // size)
    board_w = cell_size * size
    board_h = cell_size * size

    left = spec.margin_px + (avail_w - board_w) // 2
    top = spec.margin_px + header_h
    right = left + board_w
    bottom = top + board_h

    digit_font, title_font, meta_font = _load_fonts(cell_size)

    # Header left
    # Put header comfortably below the top edge so it matches typical A4 worksheet layouts.
    header_y = spec.margin_px
    if title_left:
        draw.text((spec.margin_px, header_y), title_left, font=title_font, fill=gray)

    # Header right (right-aligned)
    ry = header_y
    for line in right_lines:
        bbox = draw.textbbox((0, 0), line, font=meta_font)
        w = bbox[2] - bbox[0]
        x = spec.width_px - spec.margin_px - w
        draw.text((x, ry), line, font=meta_font, fill=gray)
        ry += int(meta_font.size * 1.35) if hasattr(meta_font, "size") else 20

    # Line widths scale with cell size
    outer_w = max(4, cell_size // 18)
    inner_w = max(2, cell_size // 55)
    block_w = max(3, cell_size // 28)

    # Outer border inside printable area
    draw.rectangle([left, top, right, bottom], outline=black, width=outer_w)

    # Internal grid lines
    for i in range(1, size):
        y = top + i * cell_size
        w = block_w if (i % bh == 0) else inner_w
        draw.line([(left, y), (right, y)], fill=black, width=w)

    for j in range(1, size):
        x = left + j * cell_size
        w = block_w if (j % bw == 0) else inner_w
        draw.line([(x, top), (x, bottom)], fill=black, width=w)

    def fmt(val: int) -> str:
        if val == 0:
            return ""
        if letters_mode:
            return chr(ord("A") + val - 1)
        return str(val)

    for r in range(size):
        for c in range(size):
            val = grid[r][c]
            if val == 0:
                continue
            text = fmt(val)

            cx = left + c * cell_size + cell_size // 2
            cy = top + r * cell_size + cell_size // 2

            bbox = draw.textbbox((0, 0), text, font=digit_font)
            tw = bbox[2] - bbox[0]
            th = bbox[3] - bbox[1]

            fill = black
            if original_clues is not None and original_clues[r][c] == 0:
                fill = blue

            draw.text((cx - tw / 2, cy - th / 2), text, font=digit_font, fill=fill)

    out_pdf.parent.mkdir(parents=True, exist_ok=True)
    img.save(out_pdf, "PDF", resolution=float(dpi))
    return out_pdf


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)