Initial commit
- .gitignore
- README.md
- docs/algorithms.md
- docs/guide.md
- package-lock.json
- package.json
- src/cli.ts
- src/core/color.ts
- src/core/cp437.ts
- src/core/glyphs.ts
- src/core/image-loader.ts
- src/dither/index.ts
- src/export/ans.ts
- src/export/html.ts
- src/index.ts
- src/render/braille.ts
- src/render/glyph.ts
- src/render/half-block.ts
- src/render/index.ts
- src/types.ts
- test/core.test.ts
- test/e2e.test.ts
- test/export.test.ts
- test/glyph.test.ts
- tsconfig.json
- vitest.config.ts
@@ -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, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +}
@@ -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, + }, +});