diff --git a/packages/preview/spryst/0.1.1/LICENSE b/packages/preview/spryst/0.1.1/LICENSE new file mode 100644 index 0000000000..207fac4d16 --- /dev/null +++ b/packages/preview/spryst/0.1.1/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Phillip Smith + +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/spryst/0.1.1/README.md b/packages/preview/spryst/0.1.1/README.md new file mode 100644 index 0000000000..ca0c67efd6 --- /dev/null +++ b/packages/preview/spryst/0.1.1/README.md @@ -0,0 +1,135 @@ +# spryst + +![spryst banner](https://raw.githubusercontent.com/TimeTravelPenguin/spryst/refs/tags/v0.1.1/assets/banner.png) + +[![Typst Package](https://img.shields.io/badge/dynamic/toml?url=https://raw.githubusercontent.com/TimeTravelPenguin/spryst/refs/heads/main/typst/typst.toml&query=%24.package.version&prefix=v&logo=typst&label=package&color=239DAD)](https://typst.app/universe/package/spryst) +[![MIT License](https://img.shields.io/badge/license-MIT-blue)](https://github.com/TimeTravelPenguin/spryst/blob/v0.1.1/LICENSE) + +A [Typst](https://typst.app) WASM plugin for slicing a spritesheet into its individual +sprites — give it an image and a grid, get back ready-to-place sprites. + +## What it does + +`spryst` takes the raw bytes of a spritesheet (PNG, JPEG, GIF, or WebP) and cuts it into a +grid of sprites, each returned as a PNG. You describe the grid in one of two ways and the +plugin works out the rest, honouring any border (`margin`) and inter-tile gap (`spacing`). + +- **Grid mode** — give `rows` and `cols`; the tile size is derived and must divide the + usable area evenly. +- **Size mode** — give `tile-width` and `tile-height`; the row/column counts are derived + as the number of whole tiles that fit. + +## Usage + +Below are some example uses for using Spryst. + +```typ +#import "@preview/spryst:0.1.1" + +#let data = read("spritesheet.png", encoding: none) + +// Inspect the sheet without decoding any sprites. +#let nfo = spryst.sheet-info(data, rows: 4, cols: 4) +// => (sheet_width, sheet_height, rows, cols, tile_width, tile_height, count) + +// Pull out a single sprite, by index (row-major) or by (row, col). +#spryst.sprite-image(spryst.sprite(data, index: 5, rows: 4, cols: 4), width: 32pt) +#spryst.sprite-image(spryst.sprite(data, row: 1, col: 1, rows: 4, cols: 4)) + +// Or slice the whole sheet and lay every sprite out. +#let sheet = spryst.spritesheet(data, rows: 4, cols: 4) +#grid( + columns: sheet.cols, + ..sheet.sprites.map(spr => spryst.sprite-image(spr, width: 24pt)), +) + +// Slice once, then pull sprites by index or (row, col) on demand. +#let get-sprite = spryst.make-getter(sheet) +#get-sprite(5, width: 32pt) +#get-sprite(1, 2, width: 32pt) +``` + +### Margin and spacing + +Both are optional and default to `0`. Pass a single number to apply it to both axes, or a +`(x, y)` array for per-axis control. A `margin` is the border between the sheet edge and +the outermost tiles; `spacing` is the gap between adjacent tiles. + +```typ +#spryst.spritesheet(data, rows: 4, cols: 4, margin: 1, spacing: (2, 2)) +``` + +### Size mode + +```typ +#spryst.spritesheet(data, tile-width: 16, tile-height: 16) +``` + +## Plugin functions (low level) + +Each function takes the sheet bytes plus CBOR-encoded arguments and returns a CBOR-encoded +response. Errors are returned as `Err` and surfaced by Typst as diagnostics. The +high-level `spritesheet` wrapper above is a thin convenience layer over `split`. + +| Function | Arguments | Returns | +| ------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- | +| `split(sheet, spec)` | sheet bytes, CBOR `SliceSpec` | `{ rows, cols, tile_width, tile_height, sprites: [...] }` | +| `sprite(sheet, spec, selector)` | sheet bytes, CBOR `SliceSpec`, CBOR `Selector` | one sprite dict | +| `info(sheet, spec)` | sheet bytes, CBOR `SliceSpec` | `{ sheet_width, sheet_height, rows, cols, tile_width, tile_height, count }` | + +A sprite dict is `{ row, col, x, y, width, height, png }`, where `png` is a CBOR byte +string (decoded directly to Typst `bytes`). + +`SliceSpec` fields: `rows`, `cols`, `tile_width`, `tile_height` (provide one pair), plus +`margin_x`, `margin_y`, `spacing_x`, `spacing_y` (default `0`). `Selector` fields: +`index`, or both `row` and `col`. + +## Building + +```sh +just install # one-time: wasm targets + wasi-stub +just build # builds typst/wasm/spryst.wasm +``` + +The Rust logic is unit-tested on the host: + +```sh +cargo test +``` + +The plugin is also tested end-to-end through Typst with +[tytanic](https://typst-community.github.io/tytanic/). Each test renders an alphanumeric +spritesheet, slices it back apart with `spryst`, re-lays the sprites into a grid, and +checks the result is pixel-identical to the original — so a slicing or coordinate-maths +regression fails the build. + +```sh +just test-typst # run every case (regenerates fixtures if missing) +just test-typst reassemble/c8-plain # one test +just test-typst -e 'glob:"*sep*"' # a test-set expression +``` + +The PNG fixtures under `typst/tests/fixtures/` are committed. Regenerate them with `just +gen-fixtures` after changing the cases, the glyph set, or the PPI (`PPI` in +`typst/tests/lib.typ`, mirrored by `default.ppi` in `typst.toml`). Both require the +[Buenard](https://fonts.google.com/specimen/Buenard) font. + +## Acknowledgement of AI usage + +The first revision of this project was developed by me, without _any_ use of AI. When I +was happy with the preliminary results, Claude Opus 4.8 was used to aid in several +tasks. + +First, it improved some Rust and Typst code (I am not particularly familiar with WASM). +This was the lesser use of the tool. + +Second, Claude largely contributed to writing the tests. I wrote the initial code to +create the test assets, and then Claude was used to generate the Tytanic suite of tests. + +Lastly, documentation (e.g., Typst docstrings), including parts of this README, were +written by Claude. Generally, I find Claude capable at this task, so I allowed it to do +so. + +One main issue I had was Claude's use of naming and convention. At times, some things were +oddly named (especially regarding tests). I have manually revised and corrected anything +out of place, but please feel free to open an issue if you find anything that I missed. diff --git a/packages/preview/spryst/0.1.1/src/lib.typ b/packages/preview/spryst/0.1.1/src/lib.typ new file mode 100644 index 0000000000..e1f2f120c6 --- /dev/null +++ b/packages/preview/spryst/0.1.1/src/lib.typ @@ -0,0 +1,237 @@ +// spryst — easy spritesheet access for Typst. +// +// Wraps the `spryst.wasm` plugin so you can slice a spritesheet into sprites +// with a single call, then drop them into your document as images. + +/// The compiled `spryst` plugin. Internal. +/// +/// -> plugin +#let _plugin = plugin("/wasm/spryst.wasm") + +/// Normalises a `margin` or `spacing` argument into an `(x, y)` pair. A single +/// number is applied to both axes; an array is returned unchanged. Internal. +/// +/// - value (int, array): a single length, or an `(x, y)` array +/// +/// -> array +#let _axes(value) = if type(value) == array { value } else { (value, value) } + +/// Builds the CBOR slice spec sent to the plugin from convenience arguments. +/// Internal. +/// +/// Pass either `rows` + `cols` (grid mode) or `tile-width` + `tile-height` +/// (size mode). +/// +/// - rows (none, int): number of sprite rows (grid mode) +/// - cols (none, int): number of sprite columns (grid mode) +/// - tile-width (none, int): width of a single sprite in pixels (size mode) +/// - tile-height (none, int): height of a single sprite in pixels (size mode) +/// - margin (int, array): border between the sheet edges and the outermost tiles +/// - spacing (int, array): gap between adjacent tiles +/// +/// -> bytes +#let _spec( + rows: none, + cols: none, + tile-width: none, + tile-height: none, + margin: 0, + spacing: 0, +) = { + let (margin-x, margin-y) = _axes(margin) + let (spacing-x, spacing-y) = _axes(spacing) + + let fields = ( + margin_x: margin-x, + margin_y: margin-y, + spacing_x: spacing-x, + spacing_y: spacing-y, + ) + + if rows != none { fields.rows = rows } + if cols != none { fields.cols = cols } + if tile-width != none { fields.tile_width = tile-width } + if tile-height != none { fields.tile_height = tile-height } + + cbor.encode(fields) +} + +/// Reports the sheet's pixel dimensions and the resolved grid, without decoding +/// any sprites. The returned dictionary has `sheet_width`, `sheet_height`, +/// `rows`, `cols`, `tile_width`, `tile_height`, and `count`. +/// +/// ```typ +/// #let data = read("sheet.png", encoding: none) +/// #sheet-info(data, rows: 4, cols: 4) +/// ``` +/// +/// - data (bytes): the raw spritesheet image (PNG, JPEG, GIF, or WebP) +/// - args (arguments): slice arguments forwarded to `_spec` (`rows`, `cols`, +/// `tile-width`, `tile-height`, `margin`, `spacing`) +/// +/// -> dictionary +#let sheet-info( + data, + rows: none, + cols: none, + tile-width: none, + tile-height: none, + margin: 0, + spacing: 0, +) = { + cbor(_plugin.info(data, _spec( + cols: cols, + rows: rows, + tile-width: tile-width, + tile-height: tile-height, + margin: margin, + spacing: spacing, + ))) +} + +/// Slices `data` into every sprite. The returned dictionary has `rows`, `cols`, +/// `tile_width`, `tile_height`, and `sprites` — an array of sprite dictionaries, +/// each with `row`, `col`, `x`, `y`, `width`, `height`, and `png`. +/// +/// ```typ +/// #let data = read("sheet.png", encoding: none) +/// #let sheet = spritesheet(data, rows: 4, cols: 4) +/// #grid( +/// columns: sheet.cols, +/// ..sheet.sprites.map(sprite-image), +/// ) +/// ``` +/// +/// - data (bytes): the raw spritesheet image (PNG, JPEG, GIF, or WebP) +/// - rows (none, int): number of sprite rows (grid mode) +/// - cols (none, int): number of sprite columns (grid mode) +/// - tile-width (none, int): width of a single sprite in pixels (size mode) +/// - tile-height (none, int): height of a single sprite in pixels (size mode) +/// - margin (int, array): border between the sheet edges and the outermost tiles +/// - spacing (int, array): gap between adjacent tiles +/// +/// -> dictionary +#let spritesheet( + data, + rows: none, + cols: none, + tile-width: none, + tile-height: none, + margin: 0, + spacing: 0, +) = { + cbor(_plugin.split(data, _spec( + rows: rows, + cols: cols, + tile-width: tile-width, + tile-height: tile-height, + margin: margin, + spacing: spacing, + ))) +} + +/// Extracts a single sprite, addressed by `index` (row-major, zero-based) or by +/// `row` + `col`. Returns a sprite dictionary with `row`, `col`, `x`, `y`, +/// `width`, `height`, and `png`. +/// +/// ```typ +/// #let data = read("sheet.png", encoding: none) +/// #sprite-image(sprite(data, index: 5, rows: 4, cols: 4)) +/// ``` +/// +/// - data (bytes): the raw spritesheet image (PNG, JPEG, GIF, or WebP) +/// - index (none, int): row-major, zero-based sprite index +/// - row (none, int): zero-based row (use together with `col`) +/// - col (none, int): zero-based column (use together with `row`) +/// - rows (none, int): number of sprite rows (grid mode) +/// - cols (none, int): number of sprite columns (grid mode) +/// - tile-width (none, int): width of a single sprite in pixels (size mode) +/// - tile-height (none, int): height of a single sprite in pixels (size mode) +/// - margin (int, array): border between the sheet edges and the outermost tiles +/// - spacing (int, array): gap between adjacent tiles +/// +/// -> dictionary +#let sprite( + data, + index: none, + row: none, + col: none, + rows: none, + cols: none, + tile-width: none, + tile-height: none, + margin: 0, + spacing: 0, +) = { + let selector = (:) + if index != none { selector.index = index } + if row != none { selector.row = row } + if col != none { selector.col = col } + + cbor(_plugin.sprite( + data, + _spec( + rows: rows, + cols: cols, + tile-width: tile-width, + tile-height: tile-height, + margin: margin, + spacing: spacing, + ), + cbor.encode(selector), + )) +} + +/// Turns a sprite dictionary (from `spritesheet` or `sprite`) into an `image`. +/// +/// ```typ +/// #sprite-image(sprite(data, index: 0, rows: 4, cols: 4), width: 32pt) +/// ``` +/// +/// - spr (dictionary): a sprite dictionary containing a `png` field +/// - args (arguments): extra named arguments forwarded to `image` (e.g. +/// `width`, `height`, `fit`) +/// +/// -> content +#let sprite-image(spr, ..args) = image(spr.png, format: "png", ..args) + + +/// Builds an indexer for an already-sliced spritesheet, addressing sprites by +/// either a single row-major index or a `(row, col)` pair. A convenience wrapper +/// around `sprite-image` so you can pull sprites straight from a `spritesheet` +/// result without indexing into `.sprites` yourself. For example: +/// +/// ```typ +/// #let sheet = spritesheet( +/// read("sheet.png", encoding: none), +/// tile-width: 32, +/// tile-height: 32, +/// ) +/// #let get-sprite = make-getter(sheet) +/// +/// #get-sprite(5, width: 32pt) +/// #get-sprite(1, 2, width: 32pt) +/// ``` +/// +/// - sheet (dictionary): the output of `spritesheet` for a given sheet +/// +/// -> function +#let make-getter(sheet) = { + let get(..args) = { + let named = args.named() + let idx = args.pos() + + if idx.len() == 1 { + sprite-image(sheet.sprites.at(idx.at(0)), ..named) + } else if idx.len() == 2 { + let (row, col) = (idx.at(0), idx.at(1)) + sprite-image(sheet.sprites.at(row * sheet.cols + col), ..named) + } else { + panic( + "Invalid sprite selector. Use either an index or a (row, col) pair.", + ) + } + } + + get +} diff --git a/packages/preview/spryst/0.1.1/typst.toml b/packages/preview/spryst/0.1.1/typst.toml new file mode 100644 index 0000000000..e98d8bd15c --- /dev/null +++ b/packages/preview/spryst/0.1.1/typst.toml @@ -0,0 +1,21 @@ +[package] +name = "spryst" +version = "0.1.1" +compiler = "0.14.0" +entrypoint = "src/lib.typ" +authors = ["TimeTravelPenguin <@TimeTravelPenguin>"] +license = "MIT" +description = "Slice a spritesheet into its individual sprites." +repository = "https://github.com/TimeTravelPenguin/spryst" +keywords = ["spritesheet", "sprite", "slicing", "image processing"] +categories = ["integration", "visualization", "utility"] + +# Tytanic test runner. `ppi` must match `PPI` in `tests/lib.typ` so that 32px +# sprite cells land on whole pixels. Comparison is exact (no per-pixel slack). +[tool.tytanic] +tests = "tests" + +[tool.tytanic.default] +ppi = 300 +max-delta = 0 +max-deviations = 0 diff --git a/packages/preview/spryst/0.1.1/wasm/spryst.wasm b/packages/preview/spryst/0.1.1/wasm/spryst.wasm new file mode 100755 index 0000000000..40a22a4b9c Binary files /dev/null and b/packages/preview/spryst/0.1.1/wasm/spryst.wasm differ