diff --git a/packages/preview/sudokyst/0.1.0/LICENSE b/packages/preview/sudokyst/0.1.0/LICENSE new file mode 100644 index 0000000000..9039d7ec02 --- /dev/null +++ b/packages/preview/sudokyst/0.1.0/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kelvin Davis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/preview/sudokyst/0.1.0/README.md b/packages/preview/sudokyst/0.1.0/README.md new file mode 100644 index 0000000000..40c4bcf1a8 --- /dev/null +++ b/packages/preview/sudokyst/0.1.0/README.md @@ -0,0 +1,113 @@ +# `sudokyst` + +`sudokyst` is a Typst package for rendering Sudoku boards with optional candidate hints and highlight overlays. + +It also includes a helper for computing legal candidate values for a cell from the current board state. + +## Rendering approach + +The board is rendered as a native Typst `table` with: + +- fixed-width columns and fixed-height rows so the board stays square +- thicker strokes on 3x3 block boundaries +- optional fills for highlighted rows, columns, 3x3 boxes, overlapping intersections, and explicit cells +- optional per-cell hint rendering as a nested `3 x 3` Typst `grid` + +This keeps the package fully Typst-native and easy to style without generating SVG manually. + +## Example + +```typ +#import "@preview/sudokyst:0.1.0": sudoku + +#let board = ( + (8, 0, 0, 0, 0, 0, 0, 0, 2), + (0, 4, 0, 7, 0, 1, 0, 5, 0), + (0, 0, 0, 0, 8, 0, 0, 0, 0), + (0, 7, 0, 4, 0, 5, 0, 2, 0), + (0, 0, 8, 0, 2, 0, 6, 0, 0), + (0, 2, 0, 6, 0, 8, 0, 1, 0), + (0, 0, 0, 0, 5, 0, 0, 0, 0), + (0, 9, 0, 8, 0, 4, 0, 7, 0), + (6, 0, 0, 0, 0, 0, 0, 0, 4), +) + +#let hints = ( + ((), (1, 3, 5), (1, 3, 5, 7), (), (), (), (), (3, 4, 6, 9), ()), + ((2, 3, 9), (), (2, 3, 6, 9), (), (3, 6, 9), (), (3, 8, 9), (), (3, 6, 8, 9)), + ((1, 2, 3, 5, 7, 9), (1, 3, 5, 6), (1, 2, 3, 5, 6, 7, 9), (2, 3, 5, 9), (), (2, 3, 6, 9), (1, 3, 4, 7, 9), (3, 4, 6, 9), (1, 3, 6, 7, 9)), + ((1, 3, 9), (), (1, 3, 6, 9), (), (3, 9), (), (3, 8, 9), (), (3, 8, 9)), + ((1, 3, 4, 5, 9), (1, 3, 5), (), (1, 3, 5, 9), (), (3, 7, 9), (), (3, 4, 9), (3, 5, 7, 9)), + ((1, 3, 4, 5, 9), (), (3, 4, 5, 9), (), (3, 7, 9), (), (3, 4, 5, 7, 9), (), (3, 5, 7, 9)), + ((1, 2, 3, 4, 7), (1, 3, 8), (1, 2, 3, 4, 7), (1, 2, 3, 9), (), (2, 3, 6, 9), (1, 2, 3, 8, 9), (3, 6, 8, 9), (1, 3, 6, 8, 9)), + ((1, 2, 3, 5), (), (1, 2, 3, 5), (), (1, 3, 6), (), (1, 2, 3, 5, 8), (), (1, 3, 5, 6, 8)), + ((), (1, 3, 5, 8), (1, 2, 3, 5, 7), (1, 2, 3, 9), (1, 3, 7, 9), (2, 3, 7, 9), (1, 2, 3, 5, 8, 9), (3, 8, 9), ()), +) + +#sudoku( + board: board, + hints: hints, + show-hints: true, + highlighted-rows: (2, 5), + highlighted-columns: (4,), + highlighted-boxes: (5,), + highlighted-cells: ((5, 5),), +) +``` + +Highlight selections are 1-based: + +- `highlighted-rows: (1,)` highlights the first row +- `highlighted-columns: (9,)` highlights the ninth column +- `highlighted-boxes: (5,)` highlights the center `3 x 3` box +- `highlighted-cells: ((5, 5),)` highlights the center cell + +Boxes are numbered left-to-right and top-to-bottom: + +- `(1, 2, 3)` for the top band +- `(4, 5, 6)` for the middle band +- `(7, 8, 9)` for the bottom band + +Empty cells should be represented with `0` or `none`. + +## Main options + +- `board`: a `9 x 9` array of values +- `hints`: a `9 x 9` array whose cells contain candidate arrays like `(1, 3, 9)` +- `show-hints`: show the nested `3 x 3` candidate grid in empty cells +- `highlighted-rows`, `highlighted-columns`, `highlighted-boxes`, `highlighted-cells`: optional 1-based highlight selections +- `cell-size`: side length of each Sudoku cell +- `thin-stroke` and `block-stroke`: cell border styling +- `value-color` and `hint-color`: text colors +- `row-highlight-fill`, `column-highlight-fill`, `box-highlight-fill`, `overlap-highlight-fill`, `cell-highlight-fill`: highlight colors +- `value-text-size` and `hint-text-size`: optional text size overrides + +## Helper functions + +- `available-values(board, row, column)`: returns the legal values for the given cell as an array like `(1, 2, 4)`. Rows and columns are 1-based. If the cell is already filled, the function returns `()`. +- `valid-move(board, row, column, value)`: returns `true` when `value` is a legal `1..9` placement for the given empty 1-based cell on the current board, otherwise `false`. +- `first-single-position(board)`: returns the 1-based position `(row, column)` of the first cell, scanned top-to-bottom and left-to-right, whose available-values list has length `1`. Returns `none` if there is no such cell. +- `first-single-move(board)`: returns `((row, column), value)` for the first cell, scanned top-to-bottom and left-to-right, whose available-values list has length `1`. Returns `none` if there is no such cell. +- `propagate-step(board)`: applies one `first-single-move(board)` if available. Returns `none` if there is no forced single, otherwise a record with `board`, `position`, `value`, and `move`. +- `propagate(board)`: repeatedly applies `first-single-move(board)` until no forced single remains. Returns a record with `board`, `positions`, and `moves`, where `positions` is the ordered list of filled 1-based cells and `moves` is the ordered list of `((row, column), value)` pairs. +- `solve(board)`: solves the Sudoku with repeated propagation plus recursive backtracking. Returns a record with `solved`, `board`, `boards`, and `move_groups`. `boards` is the ordered trace of board states after each propagation and each unforced guess, across the search tree until the first solution. `move_groups` aligns with `boards`; a propagation contributes multiple moves, while an unforced guess contributes a one-element array. Very hard puzzles may still exceed Typst's maximum function-call depth. +- `generate-hints(board, positions)`: returns a full `9 x 9` hints array for `sudoku(...)`. `positions` is a list of 1-based `(row, column)` pairs. Selected empty cells receive computed candidates; all other cells receive `()`. +- `generate-hints-all(board)`: returns a full `9 x 9` hints array for every empty cell on the board. Filled cells receive `()`. +- `set-cell(board, row, column, value)`: returns a new board with the given 1-based cell updated. `value` may be `0`, `none`, or an integer from `1` to `9`. + +Example: + +```typ +#import "@preview/sudokyst:0.1.0": available-values, valid-move, first-single-position, first-single-move, propagate-step, propagate, solve, generate-hints, generate-hints-all, set-cell + +#let candidates = available-values(board, 1, 3) +#let ok = valid-move(board, 1, 3, 4) +#let single = first-single-position(board) +#let single-move = first-single-move(board) +#let step = propagate-step(board) +#let propagation = propagate(board) +#let solution = solve(board) +#let hints = generate-hints(board, ((1, 3), (1, 4), (2, 2))) +#let all-hints = generate-hints-all(board) +#let next-board = set-cell(board, 1, 3, 4) +``` diff --git a/packages/preview/sudokyst/0.1.0/src/lib.typ b/packages/preview/sudokyst/0.1.0/src/lib.typ new file mode 100644 index 0000000000..3df2026a5c --- /dev/null +++ b/packages/preview/sudokyst/0.1.0/src/lib.typ @@ -0,0 +1,2 @@ +// Importing package files +#import "sudoku.typ": sudoku, empty-board, empty-hints, available-values, valid-move, first-single-position, first-single-move, propagate, propagate-step, solve, generate-hints, generate-hints-all, set-cell diff --git a/packages/preview/sudokyst/0.1.0/src/sudoku.typ b/packages/preview/sudokyst/0.1.0/src/sudoku.typ new file mode 100644 index 0000000000..1a1759e1f2 --- /dev/null +++ b/packages/preview/sudokyst/0.1.0/src/sudoku.typ @@ -0,0 +1,557 @@ +#let empty-board = ( + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), + (0, 0, 0, 0, 0, 0, 0, 0, 0), +) + +#let empty-hints = ( + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), + ((), (), (), (), (), (), (), (), ()), +) + +#let _normalize-selection(value) = if value == none { () } else { value } + +#let _normalize-hint-cell(value) = if value == none { () } else { value } + +#let _assert-grid-shape(name, grid) = { + assert( + grid.len() == 9 and grid.all(row => row.len() == 9), + message: name + " must be a 9x9 array.", + ) +} + +#let _assert-cell-position(row, column) = { + assert( + row >= 1 and row <= 9 and column >= 1 and column <= 9, + message: "row and column must be between 1 and 9.", + ) +} + +#let _assert-cell-value(value) = { + assert( + (value == none) or (type(value) == int and value >= 0 and value <= 9), + message: "value must be an integer between 0 and 9, or none.", + ) +} + +#let _has-value(value) = value != none and value != 0 + +#let _set-cell(board, row, column, value) = { + let row-index = row - 1 + let column-index = column - 1 + + range(9).map(current-row => { + if current-row == row-index { + range(9).map(current-column => { + if current-column == column-index { + value + } else { + board.at(current-row).at(current-column) + } + }) + } else { + board.at(current-row) + } + }) +} + +#let _row-has-value(board, row, value) = board.at(row).contains(value) + +#let _column-has-value(board, column, value) = { + range(9).any(row => board.at(row).at(column) == value) +} + +#let _column-values(board, column) = range(9).map(row => board.at(row).at(column)) + +#let _block-has-value(board, row, column, value) = { + let block-row-start = calc.floor(row / 3) * 3 + let block-column-start = calc.floor(column / 3) * 3 + + range(block-row-start, block-row-start + 3) + .any(block-row => range(block-column-start, block-column-start + 3) + .any(block-column => board.at(block-row).at(block-column) == value)) +} + +#let _available-values(board, row, column) = { + let row-index = row - 1 + let column-index = column - 1 + let current-value = board.at(row-index).at(column-index) + + if _has-value(current-value) { + () + } else { + range(1, 10).filter(value => + not _row-has-value(board, row-index, value) + and not _column-has-value(board, column-index, value) + and not _block-has-value(board, row-index, column-index, value) + ) + } +} + +#let _block-values(board, block-row-start, block-column-start) = { + range(block-row-start, block-row-start + 3) + .map(block-row => range(block-column-start, block-column-start + 3) + .map(block-column => board.at(block-row).at(block-column))) + .flatten() +} + +#let _has-duplicate-values(values) = { + range(values.len()).any(i => { + let value = values.at(i) + + _has-value(value) and range(i + 1, values.len()).any(j => values.at(j) == value) + }) +} + +#let _board-complete(board) = { + range(9).all(row => range(9).all(column => _has-value(board.at(row).at(column)))) +} + +#let _board-infeasible(board) = { + let duplicate-rows = range(9).any(row => _has-duplicate-values(board.at(row))) + let duplicate-columns = range(9).any(column => _has-duplicate-values(_column-values(board, column))) + let duplicate-blocks = (0, 3, 6).any(block-row => + (0, 3, 6).any(block-column => + _has-duplicate-values(_block-values(board, block-row, block-column)) + ) + ) + let empty-cell-without-candidates = range(1, 10).any(row => + range(1, 10).any(column => + not _has-value(board.at(row - 1).at(column - 1)) and _available-values(board, row, column).len() == 0 + ) + ) + + duplicate-rows or duplicate-columns or duplicate-blocks or empty-cell-without-candidates +} + +#let available-values(board, row, column) = { + _assert-grid-shape("board", board) + _assert-cell-position(row, column) + _available-values(board, row, column) +} + +#let valid-move(board, row, column, value) = { + _assert-grid-shape("board", board) + _assert-cell-position(row, column) + assert( + type(value) == int and value >= 1 and value <= 9, + message: "value must be an integer between 1 and 9.", + ) + + let current-value = board.at(row - 1).at(column - 1) + + if _has-value(current-value) { + false + } else { + available-values(board, row, column).contains(value) + } +} + +#let first-single-position(board) = { + _assert-grid-shape("board", board) + + for row in range(1, 10) { + for column in range(1, 10) { + if available-values(board, row, column).len() == 1 { + return (row, column) + } + } + } + + none +} + +#let first-single-move(board) = { + _assert-grid-shape("board", board) + + for row in range(1, 10) { + for column in range(1, 10) { + let candidates = available-values(board, row, column) + + if candidates.len() == 1 { + return ((row, column), candidates.at(0)) + } + } + } + + none +} + +#let _first-branch(board) = { + for row in range(1, 10) { + for column in range(1, 10) { + let candidates = available-values(board, row, column) + + if candidates.len() >= 2 { + return ((row, column), candidates) + } + } + } + + none +} + +#let _propagate(board, positions, moves) = { + let single-move = first-single-move(board) + + if single-move == none { + ( + board: board, + positions: positions, + moves: moves, + ) + } else { + let position = single-move.at(0) + let value = single-move.at(1) + let next-board = _set-cell(board, position.at(0), position.at(1), value) + + _propagate( + next-board, + positions + (position,), + moves + (single-move,), + ) + } +} + +#let propagate(board) = { + _assert-grid-shape("board", board) + _propagate(board, (), ()) +} + +#let propagate-step(board) = { + _assert-grid-shape("board", board) + + let single-move = first-single-move(board) + + if single-move == none { + none + } else { + let position = single-move.at(0) + let value = single-move.at(1) + + ( + board: _set-cell(board, position.at(0), position.at(1), value), + position: position, + value: value, + move: single-move, + ) + } +} + +#let _solve( + board, + mode: "solve", + branch-position: none, + candidates: none, + candidate-index: 0, + boards: (), + move-groups: (), +) = { + if mode == "branch" { + if candidate-index >= candidates.len() { + ( + solved: false, + board: none, + boards: boards, + move_groups: move-groups, + ) + } else { + let value = candidates.at(candidate-index) + let move = (branch-position, value) + let guessed-board = _set-cell(board, branch-position.at(0), branch-position.at(1), value) + let guessed-boards = boards + (guessed-board,) + let guessed-move-groups = move-groups + ((move,),) + let subproblem = _solve(guessed-board) + let branch-boards = guessed-boards + subproblem.boards + let branch-move-groups = guessed-move-groups + subproblem.move_groups + + if subproblem.solved { + ( + solved: true, + board: subproblem.board, + boards: branch-boards, + move_groups: branch-move-groups, + ) + } else { + _solve( + board, + mode: "branch", + branch-position: branch-position, + candidates: candidates, + candidate-index: candidate-index + 1, + boards: branch-boards, + move-groups: branch-move-groups, + ) + } + } + } else { + let propagation = propagate(board) + let boards = (propagation.board,) + let move-groups = (propagation.moves,) + + if _board-infeasible(propagation.board) { + ( + solved: false, + board: none, + boards: boards, + move_groups: move-groups, + ) + } else if _board-complete(propagation.board) { + ( + solved: true, + board: propagation.board, + boards: boards, + move_groups: move-groups, + ) + } else { + let branch = _first-branch(propagation.board) + + if branch == none { + ( + solved: false, + board: none, + boards: boards, + move_groups: move-groups, + ) + } else { + _solve( + propagation.board, + mode: "branch", + branch-position: branch.at(0), + candidates: branch.at(1), + candidate-index: 0, + boards: boards, + move-groups: move-groups, + ) + } + } + } +} + +#let solve(board) = { + _assert-grid-shape("board", board) + _solve(board) +} + +#let generate-hints(board, positions) = { + _assert-grid-shape("board", board) + + for position in positions { + assert( + type(position) == array and position.len() == 2, + message: "each position must be a (row, column) pair.", + ) + _assert-cell-position(position.at(0), position.at(1)) + } + + range(9).map(row => range(9).map(col => { + let position = (row + 1, col + 1) + + if positions.contains(position) { + available-values(board, row + 1, col + 1) + } else { + () + } + })) +} + +#let generate-hints-all(board) = { + _assert-grid-shape("board", board) + + range(9).map(row => range(9).map(col => { + available-values(board, row + 1, col + 1) + })) +} + +#let set-cell(board, row, column, value) = { + _assert-grid-shape("board", board) + _assert-cell-position(row, column) + _assert-cell-value(value) + _set-cell(board, row, column, value) +} + +#let _box-index(row, col) = calc.floor(row / 3) * 3 + calc.floor(col / 3) + 1 + +#let _cell-fill( + row, + col, + highlighted-rows, + highlighted-columns, + highlighted-boxes, + highlighted-cells, + row-highlight-fill, + column-highlight-fill, + box-highlight-fill, + overlap-highlight-fill, + cell-highlight-fill, +) = { + let row-hit = highlighted-rows.contains(row + 1) + let column-hit = highlighted-columns.contains(col + 1) + let box-hit = highlighted-boxes.contains(_box-index(row, col)) + let cell-hit = highlighted-cells.contains((row + 1, col + 1)) + + if cell-hit { + cell-highlight-fill + } else if (if row-hit { 1 } else { 0 }) + (if column-hit { 1 } else { 0 }) + (if box-hit { 1 } else { 0 }) >= 2 { + overlap-highlight-fill + } else if row-hit { + row-highlight-fill + } else if column-hit { + column-highlight-fill + } else if box-hit { + box-highlight-fill + } else { + none + } +} + +#let _cell-stroke(col, row, thin-stroke, block-stroke) = ( + left: if col == 0 { + block-stroke + } else if calc.rem(col, 3) == 0 { + block-stroke + } else { + thin-stroke + }, + top: if row == 0 { + block-stroke + } else if calc.rem(row, 3) == 0 { + block-stroke + } else { + thin-stroke + }, + right: if col == 8 { block-stroke } else { 0pt }, + bottom: if row == 8 { block-stroke } else { 0pt }, +) + +#let _hint-grid(cell-hints, cell-size, hint-text-size, hint-color) = { + let slot-size = cell-size / 3 + + grid( + columns: (slot-size,) * 3, + rows: (slot-size,) * 3, + gutter: 0pt, + align: center + horizon, + ..range(1, 10).map(n => { + if cell-hints.contains(n) { + text(size: hint-text-size, fill: hint-color)[#n] + } else { + [] + } + }), + ) +} + +#let _cell-content( + board, + hints, + row, + col, + show-hints, + cell-size, + value-text-size, + hint-text-size, + value-color, + hint-color, +) = { + let value = board.at(row).at(col) + + if _has-value(value) { + text(size: value-text-size, weight: "semibold", fill: value-color)[#value] + } else if show-hints { + let cell-hints = _normalize-hint-cell(hints.at(row).at(col)) + _hint-grid(cell-hints, cell-size, hint-text-size, hint-color) + } else { + [] + } +} + +#let sudoku( + board: empty-board, + hints: none, + show-hints: false, + highlighted-rows: none, + highlighted-columns: none, + highlighted-boxes: none, + highlighted-cells: none, + cell-size: 2.4em, + thin-stroke: 0.5pt + black, + block-stroke: 1.4pt + black, + value-color: black, + hint-color: luma(90), + row-highlight-fill: rgb("f7f1c7"), + column-highlight-fill: rgb("ddeefa"), + box-highlight-fill: rgb("f4dfef"), + overlap-highlight-fill: rgb("e7f4dc"), + cell-highlight-fill: rgb("f8d7d4"), + value-text-size: auto, + hint-text-size: auto, +) = { + _assert-grid-shape("board", board) + + let hints = if hints == none { empty-hints } else { hints } + _assert-grid-shape("hints", hints) + + let highlighted-rows = _normalize-selection(highlighted-rows) + let highlighted-columns = _normalize-selection(highlighted-columns) + let highlighted-boxes = _normalize-selection(highlighted-boxes) + let highlighted-cells = _normalize-selection(highlighted-cells) + let value-text-size = if value-text-size == auto { + cell-size * 0.5 + } else { + value-text-size + } + let hint-text-size = if hint-text-size == auto { + cell-size / 5.5 + } else { + hint-text-size + } + + table( + columns: (cell-size,) * 9, + rows: (cell-size,) * 9, + inset: 0pt, + align: center + horizon, + fill: (col, row) => _cell-fill( + row, + col, + highlighted-rows, + highlighted-columns, + highlighted-boxes, + highlighted-cells, + row-highlight-fill, + column-highlight-fill, + box-highlight-fill, + overlap-highlight-fill, + cell-highlight-fill, + ), + stroke: (col, row) => _cell-stroke(col, row, thin-stroke, block-stroke), + ..range(9) + .map(row => range(9).map(col => _cell-content( + board, + hints, + row, + col, + show-hints, + cell-size, + value-text-size, + hint-text-size, + value-color, + hint-color, + ))) + .flatten(), + ) +} diff --git a/packages/preview/sudokyst/0.1.0/typst.toml b/packages/preview/sudokyst/0.1.0/typst.toml new file mode 100644 index 0000000000..d6c95d890f --- /dev/null +++ b/packages/preview/sudokyst/0.1.0/typst.toml @@ -0,0 +1,15 @@ +[package] +name = "sudokyst" +version = "0.1.0" +entrypoint = "src/lib.typ" +authors = [ + "Kelvin Davis <@kelvin-273>", +] +compiler = "0.14.2" +license = "MIT" +description = "Render and analyze Sudoku boards." +repository = "https://github.com/kelvin-273/sudokyst" +keywords = ["sudoku", "puzzle", "grid"] +categories = ["visualization", "fun"] +disciplines = ["education", "mathematics"] +exclude = ["/examples/*", "/docs/*", "/tools/*", ".git"]