code.pablomurad.com

htmlansi

Converts images to CP437 ANSI art, as HTML or .ans

Initial commit
author
Pablo Murad
date
commit
b05dc14a3eec70af5c16e8b2620efe3c083adcdc
changes
26 files +4353 −0
@@ -0,0 +1,12 @@+# dependencies and build
+node_modules/
+dist/
+
+# local input/output
+in/
+out/
+
+# misc
+*.log
+.DS_Store
+Thumbs.db
@@ -0,0 +1,67 @@+htmlansi
+========
+
+Convert images to CP437 ANSI art (HTML or .ans).
+
+Requires Node.js >= 20.
+
+
+Build
+-----
+
+    npm install
+    npm run build
+
+
+Usage
+-----
+
+    node dist/cli.js <image> -o out.html
+    node dist/cli.js <image> -f ans -c ansi256 -o out.ans
+    node dist/cli.js <image> --preview
+    node dist/cli.js --in ./in --out ./out
+
+
+Options
+-------
+
+    -f, --format     html | ans                        (default html)
+    -m, --mode       glyph | half-block | braille       (default glyph)
+        --glyphs     safe | full                        (default safe)
+    -w, --width      columns                            (default 80)
+        --height     rows
+    -c, --colors     ansi16 | ansi256 | truecolor       (default ansi256)
+    -d, --dither     none | floyd-steinberg | atkinson  (default none)
+        --bg         #rrggbb or r,g,b                   (default #000000)
+        --max-width  n                                  (default 200)
+        --no-clamp
+        --brightness --contrast --gamma --saturation
+        --invert
+        --preview
+
+
+Modes
+-----
+
+    glyph        coverage matching over a CP437 glyph set (default)
+    half-block   two vertical pixels per cell
+    braille      2x4 dots per cell
+
+Use -c truecolor for smooth gradients; ansi16/ansi256 band.
+The default glyph set (safe) keeps rows aligned in any monospace font;
+--glyphs full adds eighth-blocks and quadrants (needs a font that renders
+them monospaced).
+
+
+Library
+-------
+
+    import { convert, convertToFormat } from 'htmlansi';
+
+    const result = await convert('img.png', { mode: 'glyph', width: 100 });
+    const html   = await convertToFormat('img.png', 'html');
+
+
+See docs/guide.md and docs/algorithms.md for details.
+
+MIT - runv.sh - https://runv.sh
@@ -0,0 +1,98 @@+# Algorithms
+
+## 1. Glyph coverage matching (the `glyph` mode)
+
+Each candidate glyph is an 8×8 binary coverage mask (`1` = foreground/ink,
+`0` = background/paper). Shading blocks (`░▒▓`) are modelled as fixed dot
+patterns so a single code path handles every glyph.
+
+For each character cell:
+
+1. Sample the source into 64 sub-pixel colors `src[i]` and their sum.
+2. For each glyph, split the sub-pixels by the mask into ink and paper groups:
+   ```
+   fg = mean(src[i] where mask[i] = 1)
+   bg = mean(src[i] where mask[i] = 0)
+   ```
+   (For all-ink or all-paper glyphs, `fg = bg = mean(all)`.)
+3. Quantize `fg`, `bg` to the target palette (cached).
+4. Score the total squared error:
+   ```
+   err = Σ_i distance(src[i], mask[i] ? fg : bg)
+   ```
+5. Keep the glyph + colors with the lowest error.
+
+This is the coverage-bitmap technique used by tools like *chafa*. Because the
+glyph set spans blocks, halves, eighths, quadrants, shading and lines, the
+output uses a genuinely varied character set instead of only `█▀▄`.
+
+## 2. Color distance
+
+All distances are returned squared (monotonic — no `sqrt` needed for comparison).
+
+**Redmean (default)** — weighted RGB approximating eye sensitivity:
+```
+r̄ = (r₁ + r₂) / 2
+d = (2 + r̄/256)·Δr² + 4·Δg² + (2 + (255−r̄)/256)·Δb²
+```
+
+**RGB** — plain Euclidean (`Δr² + Δg² + Δb²`).
+
+**CIE Lab (Delta E, CIE76)** — most perceptually accurate, slowest:
+```
+RGB → XYZ → Lab,   d = ΔL² + Δa² + Δb²
+```
+
+## 3. Palettes and quantization
+
+- **ANSI 16** — VGA 16-color.
+- **ANSI 256** — 16 system + 6×6×6 color cube (levels 0,95,135,175,215,255) + 24 grays.
+- **True color** — exact 24-bit RGB (no quantization).
+
+`ColorCache` memoizes `color → nearest palette entry` per conversion, keyed by
+packed RGB, so repeated colors are looked up once.
+
+## 4. Half-block
+
+Each cell = 2 vertical pixels. Quantize top and bottom; if equal, emit `█`
+(single color), otherwise `▀` with `fg = top`, `bg = bottom`.
+
+## 5. Braille
+
+Each cell = 2×4 dots (`U+2800 + pattern`). A dot is "lit" if its brightness is
+at or above the cell's local mean; the foreground is the mean color of lit dots.
+
+## 6. Dithering
+
+Error diffusion applied once against the exact target palette before rendering:
+
+**Floyd–Steinberg**
+```
+      X   7/16
+3/16 5/16 1/16
+```
+
+**Atkinson** (6/8 of the error distributed, more contrast)
+```
+    X  1/8 1/8
+1/8 1/8 1/8
+    1/8
+```
+
+True color is a no-op (colors are exact). The `glyph` mode skips global
+dithering because its two-colors-per-cell fit already dithers spatially.
+
+## 7. Aspect and clamp
+
+Rows are derived from columns using a font-aspect factor (~0.5) because terminal
+cells are ~1:2. Output is clamped to `maxWidth`/`maxHeight` (defaults 200/2000)
+with a warning, preserving proportion, so no input produces a runaway file.
+
+## References
+
+- [Color difference](https://en.wikipedia.org/wiki/Color_difference)
+- [Floyd–Steinberg dithering](https://en.wikipedia.org/wiki/Floyd%E2%80%93Steinberg_dithering)
+- [Block Elements](https://en.wikipedia.org/wiki/Block_Elements)
+- [Braille Patterns](https://en.wikipedia.org/wiki/Braille_Patterns)
+- [Code Page 437](https://en.wikipedia.org/wiki/Code_page_437)
+- [chafa](https://hpjansson.org/chafa/)
@@ -0,0 +1,93 @@+# htmlansi — Guide
+
+htmlansi converts images into colorful CP437 ANSI art (HTML or `.ans`).
+
+## Quick start
+
+```bash
+npm install
+npm run build
+node dist/cli.js in/photo.jpg -o out/photo.html
+```
+
+Put images in `in/`, results go to `out/` (or wherever `-o` points).
+
+## Render modes
+
+### glyph (default)
+For every character cell, the source is sampled into an 8×8 block. The renderer
+tests every candidate glyph — each defined by an 8×8 coverage mask — splitting
+the 64 sub-pixels into ink/paper groups, taking the mean color of each group,
+quantizing to the target palette, and scoring the total perceptual error. The
+glyph + foreground + background with the lowest error wins.
+
+Glyph sets:
+- `--glyphs safe` (default): full block, halves (`█ ▀ ▄ ▌ ▐`), shading
+  (`░ ▒ ▓`) and box/diagonal lines (`─ │ ┼ ╲ ╱ ╳`). These render at a uniform
+  advance width in the common monospace fonts (verified in Consolas), so rows
+  stay perfectly aligned.
+- `--glyphs full`: also adds eighth-blocks (`▁▂▃▅▆▇ ▏▎▍▋▊▉`) and quadrants
+  (`▖▗▘▝▚▞▙▛▜▟`) for finer gradient/corner detail. Consolas renders these ~9.5%
+  wider than a normal cell, which misaligns the grid in the browser, so use
+  `full` only with a font whose Block Elements are truly monospaced.
+
+Best for: photos, gradients, anything where character variety and fidelity
+matter. Use `-c truecolor` for smooth gradients (limited palettes band).
+
+### half-block
+Each character represents 2 vertical pixels using `▀` (top color as foreground,
+bottom as background) or `█` when both halves match. One cached quantization per
+pixel — very fast, excellent color fidelity, classic look.
+
+### braille
+Each character is a 2×4 grid of dots (`⠀`–`⣿`). Dots brighter than the cell's
+local mean form the pattern; the foreground is the mean color of lit dots.
+Highest spatial resolution; great for line art and diagrams.
+
+## Color modes
+
+| Mode | Colors | Use |
+|---|---|---|
+| `ansi16` | 16 | Maximum compatibility (BBS, old terminals) |
+| `ansi256` | 256 | Recommended default |
+| `truecolor` | 16.7M | Modern terminals, smoothest gradients |
+
+## Sizing and proportions
+
+Terminal character cells are about twice as tall as they are wide. htmlansi
+derives the row count from the column count using that ratio, so a square image
+becomes a grid that *displays* square instead of stretched.
+
+Output is clamped to `--max-width` columns (default 200) and a max row count so
+large inputs never produce enormous files. A note is printed when clamping
+happens. Use `--no-clamp` to opt out, or `-w`/`--height` for exact sizes.
+
+## Transparency
+
+Transparent regions are flattened onto `--bg` (default black) before
+conversion, so PNGs with alpha convert cleanly.
+
+## Dithering
+
+`-d floyd-steinberg` or `-d atkinson` apply error diffusion against the exact
+target palette before rendering. Most useful for `half-block`/`braille` with
+`ansi16`/`ansi256`. The `glyph` mode already dithers spatially via its
+two-colors-per-cell fit, so global dithering is skipped there.
+
+## Output formats
+
+- **html** — standalone page; each distinct color pair is a CSS class and
+  adjacent cells coalesce, keeping files small.
+- **ans** — ANSI escape codes (UTF-8, so quadrants/eighth-blocks render in
+  modern terminals) with a 128-byte SAUCE metadata record.
+
+## Batch
+
+```bash
+node dist/cli.js --in ./in --out ./out -f ans -c ansi256
+```
+
+Converts every image in the folder, writing one output per input.
+
+See [algorithms.md](algorithms.md) for the math behind color distance,
+quantization, and dithering.
@@ -0,0 +1,2262 @@+{
+  "name": "htmlansi",
+  "version": "4.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "htmlansi",
+      "version": "4.0.0",
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "sharp": "^0.34.1"
+      },
+      "bin": {
+        "htmlansi": "dist/cli.js"
+      },
+      "devDependencies": {
+        "@types/node": "^22.13.10",
+        "typescript": "^5.8.2",
+        "vitest": "^3.0.8"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.11.3",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+      "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "glibc"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "libc": [
+        "musl"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz",
+      "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz",
+      "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz",
+      "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz",
+      "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz",
+      "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz",
+      "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz",
+      "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz",
+      "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz",
+      "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz",
+      "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz",
+      "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz",
+      "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz",
+      "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz",
+      "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz",
+      "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz",
+      "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz",
+      "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz",
+      "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz",
+      "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz",
+      "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz",
+      "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz",
+      "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz",
+      "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz",
+      "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz",
+      "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.20.1",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+      "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@vitest/expect": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
+      "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "3.2.7",
+        "@vitest/utils": "3.2.7",
+        "chai": "^5.2.0",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
+      "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "3.2.7",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.17"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
+      "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
+      "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "3.2.7",
+        "pathe": "^2.0.3",
+        "strip-literal": "^3.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
+      "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.7",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
+      "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^4.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
+      "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "3.2.7",
+        "loupe": "^3.1.4",
+        "tinyrainbow": "^2.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+      "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/js-tokens": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+      "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.16",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+      "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+      "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.25",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
+      "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.16",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.62.3",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz",
+      "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.9"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.62.3",
+        "@rollup/rollup-android-arm64": "4.62.3",
+        "@rollup/rollup-darwin-arm64": "4.62.3",
+        "@rollup/rollup-darwin-x64": "4.62.3",
+        "@rollup/rollup-freebsd-arm64": "4.62.3",
+        "@rollup/rollup-freebsd-x64": "4.62.3",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.62.3",
+        "@rollup/rollup-linux-arm-musleabihf": "4.62.3",
+        "@rollup/rollup-linux-arm64-gnu": "4.62.3",
+        "@rollup/rollup-linux-arm64-musl": "4.62.3",
+        "@rollup/rollup-linux-loong64-gnu": "4.62.3",
+        "@rollup/rollup-linux-loong64-musl": "4.62.3",
+        "@rollup/rollup-linux-ppc64-gnu": "4.62.3",
+        "@rollup/rollup-linux-ppc64-musl": "4.62.3",
+        "@rollup/rollup-linux-riscv64-gnu": "4.62.3",
+        "@rollup/rollup-linux-riscv64-musl": "4.62.3",
+        "@rollup/rollup-linux-s390x-gnu": "4.62.3",
+        "@rollup/rollup-linux-x64-gnu": "4.62.3",
+        "@rollup/rollup-linux-x64-musl": "4.62.3",
+        "@rollup/rollup-openbsd-x64": "4.62.3",
+        "@rollup/rollup-openharmony-arm64": "4.62.3",
+        "@rollup/rollup-win32-arm64-msvc": "4.62.3",
+        "@rollup/rollup-win32-ia32-msvc": "4.62.3",
+        "@rollup/rollup-win32-x64-gnu": "4.62.3",
+        "@rollup/rollup-win32-x64-msvc": "4.62.3",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "3.10.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/strip-literal": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
+      "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "js-tokens": "^9.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+      "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.17",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+      "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
+      "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
+      "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vite": {
+      "version": "7.3.6",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
+      "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3",
+        "postcss": "^8.5.6",
+        "rollup": "^4.43.0",
+        "tinyglobby": "^0.2.15"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "lightningcss": "^1.21.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+      "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.4.1",
+        "es-module-lexer": "^1.7.0",
+        "pathe": "^2.0.3",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
+      "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/chai": "^5.2.2",
+        "@vitest/expect": "3.2.7",
+        "@vitest/mocker": "3.2.7",
+        "@vitest/pretty-format": "^3.2.7",
+        "@vitest/runner": "3.2.7",
+        "@vitest/snapshot": "3.2.7",
+        "@vitest/spy": "3.2.7",
+        "@vitest/utils": "3.2.7",
+        "chai": "^5.2.0",
+        "debug": "^4.4.1",
+        "expect-type": "^1.2.1",
+        "magic-string": "^0.30.17",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.2",
+        "std-env": "^3.9.0",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^0.3.2",
+        "tinyglobby": "^0.2.14",
+        "tinypool": "^1.1.1",
+        "tinyrainbow": "^2.0.0",
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
+        "vite-node": "3.2.4",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/debug": "^4.1.12",
+        "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+        "@vitest/browser": "3.2.7",
+        "@vitest/ui": "3.2.7",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/debug": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    }
+  }
+}
@@ -0,0 +1,45 @@+{
+  "name": "htmlansi",
+  "version": "0.0.1",
+  "description": "Convert images to colorful CP437 ANSI art (HTML and .ans)",
+  "author": "Pablo Murad <pablomurad@pm.me>",
+  "type": "module",
+  "main": "dist/index.js",
+  "types": "dist/index.d.ts",
+  "bin": {
+    "htmlansi": "dist/cli.js"
+  },
+  "scripts": {
+    "build": "tsc",
+    "dev": "tsc --watch",
+    "convert": "node dist/cli.js",
+    "test": "vitest run",
+    "test:watch": "vitest"
+  },
+  "keywords": [
+    "ansi",
+    "ansi-art",
+    "image",
+    "converter",
+    "cp437",
+    "terminal",
+    "bbs",
+    "sauce",
+    "chafa"
+  ],
+  "license": "MIT",
+  "homepage": "https://runv.sh",
+  "dependencies": {
+    "sharp": "^0.34.1",
+    "cac": "^6.7.14"
+  },
+  "devDependencies": {
+    "@types/node": "^22.13.10",
+    "typescript": "^5.8.2",
+    "vitest": "^3.0.8"
+  },
+  "engines": {
+    "node": ">=20"
+  },
+  "files": ["dist", "README.md"]
+}
@@ -0,0 +1,210 @@+#!/usr/bin/env node
+/**
+ * htmlansi CLI - convert images to colorful ANSI art (HTML / .ans).
+ *
+ *   htmlansi <image> [options]
+ *   htmlansi --in ./in --out ./out        (batch a folder)
+ */
+
+import { cac } from 'cac';
+import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
+import { existsSync } from 'fs';
+import { basename, extname, dirname, join, resolve } from 'path';
+
+import { convert } from './index.js';
+import { exportHTML } from './export/html.js';
+import { exportANS } from './export/ans.js';
+import type { ConvertOptions, RenderMode, ColorMode, DitherMode, OutputFormat, RGB, Grid } from './types.js';
+
+const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif']);
+
+const cli = cac('htmlansi');
+
+cli
+  .command('[input]', 'Convert an image to ANSI art')
+  .option('-o, --output <path>', 'Output file path')
+  .option('-f, --format <format>', 'Output format: html, ans', { default: 'html' })
+  .option('-m, --mode <mode>', 'Render mode: glyph, half-block, braille', { default: 'glyph' })
+  .option('--glyphs <set>', 'Glyph set for glyph mode: safe, full', { default: 'safe' })
+  .option('-w, --width <n>', 'Output width in characters')
+  .option('--height <n>', 'Output height in characters')
+  .option('-c, --colors <mode>', 'Color mode: ansi16, ansi256, truecolor', { default: 'ansi256' })
+  .option('-d, --dither <mode>', 'Dithering: none, floyd-steinberg, atkinson', { default: 'none' })
+  .option('--bg <color>', 'Background color (#rrggbb or r,g,b) for transparency', { default: '#000000' })
+  .option('--max-width <n>', 'Max columns before auto-downscale', { default: 200 })
+  .option('--no-clamp', 'Disable automatic size clamp')
+  .option('--brightness <n>', 'Brightness multiplier (~0.5-2.0)')
+  .option('--contrast <n>', 'Contrast multiplier (~0.5-2.0)')
+  .option('--gamma <n>', 'Gamma (1.0-3.0)')
+  .option('--saturation <n>', 'Saturation multiplier (0-2.0)')
+  .option('--invert', 'Invert colors')
+  .option('--preview', 'Print to the terminal instead of writing a file')
+  .option('--in <dir>', 'Batch: input folder')
+  .option('--out <dir>', 'Batch: output folder')
+  .action(async (input: string | undefined, options: any) => {
+    try {
+      if (options.in && options.out) {
+        await runBatch(options);
+      } else if (input) {
+        await runSingle(input, options);
+      } else {
+        console.error('Error: provide an input image, or use --in <dir> --out <dir>');
+        console.log('Run "htmlansi --help" for options.');
+        process.exit(1);
+      }
+    } catch (err: any) {
+      console.error(`Error: ${err.message}`);
+      process.exit(1);
+    }
+  });
+
+cli.help();
+cli.version('0.0.1');
+cli.parse();
+
+// ============================================================================
+// Single file
+// ============================================================================
+
+async function runSingle(input: string, options: any): Promise<void> {
+  const inputPath = resolve(input);
+  if (!existsSync(inputPath)) throw new Error(`Input file not found: ${inputPath}`);
+
+  const format = normalizeFormat(options.format);
+  const convertOptions = buildOptions(options);
+
+  const buf = await readFile(inputPath);
+  const result = await convert(buf, convertOptions);
+
+  if (result.metadata.clamped) {
+    console.warn(
+      `Note: output clamped to ${result.width}x${result.height} chars to keep the file reasonable (use --no-clamp or --max-width to change).`
+    );
+  }
+
+  if (options.preview) {
+    process.stdout.write(previewAnsi(result.grid, convertOptions.colorMode!));
+    process.stdout.write('\n');
+    return;
+  }
+
+  const outputPath = resolve(
+    options.output || `${basename(input, extname(input))}.${format}`
+  );
+  await ensureDir(dirname(outputPath));
+  await writeOutput(outputPath, result.grid, format, convertOptions.colorMode!, basename(input));
+  console.log(`Saved ${outputPath}  (${result.width}x${result.height}, ${result.metadata.processingTime}ms)`);
+}
+
+// ============================================================================
+// Batch
+// ============================================================================
+
+async function runBatch(options: any): Promise<void> {
+  const inDir = resolve(options.in);
+  const outDir = resolve(options.out);
+  if (!existsSync(inDir)) throw new Error(`Input folder not found: ${inDir}`);
+  await ensureDir(outDir);
+
+  const format = normalizeFormat(options.format);
+  const convertOptions = buildOptions(options);
+  const entries = await readdir(inDir, { withFileTypes: true });
+
+  let count = 0;
+  for (const e of entries) {
+    if (!e.isFile() || !IMAGE_EXT.has(extname(e.name).toLowerCase())) continue;
+    const inputPath = join(inDir, e.name);
+    const outputPath = join(outDir, `${basename(e.name, extname(e.name))}.${format}`);
+    try {
+      const buf = await readFile(inputPath);
+      const result = await convert(buf, convertOptions);
+      await writeOutput(outputPath, result.grid, format, convertOptions.colorMode!, e.name);
+      count++;
+      console.log(`  ${e.name} -> ${basename(outputPath)} (${result.width}x${result.height})`);
+    } catch (err: any) {
+      console.error(`  ${e.name}: ${err.message}`);
+    }
+  }
+  console.log(`Done. ${count} image(s) converted to ${outDir}`);
+}
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+function buildOptions(options: any): ConvertOptions {
+  return {
+    mode: options.mode as RenderMode,
+    glyphs: options.glyphs === 'full' ? 'full' : 'safe',
+    colorMode: options.colors as ColorMode,
+    dither: options.dither as DitherMode,
+    width: options.width ? parseInt(options.width, 10) : undefined,
+    height: options.height ? parseInt(options.height, 10) : undefined,
+    maxWidth: options.maxWidth ? parseInt(options.maxWidth, 10) : undefined,
+    noClamp: options.clamp === false,
+    background: parseColor(options.bg),
+    brightness: options.brightness ? parseFloat(options.brightness) : undefined,
+    contrast: options.contrast ? parseFloat(options.contrast) : undefined,
+    gamma: options.gamma ? parseFloat(options.gamma) : undefined,
+    saturation: options.saturation ? parseFloat(options.saturation) : undefined,
+    invert: !!options.invert,
+  };
+}
+
+function normalizeFormat(f: string): OutputFormat {
+  return f === 'ans' ? 'ans' : 'html';
+}
+
+async function writeOutput(
+  path: string,
+  grid: Grid,
+  format: OutputFormat,
+  colorMode: ColorMode,
+  title: string
+): Promise<void> {
+  if (format === 'ans') {
+    await writeFile(path, exportANS(grid, { colorMode, sauce: true, title }));
+  } else {
+    await writeFile(path, exportHTML(grid, { title }), 'utf-8');
+  }
+}
+
+async function ensureDir(dir: string): Promise<void> {
+  if (!existsSync(dir)) await mkdir(dir, { recursive: true });
+}
+
+function parseColor(s: string | undefined): RGB {
+  if (!s) return { r: 0, g: 0, b: 0 };
+  if (s.startsWith('#')) {
+    const h = s.slice(1);
+    const v = h.length === 3
+      ? h.split('').map((c) => parseInt(c + c, 16))
+      : [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
+    return { r: v[0] || 0, g: v[1] || 0, b: v[2] || 0 };
+  }
+  if (s.includes(',')) {
+    const v = s.split(',').map((n) => parseInt(n.trim(), 10));
+    return { r: v[0] || 0, g: v[1] || 0, b: v[2] || 0 };
+  }
+  return { r: 0, g: 0, b: 0 };
+}
+
+// ============================================================================
+// Terminal preview
+// ============================================================================
+
+function previewAnsi(grid: Grid, _colorMode: ColorMode): string {
+  // Cells hold already-quantized colors, so emitting them as truecolor
+  // escapes reproduces the exact stored color in any capable terminal.
+  const CSI = '\x1b[';
+  const RESET = `${CSI}0m`;
+  let out = '';
+  for (const row of grid.cells) {
+    for (const cell of row) {
+      const { fg, bg } = cell;
+      out += `${CSI}38;2;${fg.r};${fg.g};${fg.b}m${CSI}48;2;${bg.r};${bg.g};${bg.b}m${cell.char}`;
+    }
+    out += RESET + '\n';
+  }
+  return out;
+}
@@ -0,0 +1,201 @@+/**
+ * Color - palettes, perceptual distance functions, Lab conversion, quantization cache.
+ */
+
+import type { RGB, Lab, ColorMode, ColorSpace } from '../types.js';
+import { ANSI_16_COLORS } from '../types.js';
+
+export { ANSI_16_COLORS };
+
+// ============================================================================
+// Palettes
+// ============================================================================
+
+/** Standard xterm 256-color palette: 16 system + 216 cube + 24 grayscale. */
+function buildAnsi256(): RGB[] {
+  const colors: RGB[] = [...ANSI_16_COLORS];
+  const levels = [0, 95, 135, 175, 215, 255];
+  for (let r = 0; r < 6; r++) {
+    for (let g = 0; g < 6; g++) {
+      for (let b = 0; b < 6; b++) {
+        colors.push({ r: levels[r], g: levels[g], b: levels[b] });
+      }
+    }
+  }
+  for (let i = 0; i < 24; i++) {
+    const v = 8 + i * 10;
+    colors.push({ r: v, g: v, b: v });
+  }
+  return colors;
+}
+
+export const ANSI_256_COLORS: RGB[] = buildAnsi256();
+
+/** Return the reference palette for a color mode (truecolor has no fixed palette). */
+export function paletteFor(mode: ColorMode): RGB[] {
+  switch (mode) {
+    case 'ansi16': return ANSI_16_COLORS;
+    case 'ansi256': return ANSI_256_COLORS;
+    default: return ANSI_256_COLORS; // truecolor uses exact colors; palette only for index lookup
+  }
+}
+
+// ============================================================================
+// Lab color space
+// ============================================================================
+
+/** RGB (0-255) -> CIE Lab (perceptually uniform). */
+export function rgbToLab(rgb: RGB): Lab {
+  let r = rgb.r / 255;
+  let g = rgb.g / 255;
+  let b = rgb.b / 255;
+
+  r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;
+  g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;
+  b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;
+
+  const x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
+  const y = (r * 0.2126729 + g * 0.7151522 + b * 0.0721750);
+  const z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
+
+  const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
+  const fx = f(x), fy = f(y), fz = f(z);
+
+  return {
+    l: 116 * fy - 16,
+    a: 500 * (fx - fy),
+    b: 200 * (fy - fz),
+  };
+}
+
+/**
+ * CIE Lab -> RGB (0-255).
+ * Uses distinct intermediates so the XYZ->RGB step never reads an already
+ * mutated channel (the aliasing bug in the previous version).
+ */
+export function labToRgb(lab: Lab): RGB {
+  const fy = (lab.l + 16) / 116;
+  const fx = lab.a / 500 + fy;
+  const fz = fy - lab.b / 200;
+
+  const inv = (t: number) => {
+    const t3 = t * t * t;
+    return t3 > 0.008856 ? t3 : (t - 16 / 116) / 7.787;
+  };
+
+  const x = inv(fx) * 0.95047;
+  const y = inv(fy);
+  const z = inv(fz) * 1.08883;
+
+  // XYZ -> linear RGB (read only from x/y/z, write to fresh vars)
+  const lr = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
+  const lg = x * -0.9692660 + y * 1.8760108 + z * 0.0415560;
+  const lb = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
+
+  const gamma = (v: number) =>
+    v > 0.0031308 ? 1.055 * Math.pow(v, 1 / 2.4) - 0.055 : 12.92 * v;
+  const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v * 255)));
+
+  return { r: clamp(gamma(lr)), g: clamp(gamma(lg)), b: clamp(gamma(lb)) };
+}
+
+// ============================================================================
+// Distance functions (squared where possible — monotonic, avoids sqrt)
+// ============================================================================
+
+/** Squared Euclidean RGB distance. */
+export function distanceRGB(c1: RGB, c2: RGB): number {
+  const dr = c1.r - c2.r, dg = c1.g - c2.g, db = c1.b - c2.b;
+  return dr * dr + dg * dg + db * db;
+}
+
+/** Redmean weighted RGB distance (squared) — cheap and perceptually decent. */
+export function distanceRedmean(c1: RGB, c2: RGB): number {
+  const rmean = (c1.r + c2.r) / 2;
+  const dr = c1.r - c2.r, dg = c1.g - c2.g, db = c1.b - c2.b;
+  return (2 + rmean / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rmean) / 256) * db * db;
+}
+
+/** CIE76 Delta-E (squared) in Lab space — most accurate, slowest. */
+export function distanceLab(c1: RGB, c2: RGB): number {
+  const a = rgbToLab(c1), b = rgbToLab(c2);
+  const dl = a.l - b.l, da = a.a - b.a, db = a.b - b.b;
+  return dl * dl + da * da + db * db;
+}
+
+export function distanceFn(space: ColorSpace): (a: RGB, b: RGB) => number {
+  switch (space) {
+    case 'lab': return distanceLab;
+    case 'rgb': return distanceRGB;
+    case 'redmean':
+    default: return distanceRedmean;
+  }
+}
+
+// ============================================================================
+// Quantization
+// ============================================================================
+
+export function findClosest(
+  color: RGB,
+  palette: RGB[],
+  dist: (a: RGB, b: RGB) => number = distanceRedmean
+): { color: RGB; index: number } {
+  let best = 0;
+  let min = Infinity;
+  for (let i = 0; i < palette.length; i++) {
+    const d = dist(color, palette[i]);
+    if (d < min) { min = d; best = i; }
+  }
+  return { color: palette[best], index: best };
+}
+
+/**
+ * Caches color -> nearest-palette-entry lookups. One instance per conversion.
+ * For truecolor, quantize() returns the color unchanged (index -1).
+ */
+export class ColorCache {
+  private cache = new Map<number, { color: RGB; index: number }>();
+  private palette: RGB[];
+  private dist: (a: RGB, b: RGB) => number;
+  private truecolor: boolean;
+
+  constructor(mode: ColorMode, space: ColorSpace = 'redmean') {
+    this.truecolor = mode === 'truecolor';
+    this.palette = paletteFor(mode);
+    this.dist = distanceFn(space);
+  }
+
+  quantize(color: RGB): { color: RGB; index: number } {
+    if (this.truecolor) return { color, index: -1 };
+    const key = (color.r << 16) | (color.g << 8) | color.b;
+    const hit = this.cache.get(key);
+    if (hit) return hit;
+    const res = findClosest(color, this.palette, this.dist);
+    this.cache.set(key, res);
+    return res;
+  }
+
+  get colorPalette(): RGB[] { return this.palette; }
+  get isTruecolor(): boolean { return this.truecolor; }
+}
+
+// ============================================================================
+// Small helpers
+// ============================================================================
+
+export function averageColor(pixels: RGB[]): RGB {
+  if (pixels.length === 0) return { r: 0, g: 0, b: 0 };
+  let r = 0, g = 0, b = 0;
+  for (const p of pixels) { r += p.r; g += p.g; b += p.b; }
+  const n = pixels.length;
+  return { r: Math.round(r / n), g: Math.round(g / n), b: Math.round(b / n) };
+}
+
+export function brightness(c: RGB): number {
+  return (0.299 * c.r + 0.587 * c.g + 0.114 * c.b) / 255;
+}
+
+export function colorsEqual(a: RGB, b: RGB): boolean {
+  return a.r === b.r && a.g === b.g && a.b === b.b;
+}
@@ -0,0 +1,140 @@+/**
+ * Glyph bank - the heart of the "glyph" render mode.
+ *
+ * Every candidate glyph is modelled as an 8x8 binary coverage mask where
+ * 1 = foreground (ink) and 0 = background (paper). Shading blocks (░▒▓) are
+ * modelled as fixed dot patterns so a single code path (split ink/paper,
+ * take the mean of each group) handles blocks, quadrants, eighth-blocks,
+ * box/diagonal lines AND shading uniformly.
+ *
+ * This is what gives the output its varied character set: instead of only
+ * █▀▄, the matcher can pick from ~45 shapes to best fit each cell.
+ */
+
+export const CELL = 8; // sub-sample resolution per character cell (8x8)
+
+export interface Glyph {
+  char: string;
+  /** 64 entries, row-major (y*8 + x); 1 = ink/fg, 0 = paper/bg. */
+  mask: Uint8Array;
+  /** Indices where mask === 1 (precomputed for fast mean computation). */
+  ink: number[];
+}
+
+// ============================================================================
+// Mask builders
+// ============================================================================
+
+function make(predicate: (x: number, y: number) => boolean): Uint8Array {
+  const mask = new Uint8Array(CELL * CELL);
+  for (let y = 0; y < CELL; y++) {
+    for (let x = 0; x < CELL; x++) {
+      mask[y * CELL + x] = predicate(x, y) ? 1 : 0;
+    }
+  }
+  return mask;
+}
+
+function glyph(char: string, mask: Uint8Array): Glyph {
+  const ink: number[] = [];
+  for (let i = 0; i < mask.length; i++) if (mask[i]) ink.push(i);
+  return { char, mask, ink };
+}
+
+const rows = (from: number, to: number) => make((_x, y) => y >= from && y <= to);
+const cols = (from: number, to: number) => make((x, _y) => x >= from && x <= to);
+const quad = (tl: boolean, tr: boolean, bl: boolean, br: boolean) =>
+  make((x, y) => {
+    const left = x < 4, top = y < 4;
+    if (top && left) return tl;
+    if (top && !left) return tr;
+    if (!top && left) return bl;
+    return br;
+  });
+
+// ============================================================================
+// Glyph set
+// ============================================================================
+
+/**
+ * "Safe" glyphs: characters that render at exactly the space/full-block advance
+ * width in the common monospace fonts (verified in Consolas, which is the
+ * Windows default). These are full block, halves, shading and box/diagonal
+ * lines. This is the default set, so rows never drift out of alignment.
+ *
+ * IMPORTANT: eighth-blocks and quadrants are deliberately NOT here — Consolas
+ * renders them ~9.5% wider than a normal cell, which accumulates into a ragged
+ * right edge. They live in EXTRA_GLYPHS (opt-in via `--glyphs full`).
+ */
+export const SAFE_GLYPHS: Glyph[] = [
+  // Full block first so solid cells prefer █ over an equal-scoring space.
+  glyph('█', make(() => true)),
+  glyph(' ', make(() => false)),
+
+  // Halves
+  glyph('▀', rows(0, 3)),
+  glyph('▄', rows(4, 7)),
+  glyph('▌', cols(0, 3)),
+  glyph('▐', cols(4, 7)),
+
+  // Shading (dot patterns → blend two colors; give density between the halves)
+  glyph('░', make((x, y) => x % 2 === 0 && y % 2 === 0)),    // ~25%
+  glyph('▒', make((x, y) => (x + y) % 2 === 0)),              // 50% checker
+  glyph('▓', make((x, y) => !(x % 2 === 1 && y % 2 === 1))),  // ~75%
+
+  // Box / line glyphs (thin) — good for edges and structure
+  glyph('─', rows(3, 4)),
+  glyph('│', cols(3, 4)),
+  glyph('┼', make((x, y) => (x >= 3 && x <= 4) || (y >= 3 && y <= 4))),
+  glyph('╲', make((x, y) => Math.abs(x - y) <= 0)),
+  glyph('╱', make((x, y) => x + y === 7 || x + y === 6)),
+  glyph('╳', make((x, y) => Math.abs(x - y) <= 0 || x + y === 7)),
+];
+
+/**
+ * Eighth-block and quadrant glyphs. They add finer gradient/corner detail but
+ * render at a non-uniform advance width in Consolas (and often elsewhere), so
+ * they break grid alignment in the browser. Enabled only via `--glyphs full`,
+ * intended for a font with truly monospaced Block Elements.
+ */
+export const EXTRA_GLYPHS: Glyph[] = [
+  // Vertical eighth-blocks (fill from bottom)
+  glyph('▁', rows(7, 7)),
+  glyph('▂', rows(6, 7)),
+  glyph('▃', rows(5, 7)),
+  glyph('▅', rows(3, 7)),
+  glyph('▆', rows(2, 7)),
+  glyph('▇', rows(1, 7)),
+
+  // Horizontal eighth-blocks (fill from left)
+  glyph('▏', cols(0, 0)),
+  glyph('▎', cols(0, 1)),
+  glyph('▍', cols(0, 2)),
+  glyph('▋', cols(0, 4)),
+  glyph('▊', cols(0, 5)),
+  glyph('▉', cols(0, 6)),
+
+  // Quadrants
+  glyph('▖', quad(false, false, true, false)),
+  glyph('▗', quad(false, false, false, true)),
+  glyph('▘', quad(true, false, false, false)),
+  glyph('▝', quad(false, true, false, false)),
+  glyph('▚', quad(true, false, false, true)),
+  glyph('▞', quad(false, true, true, false)),
+  glyph('▙', quad(true, false, true, true)),
+  glyph('▛', quad(true, true, true, false)),
+  glyph('▜', quad(true, true, false, true)),
+  glyph('▟', quad(false, true, true, true)),
+];
+
+/** Default glyph set (alignment-safe across fonts). */
+export const GLYPHS: Glyph[] = SAFE_GLYPHS;
+
+/** Full glyph set (safe + quadrants/lines); needs a font with full block coverage. */
+export const FULL_GLYPHS: Glyph[] = [...SAFE_GLYPHS, ...EXTRA_GLYPHS];
+
+export type GlyphSet = 'safe' | 'full';
+
+export function glyphSet(set: GlyphSet = 'safe'): Glyph[] {
+  return set === 'full' ? FULL_GLYPHS : SAFE_GLYPHS;
+}
@@ -0,0 +1,203 @@+/**
+ * Image loader - sharp-backed loading, transparency flattening, adjustments,
+ * and the single source of truth for grid sizing (aspect + clamp).
+ */
+
+import sharp from 'sharp';
+import type { RGB, RenderMode, ConvertOptions } from '../types.js';
+
+export interface ImageData {
+  width: number;
+  height: number;
+  channels: number;
+  pixels: Uint8Array;
+}
+
+export interface LoadedImage {
+  image: ImageData;
+  cols: number;
+  rows: number;
+  clamped: boolean;
+  originalWidth: number;
+  originalHeight: number;
+}
+
+/**
+ * A monospace character cell's width-to-height ratio. Rows are scaled by this
+ * so the art keeps the source image's proportions instead of stretching.
+ * Measured for Consolas at line-height 1.0 (6.6px / 12px ≈ 0.55), which the
+ * HTML export leads with; other monospace fonts sit in the 0.5–0.6 range.
+ */
+export const FONT_ASPECT = 0.55;
+
+const DEFAULT_WIDTH = 80;
+const DEFAULT_MAX_WIDTH = 200;
+const DEFAULT_MAX_HEIGHT = 2000;
+
+/** Pixels sampled per character cell, per render mode. */
+export const SUBSAMPLE: Record<RenderMode, { x: number; y: number }> = {
+  glyph: { x: 8, y: 8 },
+  'half-block': { x: 1, y: 2 },
+  braille: { x: 2, y: 4 },
+};
+
+// ============================================================================
+// Grid sizing (aspect-correct + clamped)
+// ============================================================================
+
+export interface GridSize {
+  cols: number;
+  rows: number;
+  clamped: boolean;
+}
+
+/**
+ * Compute the character grid (cols x rows) for an image, applying the font
+ * aspect correction and an automatic clamp so output never blows up in size.
+ */
+export function computeGrid(
+  imgW: number,
+  imgH: number,
+  opts: ConvertOptions = {}
+): GridSize {
+  const maxWidth = opts.maxWidth ?? DEFAULT_MAX_WIDTH;
+  const maxHeight = opts.maxHeight ?? DEFAULT_MAX_HEIGHT;
+  const ratio = imgH / imgW;
+
+  let cols: number;
+  let rows: number;
+
+  if (opts.width && opts.height) {
+    cols = opts.width;
+    rows = opts.height;
+  } else if (opts.width) {
+    cols = opts.width;
+    rows = Math.round(cols * ratio * FONT_ASPECT);
+  } else if (opts.height) {
+    rows = opts.height;
+    cols = Math.round((rows / FONT_ASPECT) / ratio);
+  } else {
+    cols = Math.min(DEFAULT_WIDTH, Math.max(1, imgW));
+    rows = Math.round(cols * ratio * FONT_ASPECT);
+  }
+
+  cols = Math.max(1, cols);
+  rows = Math.max(1, rows);
+
+  let clamped = false;
+  if (!opts.noClamp) {
+    if (cols > maxWidth) {
+      rows = Math.max(1, Math.round(rows * (maxWidth / cols)));
+      cols = maxWidth;
+      clamped = true;
+    }
+    if (rows > maxHeight) {
+      cols = Math.max(1, Math.round(cols * (maxHeight / rows)));
+      rows = maxHeight;
+      clamped = true;
+    }
+  }
+
+  return { cols, rows, clamped };
+}
+
+// ============================================================================
+// Loading
+// ============================================================================
+
+/**
+ * Load an image, size the grid for the given mode, and return raw RGB pixels
+ * resized to exactly cols*subX by rows*subY (so each cell samples cleanly).
+ */
+export async function loadImage(
+  input: string | Buffer,
+  mode: RenderMode,
+  opts: ConvertOptions = {}
+): Promise<LoadedImage> {
+  const meta = await sharp(input).metadata();
+  const originalWidth = meta.width ?? 0;
+  const originalHeight = meta.height ?? 0;
+  if (!originalWidth || !originalHeight) {
+    throw new Error('Could not read image dimensions');
+  }
+
+  const { cols, rows, clamped } = computeGrid(originalWidth, originalHeight, opts);
+  const sub = SUBSAMPLE[mode];
+  const pxW = cols * sub.x;
+  const pxH = rows * sub.y;
+
+  const bg = opts.background ?? { r: 0, g: 0, b: 0 };
+
+  let pipeline = sharp(input)
+    .resize(pxW, pxH, { fit: 'fill', kernel: 'lanczos3' })
+    // Composite over the background so transparent areas get a real color
+    // instead of leftover/garbage RGB from removeAlpha.
+    .flatten({ background: { r: bg.r, g: bg.g, b: bg.b } });
+
+  if (opts.brightness !== undefined || opts.saturation !== undefined) {
+    pipeline = pipeline.modulate({
+      brightness: opts.brightness,
+      saturation: opts.saturation,
+    });
+  }
+  if (opts.gamma !== undefined) {
+    // sharp accepts gamma in [1.0, 3.0]
+    pipeline = pipeline.gamma(Math.max(1, Math.min(3, opts.gamma)));
+  }
+  if (opts.contrast !== undefined) {
+    const c = opts.contrast;
+    pipeline = pipeline.linear(c, 128 * (1 - c));
+  }
+  if (opts.invert) {
+    pipeline = pipeline.negate({ alpha: false } as any);
+  }
+
+  const { data, info } = await pipeline
+    .toColourspace('srgb')
+    .removeAlpha()
+    .raw()
+    .toBuffer({ resolveWithObject: true });
+
+  return {
+    image: {
+      width: info.width,
+      height: info.height,
+      channels: info.channels,
+      pixels: new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
+    },
+    cols,
+    rows,
+    clamped,
+    originalWidth,
+    originalHeight,
+  };
+}
+
+// ============================================================================
+// Pixel access
+// ============================================================================
+
+export function getPixel(image: ImageData, x: number, y: number): RGB {
+  const idx = (y * image.width + x) * image.channels;
+  return { r: image.pixels[idx], g: image.pixels[idx + 1], b: image.pixels[idx + 2] };
+}
+
+export function setPixel(image: ImageData, x: number, y: number, color: RGB): void {
+  const idx = (y * image.width + x) * image.channels;
+  image.pixels[idx] = clamp255(color.r);
+  image.pixels[idx + 1] = clamp255(color.g);
+  image.pixels[idx + 2] = clamp255(color.b);
+}
+
+export function cloneImageData(image: ImageData): ImageData {
+  return {
+    width: image.width,
+    height: image.height,
+    channels: image.channels,
+    pixels: new Uint8Array(image.pixels),
+  };
+}
+
+function clamp255(v: number): number {
+  return v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
+}
@@ -0,0 +1,68 @@+/**
+ * Dithering - error diffusion applied ONCE against the exact target palette,
+ * before rendering. This keeps it coherent with the renderer (the previous
+ * version dithered and then re-quantized differently, wasting the effect).
+ *
+ * For truecolor the cache returns colors unchanged, so dithering is a no-op.
+ */
+
+import type { RGB, DitherMode } from '../types.js';
+import { getPixel, setPixel, cloneImageData, type ImageData } from '../core/image-loader.js';
+import { ColorCache } from '../core/color.js';
+
+interface Weight { dx: number; dy: number; w: number }
+
+const FLOYD_STEINBERG: Weight[] = [
+  { dx: 1, dy: 0, w: 7 / 16 },
+  { dx: -1, dy: 1, w: 3 / 16 },
+  { dx: 0, dy: 1, w: 5 / 16 },
+  { dx: 1, dy: 1, w: 1 / 16 },
+];
+
+const ATKINSON: Weight[] = [
+  { dx: 1, dy: 0, w: 1 / 8 },
+  { dx: 2, dy: 0, w: 1 / 8 },
+  { dx: -1, dy: 1, w: 1 / 8 },
+  { dx: 0, dy: 1, w: 1 / 8 },
+  { dx: 1, dy: 1, w: 1 / 8 },
+  { dx: 0, dy: 2, w: 1 / 8 },
+];
+
+/**
+ * Return a dithered copy of the image, quantized to the cache's palette.
+ * `mode === 'none'` (or truecolor cache) returns the image unchanged.
+ */
+export function applyDithering(
+  image: ImageData,
+  mode: DitherMode,
+  cache: ColorCache
+): ImageData {
+  if (mode === 'none' || cache.isTruecolor) return image;
+  const matrix = mode === 'atkinson' ? ATKINSON : FLOYD_STEINBERG;
+
+  const out = cloneImageData(image);
+  for (let y = 0; y < out.height; y++) {
+    for (let x = 0; x < out.width; x++) {
+      const oldColor = getPixel(out, x, y);
+      const newColor = cache.quantize(oldColor).color;
+      setPixel(out, x, y, newColor);
+
+      const er = oldColor.r - newColor.r;
+      const eg = oldColor.g - newColor.g;
+      const eb = oldColor.b - newColor.b;
+
+      for (const { dx, dy, w } of matrix) {
+        const nx = x + dx;
+        const ny = y + dy;
+        if (nx < 0 || nx >= out.width || ny < 0 || ny >= out.height) continue;
+        const n = getPixel(out, nx, ny);
+        setPixel(out, nx, ny, {
+          r: n.r + er * w,
+          g: n.g + eg * w,
+          b: n.b + eb * w,
+        });
+      }
+    }
+  }
+  return out;
+}
@@ -0,0 +1,104 @@+/**
+ * ANS export - ANSI escape codes, UTF-8 encoded so the full glyph set (quadrants,
+ * eighth-blocks) renders in modern terminals and ANSI viewers. Optional SAUCE
+ * metadata record with a correctly filled file-size field.
+ */
+
+import type { Grid, RGB, ANSExportOptions, ColorMode } from '../types.js';
+import { ANSI_16_COLORS, ANSI_256_COLORS, findClosest, colorsEqual } from '../core/color.js';
+
+const ESC = '\x1b';
+const CSI = `${ESC}[`;
+const RESET = `${CSI}0m`;
+
+const FG16 = [30, 31, 32, 33, 34, 35, 36, 37, 90, 91, 92, 93, 94, 95, 96, 97];
+const BG16 = [40, 41, 42, 43, 44, 45, 46, 47, 100, 101, 102, 103, 104, 105, 106, 107];
+
+export function exportANS(grid: Grid, options: ANSExportOptions = {}): Buffer {
+  const mode: ColorMode = options.colorMode ?? 'ansi256';
+
+  const lines: string[] = [];
+  let lastFg: RGB | null = null;
+  let lastBg: RGB | null = null;
+
+  for (const row of grid.cells) {
+    let line = '';
+    lastFg = null; // reset per line so each row is self-contained
+    lastBg = null;
+    for (const cell of row) {
+      const codes: string[] = [];
+      if (!lastFg || !colorsEqual(lastFg, cell.fg)) {
+        codes.push(...fgCodes(cell.fg, mode));
+        lastFg = cell.fg;
+      }
+      if (!lastBg || !colorsEqual(lastBg, cell.bg)) {
+        codes.push(...bgCodes(cell.bg, mode));
+        lastBg = cell.bg;
+      }
+      if (codes.length) line += `${CSI}${codes.join(';')}m`;
+      line += cell.char;
+    }
+    lines.push(line + RESET);
+  }
+
+  const content = lines.join('\r\n');
+  const contentBuf = Buffer.from(content, 'utf-8');
+
+  if (!options.sauce) return contentBuf;
+
+  const sauce = buildSauce(options, grid, contentBuf.length);
+  return Buffer.concat([contentBuf, Buffer.from([0x1a]), sauce]);
+}
+
+function fgCodes(c: RGB, mode: ColorMode): string[] {
+  if (mode === 'truecolor') return [`38;2;${c.r};${c.g};${c.b}`];
+  if (mode === 'ansi16') return [`${FG16[findClosest(c, ANSI_16_COLORS).index]}`];
+  return [`38;5;${findClosest(c, ANSI_256_COLORS).index}`];
+}
+
+function bgCodes(c: RGB, mode: ColorMode): string[] {
+  if (mode === 'truecolor') return [`48;2;${c.r};${c.g};${c.b}`];
+  if (mode === 'ansi16') return [`${BG16[findClosest(c, ANSI_16_COLORS).index]}`];
+  return [`48;5;${findClosest(c, ANSI_256_COLORS).index}`];
+}
+
+// ============================================================================
+// SAUCE
+// ============================================================================
+
+function buildSauce(options: ANSExportOptions, grid: Grid, fileSize: number): Buffer {
+  const {
+    title = 'Untitled',
+    author = 'htmlansi',
+    group = '',
+  } = options;
+
+  const sauce = Buffer.alloc(128, 0x20); // pad with spaces
+  sauce.fill(0, 94); // zero the numeric/flags region
+
+  sauce.write('SAUCE', 0, 'ascii');
+  sauce.write('00', 5, 'ascii');
+  sauce.write(title.substring(0, 35).padEnd(35, ' '), 7, 'ascii');
+  sauce.write(author.substring(0, 20).padEnd(20, ' '), 42, 'ascii');
+  sauce.write(group.substring(0, 20).padEnd(20, ' '), 62, 'ascii');
+
+  const d = new Date();
+  const date =
+    `${d.getFullYear()}` +
+    `${String(d.getMonth() + 1).padStart(2, '0')}` +
+    `${String(d.getDate()).padStart(2, '0')}`;
+  sauce.write(date, 82, 'ascii');
+
+  sauce.writeUInt32LE(fileSize >>> 0, 90); // FileSize (excludes EOF + SAUCE)
+  sauce.writeUInt8(1, 94); // DataType: Character
+  sauce.writeUInt8(1, 95); // FileType: ANSi
+  sauce.writeUInt16LE(grid.width, 96);  // TInfo1: width
+  sauce.writeUInt16LE(grid.height, 98); // TInfo2: lines
+  sauce.writeUInt16LE(0, 100);
+  sauce.writeUInt16LE(0, 102);
+  sauce.writeUInt8(0, 104);  // Comments
+  sauce.writeUInt8(0, 105);  // TFlags
+  sauce.write('IBM VGA'.padEnd(22, '\0'), 106, 'ascii'); // TInfoS: font
+
+  return sauce;
+}
@@ -0,0 +1,80 @@+/**
+ * HTML export - standalone, self-contained page.
+ * Uses CSS classes for each distinct fg/bg pair and run-length coalesces
+ * adjacent cells so the file stays small. Output size is primarily bounded by
+ * the grid clamp in image-loader, not here.
+ */
+
+import type { Grid, RGB, HTMLExportOptions } from '../types.js';
+
+export function exportHTML(grid: Grid, options: HTMLExportOptions = {}): string {
+  const {
+    title = 'ANSI Art',
+    fontFamily = 'Consolas, "DejaVu Sans Mono", "Courier New", monospace',
+    fontSize = 12,
+    lineHeight = 1.0,
+    backgroundColor = '#000000',
+  } = options;
+
+  // Assign a CSS class to each distinct fg/bg pair.
+  const classes = new Map<string, string>();
+  let n = 0;
+  for (const row of grid.cells) {
+    for (const cell of row) {
+      const key = pairKey(cell.fg, cell.bg);
+      if (!classes.has(key)) classes.set(key, `c${n++}`);
+    }
+  }
+
+  let css =
+    `*{margin:0;padding:0;box-sizing:border-box}` +
+    `body{background:${backgroundColor};display:flex;justify-content:center;padding:16px}` +
+    `pre{font-family:${fontFamily};font-size:${fontSize}px;line-height:${lineHeight};` +
+    `white-space:pre;letter-spacing:0;font-variant-ligatures:none;font-kerning:none}`;
+  for (const [key, cls] of classes) {
+    const [fgHex, bgHex] = key.split('|');
+    css += `.${cls}{color:${fgHex}` + (bgHex !== '#000000' ? `;background:${bgHex}` : '') + `}`;
+  }
+
+  const lines: string[] = [];
+  for (const row of grid.cells) {
+    let out = '';
+    let curClass: string | null = null;
+    let buf = '';
+    for (const cell of row) {
+      const cls = classes.get(pairKey(cell.fg, cell.bg))!;
+      if (cls === curClass) {
+        buf += escapeHtml(cell.char);
+      } else {
+        if (curClass !== null) out += `<span class="${curClass}">${buf}</span>`;
+        curClass = cls;
+        buf = escapeHtml(cell.char);
+      }
+    }
+    if (curClass !== null) out += `<span class="${curClass}">${buf}</span>`;
+    lines.push(out);
+  }
+
+  return (
+    `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">` +
+    `<meta name="viewport" content="width=device-width, initial-scale=1.0">` +
+    `<title>${escapeHtml(title)}</title><style>${css}</style></head>` +
+    `<body><pre class="ansi-art">${lines.join('\n')}</pre></body></html>`
+  );
+}
+
+function pairKey(fg: RGB, bg: RGB): string {
+  return `${hex(fg)}|${hex(bg)}`;
+}
+
+function hex(c: RGB): string {
+  const to = (v: number) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0');
+  return `#${to(c.r)}${to(c.g)}${to(c.b)}`;
+}
+
+function escapeHtml(s: string): string {
+  return s
+    .replace(/&/g, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;');
+}
@@ -0,0 +1,107 @@+/**
+ * htmlansi - convert images to colorful CP437 ANSI art.
+ *
+ * Public API: convert() produces a Grid + metadata; convertToFormat() renders
+ * straight to an HTML string or an .ans Buffer.
+ */
+
+import type {
+  ConvertOptions,
+  ConvertResult,
+  OutputFormat,
+  ColorMode,
+} from './types.js';
+import { loadImage } from './core/image-loader.js';
+import { ColorCache } from './core/color.js';
+import { applyDithering } from './dither/index.js';
+import { render } from './render/index.js';
+import { exportHTML } from './export/html.js';
+import { exportANS } from './export/ans.js';
+
+// Re-exports for library users
+export type {
+  RGB,
+  Lab,
+  Cell,
+  Grid,
+  RenderMode,
+  ColorMode,
+  ColorSpace,
+  DitherMode,
+  OutputFormat,
+  ConvertOptions,
+  ConvertResult,
+  HTMLExportOptions,
+  ANSExportOptions,
+} from './types.js';
+export { ANSI_16_COLORS } from './types.js';
+export { ANSI_256_COLORS, ColorCache, rgbToLab, labToRgb } from './core/color.js';
+export { GLYPHS } from './core/glyphs.js';
+export { computeGrid, loadImage } from './core/image-loader.js';
+export { render } from './render/index.js';
+export { exportHTML } from './export/html.js';
+export { exportANS } from './export/ans.js';
+
+/**
+ * Convert an image into an ANSI-art character grid.
+ */
+export async function convert(
+  input: string | Buffer,
+  options: ConvertOptions = {}
+): Promise<ConvertResult> {
+  const start = Date.now();
+
+  const mode = options.mode ?? 'glyph';
+  const colorMode: ColorMode = options.colorMode ?? 'ansi256';
+
+  const loaded = await loadImage(input, mode, options);
+  const cache = new ColorCache(colorMode, options.colorSpace ?? 'redmean');
+
+  // Dithering only helps the direct-quantization modes (half-block/braille);
+  // the glyph matcher already dithers spatially via its two-color-per-cell fit.
+  let image = loaded.image;
+  if (mode !== 'glyph' && options.dither && options.dither !== 'none') {
+    image = applyDithering(image, options.dither, cache);
+  }
+
+  const grid = render(image, {
+    mode,
+    cache,
+    cols: loaded.cols,
+    rows: loaded.rows,
+    background: options.background,
+    glyphs: options.glyphs,
+  });
+
+  return {
+    grid,
+    width: grid.width,
+    height: grid.height,
+    mode,
+    colorMode,
+    metadata: {
+      originalWidth: loaded.originalWidth,
+      originalHeight: loaded.originalHeight,
+      clamped: loaded.clamped,
+      processingTime: Date.now() - start,
+    },
+  };
+}
+
+/**
+ * Convert and serialize directly to a format.
+ * Returns a string for 'html' and a Buffer for 'ans'.
+ */
+export async function convertToFormat(
+  input: string | Buffer,
+  format: OutputFormat,
+  options: ConvertOptions = {}
+): Promise<string | Buffer> {
+  const result = await convert(input, options);
+  if (format === 'ans') {
+    return exportANS(result.grid, { colorMode: result.colorMode, sauce: true });
+  }
+  return exportHTML(result.grid, { title: 'htmlansi' });
+}
+
+export default { convert, convertToFormat };
@@ -0,0 +1,73 @@+/**
+ * Braille renderer - 1 character = 2x4 dots (⠀-⣿), maximum spatial resolution.
+ * Colored: dots that are "lit" (brighter than the local mean) form the pattern;
+ * the cell's foreground is the mean color of the lit dots. Best for line art.
+ * Image is expected pre-resized to cols*2 x rows*4.
+ */
+
+import type { RGB, Grid, Cell } from '../types.js';
+import { getPixel, type ImageData } from '../core/image-loader.js';
+import { ColorCache, brightness, averageColor } from '../core/color.js';
+
+export interface BrailleRenderOptions {
+  cache: ColorCache;
+  cols: number;
+  rows: number;
+  background?: RGB;
+}
+
+// (dx, dy) -> braille bit
+const DOTS: Array<{ dx: number; dy: number; bit: number }> = [
+  { dx: 0, dy: 0, bit: 0x01 },
+  { dx: 0, dy: 1, bit: 0x02 },
+  { dx: 0, dy: 2, bit: 0x04 },
+  { dx: 1, dy: 0, bit: 0x08 },
+  { dx: 1, dy: 1, bit: 0x10 },
+  { dx: 1, dy: 2, bit: 0x20 },
+  { dx: 0, dy: 3, bit: 0x40 },
+  { dx: 1, dy: 3, bit: 0x80 },
+];
+
+export function renderBraille(image: ImageData, opts: BrailleRenderOptions): Grid {
+  const { cache, cols, rows } = opts;
+  const bg = opts.background ?? { r: 0, g: 0, b: 0 };
+  const grid: Grid = { width: cols, height: rows, cells: [] };
+
+  for (let cy = 0; cy < rows; cy++) {
+    const row: Cell[] = [];
+    const baseY = cy * 4;
+    for (let cx = 0; cx < cols; cx++) {
+      const baseX = cx * 2;
+
+      const pixels: RGB[] = [];
+      const brights: number[] = [];
+      for (const d of DOTS) {
+        const px = Math.min(baseX + d.dx, image.width - 1);
+        const py = Math.min(baseY + d.dy, image.height - 1);
+        const p = getPixel(image, px, py);
+        pixels.push(p);
+        brights.push(brightness(p));
+      }
+
+      // Local threshold = mean brightness of the 8 dots.
+      const threshold = brights.reduce((a, b) => a + b, 0) / brights.length;
+
+      let pattern = 0;
+      const lit: RGB[] = [];
+      for (let i = 0; i < DOTS.length; i++) {
+        if (brights[i] >= threshold) {
+          pattern |= DOTS[i].bit;
+          lit.push(pixels[i]);
+        }
+      }
+
+      const char = String.fromCharCode(0x2800 + pattern);
+      const fgSource = lit.length > 0 ? averageColor(lit) : averageColor(pixels);
+      const fg = cache.quantize(fgSource).color;
+      row.push({ char, fg, bg });
+    }
+    grid.cells.push(row);
+  }
+
+  return grid;
+}
@@ -0,0 +1,117 @@+/**
+ * Glyph renderer - the default mode.
+ *
+ * For every character cell it samples an 8x8 block of the source image, then
+ * for each candidate glyph splits those 64 sub-pixels into ink/paper groups,
+ * takes the mean color of each, quantizes to the target palette, and scores
+ * the total perceptual error. The glyph+fg+bg with the lowest error wins.
+ *
+ * This is the coverage-bitmap matching technique used by tools like chafa; it
+ * naturally uses the full varied CP437-style glyph set instead of just █▀▄.
+ */
+
+import type { RGB, Grid, Cell } from '../types.js';
+import type { ImageData } from '../core/image-loader.js';
+import { GLYPHS, CELL, type Glyph } from '../core/glyphs.js';
+import { ColorCache, colorsEqual } from '../core/color.js';
+
+export interface GlyphRenderOptions {
+  cache: ColorCache;
+  cols: number;
+  rows: number;
+  glyphs?: Glyph[];
+}
+
+const SUB = CELL * CELL; // 64
+
+export function renderGlyph(image: ImageData, opts: GlyphRenderOptions): Grid {
+  const { cache, cols, rows, glyphs = GLYPHS } = opts;
+
+  const grid: Grid = { width: cols, height: rows, cells: [] };
+
+  // Reused per-cell scratch buffers (avoid per-pixel allocations)
+  const sr = new Float64Array(SUB);
+  const sg = new Float64Array(SUB);
+  const sb = new Float64Array(SUB);
+
+  for (let cy = 0; cy < rows; cy++) {
+    const row: Cell[] = [];
+    const baseY = cy * CELL;
+
+    for (let cx = 0; cx < cols; cx++) {
+      const baseX = cx * CELL;
+
+      // Gather the cell's 8x8 sub-pixels and their total sum.
+      let tr = 0, tg = 0, tb = 0;
+      for (let y = 0; y < CELL; y++) {
+        const py = baseY + y;
+        let idx = (py * image.width + baseX) * image.channels;
+        for (let x = 0; x < CELL; x++) {
+          const i = y * CELL + x;
+          const r = image.pixels[idx];
+          const g = image.pixels[idx + 1];
+          const b = image.pixels[idx + 2];
+          sr[i] = r; sg[i] = g; sb[i] = b;
+          tr += r; tg += g; tb += b;
+          idx += image.channels;
+        }
+      }
+      const meanAll: RGB = { r: tr / SUB, g: tg / SUB, b: tb / SUB };
+
+      let bestErr = Infinity;
+      let bestChar = '█';
+      let bestFg: RGB = meanAll;
+      let bestBg: RGB = meanAll;
+
+      for (const glyph of glyphs) {
+        const inkCount = glyph.ink.length;
+
+        // Ideal ink / paper colors for this coverage mask.
+        let fg: RGB;
+        let bg: RGB;
+        if (inkCount === 0) {
+          fg = bg = meanAll;
+        } else if (inkCount === SUB) {
+          fg = bg = meanAll;
+        } else {
+          let ir = 0, ig = 0, ib = 0;
+          for (const i of glyph.ink) { ir += sr[i]; ig += sg[i]; ib += sb[i]; }
+          const pc = SUB - inkCount;
+          fg = { r: ir / inkCount, g: ig / inkCount, b: ib / inkCount };
+          bg = { r: (tr - ir) / pc, g: (tg - ig) / pc, b: (tb - ib) / pc };
+        }
+
+        const fgQ = cache.quantize(fg).color;
+        const bgQ = cache.quantize(bg).color;
+
+        // Total error of rendering this glyph with fgQ/bgQ.
+        let err = 0;
+        const mask = glyph.mask;
+        for (let i = 0; i < SUB; i++) {
+          const ref = mask[i] ? fgQ : bgQ;
+          const dr = sr[i] - ref.r, dg = sg[i] - ref.g, db = sb[i] - ref.b;
+          err += dr * dr + dg * dg + db * db;
+          if (err >= bestErr) break; // early-out
+        }
+
+        if (err < bestErr) {
+          bestErr = err;
+          bestChar = glyph.char;
+          bestFg = fgQ;
+          bestBg = bgQ;
+        }
+      }
+
+      // Collapse to a solid block when both colors ended up identical, so
+      // near-uniform cells don't render a split glyph with a needless seam.
+      if (bestChar !== '█' && bestChar !== ' ' && colorsEqual(bestFg, bestBg)) {
+        bestChar = '█';
+      }
+
+      row.push({ char: bestChar, fg: bestFg, bg: bestBg });
+    }
+    grid.cells.push(row);
+  }
+
+  return grid;
+}
@@ -0,0 +1,49 @@+/**
+ * Half-block renderer - 1 character = 2 vertical pixels using ▀ / █ / space.
+ * Fast: one cached quantization per pixel (no O(palette^2) search).
+ * Image is expected pre-resized to cols x rows*2.
+ */
+
+import type { RGB, Grid, Cell } from '../types.js';
+import { getPixel, type ImageData } from '../core/image-loader.js';
+import { ColorCache, colorsEqual } from '../core/color.js';
+
+export interface HalfBlockRenderOptions {
+  cache: ColorCache;
+  cols: number;
+  rows: number;
+}
+
+export function renderHalfBlock(image: ImageData, opts: HalfBlockRenderOptions): Grid {
+  const { cache, cols, rows } = opts;
+  const grid: Grid = { width: cols, height: rows, cells: [] };
+
+  for (let cy = 0; cy < rows; cy++) {
+    const row: Cell[] = [];
+    const topY = cy * 2;
+    const botY = topY + 1 < image.height ? topY + 1 : topY;
+
+    for (let cx = 0; cx < cols; cx++) {
+      const top = cache.quantize(getPixel(image, cx, topY)).color;
+      const bottom = cache.quantize(getPixel(image, cx, botY)).color;
+
+      let cell: Cell;
+      if (colorsEqual(top, bottom)) {
+        cell = { char: '█', fg: top, bg: top };
+      } else {
+        cell = { char: '▀', fg: top, bg: bottom };
+      }
+      row.push(cell);
+    }
+    grid.cells.push(row);
+  }
+
+  return grid;
+}
+
+/** Utility used by tests / simple callers. */
+export function halfBlockCell(top: RGB, bottom: RGB, cache: ColorCache): Cell {
+  const t = cache.quantize(top).color;
+  const b = cache.quantize(bottom).color;
+  return colorsEqual(t, b) ? { char: '█', fg: t, bg: t } : { char: '▀', fg: t, bg: b };
+}
@@ -0,0 +1,38 @@+/**
+ * Render router - dispatches to the glyph / half-block / braille renderers.
+ */
+
+import type { Grid, RenderMode } from '../types.js';
+import type { ImageData } from '../core/image-loader.js';
+import { ColorCache } from '../core/color.js';
+import { renderGlyph } from './glyph.js';
+import { renderHalfBlock } from './half-block.js';
+import { renderBraille } from './braille.js';
+import { glyphSet, type GlyphSet } from '../core/glyphs.js';
+import type { RGB } from '../types.js';
+
+export { renderGlyph } from './glyph.js';
+export { renderHalfBlock } from './half-block.js';
+export { renderBraille } from './braille.js';
+
+export interface RenderParams {
+  mode: RenderMode;
+  cache: ColorCache;
+  cols: number;
+  rows: number;
+  background?: RGB;
+  glyphs?: GlyphSet;
+}
+
+export function render(image: ImageData, params: RenderParams): Grid {
+  const { mode, cache, cols, rows, background } = params;
+  switch (mode) {
+    case 'half-block':
+      return renderHalfBlock(image, { cache, cols, rows });
+    case 'braille':
+      return renderBraille(image, { cache, cols, rows, background });
+    case 'glyph':
+    default:
+      return renderGlyph(image, { cache, cols, rows, glyphs: glyphSet(params.glyphs) });
+  }
+}
@@ -0,0 +1,130 @@+/**
+ * htmlansi - Type definitions
+ * Image -> colorful CP437 ANSI art
+ */
+
+// ============================================================================
+// Core
+// ============================================================================
+
+export interface RGB {
+  r: number;
+  g: number;
+  b: number;
+}
+
+export interface Lab {
+  l: number;
+  a: number;
+  b: number;
+}
+
+/** A single character cell: a glyph with a foreground and background color. */
+export interface Cell {
+  char: string;
+  fg: RGB;
+  bg: RGB;
+}
+
+export interface Grid {
+  width: number;   // columns
+  height: number;  // rows
+  cells: Cell[][];
+}
+
+// ============================================================================
+// Options
+// ============================================================================
+
+export type RenderMode = 'glyph' | 'half-block' | 'braille';
+export type ColorMode = 'ansi16' | 'ansi256' | 'truecolor';
+export type ColorSpace = 'rgb' | 'redmean' | 'lab';
+export type DitherMode = 'none' | 'floyd-steinberg' | 'atkinson';
+export type OutputFormat = 'html' | 'ans';
+
+export interface ConvertOptions {
+  // Grid sizing (in characters). Height is derived from width unless given.
+  width?: number;
+  height?: number;
+  /** Max columns before automatic downscale (default 200). */
+  maxWidth?: number;
+  /** Max rows before automatic downscale (default 2000). */
+  maxHeight?: number;
+  /** Disable the automatic size clamp entirely. */
+  noClamp?: boolean;
+
+  // Rendering
+  mode?: RenderMode;
+  colorMode?: ColorMode;
+  colorSpace?: ColorSpace;
+  dither?: DitherMode;
+  /** Glyph set for 'glyph' mode: 'safe' (default, alignment-safe) or 'full'. */
+  glyphs?: 'safe' | 'full';
+
+  // Image adjustments (applied by sharp before sampling)
+  brightness?: number; // multiplier, ~0.5-2.0
+  contrast?: number;   // multiplier, ~0.5-2.0
+  gamma?: number;      // 1.0-3.0 (sharp range)
+  saturation?: number; // multiplier, 0-2.0
+  invert?: boolean;
+
+  /** Background color used to flatten transparency (default black). */
+  background?: RGB;
+}
+
+export interface ConvertResult {
+  grid: Grid;
+  width: number;
+  height: number;
+  mode: RenderMode;
+  colorMode: ColorMode;
+  metadata: {
+    originalWidth: number;
+    originalHeight: number;
+    clamped: boolean;
+    processingTime: number;
+  };
+}
+
+// ============================================================================
+// Export options
+// ============================================================================
+
+export interface HTMLExportOptions {
+  title?: string;
+  fontFamily?: string;
+  fontSize?: number;
+  lineHeight?: number;
+  backgroundColor?: string;
+}
+
+export interface ANSExportOptions {
+  colorMode?: ColorMode;
+  sauce?: boolean;
+  title?: string;
+  author?: string;
+  group?: string;
+}
+
+// ============================================================================
+// ANSI 16-color reference palette (VGA)
+// ============================================================================
+
+export const ANSI_16_COLORS: RGB[] = [
+  { r: 0, g: 0, b: 0 },       // 0 Black
+  { r: 170, g: 0, b: 0 },     // 1 Red
+  { r: 0, g: 170, b: 0 },     // 2 Green
+  { r: 170, g: 85, b: 0 },    // 3 Brown/Yellow
+  { r: 0, g: 0, b: 170 },     // 4 Blue
+  { r: 170, g: 0, b: 170 },   // 5 Magenta
+  { r: 0, g: 170, b: 170 },   // 6 Cyan
+  { r: 170, g: 170, b: 170 }, // 7 Light Gray
+  { r: 85, g: 85, b: 85 },    // 8 Dark Gray
+  { r: 255, g: 85, b: 85 },   // 9 Bright Red
+  { r: 85, g: 255, b: 85 },   // 10 Bright Green
+  { r: 255, g: 255, b: 85 },  // 11 Bright Yellow
+  { r: 85, g: 85, b: 255 },   // 12 Bright Blue
+  { r: 255, g: 85, b: 255 },  // 13 Bright Magenta
+  { r: 85, g: 255, b: 255 },  // 14 Bright Cyan
+  { r: 255, g: 255, b: 255 }, // 15 White
+];
@@ -0,0 +1,58 @@+import { describe, it, expect } from 'vitest';
+import { rgbToLab, labToRgb, ColorCache, ANSI_256_COLORS } from '../src/core/color.js';
+import { computeGrid } from '../src/core/image-loader.js';
+import type { RGB } from '../src/types.js';
+
+describe('Lab conversion', () => {
+  it('round-trips RGB -> Lab -> RGB with small error', () => {
+    const samples: RGB[] = [
+      { r: 255, g: 0, b: 0 },
+      { r: 0, g: 128, b: 64 },
+      { r: 30, g: 60, b: 200 },
+      { r: 200, g: 200, b: 200 },
+      { r: 12, g: 240, b: 130 },
+    ];
+    for (const c of samples) {
+      const back = labToRgb(rgbToLab(c));
+      expect(Math.abs(back.r - c.r)).toBeLessThanOrEqual(3);
+      expect(Math.abs(back.g - c.g)).toBeLessThanOrEqual(3);
+      expect(Math.abs(back.b - c.b)).toBeLessThanOrEqual(3);
+    }
+  });
+});
+
+describe('ColorCache', () => {
+  it('quantizes to a palette entry and caches', () => {
+    const cache = new ColorCache('ansi256');
+    const q = cache.quantize({ r: 254, g: 254, b: 254 });
+    expect(ANSI_256_COLORS).toContainEqual(q.color);
+    // same input returns identical (cached) object
+    expect(cache.quantize({ r: 254, g: 254, b: 254 })).toBe(q);
+  });
+
+  it('truecolor passes colors through unchanged', () => {
+    const cache = new ColorCache('truecolor');
+    const c = { r: 13, g: 77, b: 199 };
+    expect(cache.quantize(c).color).toEqual(c);
+  });
+});
+
+describe('computeGrid (aspect + clamp)', () => {
+  it('derives rows from width using the font aspect (~0.55)', () => {
+    const { cols, rows } = computeGrid(100, 100, { width: 80 });
+    expect(cols).toBe(80);
+    expect(rows).toBe(44); // square image -> 80 * 0.55
+  });
+
+  it('clamps runaway widths and flags it', () => {
+    const g = computeGrid(4000, 3000, { width: 5000 });
+    expect(g.cols).toBeLessThanOrEqual(200);
+    expect(g.clamped).toBe(true);
+  });
+
+  it('respects --no-clamp', () => {
+    const g = computeGrid(4000, 3000, { width: 5000, noClamp: true });
+    expect(g.cols).toBe(5000);
+    expect(g.clamped).toBe(false);
+  });
+});
@@ -0,0 +1,45 @@+import { describe, it, expect } from 'vitest';
+import sharp from 'sharp';
+import { convert } from '../src/index.js';
+
+/** Generate a colorful test image (gradient with vertical detail) as a PNG buffer. */
+async function testImage(w = 120, h = 120): Promise<Buffer> {
+  const pixels = Buffer.alloc(w * h * 3);
+  for (let y = 0; y < h; y++) {
+    for (let x = 0; x < w; x++) {
+      const i = (y * w + x) * 3;
+      pixels[i] = Math.round((x / w) * 255);
+      pixels[i + 1] = Math.round((y / h) * 255);
+      pixels[i + 2] = (x + y) % 32 < 16 ? 200 : 40;
+    }
+  }
+  return sharp(pixels, { raw: { width: w, height: h, channels: 3 } }).png().toBuffer();
+}
+
+describe('end-to-end convert', () => {
+  it('glyph mode: aspect-correct grid and varied glyphs', async () => {
+    const img = await testImage();
+    const res = await convert(img, { mode: 'glyph', width: 80, colorMode: 'ansi256' });
+    expect(res.width).toBe(80);
+    expect(res.height).toBe(44); // 120x120 square -> 80 * 0.55
+
+    const chars = new Set<string>();
+    for (const row of res.grid.cells) for (const c of row) chars.add(c.char);
+    expect(chars.size).toBeGreaterThan(3); // varied beyond the old █▀▄ baseline
+  });
+
+  it('half-block mode works and only uses block glyphs', async () => {
+    const img = await testImage();
+    const res = await convert(img, { mode: 'half-block', width: 60, colorMode: 'truecolor' });
+    for (const row of res.grid.cells) {
+      for (const c of row) expect(' █▀'.includes(c.char)).toBe(true);
+    }
+  });
+
+  it('clamps oversized requests', async () => {
+    const img = await testImage(400, 400);
+    const res = await convert(img, { width: 9999 });
+    expect(res.width).toBeLessThanOrEqual(200);
+    expect(res.metadata.clamped).toBe(true);
+  });
+});
@@ -0,0 +1,49 @@+import { describe, it, expect } from 'vitest';
+import { exportHTML } from '../src/export/html.js';
+import { exportANS } from '../src/export/ans.js';
+import type { Grid, Cell } from '../src/types.js';
+
+function grid(cells: Cell[][]): Grid {
+  return { width: cells[0].length, height: cells.length, cells };
+}
+
+const W = { r: 255, g: 255, b: 255 };
+const K = { r: 0, g: 0, b: 0 };
+
+describe('HTML export', () => {
+  it('produces a self-contained document within a bounded size', () => {
+    const rows: Cell[][] = [];
+    for (let y = 0; y < 40; y++) {
+      const row: Cell[] = [];
+      for (let x = 0; x < 80; x++) row.push({ char: '█', fg: W, bg: K });
+      rows.push(row);
+    }
+    const html = exportHTML(grid(rows), { title: 'x' });
+    expect(html.startsWith('<!DOCTYPE html>')).toBe(true);
+    expect(html).toContain('<pre');
+    // 80x40 solid grid coalesces heavily -> should be tiny
+    expect(html.length).toBeLessThan(20_000);
+  });
+});
+
+describe('ANS export', () => {
+  it('emits escape codes and a 128-byte SAUCE record with filesize', () => {
+    const g = grid([[{ char: '▀', fg: W, bg: K }, { char: '█', fg: W, bg: W }]]);
+    const buf = exportANS(g, { colorMode: 'truecolor', sauce: true, title: 't', author: 'a' });
+
+    // SAUCE is the last 128 bytes, preceded by the 0x1A EOF marker.
+    const sauce = buf.subarray(buf.length - 128);
+    expect(sauce.subarray(0, 5).toString('ascii')).toBe('SAUCE');
+    expect(buf[buf.length - 128 - 1]).toBe(0x1a);
+
+    const declaredSize = sauce.readUInt32LE(90);
+    const contentSize = buf.length - 128 - 1;
+    expect(declaredSize).toBe(contentSize);
+
+    // width/lines recorded
+    expect(sauce.readUInt16LE(96)).toBe(2);
+    expect(sauce.readUInt16LE(98)).toBe(1);
+    // contains a truecolor escape
+    expect(buf.toString('utf-8')).toContain('\x1b[38;2;255;255;255');
+  });
+});
@@ -0,0 +1,76 @@+import { describe, it, expect } from 'vitest';
+import { renderGlyph } from '../src/render/glyph.js';
+import { ColorCache } from '../src/core/color.js';
+import { CELL, SAFE_GLYPHS, FULL_GLYPHS } from '../src/core/glyphs.js';
+import type { ImageData } from '../src/core/image-loader.js';
+import type { RGB } from '../src/types.js';
+
+/** Build a 1-cell (8x8) image from a function giving each subpixel's color. */
+function cell(fn: (x: number, y: number) => RGB): ImageData {
+  const pixels = new Uint8Array(CELL * CELL * 3);
+  for (let y = 0; y < CELL; y++) {
+    for (let x = 0; x < CELL; x++) {
+      const c = fn(x, y);
+      const i = (y * CELL + x) * 3;
+      pixels[i] = c.r; pixels[i + 1] = c.g; pixels[i + 2] = c.b;
+    }
+  }
+  return { width: CELL, height: CELL, channels: 3, pixels };
+}
+
+describe('glyph matcher', () => {
+  it('solid cell -> full block with matching colors', () => {
+    const img = cell(() => ({ r: 200, g: 50, b: 50 }));
+    const grid = renderGlyph(img, { cache: new ColorCache('truecolor'), cols: 1, rows: 1 });
+    const c = grid.cells[0][0];
+    expect(c.char).toBe('█');
+    expect(c.fg).toEqual({ r: 200, g: 50, b: 50 });
+  });
+
+  it('top/bottom split -> upper-half block with the two colors', () => {
+    const top: RGB = { r: 255, g: 255, b: 255 };
+    const bot: RGB = { r: 0, g: 0, b: 0 };
+    const img = cell((_x, y) => (y < 4 ? top : bot));
+    const grid = renderGlyph(img, { cache: new ColorCache('truecolor'), cols: 1, rows: 1 });
+    const c = grid.cells[0][0];
+    expect(c.char).toBe('▀');
+    expect(c.fg).toEqual(top);
+    expect(c.bg).toEqual(bot);
+  });
+
+  it('left/right split -> a left/right half block', () => {
+    const img = cell((x) => (x < 4 ? { r: 255, g: 255, b: 255 } : { r: 0, g: 0, b: 0 }));
+    const grid = renderGlyph(img, { cache: new ColorCache('truecolor'), cols: 1, rows: 1 });
+    expect(['▌', '▐']).toContain(grid.cells[0][0].char);
+  });
+
+  it('uses a varied character set (beyond █▀▄), and the full set is richer', () => {
+    // Diagonal wave pattern so each cell has different internal structure.
+    const cols = 24;
+    const rows = 12;
+    const w = cols * CELL;
+    const h = rows * CELL;
+    const pixels = new Uint8Array(w * h * 3);
+    for (let y = 0; y < h; y++) {
+      for (let x = 0; x < w; x++) {
+        const v = 127 + 80 * Math.sin((x + y) * 0.15) + 60 * Math.sin((x - y) * 0.09);
+        const vv = Math.min(255, Math.max(0, Math.round(v)));
+        const i = (y * w + x) * 3;
+        pixels[i] = vv; pixels[i + 1] = vv; pixels[i + 2] = vv;
+      }
+    }
+    const img: ImageData = { width: w, height: h, channels: 3, pixels };
+
+    const countDistinct = (glyphs?: typeof SAFE_GLYPHS) => {
+      const grid = renderGlyph(img, { cache: new ColorCache('ansi256'), cols, rows, glyphs });
+      const s = new Set<string>();
+      for (const row of grid.cells) for (const c of row) s.add(c.char);
+      return s.size;
+    };
+
+    // Default (alignment-safe) set: more than the old █▀▄+space baseline.
+    expect(countDistinct(SAFE_GLYPHS)).toBeGreaterThan(3);
+    // Full set explores many more shapes.
+    expect(countDistinct(FULL_GLYPHS)).toBeGreaterThan(8);
+  });
+});
@@ -0,0 +1,20 @@+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext",
+    "lib": ["ES2022"],
+    "outDir": "dist",
+    "rootDir": "src",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "declaration": true,
+    "declarationMap": true,
+    "sourceMap": true,
+    "resolveJsonModule": true
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist", "test"]
+}
@@ -0,0 +1,8 @@+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    include: ['test/**/*.test.ts'],
+    globals: true,
+  },
+});