first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
# `image`
Image compression, colour conversion, EXIF handling, and optimisation.
<!-- begin:auto-docs -->
## Packages in `image`
15 packages.
| Package | Description |
|---|---|
| [`@suaveplan/caddy-imgproxy`](./caddy-imgproxy/README.md) | Universal TypeScript port of the Caddy imgproxy URL rewriter. Parses the compact `/u<op><gravity><dims><modifiers>/<path>.<format>` grammar, resolves aspect-ra… |
| [`@suaveplan/colour-convert-adapt`](./colour-convert-adapt/README.md) | Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's appearance so that it looks the same under different light so… |
| [`@suaveplan/colour-convert-cieluv`](./colour-convert-cieluv/README.md) | CIELUV, xyY, LMS, and Bradford chromatic adaptation conversions. This plugin provides colour-space transforms between RGB, CIELUV, xyY, and LMS cone-response s… |
| [`@suaveplan/colour-convert-cmyk`](./colour-convert-cmyk/README.md) | Bidirectional conversion between RGB and CMYK colour spaces using the standard Euclidean algorithm with black generation. CMYK is the subtractive model used in… |
| [`@suaveplan/colour-convert-hsluv`](./colour-convert-hsluv/README.md) | HSLuv perceptually-uniform colour space with gamut-mapped ray intersection for sRGB. This plugin provides bidirectional RGB↔HSLuv/HPLuv conversions and uniform… |
| [`@suaveplan/colour-filter-cct`](./colour-filter-cct/README.md) | Correlated Colour Temperature (CCT) conversions using the McCamy formula and black-body RGB approximation. Convert any RGB colour to its approximate colour tem… |
| [`@suaveplan/colour-filter-cmc`](./colour-filter-cmc/README.md) | CMC l:c Delta-E colour difference metric with hue-dependent chroma scaling and low-luminance branch handling. This plugin computes perceptual distance between… |
| [`@suaveplan/colour-filter-greyscale`](./colour-filter-greyscale/README.md) | Converts any RGB colour to its perceptual greyscale equivalent using the ITU-R BT.709 luminance coefficients. Unlike simple channel averaging, this formula wei… |
| [`@suaveplan/colour-filter-sepia`](./colour-filter-sepia/README.md) | Applies a photographic sepia tone filter to RGB colours using the standard W3C CSS Filters 3×3 matrix. This plugin converts any colour into a warm brownish ton… |
| [`@suaveplan/colours`](./colours/README.md) | Universal colour manipulation library: parsing, conversion, analysis, mixing, blending, and palette generation. Parses hex (3/4/6/8 digit), CSS `rgb()`, `hsl()… |
| [`@suaveplan/compress-image`](./compress-image/README.md) | Runtime-agnostic image compression utilities with pluggable adapters for Node.js (Sharp) and testing (MemoryAdapter). Supports resize, quality reduction, forma… |
| [`@suaveplan/emblem-solver`](./emblem-solver/README.md) | Minimal-rectangle SVG converter for pixelated sprite emblems. |
| [`@suaveplan/exif`](./exif/README.md) | Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images. Parses JPEG segment chains to locate and decode… |
| [`@suaveplan/image`](./image/README.md) | Universal image metadata extraction, format detection, resize math, crop validation, and transform pipeline planning. Zero browser or Node dependencies — runs… |
| [`@suaveplan/image-quantisation`](./image-quantisation/README.md) | Image complexity analysis and adaptive colour quantisation with pool detection, spatial-aware anti-aliasing cleanup, and multiple quantisation algorithms. Desi… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Image compression, colour conversion, EXIF handling, and optimisation.

View File

@@ -0,0 +1,101 @@
# @suaveplan/caddy-imgproxy - Universal TypeScript port of the Caddy imgproxy URL rewriter — compact-grammar parser, aspect / pixel dimension resolver, gravity + operation translator, HMAC-SHA256 URL signer, full handler with Zod-validated config and OTel telemetry
Universal TypeScript port of the Caddy imgproxy URL rewriter.
Parses the compact `/u<op><gravity><dims><modifiers>/<path>.<format>`
grammar, resolves aspect-ratio + hint dimensions, translates gravity +
operations into imgproxy's URL DSL, signs the result with
HMAC-SHA256, and exposes a high-level `ImgproxyHandler` orchestrator
with injectable `Clock`, `LoggerLike`, and signing hash, plus
Zod-validated configuration and OTel telemetry.
<!-- begin:auto-docs -->
## Documentation tree
- [config](./src/config/config.md) — The `config` module exposes the Zod schema and the validating `parseConfig` function for the package's public `Imgproxy…
- [config](./src/config/config.md) — The `config` module exposes the Zod schema and the validating `parseConfig` function for the package's public `Imgproxy…
- [dimensions](./src/dimensions/calculate-dimensions.md) — `calculateDimensions` resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth` into a concrete `{ width, heigh…
- [calculate-dimensions](./src/dimensions/calculate-dimensions.md) — `calculateDimensions` resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth` into a concrete `{ width, heigh…
- [domain](./src/domain/extract-domain.md) — `extractDomain` translates an incoming HTTP `Host` header into the S3 sub-path segment that the URL builder interpolate…
- [extract-domain](./src/domain/extract-domain.md) — `extractDomain` translates an incoming HTTP `Host` header into the S3 sub-path segment that the URL builder interpolate…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the canonical home of the package's stable error-code registry and the typed `CaddyImgproxy…
- [error-codes](./src/error-codes/error-codes.md) — The `error-codes` module is the canonical home of the package's stable error-code registry and the typed `CaddyImgproxy…
- [gravity](./src/gravity/gravity.md) — The `gravity` module exposes the bidirectional translation between the compact one- or two-character grammar codes (`t`…
- [gravity](./src/gravity/gravity.md) — The `gravity` module exposes the bidirectional translation between the compact one- or two-character grammar codes (`t`…
- [handler](./src/handler/handler.md) — `ImgproxyHandler` is the high-level orchestrator that wraps `parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl`…
- [handler](./src/handler/handler.md) — `ImgproxyHandler` is the high-level orchestrator that wraps `parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl`…
- [operation](./src/operation/operation.md) — `operationToImgproxy` converts a resolved operation + concrete pixel dimensions + gravity into the imgproxy processing-…
- [operation](./src/operation/operation.md) — `operationToImgproxy` converts a resolved operation + concrete pixel dimensions + gravity into the imgproxy processing-…
- [parse](./src/parse/parse-url.md) — `parseImgproxyUrl` parses the compact imgproxy URL grammar used by the Caddy module — `/[s|u]<op><gravity><dims><modifi…
- [parse-url](./src/parse/parse-url.md) — `parseImgproxyUrl` parses the compact imgproxy URL grammar used by the Caddy module — `/[s|u]<op><gravity><dims><modifi…
- [signing](./src/signing/sign-path.md) — `signImgproxyPath` computes the HMAC-SHA256 URL signature that imgproxy requires when it is configured with a key + sal…
- [sign-path](./src/signing/sign-path.md) — `signImgproxyPath` computes the HMAC-SHA256 URL signature that imgproxy requires when it is configured with a key + sal…
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module owns the OTel instruments emitted by every public entry point in `@suaveplan/caddy-imgproxy`. It…
- [telemetry](./src/telemetry/telemetry.md) — The `telemetry` module owns the OTel instruments emitted by every public entry point in `@suaveplan/caddy-imgproxy`. It…
- [types](./src/types/types.md) — The `types` module declares every cross-feature type that flows between the package's runtime modules: `ParsedImgproxyU…
- [types](./src/types/types.md) — The `types` module declares every cross-feature type that flows between the package's runtime modules: `ParsedImgproxyU…
- [url-builder](./src/url-builder/url-builder.md) — `buildImgproxyUrl` composes the final imgproxy URL path from a parsed grammar representation. It is the orchestrator th…
- [url-builder](./src/url-builder/url-builder.md) — `buildImgproxyUrl` composes the final imgproxy URL path from a parsed grammar representation. It is the orchestrator th…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/caddy-imgproxy
```
## Quick start
```ts
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
const handler = new ImgproxyHandler({
config: parseConfig({
upstream: "imgproxy:8080",
s3Bucket: "media",
defaultWidth: 1200,
key: "aa11bbcc",
salt: "deadbeef",
}),
});
const result = handler.rewrite({
path: "/uvt9:16h1080/photos/hero.webp",
host: "images.example.com",
});
if (result.rewritten) {
forwardToImgproxy(result.targetPath);
} else if (result.reason === "passthrough") {
forwardToImgproxy(originalPath);
}
```
## Modules
- [parse](./src/parse/parse-url.md) — compact-grammar parser.
- [dimensions](./src/dimensions/calculate-dimensions.md) — aspect /
hint → pixel resolution.
- [gravity](./src/gravity/gravity.md) — grammar gravity → imgproxy
compass tokens.
- [operation](./src/operation/operation.md) — operation +
dimensions → imgproxy processing string.
- [signing](./src/signing/sign-path.md) — HMAC-SHA256 URL signing.
- [url-builder](./src/url-builder/url-builder.md) — composes the
final imgproxy path.
- [domain](./src/domain/extract-domain.md) — Host header → S3
sub-path.
- [handler](./src/handler/handler.md) — `ImgproxyHandler`
orchestrator.
- [config](./src/config/config.md) — Zod-validated `ImgproxyConfig`.
- [error-codes](./src/error-codes/error-codes.md) —
`CADDY_IMGPROXY_CODES` + `CaddyImgproxyError`.
- [telemetry](./src/telemetry/telemetry.md) — OTel counters +
histogram.
## Tier & dependencies
Universal — no DOM or Node-only APIs at the package boundary.
`sideEffects: false`. Internal deps: `@suaveplan/crypto-hash`,
`@suaveplan/error`, `@suaveplan/types`. External deps:
`@opentelemetry/api`, `tslib`, `zod`.

View File

@@ -0,0 +1,86 @@
# config
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `config` module exposes the Zod schema and the validating
`parseConfig` function for the package's public `ImgproxyConfig`. It
is the single boundary where a raw object — typically loaded from
environment variables, JSON, or a Caddyfile shim — is converted into
the immutable, defaulted `ImgproxyConfig` that the URL builder and
the handler consume.
`parseConfig` raises stable, typed `CaddyImgproxyError` instances on
every failure path: `INVALID_OPTIONS` for general schema failures,
`INVALID_KEY` for a malformed `key`, and `INVALID_SALT` for a
malformed `salt`. The schema applies the same defaults the upstream
Go module ships with (`localhost:8080`, `images`, `800`) so a bare
`parseConfig({})` returns a usable production configuration.
## Features
- Zod schema is exported so callers can compose it into larger
configs.
- Defaults match the Go reference exactly.
- Hex validation built into the schema — even-length, lower-case
only.
- Distinct error codes for key vs salt failures so consumers can
surface targeted remediation in their UI.
- The returned `ImgproxyConfig` is frozen for safe sharing.
## Usage: Basic
```ts
import { parseConfig } from "@suaveplan/caddy-imgproxy";
const cfg = parseConfig({});
cfg.upstream; // "localhost:8080"
cfg.s3Bucket; // "images"
cfg.defaultWidth; // 800
```
## Usage: Advanced
```ts
import { parseConfig, ImgproxyConfigSchema } from "@suaveplan/caddy-imgproxy";
import z from "zod";
// Compose into a larger application schema:
const AppEnvSchema = z.object({
NODE_ENV: z.enum(["development", "production"]),
IMGPROXY: ImgproxyConfigSchema,
});
const env = AppEnvSchema.parse(process.env);
const cfg = parseConfig(env.IMGPROXY);
```
## API Reference
### `ImgproxyConfigSchema`
A Zod object schema with the following fields:
- `upstream: string` — default `"localhost:8080"`, non-empty.
- `s3Bucket: string` — default `"images"`, non-empty.
- `defaultWidth: number` — positive integer, default `800`.
- `key?: string` — optional even-length lower-hex string.
- `salt?: string` — optional even-length lower-hex string.
### `parseConfig(raw: unknown): ImgproxyConfig`
- **Returns:** a frozen `ImgproxyConfig`.
- **Throws:** `CaddyImgproxyError` with one of `INVALID_OPTIONS`,
`INVALID_KEY`, `INVALID_SALT`.
## Implementation Notes
`safeParse` is used instead of `parse` so the function can branch on
the first issue's `path` to choose the appropriate error code. The
schema lifts the hex check into a `refine` so the Zod issue path is
exactly `["key"]` or `["salt"]` when the hex format fails — that
makes the `parseConfig` dispatch one-liner reliable. The returned
config is frozen so downstream code cannot mutate it accidentally.

View File

@@ -0,0 +1,77 @@
# calculateDimensions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`calculateDimensions` resolves a `ParsedImgproxyUrl` plus the
configured `defaultWidth` into a concrete `{ width, height }` pair in
integer pixels. It is the single source of truth for how the compact
grammar's aspect-ratio + hint syntax becomes pixel values; every
processing string emitted by `operationToImgproxy` is derived from
this result.
Semantics mirror the upstream Go `calculateDimensions` byte-for-byte:
no dimensions → `{0, 0}` (passthrough); pixel dimensions return
verbatim; aspect-ratio with an `h` hint drives height first; otherwise
width drives (from the `w` hint or the configured default). All
divisions truncate toward zero via `Math.trunc` so the TypeScript and
Go implementations agree on every fixture.
## Features
- Total function — never throws, always returns concrete numbers.
- Honors the same precedence as the Go reference: hint axis wins over
default width.
- Integer truncation matches Go integer division exactly.
- Zero runtime cost beyond two multiplications and one divide on the
aspect path.
## Usage: Basic
```ts
import { calculateDimensions, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const dims = calculateDimensions(parsed, 800);
dims; // { width: 800, height: 450 }
```
## Usage: Advanced
```ts
import { calculateDimensions, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
// Height hint with portrait aspect ratio
const portrait = parseImgproxyUrl("/uv9:16h1080/photo.jpg");
calculateDimensions(portrait, 800);
// → { width: 607, height: 1080 }
// Pixel passthrough — the default-width parameter is ignored
const pixel = parseImgproxyUrl("/uf800x600/photo.jpg");
calculateDimensions(pixel, 9999);
// → { width: 800, height: 600 }
```
## API Reference
### `calculateDimensions(parsed: ParsedImgproxyUrl, defaultWidth: number): ImgproxyDimensions`
- **Parameters:**
- `parsed` — output of `parseImgproxyUrl`.
- `defaultWidth` — width used when the URL is in aspect-ratio mode
without a `w`/`h` hint.
- **Returns:** `{ width, height }`, both non-negative integers. Either
axis may be `0` (meaning "auto / unspecified").
## Implementation Notes
The function is intentionally a pure expression — it never touches
the clock, the meter, or the logger so it composes cleanly under
property-based tests. The default-width branch is reached when the
parsed URL specifies an aspect ratio but neither a `w` nor an `h`
hint, or when `hintType === "h"` but `hintValue === 0` (the
zero-value clause is unreachable from the public regex but defended
for callers who construct `ParsedImgproxyUrl` values directly).

View File

@@ -0,0 +1,67 @@
# extractDomain
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`extractDomain` translates an incoming HTTP `Host` header into the
S3 sub-path segment that the URL builder interpolates into the
`s3://<bucket>/<domain>/<path>@<format>` template. It is the single
place where the multi-tenant routing convention "the `images.` host
prefix maps to the bare apex domain" is enforced.
The function is total — it never throws — so callers may apply it
to any string. Hosts shorter than two labels (such as `localhost`)
are returned unchanged because the heuristic only makes sense for
fully-qualified domain names. Port suffixes are removed before
splitting so `images.example.com:8080` and `images.example.com` map
to the same value.
## Features
- Total function over arbitrary input strings.
- Port-aware: strips `:<port>` before label splitting.
- Single-label hosts (`localhost`) pass through unchanged.
- Mirrors the upstream Go heuristic exactly.
## Usage: Basic
```ts
import { extractDomain } from "@suaveplan/caddy-imgproxy";
extractDomain("images.example.com:8080"); // "example.com"
extractDomain("cdn.example.com"); // "cdn.example.com"
extractDomain("localhost"); // "localhost"
```
## Usage: Advanced
```ts
import { extractDomain } from "@suaveplan/caddy-imgproxy";
// Apply per-request to build a tenant key:
function tenantKey(host: string): string {
return extractDomain(host).replace(/\./g, "-");
}
tenantKey("images.acme.example.com"); // "acme-example-com"
```
## API Reference
### `extractDomain(host: string): string`
- **Parameters:** `host` — the raw `Host` header value (with or
without a port suffix).
- **Returns:** the apex domain when the prefix is `images.`,
otherwise the host unchanged (minus any port suffix).
## Implementation Notes
Implemented as a two-step string scan: `indexOf(":")` to locate the
optional port boundary, then `String.prototype.split(".")` to break
the host into labels. The label check requires at least two segments
because dropping the first label of a one-label host would yield the
empty string. The function does not validate the host against any
DNS spec — it accepts whatever the caller passes through.

View File

@@ -0,0 +1,101 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `error-codes` module is the canonical home of the package's
stable error-code registry and the typed `CaddyImgproxyError` class
that carries those codes. Every failure path in the package raises a
`CaddyImgproxyError` whose `code` is drawn from this registry;
consumers discriminate on `error.code` rather than `instanceof` so
the namespace survives module boundaries and runtime serialisation.
Codes are built with `@suaveplan/error`'s `defineCodes` helper, which
emits the canonical `NAMESPACE.REASON` form (e.g.
`"CADDY_IMGPROXY.INVALID_URL"`). The `CaddyImgproxyError` class
extends `SuaveplanError<CaddyImgproxyCode>` so `code` narrows
against the literal union at compile time.
## Features
- Built via `defineCodes` so the namespace + reason form is uniform.
- `CaddyImgproxyError` carries `code`, `message`, `cause`, `context`,
`timestamp`, and the standard `SuaveplanError` envelope.
- Discriminating union literal (`CaddyImgproxyCode`) for exhaustive
`switch` checks.
- Every code in the registry is reachable from at least one call
site exercised by the tests.
## Usage: Basic
```ts
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "@suaveplan/caddy-imgproxy";
try {
// … call into the package
} catch (err) {
if (
err instanceof CaddyImgproxyError &&
err.code === CADDY_IMGPROXY_CODES.INVALID_URL
) {
// handle parse failure
}
}
```
## Usage: Advanced
```ts
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "@suaveplan/caddy-imgproxy";
function classify(err: unknown): "parse" | "config" | "host" | "other" {
if (!(err instanceof CaddyImgproxyError)) return "other";
switch (err.code) {
case CADDY_IMGPROXY_CODES.INVALID_URL:
case CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS:
return "parse";
case CADDY_IMGPROXY_CODES.INVALID_OPTIONS:
case CADDY_IMGPROXY_CODES.INVALID_KEY:
case CADDY_IMGPROXY_CODES.INVALID_SALT:
return "config";
case CADDY_IMGPROXY_CODES.INVALID_HOST:
return "host";
}
}
```
## API Reference
### `CADDY_IMGPROXY_CODES`
A frozen object mapping reason names to their `CADDY_IMGPROXY.REASON`
code strings. Reasons: `INVALID_URL`, `MISSING_DIMENSIONS`,
`INVALID_OPTIONS`, `INVALID_KEY`, `INVALID_SALT`, `INVALID_HOST`.
### `class CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode>`
Public constructor: `(code, message, options?)`. Options carry
`cause` and `context`.
### `type CaddyImgproxyCode`
Discriminating union literal extracted from the registry values.
## Implementation Notes
`defineCodes` returns a frozen object indexed by reason name, with
each value typed as `${Namespace}.${Reason}` for compile-time literal
narrowing. The class deliberately exposes a public constructor —
`SuaveplanError`'s own constructor is `protected` so subclasses must
provide a concrete entry point. The `name = "CaddyImgproxyError"`
assignment matters for stack-trace clarity; without it `new.target`
would still set it correctly but explicit is clearer.

View File

@@ -0,0 +1,74 @@
# gravity
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `gravity` module exposes the bidirectional translation between the
compact one- or two-character grammar codes (`t`, `tl`, `bl`, …) and
the two-/four-letter compass tokens that imgproxy itself consumes
(`no`, `nowe`, `sowe`, …). Every operation that takes a gravity
parameter — `cover`, `crop` — routes through this module so the
mapping lives in exactly one place.
The map is exported as a frozen `Readonly<Record<GravityCode, string>>`
so consumers may inspect or build their own translation tables on top
of it. The `gravityToImgproxy` helper handles unknown / empty input by
falling back to `"ce"` (center), matching the Go default branch
exactly.
## Features
- Total function — never throws.
- Single source of truth for the gravity grammar.
- Frozen map for safe sharing.
- Center default for forward-compatibility with grammar extensions.
## Usage: Basic
```ts
import { gravityToImgproxy } from "@suaveplan/caddy-imgproxy";
gravityToImgproxy("t"); // "no"
gravityToImgproxy("br"); // "soea"
gravityToImgproxy(""); // "ce" (default)
```
## Usage: Advanced
```ts
import { GRAVITY_MAP } from "@suaveplan/caddy-imgproxy";
// Build a reverse lookup for parsing imgproxy URLs back into the
// compact grammar.
const reverse = Object.fromEntries(
Object.entries(GRAVITY_MAP).map(([k, v]) => [v, k] as const)
);
reverse["sowe"]; // "bl"
```
## API Reference
### `gravityToImgproxy(code: string): string`
- **Parameters:** `code` — one of `t|b|l|r|c|tl|tr|bl|br`, or any
unknown value.
- **Returns:** the imgproxy compass token, or `"ce"` when input is
unknown or empty.
### `GRAVITY_MAP: Readonly<Record<GravityCode, string>>`
The frozen lookup table.
## Implementation Notes
The function uses an indexed object lookup rather than a `switch`
statement because the map is exposed as a frozen value; the default
branch handles both the empty-string and the unknown-code cases in a
single line. Coverage for the unknown branch is exercised by the test
`"defaults unknown codes to ce"`. The cast to `Record<string, string>`
inside the function is necessary because the formal type uses the
`GravityCode` union; at runtime any string may arrive (e.g. from a
caller-constructed `ParsedImgproxyUrl`).

View File

@@ -0,0 +1,121 @@
# ImgproxyHandler
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ImgproxyHandler` is the high-level orchestrator that wraps
`parseImgproxyUrl`, `extractDomain`, and `buildImgproxyUrl` into a
single `rewrite(request)` call suitable for use inside a CDN worker,
an edge reverse proxy, or a build-time tooling pass. It owns the
telemetry envelope around every rewrite, the structured logging on
parse failures, and the discriminated `RewriteResult` that lets
callers decide whether to forward to imgproxy, fall back, or return
an error to the client.
The handler exposes three observable outcomes: `rewritten` (the path
matched the grammar and a target imgproxy path was produced),
`passthrough` (the path is `/unsafe/...` or `/health*`, meaning the
caller should forward to imgproxy unchanged), and `invalid` (the
path is not an imgproxy URL and the caller should fall back to the
next handler in the chain).
## Features
- Injectable `Clock` for deterministic duration histograms.
- Injectable `LoggerLike` for structured parse-failure warnings.
- Injectable `SignerHashLike` for native / HSM / WebCrypto signing.
- Discriminated `RewriteResult` — no exceptions on the happy path.
- Telemetry: counter (`caddy_imgproxy_rewrite_total{outcome,
operation}`) + histogram (`caddy_imgproxy_rewrite_duration_ms`).
## Usage: Basic
```ts
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
const handler = new ImgproxyHandler({
config: parseConfig({
defaultWidth: 800,
key: "aa11bbcc",
salt: "deadbeef",
}),
});
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "images.example.com",
});
if (result.rewritten) {
forwardToImgproxy(result.targetPath);
} else if (result.reason === "passthrough") {
forwardToImgproxy(originalPath);
} else {
fallbackToNextHandler();
}
```
## Usage: Advanced
```ts
import type { Clock, LoggerLike } from "@suaveplan/types";
import { ImgproxyHandler, parseConfig } from "@suaveplan/caddy-imgproxy";
const tick = createMonotonicTick();
const clock: Clock = () => tick.nowMs();
const logger: LoggerLike = {
debug: (m, c) => log("debug", m, c),
info: (m, c) => log("info", m, c),
warn: (m, c) => log("warn", m, c),
error: (m, c) => log("error", m, c),
};
const handler = new ImgproxyHandler({
config: parseConfig({}),
clock,
logger,
});
const result = handler.rewrite({
path: "/uvtr9:16h1080/hero.webp",
host: "images.example.com",
});
```
## API Reference
### `class ImgproxyHandler`
#### `new ImgproxyHandler(options: ImgproxyHandlerOptions)`
- `options.config` — validated `ImgproxyConfig`.
- `options.clock` — optional wall-clock; defaults to `Date.now`.
- `options.logger` — optional `LoggerLike`; defaults to a silent
no-op.
- `options.hash` — optional signer hash override.
#### `rewrite(request: RewriteRequest): RewriteResult`
Returns one of `{ rewritten: true; targetPath; operation }`,
`{ rewritten: false; reason: "passthrough" }`, or
`{ rewritten: false; reason: "invalid"; errorCode; errorMessage }`.
### `RewriteRequest`
- `path` — incoming URL path including leading `/`.
- `host` — incoming `Host` header.
## Implementation Notes
Empty host is treated as a hard error (`INVALID_HOST`) rather than a
silent passthrough because the S3 path interpolation depends on the
host. `/unsafe/...` and `/health*` are detected only when
`parseImgproxyUrl` throws — the grammar deliberately does not match
these paths, so we treat the parse failure as a signal that this is a
direct imgproxy passthrough rather than a user-facing rewrite. Both
the clock and the logger are captured into instance fields at
construction time so a single handler is safe to share across many
requests in a long-lived server.

View File

@@ -0,0 +1,76 @@
# operationToImgproxy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`operationToImgproxy` converts a resolved operation + concrete pixel
dimensions + gravity into the imgproxy processing-string segment that
sits between the `/<signature>/` prefix and the `/plain/s3://…@<fmt>`
suffix. It is the bridge between the compact grammar's abstract
operations and imgproxy's concrete URL DSL.
The five operations behave exactly as the upstream Go module: `cover`
and `crop` require a gravity prefix so anchoring takes effect during
cropping; `fit` ignores gravity (it always preserves aspect ratio);
`stretch` always emits `rs:force:W:H`; `resize` is the single-axis
variant of `fit`. Passthrough (`""`) and unknown operations emit an
empty string so the URL builder skips the processing segment entirely.
## Features
- Total function — every input returns a defined string, never
throws.
- Mirrors the Go switch table byte-for-byte.
- Single-axis fallbacks (`g:<g>/w:W`, `w:W`, `h:H`) so callers can
produce auto-axis resizes without re-implementing the table.
- Empty-string fallback drives the URL builder to skip the segment.
## Usage: Basic
```ts
import { operationToImgproxy } from "@suaveplan/caddy-imgproxy";
operationToImgproxy("cover", 1920, 1080, "t");
// → "g:no/rs:fill:1920:1080"
operationToImgproxy("fit", 800, 0, "c");
// → "w:800"
```
## Usage: Advanced
```ts
import {
calculateDimensions,
operationToImgproxy,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/ucbl4:3w1920/photo.png");
const { width, height } = calculateDimensions(parsed, 800);
operationToImgproxy(parsed.operation, width, height, parsed.gravity);
// → "g:sowe/c:1920:1440"
```
## API Reference
### `operationToImgproxy(op: ImgproxyOperation, width: number, height: number, gravity: string): string`
- **Parameters:**
- `op` — one of `""|cover|crop|fit|stretch|resize`.
- `width`, `height` — non-negative integers; either may be zero.
- `gravity` — a grammar code accepted by `gravityToImgproxy`.
- **Returns:** the imgproxy processing string, or `""` when the
operation cannot be expressed (e.g. `crop` without both dims).
## Implementation Notes
The switch is exhaustive over the `ImgproxyOperation` union; the
TypeScript compiler verifies no case escapes. Gravity is resolved
once at the top of the function so the `cover`/`crop` branches share
a single translation step. The empty-operation case returns `""`
explicitly rather than falling through so the exhaustiveness check
stays clean.

View File

@@ -0,0 +1,98 @@
# parseImgproxyUrl
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseImgproxyUrl` parses the compact imgproxy URL grammar used by the
Caddy module — `/[s|u]<op><gravity><dims><modifiers>/<path>.<format>` —
into a typed, frozen `ParsedImgproxyUrl`. It is the canonical entry
point for every other helper in the package; the URL builder, the
handler, and the dimensions calculator all consume the value it
returns.
Behaviour mirrors the upstream Go implementation byte-for-byte so the
TypeScript port and the Go module can be tested against a shared
fixture corpus. The function never partially succeeds: invalid input
throws a `CaddyImgproxyError` carrying a stable code from
`CADDY_IMGPROXY_CODES`, and the parse-total counter ticks once per
call with the matching outcome tag.
## Features
- Single-pass regex grammar covering signed / unsigned prefix, five
operations (`cover`, `crop`, `fit`, `stretch`, `resize`), nine
gravities, aspect / pixel / square dimensions, and width / height /
quality modifiers in arbitrary order.
- Quality validation built into the grammar (`q1..q100`) — out-of-band
values raise `INVALID_URL`.
- Operation + dimension constraint enforcement (`MISSING_DIMENSIONS`).
- Frozen, immutable return value safe to memoise.
- OTel counter on every call (`caddy_imgproxy_parse_total`).
## Usage: Basic
```ts
import { parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
parsed.operation; // "cover"
parsed.aspectW; // 16
parsed.path; // "avatar"
parsed.format; // "png"
```
## Usage: Advanced
```ts
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
function tryParse(path: string): string | null {
try {
const parsed = parseImgproxyUrl(path);
return `${parsed.operation || "passthrough"} → ${parsed.path}.${parsed.format}`;
} catch (err) {
if (
err instanceof CaddyImgproxyError &&
err.code === CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS
) {
return "needs dims";
}
throw err;
}
}
```
## API Reference
### `parseImgproxyUrl(path: string): ParsedImgproxyUrl`
- **Parameters:** `path` — the URL path including leading `/`.
- **Returns:** a frozen `ParsedImgproxyUrl` (see `types/types.ts`).
- **Throws:**
- `CaddyImgproxyError(INVALID_URL)` when the regex rejects the
input — bad prefix, malformed dimensions, quality outside
`1..100`.
- `CaddyImgproxyError(MISSING_DIMENSIONS)` when an operation
(`v|c|f|s|r`) is present without any dimension grammar.
## Implementation Notes
The grammar regex is a single anchored expression with thirteen
capture groups that mirror the field order of the Go regex
exactly. The modifier sub-grammar (`w<int>`, `h<int>`, `q1..100`) is
extracted via a global secondary regex so order is irrelevant — the
last `w`/`h` wins (matching Go), and `q` always wins for quality.
`Number.parseInt` with radix 10 is used for every numeric extraction
to avoid the legacy octal-from-leading-zero behaviour. Defensive
defaults are deliberately omitted from branches the regex can never
reach so coverage stays honest; the only fallbacks in the function
body protect against future grammar evolution (e.g. an empty gravity
capture defaulting to `"c"`).

View File

@@ -0,0 +1,95 @@
# signImgproxyPath
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`signImgproxyPath` computes the HMAC-SHA256 URL signature that
imgproxy requires when it is configured with a key + salt. It is the
single source of truth for how the TypeScript port and the upstream
Go module agree on bytes: any URL signed here verifies against the Go
implementation and vice-versa.
The function decodes the hex-encoded `key` and `salt` into raw bytes,
concatenates the salt with the UTF-8 encoded `path`, runs HMAC-SHA256
under the key, and base64url-encodes the digest without padding. Bad
hex on either input raises a typed `CaddyImgproxyError`
(`INVALID_KEY` / `INVALID_SALT`) so misconfiguration surfaces
immediately rather than at the imgproxy boundary.
## Features
- Verbatim byte-level port of the Go `signPath` function.
- Pluggable hash via the optional `hash` parameter — supply a native
/ HSM / WebCrypto-backed implementation when needed.
- Hex validation stricter than `@suaveplan/crypto-hash`'s `fromHex`
(lower-case only) to keep wire-format round-trips deterministic.
- Throws typed `CaddyImgproxyError` on every failure path.
## Usage: Basic
```ts
import { signImgproxyPath } from "@suaveplan/caddy-imgproxy";
const signature = signImgproxyPath({
key: "aa11bbcc",
salt: "deadbeef",
path: "/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png",
});
signature; // "E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U"
```
## Usage: Advanced
```ts
import { signImgproxyPath, type SignerHashLike } from "@suaveplan/caddy-imgproxy";
// Plug a WebCrypto-backed signer in a Cloudflare Worker:
const wcHash: SignerHashLike = {
hmac(_algo, key, data) {
// ... call into crypto.subtle.sign synchronously via a vendored
// implementation, return the digest as Uint8Array.
return data;
},
};
const sig = signImgproxyPath({
key: "deadbeef",
salt: "feedface",
path: "/plain/s3://images/example.com/photo@webp",
hash: wcHash,
});
```
## API Reference
### `signImgproxyPath(options: SignImgproxyPathOptions): string`
- **Parameters:**
- `options.key` — lower-hex HMAC key, even length, non-empty.
- `options.salt` — lower-hex salt, even length, non-empty.
- `options.path` — the imgproxy path being signed.
- `options.hash` — optional `SignerHashLike` injection seam.
- **Returns:** base64url-encoded signature, no padding.
- **Throws:**
- `CaddyImgproxyError(INVALID_KEY)` for malformed / empty / odd-length key.
- `CaddyImgproxyError(INVALID_SALT)` for malformed / empty / odd-length salt.
### `SignerHashLike`
Structural type with a single `hmac(algorithm, key, data)` method
returning a `Uint8Array`. Lets callers swap the default Noble-backed
implementation for a native, HSM, or WebCrypto-backed alternative.
## Implementation Notes
The function pre-allocates the `salt || path` buffer in one
`Uint8Array(saltBytes.length + pathBytes.length)` allocation so the
hot path never reallocates. UTF-8 encoding goes through
`TextEncoder`, which is available in every modern JS runtime
(Node ≥ 14, all browsers, Cloudflare Workers, Deno, Bun). The Go
reference uses `[]byte(path)` which is identical for ASCII paths and
spec-compliant for arbitrary UTF-8 (the imgproxy upstream uses the
same convention).

View File

@@ -0,0 +1,89 @@
# telemetry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `telemetry` module owns the OTel instruments emitted by every
public entry point in `@suaveplan/caddy-imgproxy`. It declares three
counters and one histogram, registers them against a single meter
named `@suaveplan/caddy-imgproxy`, and exposes thin `record*`
helpers so the consuming feature modules never need to import the
raw OTel API.
When no `MeterProvider` is registered (the default in unit tests and
production code that has not opted into telemetry), the OTel API
returns a no-op meter and the helpers become no-ops. This makes the
instruments safe to import unconditionally — the package never
forces a runtime cost on consumers who do not want telemetry.
## Features
- Three counters: parse outcomes, rewrite outcomes (tagged with
operation), sign mode.
- One histogram: rewrite duration in milliseconds.
- Helpers (`recordParseOutcome`, `recordRewriteOutcome`,
`recordSignMode`, `recordRewriteDurationMs`) so feature code stays
free of OTel boilerplate.
- Discriminated tag literals (`ParseOutcome`, `RewriteOutcome`,
`SignMode`) for compile-time safety on tag values.
## Usage: Basic
```ts
import { recordParseOutcome, recordSignMode } from "@suaveplan/caddy-imgproxy";
recordParseOutcome("ok");
recordSignMode("signed");
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { rewriteDurationHistogram } from "@suaveplan/caddy-imgproxy";
// Wire a real MeterProvider before importing the rest of the package
// so the histogram is bound to an active exporter.
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
// rewriteDurationHistogram now records into the live provider.
expect(rewriteDurationHistogram).toBeDefined();
```
## API Reference
### Counters
- `parseTotalCounter` — name `caddy_imgproxy_parse_total`, tagged
with `outcome` ∈ `ok | invalid | missing_dims`.
- `rewriteTotalCounter` — `caddy_imgproxy_rewrite_total`, tagged
with `outcome` ∈ `rewritten | passthrough | invalid` and
`operation`.
- `signTotalCounter` — `caddy_imgproxy_sign_total`, tagged with
`mode` ∈ `signed | unsafe`.
### Histogram
- `rewriteDurationHistogram` — `caddy_imgproxy_rewrite_duration_ms`,
unit `ms`.
### Helpers
- `recordParseOutcome(outcome)`
- `recordRewriteOutcome(outcome, operation)`
- `recordSignMode(mode)`
- `recordRewriteDurationMs(durationMs)`
## Implementation Notes
The meter is bound at module import time via `metrics.getMeter(...)`.
When no provider is registered the OTel API returns a no-op meter,
so the instrument constructors do not throw. Helpers funnel through
the counters/histogram via the standard `add(value, tags)` /
`record(value)` API; the tag object literal is constructed inline
each call so V8 can inline it into the call site under hot loops.

View File

@@ -0,0 +1,98 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `types` module declares every cross-feature type that flows
between the package's runtime modules: `ParsedImgproxyUrl` (the
output of the parser), `ImgproxyConfig` (the public configuration
shape), `ImgproxyDimensions` (the resolved pixel pair), and the
discriminated `RewriteResult` union that `ImgproxyHandler.rewrite`
returns. Centralising them here keeps the package's contract in one
place and prevents circular imports between feature modules.
These types are runtime-free — `types.ts` has no executable code and
is excluded from coverage by the vitest config. Consumers that only
want the type contract can import from `@suaveplan/caddy-imgproxy/types`
without pulling in any runtime dependency.
## Features
- Single source of truth for the package's data contracts.
- Discriminated union (`RewriteResult`) for branchless handling of
the three rewrite outcomes.
- Frozen-by-convention return values throughout the package — the
types use `readonly` modifiers so accidental mutation is a
compile-time error.
- Closed unions (`ImgproxyOperation`, `GravityCode`, `HintType`)
give the rest of the package exhaustive `switch` coverage.
## Usage: Basic
```ts
import type { ImgproxyConfig, ParsedImgproxyUrl } from "@suaveplan/caddy-imgproxy";
function describe(parsed: ParsedImgproxyUrl, config: ImgproxyConfig): string {
return `${parsed.operation || "passthrough"} for ${config.s3Bucket}`;
}
```
## Usage: Advanced
```ts
import type { RewriteResult } from "@suaveplan/caddy-imgproxy";
function summarise(result: RewriteResult): string {
if (result.rewritten) return `→ ${result.targetPath}`;
if (result.reason === "passthrough") return "passthrough";
return `invalid (${result.errorCode}): ${result.errorMessage}`;
}
```
## API Reference
### `ImgproxyOperation`
Union of `""|cover|crop|fit|stretch|resize`.
### `GravityCode`
Union of `t|b|l|r|c|tl|tr|bl|br`.
### `HintType`
Union of `""|w|h`.
### `ParsedImgproxyUrl`
Frozen, immutable parser output. Carries `signed`, `operation`,
`gravity`, `aspectW`, `aspectH`, `pixelW`, `pixelH`, `hintType`,
`hintValue`, `quality`, `path`, `format`, `isAspect`, `hasDims`.
### `ImgproxyConfig`
Validated configuration carrying `upstream`, `s3Bucket`,
`defaultWidth`, and optional `key` / `salt`.
### `ImgproxyDimensions`
`{ width: number; height: number }` — output of
`calculateDimensions`.
### `RewriteResult`
Discriminated union of `RewriteRewrittenResult`,
`RewritePassthroughResult`, and `RewriteInvalidResult`.
## Implementation Notes
Every field uses `readonly` to communicate immutability through the
type. The discriminated `RewriteResult` shape is deliberately tagged
twice (`rewritten` for the boolean discriminator and `reason` for
the failure sub-discriminator) so consumers can branch on either
side without losing TypeScript narrowing. The optional `key` / `salt`
on `ImgproxyConfig` carry an explicit `| undefined` because the
package is built under `exactOptionalPropertyTypes`.

View File

@@ -0,0 +1,92 @@
# buildImgproxyUrl
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`buildImgproxyUrl` composes the final imgproxy URL path from a parsed
grammar representation. It is the orchestrator that ties together
`calculateDimensions`, `operationToImgproxy`, and `signImgproxyPath`
into the single string a downstream proxy or CDN edge needs to
forward an image-rewrite request to imgproxy.
The function mirrors the upstream Go `buildImgproxyURL` semantics in
every detail: processing string first, optional `q:<quality>` next,
then the `plain/s3://<bucket>/<domain>/<path>@<format>` payload, and
finally either a `/<base64url-signature>` prefix (when the config
carries both a key and a salt) or `/unsafe`. Every call records a
single `caddy_imgproxy_sign_total` sample tagged with the mode.
## Features
- Single source of truth for the URL composition order.
- Telemetry on every call (sign-mode counter).
- Honours the empty-string fallbacks from `operationToImgproxy` —
passthrough URLs produce a clean `/unsafe/plain/...` shape.
- Supports the injectable signer hash from `signImgproxyPath`.
## Usage: Basic
```ts
import {
buildImgproxyUrl,
parseConfig,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const config = parseConfig({ defaultWidth: 800 });
buildImgproxyUrl({ parsed, domain: "example.com", config });
// → "/unsafe/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
```
## Usage: Advanced
```ts
import {
buildImgproxyUrl,
parseConfig,
parseImgproxyUrl,
} from "@suaveplan/caddy-imgproxy";
const config = parseConfig({
s3Bucket: "media",
defaultWidth: 1200,
key: "aa11bbcc",
salt: "deadbeef",
});
// Quality + width hint + signed
const parsed = parseImgproxyUrl("/uv16:9q90w1920/photo.png");
const url = buildImgproxyUrl({ parsed, domain: "example.com", config });
// → "/<signature>/g:ce/rs:fill:1920:1080/q:90/plain/s3://media/example.com/photo@png"
```
## API Reference
### `buildImgproxyUrl(options: BuildImgproxyUrlOptions): string`
- **Parameters:**
- `options.parsed` — output of `parseImgproxyUrl`.
- `options.domain` — virtual-host segment for the S3 path.
- `options.config` — validated `ImgproxyConfig`.
- `options.hash` — optional signer hash override.
- **Returns:** the full imgproxy path including the signature prefix.
### `BuildImgproxyUrlOptions`
Inputs interface — see above.
## Implementation Notes
Signature mode is selected only when BOTH `config.key` and
`config.salt` are non-empty strings. An empty string in either slot
falls back to `/unsafe` so callers can disable signing at runtime
without re-parsing the config. The `caddy_imgproxy_sign_total`
counter is incremented exactly once per call — either as `signed` or
as `unsafe` — even when the upstream signer throws (the throw
propagates after the counter increments via the signer's own error
path, since `signImgproxyPath` raises before this function records).

View File

@@ -0,0 +1,29 @@
# @suaveplan/colour-convert-adapt - Bradford chromatic adaptation between XYZ illuminants for ICC-style colour management
Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's appearance so that it looks the same under different light sources (illuminants). The Bradford transform is the industry-standard method, used in ICC colour management and many colour science toolkits.
<!-- begin:auto-docs -->
## Documentation tree
- [adapt](./src/adapt/adapt.md) — `adapt.ts` provides Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's a…
- [adapt](./src/adapt/adapt.md) — `adapt.ts` provides Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's a…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-convert-adapt
```
## Quick Start
```ts
import { bradfordAdapt } from "@suaveplan/colour-convert-adapt";
const d50 = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
console.log(d50); // { x: ~52.9, y: ~50, z: ~37.7 }
```
## Modules
- [`src/adapt/adapt.md`](./src/adapt/adapt.md) — Bradford chromatic adaptation implementation and API reference

View File

@@ -0,0 +1,48 @@
# adapt
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`adapt.ts` provides Bradford chromatic adaptation for XYZ colour coordinates. Chromatic adaptation adjusts a colour's appearance so that it looks the same under different light sources (illuminants). The Bradford transform is the industry-standard method for this, used in ICC colour management and many colour science toolkits. When a photograph is taken under tungsten lighting but viewed under daylight, chromatic adaptation predicts how the colours should be shifted to preserve their perceived appearance.
## Features
- 11 built-in standard illuminants (A, B, C, D50/55/65/75, E, F2/F7/F11)
- Custom XYZ illuminant support for specialised workflows
- Default destination is D65, the most common reference white
- Full XYZ round-trip fidelity when source and destination match
## Usage: Basic Example
```ts
import { bradfordAdapt } from "@suaveplan/colour-convert-adapt";
const d50 = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
```
## Usage: Advanced Example
```ts
import { bradfordAdapt } from "@suaveplan/colour-convert-adapt";
function matchIlluminant(colours: XYZ[], from: string, to: string) {
return colours.map((c) => bradfordAdapt(c, from, to));
}
```
## API Reference
### `bradfordAdapt(xyz: XYZ, source: string | XYZ, dest?: string | XYZ): XYZ`
Adapts an XYZ colour from one illuminant to another using the Bradford chromatic adaptation transform. The `source` and `dest` parameters may be a named illuminant string or a custom XYZ white point. Defaults to D65 when `dest` is omitted.
### `ILLUMINANTS: Record<string, XYZ>`
Map of standard CIE illuminant white points used by the Bradford transform.
## Implementation Notes
The Bradford adaptation uses a cone-response space (LMS) via the Bradford matrix and its inverse. The XYZ colour is first converted to LMS, scaled by the ratio of destination to source white point in LMS space, then converted back to XYZ. This preserves colour appearance under changing lighting conditions. The matrix coefficients are from the ICC specification. Small floating-point differences may occur in identity transformations due to the finite precision of the matrix coefficients, but these are typically on the order of 0.0001% and negligible for practical applications.

View File

@@ -0,0 +1,32 @@
# @suaveplan/colour-convert-cieluv - CIELUV, xyY, LMS, and Bradford chromatic adaptation for professional colour workflows
CIELUV, xyY, LMS, and Bradford chromatic adaptation conversions. This plugin provides colour-space transforms between RGB, CIELUV, xyY, and LMS cone-response space, including Bradford adaptation across 11 standard illuminants (A, B, C, D50/55/65/75, E, F2/F7/F11).
<!-- begin:auto-docs -->
## Documentation tree
- [colour-convert-cieluv](./src/colour-convert-cieluv/colour-convert-cieluv.md) — `colour-convert-cieluv.ts` extends the CIE colour family with CIELUV, xyY chromaticity, LMS cone response, and Bradford…
- [colour-convert-cieluv](./src/colour-convert-cieluv/colour-convert-cieluv.md) — `colour-convert-cieluv.ts` extends the CIE colour family with CIELUV, xyY chromaticity, LMS cone response, and Bradford…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-convert-cieluv
```
## Quick Start
```ts
import { rgbToLuv, bradfordAdapt } from "@suaveplan/colour-convert-cieluv";
const luv = rgbToLuv({ r: 128, g: 64, b: 32 });
console.log(luv); // { l: ~32.9, u: ~40.6, v: ~37.8 }
const d50 = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
console.log(d50); // { x: ~52.9, y: ~50, z: ~37.7 }
```
## Modules
- [`src/colour-convert-cieluv/colour-convert-cieluv.md`](./src/colour-convert-cieluv/colour-convert-cieluv.md) — CIELUV, xyY, LMS, and Bradford adaptation implementation

View File

@@ -0,0 +1,67 @@
# colour-convert-cieluv
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-convert-cieluv.ts` extends the CIE colour family with CIELUV, xyY chromaticity, LMS cone response, and Bradford chromatic adaptation. These spaces are essential for professional colour workflows including display calibration, print proofing, and cross-media colour reproduction.
## Features
- RGB ↔ CIELUV conversions
- XYZ ↔ xyY conversions
- XYZ ↔ LMS cone response conversions
- Bradford chromatic adaptation with CIE standard illuminants
## Usage: Basic Example
```ts
import { rgbToLuv, luvToRgb } from "@suaveplan/colour-convert-cieluv";
const luv = rgbToLuv({ r: 128, g: 64, b: 32 });
const back = luvToRgb(luv);
```
## Usage: Advanced Example
```ts
import { bradfordAdapt, ILLUMINANTS } from "@suaveplan/colour-convert-cieluv";
const d50 = bradfordAdapt(xyz, "D65", "D50");
```
## API Reference
### CIELUV
- `xyzToLuv(xyz: XYZ): LUV`
- `luvToXyz(luv: LUV): XYZ`
- `rgbToLuv(rgb: RGB): LUV`
- `luvToRgb(luv: LUV): RGBA`
### xyY
- `xyzToxyY(xyz: XYZ): xyY`
- `xyYToXyz(xy: xyY): XYZ`
- `rgbToxyY(rgb: RGB): xyY`
- `xyYToRgb(xy: xyY): RGBA`
### LMS
- `xyzToLms(xyz: XYZ): LMS`
- `lmsToXyz(lms: LMS): XYZ`
- `rgbToLms(rgb: RGB): LMS`
- `lmsToRgb(lms: LMS): RGBA`
### Bradford Adaptation
- `bradfordAdapt(xyz: XYZ, source: string | XYZ, dest: string | XYZ): XYZ`
- `ILLUMINANTS` — map of CIE standard illuminants
## Implementation Notes
Bradford adaptation uses the standard CIECAM02 matrix and its inverse. The implementation accepts either named illuminant strings (A, B, C, D50, D55, D65, D75, E, F2, F7, F11) or raw XYZ values for custom white points. All conversion pipelines are deterministic and side-effect-free.
The CIELUV implementation follows the standard CIE 1976 formula with D65 as the default reference white. Internal helper functions handle the low-luminance linear branch automatically, ensuring accurate results even for very dark colours. The module has no runtime dependencies beyond the base `@suaveplan/colours` types.

View File

@@ -0,0 +1,32 @@
# @suaveplan/colour-convert-cmyk - Bidirectional RGB <-> CMYK colour space conversion with black generation for print workflows
Bidirectional conversion between RGB and CMYK colour spaces using the standard Euclidean algorithm with black generation. CMYK is the subtractive model used in professional printing; these conversions are essential for print preview, pre-press workflows, and bridging screen colours with physical output.
<!-- begin:auto-docs -->
## Documentation tree
- [cmyk](./src/cmyk/cmyk.md) — `cmyk.ts` provides bidirectional conversion between RGB and CMYK colour spaces. CMYK is the subtractive model used in p…
- [cmyk](./src/cmyk/cmyk.md) — `cmyk.ts` provides bidirectional conversion between RGB and CMYK colour spaces. CMYK is the subtractive model used in p…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-convert-cmyk
```
## Quick Start
```ts
import { rgbToCmyk, cmykToRgb } from "@suaveplan/colour-convert-cmyk";
const ink = rgbToCmyk({ r: 59, g: 130, b: 246 });
console.log(ink); // { c: 76, m: 47, y: 0, k: 4 }
const screen = cmykToRgb(ink);
console.log(screen); // { r: 59, g: 130, b: 246, a: 1 }
```
## Modules
- [`src/cmyk/cmyk.md`](./src/cmyk/cmyk.md) — RGB↔CMYK conversion implementation and API reference

View File

@@ -0,0 +1,47 @@
# cmyk
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`cmyk.ts` provides bidirectional conversion between RGB and CMYK colour spaces. CMYK is the subtractive model used in professional printing, where colours are created by mixing cyan, magenta, yellow, and black inks. These conversions are essential for print preview, pre-press workflows, and any application that needs to bridge screen colours with physical output. While RGB is additive (light-emitting), CMYK is subtractive (light-absorbing), making direct translation necessary for accurate colour reproduction across media.
## Features
- RGB to CMYK conversion with black generation
- CMYK to RGB conversion
- Percentage-based CMYK output (0–100)
- Handles edge cases like pure black and pure white
## Usage: Basic Example
```ts
import { rgbToCmyk } from "@suaveplan/colour-convert-cmyk";
const cmyk = rgbToCmyk({ r: 128, g: 64, b: 32 });
```
## Usage: Advanced Example
```ts
import { rgbToCmyk, cmykToRgb } from "@suaveplan/colour-convert-cmyk";
const ink = rgbToCmyk({ r: 59, g: 130, b: 246 });
const screen = cmykToRgb(ink);
```
## API Reference
### `rgbToCmyk(rgb: RGB): CMYK`
Converts an RGB colour to CMYK percentages. The black channel (`k`) is generated from the maximum of the inverted RGB channels, and the cyan, magenta, and yellow channels are computed relative to this black. This is the standard Euclidean algorithm used in most graphics software.
### `cmykToRgb(cmyk: CMYK): RGBA`
Converts CMYK percentages back to RGB. The formula applies the subtractive model in reverse: each RGB channel is the product of `(1 - ink) × (1 - black)`. The alpha channel is always set to 1 because CMYK has no native alpha channel.
## Implementation Notes
The conversion uses the standard Euclidean algorithm for RGB to CMYK. When the input colour is pure black (`k = 100%`), the cyan, magenta, and yellow channels are defined as zero to avoid division by zero. All CMYK values are rounded to the nearest integer percentage. The inverse conversion always produces alpha 1 because CMYK has no native alpha channel. Round-trip conversion may introduce small quantization errors due to the integer rounding of CMYK percentages.

View File

@@ -0,0 +1,32 @@
# @suaveplan/colour-convert-hsluv - HSLuv perceptually uniform colour space: conversions and harmony generators
HSLuv perceptually-uniform colour space with gamut-mapped ray intersection for sRGB. This plugin provides bidirectional RGB↔HSLuv/HPLuv conversions and uniform colour harmonies (complementary, triad, tetrad) that preserve perceptual lightness across hue rotations.
<!-- begin:auto-docs -->
## Documentation tree
- [colour-convert-hsluv](./src/colour-convert-hsluv/colour-convert-hsluv.md) — `colour-convert-hsluv.ts` implements the HSLuv perceptually uniform colour space. Unlike HSL, which stretches and compr…
- [colour-convert-hsluv](./src/colour-convert-hsluv/colour-convert-hsluv.md) — `colour-convert-hsluv.ts` implements the HSLuv perceptually uniform colour space. Unlike HSL, which stretches and compr…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-convert-hsluv
```
## Quick Start
```ts
import { rgbToHsluv, hsluvToRgb, uniformTriad } from "@suaveplan/colour-convert-hsluv";
const hsluv = rgbToHsluv({ r: 255, g: 0, b: 0 });
console.log(hsluv); // { h: ~12.2, s: ~100, l: ~53.2 }
const colours = uniformTriad({ r: 59, g: 130, b: 246 });
console.log(colours); // [primary, secondary, tertiary]
```
## Modules
- [`src/colour-convert-hsluv/colour-convert-hsluv.md`](./src/colour-convert-hsluv/colour-convert-hsluv.md) — HSLuv/HPLuv conversion and uniform harmonies implementation

View File

@@ -0,0 +1,53 @@
# colour-convert-hsluv
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-convert-hsluv.ts` implements the HSLuv perceptually uniform colour space. Unlike HSL, which stretches and compresses hue angles unevenly across lightness levels, HSLuv guarantees that a given saturation percentage produces the same visual intensity regardless of hue. This makes it ideal for generating harmonious colour palettes and accessible UI themes.
## Features
- RGB ↔ HSLuv bidirectional conversion with gamut mapping
- RGB ↔ HPLuv (pastel variant) bidirectional conversion
- Uniform colour harmonies: complementary, triad, and tetrad
## Usage: Basic Example
```ts
import { rgbToHsluv, hsluvToRgb } from "@suaveplan/colour-convert-hsluv";
const hsluv = rgbToHsluv({ r: 255, g: 0, b: 0 });
const back = hsluvToRgb(hsluv);
```
## Usage: Advanced Example
```ts
import { uniformTriad } from "@suaveplan/colour-convert-hsluv";
const colours = uniformTriad({ r: 59, g: 130, b: 246 });
```
## API Reference
### Conversions
- `rgbToHsluv(rgb: RGB): HSLUV`
- `hsluvToRgb(hsluv: HSLUV): RGBA`
- `rgbToHpluv(rgb: RGB): HPLUV`
- `hpluvToRgb(hpluv: HPLUV): RGBA`
### Harmonies
- `uniformComplementary(rgb: RGB): RGB[]`
- `uniformTriad(rgb: RGB): RGB[]`
- `uniformTetrad(rgb: RGB): RGB[]`
## Implementation Notes
The HSLuv algorithm computes the maximum chroma for a given hue by intersecting rays from the white point with the sRGB gamut boundary in CIELUV space. Six rays are tested (one per RGB cube edge) and the minimum positive intersection distance becomes the saturation scale factor. This ensures that 100% saturation always touches the gamut boundary, regardless of hue or lightness.
All conversion pipelines are deterministic and free of side effects. The module has no runtime dependencies beyond the base `@suaveplan/colours` types, making it suitable for both server-side rendering and client-side bundling. Gamut mapping is performed automatically during RGB to HSLuv conversion, so out-of-range inputs are handled gracefully without throwing exceptions.

View File

@@ -0,0 +1,32 @@
# @suaveplan/colour-filter-cct - Correlated Colour Temperature (CCT) calculation and approximation for lighting and display calibration
Correlated Colour Temperature (CCT) conversions using the McCamy formula and black-body RGB approximation. Convert any RGB colour to its approximate colour temperature in Kelvin, or generate an RGB colour from a given temperature. Useful for white-balance correction, lighting design, and temperature-based theming.
<!-- begin:auto-docs -->
## Documentation tree
- [colour-filter-cct](./src/colour-filter-cct/colour-filter-cct.md) — `colour-filter-cct.ts` provides Correlated Colour Temperature (CCT) calculation and approximation. CCT describes the co…
- [colour-filter-cct](./src/colour-filter-cct/colour-filter-cct.md) — `colour-filter-cct.ts` provides Correlated Colour Temperature (CCT) calculation and approximation. CCT describes the co…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-filter-cct
```
## Quick Start
```ts
import { cctFromRgb, cctToRgb } from "@suaveplan/colour-filter-cct";
const kelvin = cctFromRgb({ r: 255, g: 240, b: 220 });
console.log(kelvin); // ~6500
const warmWhite = cctToRgb(2700);
console.log(warmWhite); // { r: ~255, g: ~169, b: ~87, a: 1 }
```
## Modules
- [`src/colour-filter-cct/colour-filter-cct.md`](./src/colour-filter-cct/colour-filter-cct.md) — CCT conversion implementation and API reference

View File

@@ -0,0 +1,52 @@
# colour-filter-cct
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-filter-cct.ts` provides Correlated Colour Temperature (CCT) calculation and approximation. CCT describes the colour appearance of a light source by comparing its chromaticity to that of an ideal black-body radiator. It is widely used in lighting design, photography, and display calibration.
## Features
- CCT from RGB via McCamy's formula
- RGB approximation from CCT via black-body curves
## Usage: Basic Example
```ts
import { cctFromRgb, cctToRgb } from "@suaveplan/colour-filter-cct";
const kelvin = cctFromRgb({ r: 255, g: 240, b: 220 });
const rgb = cctToRgb(6500);
```
## Usage: Advanced Example
```ts
import { cctFromRgb } from "@suaveplan/colour-filter-cct";
function classifyWhiteBalance(rgb: RGB): string {
const cct = cctFromRgb(rgb);
if (cct < 3300) return "warm";
if (cct < 5300) return "neutral";
return "cool";
}
```
## API Reference
### `cctFromRgb(rgb: RGB): number`
Returns the correlated colour temperature in Kelvin. Uses McCamy's formula based on CIE 1931 chromaticity coordinates derived from the input RGB.
### `cctToRgb(kelvin: number): RGBA`
Approximates an RGB colour for a given CCT in Kelvin. Input is clamped to the valid range of 1000K–40000K. Uses the standard black-body polynomial approximation.
## Implementation Notes
McCamy's formula is accurate for CCT values between 2000K and 10000K. Outside this range the approximation becomes less reliable, though the function still returns a value. The inverse function (`cctToRgb`) uses the same polynomial coefficients as `@suaveplan/colours`' `fromTemperature` for consistency.
The implementation clamps input CCT values to the valid range of 1000K–40000K to prevent numerical instability. At the low end of this range the blue channel is zero because the black-body curve produces negligible short-wavelength energy. All conversions are deterministic and have no external runtime dependencies.

View File

@@ -0,0 +1,29 @@
# @suaveplan/colour-filter-cmc - CMC l:c Delta-E colour difference metric for textile and print quality control
CMC l:c Delta-E colour difference metric with hue-dependent chroma scaling and low-luminance branch handling. This plugin computes perceptual distance between two colours using the Colour Measurement Committee's l:c formula, widely used in textile and industrial colour matching where human-perceptible differences matter more than Euclidean distance.
<!-- begin:auto-docs -->
## Documentation tree
- [colour-filter-cmc](./src/colour-filter-cmc/colour-filter-cmc.md) — `colour-filter-cmc.ts` implements the CMC l:c Delta-E colour difference metric. Unlike Euclidean distance or simple Del…
- [colour-filter-cmc](./src/colour-filter-cmc/colour-filter-cmc.md) — `colour-filter-cmc.ts` implements the CMC l:c Delta-E colour difference metric. Unlike Euclidean distance or simple Del…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-filter-cmc
```
## Quick Start
```ts
import { cmcDistance } from "@suaveplan/colour-filter-cmc";
const d = cmcDistance({ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 });
console.log(d); // perceptual distance between red and blue
```
## Modules
- [`src/colour-filter-cmc/colour-filter-cmc.md`](./src/colour-filter-cmc/colour-filter-cmc.md) — CMC l:c Delta-E implementation and API reference

View File

@@ -0,0 +1,43 @@
# colour-filter-cmc
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-filter-cmc.ts` implements the CMC l:c Delta-E colour difference metric. Unlike Euclidean distance or simple Delta-E 76, CMC accounts for the non-uniformity of CIELAB space by weighting luminance and chroma differences separately. It is the industry standard for textile and print quality control.
## Features
- CMC l:c Delta-E calculation between two RGB colours
- Configurable luminance and chroma weights
## Usage: Basic Example
```ts
import { cmcDistance } from "@suaveplan/colour-filter-cmc";
const d = cmcDistance({ r: 255, g: 0, b: 0 }, { r: 0, g: 0, b: 255 });
```
## Usage: Advanced Example
```ts
import { cmcDistance } from "@suaveplan/colour-filter-cmc";
// Stricter luminance weighting for textile applications
const d = cmcDistance(brand, sample, 1, 1);
```
## API Reference
### `cmcDistance(a: RGB, b: RGB, luminanceWeight = 2, chromaWeight = 1): number`
Returns the CMC l:c Delta-E distance between two colours. Lower values indicate closer matches. A value below 1.0 is generally considered a commercial match in textile applications.
## Implementation Notes
The algorithm first converts both colours to CIELAB, then applies the CMC l:c formula with hue-dependent scaling factors. The `t` factor adjusts for the eye's reduced sensitivity to chroma differences in blue hues. The `f` factor compensates for the increased sensitivity to chroma differences at low chroma values.
All calculations use double-precision floating point arithmetic. The implementation guards against negative values under the square root that can arise from floating-point error when comparing near-identical colours. Default weights of 2:1 for luminance to chroma match the standard textile industry convention.

View File

@@ -0,0 +1,29 @@
# @suaveplan/colour-filter-greyscale - Perceptual RGB-to-greyscale filter using the ITU-R BT.709 luminance coefficients
Converts any RGB colour to its perceptual greyscale equivalent using the ITU-R BT.709 luminance coefficients. Unlike simple channel averaging, this formula weights green most heavily to match human visual sensitivity, producing greyscale results that look correctly balanced to observers.
<!-- begin:auto-docs -->
## Documentation tree
- [greyscale](./src/greyscale/greyscale.md) — `greyscale.ts` converts any RGB colour to its perceptual greyscale equivalent using the ITU-R BT.709 luminance coeffici…
- [greyscale](./src/greyscale/greyscale.md) — `greyscale.ts` converts any RGB colour to its perceptual greyscale equivalent using the ITU-R BT.709 luminance coeffici…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-filter-greyscale
```
## Quick Start
```ts
import { greyscale } from "@suaveplan/colour-filter-greyscale";
const mono = greyscale({ r: 255, g: 128, b: 0 });
console.log(mono); // { r: 150, g: 150, b: 150, a: 1 }
```
## Modules
- [`src/greyscale/greyscale.md`](./src/greyscale/greyscale.md) — ITU-R BT.709 luminance greyscale implementation and API reference

View File

@@ -0,0 +1,44 @@
# greyscale
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`greyscale.ts` converts any RGB colour to its perceptual greyscale equivalent using the ITU-R BT.709 luminance coefficients. Unlike simple averaging of channels, this formula weights green most heavily because human vision is most sensitive to mid-wavelength light. This approach produces greyscale results that appear correctly balanced to human observers rather than mathematically averaged values that can look too dark or too bright.
## Features
- ITU-R BT.709 luminance-based greyscale conversion
- Deterministic single-channel output
- Alpha channel preserved at 1
- Matches standard video and image processing conventions
## Usage: Basic Example
```ts
import { greyscale } from "@suaveplan/colour-filter-greyscale";
const mono = greyscale({ r: 255, g: 128, b: 0 });
```
## Usage: Advanced Example
```ts
import { greyscale } from "@suaveplan/colour-filter-greyscale";
function desaturatePalette(colours: RGB[]) {
return colours.map((c) => greyscale(c));
}
```
## API Reference
### `greyscale(rgb: RGB): RGBA`
Returns a greyscale RGBA colour where all three channels are set to the perceptual luminance of the input. The alpha channel is always 1. The luminance is computed using the ITU-R BT.709 standard coefficients and rounded to the nearest integer.
## Implementation Notes
The luminance formula `Y = 0.2126·R + 0.7152·G + 0.0722·B` is the standard recommendation from ITU-R BT.709 for high-definition video and is widely used in image processing. The coefficients sum to 1.0, ensuring that white input maps exactly to white output and black maps to black. Green receives the highest weight because the human eye has peak sensitivity in the green portion of the spectrum. Blue receives the lowest weight because human vision is least sensitive to short-wavelength light. This formula is also used in JPEG compression, HDTV signal processing, and many CSS `filter: grayscale()` implementations.

View File

@@ -0,0 +1,29 @@
# @suaveplan/colour-filter-sepia - Photographic sepia tone filter for RGB colours using the W3C CSS Filters 3x3 matrix
Applies a photographic sepia tone filter to RGB colours using the standard W3C CSS Filters 3×3 matrix. This plugin converts any colour into a warm brownish tone that simulates the look of aged photographs, with output clamped to the valid 8-bit sRGB range.
<!-- begin:auto-docs -->
## Documentation tree
- [sepia](./src/sepia/sepia.md) — `sepia.ts` applies a photographic sepia tone filter to RGB colours. Sepia simulates the warm brownish tone of old photo…
- [sepia](./src/sepia/sepia.md) — `sepia.ts` applies a photographic sepia tone filter to RGB colours. Sepia simulates the warm brownish tone of old photo…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colour-filter-sepia
```
## Quick Start
```ts
import { sepia } from "@suaveplan/colour-filter-sepia";
const vintage = sepia({ r: 255, g: 0, b: 0 });
console.log(vintage); // { r: 100, g: 89, b: 69, a: 1 }
```
## Modules
- [`src/sepia/sepia.md`](./src/sepia/sepia.md) — Sepia matrix filter implementation and API reference

View File

@@ -0,0 +1,49 @@
# sepia
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`sepia.ts` applies a photographic sepia tone filter to RGB colours. Sepia simulates the warm brownish tone of old photographs by re-weighting the red, green, and blue channels through a fixed 3×3 matrix transformation.
## Features
- Deterministic sepia matrix applied to any RGB colour
- Output clamped to valid 8-bit range
- Zero runtime dependencies beyond base colour types
## Usage: Basic Example
```ts
import { sepia } from "@suaveplan/colour-filter-sepia";
const brown = sepia({ r: 255, g: 0, b: 0 });
```
## Usage: Advanced Example
```ts
import { sepia } from "@suaveplan/colour-filter-sepia";
import { Colour } from "@suaveplan/colours/colour";
function vintageTheme(base: RGB) {
return {
primary: sepia(base),
secondary: sepia(new Colour(base).darken(50).toRgb()),
};
}
```
## API Reference
### `sepia(rgb: RGB): RGBA`
Applies the standard sepia matrix to an RGB colour and returns the result with alpha set to 1. The matrix coefficients follow the W3C CSS Filters specification.
## Implementation Notes
The sepia matrix is a fixed linear transform. No gamma correction or perceptual scaling is applied beyond the matrix coefficients themselves. Black and white are mapped to slightly different values due to the matrix weights summing to more than 1.0 for some channels. For example, white input produces an output of approximately (255, 255, 239) because the blue channel weight sum is less than 1.0. Saturated colours shift toward warm brown tones, while neutral greys take on a subtle warm cast.
The matrix coefficients are the standard values from the W3C CSS Filters specification: `[[0.393, 0.769, 0.189], [0.349, 0.686, 0.168], [0.272, 0.534, 0.131]]`. These weights were chosen to simulate the chemical process of sepia toning in traditional photography, where silver halide crystals are replaced with silver sulphide, shifting the spectral response toward longer wavelengths. The output channels are individually clamped to the 0–255 range after rounding to prevent overflow from the matrix multiplication.

View File

@@ -0,0 +1,65 @@
# @suaveplan/colours - Universal colour manipulation: parsing, conversion, analysis, mixing, blending, and palette generation
Universal colour manipulation library: parsing, conversion, analysis, mixing, blending, and palette generation. Parses hex (3/4/6/8 digit), CSS `rgb()`, `hsl()`, `lab()`, `lch()`, `oklab()`, `oklch()`, named colours, and plain objects. Converts between RGB, HSL, HSV, LAB, LCH, OKLAB, OKLCH, XYZ, and CMYK. Supports manipulation (lighten, darken, saturate, desaturate, rotate, alpha, fade, opaquer, negate, grayscale, mix), analysis (luminance, perceived brightness, contrast ratio, distance), and factories (random colours, temperature-based black-body curves, blend modes, gradient steps, harmonious palettes).
<!-- begin:auto-docs -->
## Documentation tree
- [colour](./src/colour/colour.md) — `colour.ts` implements the `Colour` class — an immutable, chainable colour value object. It wraps an internal `RGBA` st…
- [colour-formatters](./src/colour/colour-formatters.md) — `colour-formatters.ts` provides deterministic string-formatting functions for every colour representation supported by…
- [colour](./src/colour/colour.md) — `colour.ts` implements the `Colour` class — an immutable, chainable colour value object. It wraps an internal `RGBA` st…
- [colour-factories](./src/colour-factories/colour-factories.md) — `colour-factories.ts` provides pure, deterministic helpers for generating colours and colour collections. It is complet…
- [colour-factories](./src/colour-factories/colour-factories.md) — `colour-factories.ts` provides pure, deterministic helpers for generating colours and colour collections. It is complet…
- [conversions](./src/conversions/conversions.md) — `conversions.ts` provides deterministic, side-effect-free mathematical transforms between colour spaces. Every function…
- [conversions](./src/conversions/conversions.md) — `conversions.ts` provides deterministic, side-effect-free mathematical transforms between colour spaces. Every function…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` declares the canonical error-code registry for `@suaveplan/colours`. Every failure mode that the colou…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` declares the canonical error-code registry for `@suaveplan/colours`. Every failure mode that the colou…
- [errors](./src/errors/errors.md) — `errors.ts` provides the typed error hierarchy for `@suaveplan/colours`. `ColourError` extends `SuaveplanError` so that…
- [errors](./src/errors/errors.md) — `errors.ts` provides the typed error hierarchy for `@suaveplan/colours`. `ColourError` extends `SuaveplanError` so that…
- [parser](./src/parser/parser.md) — `parser.ts` converts arbitrary colour input into a canonical `RGBA` object. It supports hex strings (3/4/6/8 digit), mo…
- [parser](./src/parser/parser.md) — `parser.ts` converts arbitrary colour input into a canonical `RGBA` object. It supports hex strings (3/4/6/8 digit), mo…
- [colour-types](./src/types/colour-types/colour-types.md) — `colour-types.ts` is the universal type system and Zod schema registry for `@suaveplan/colours`. It defines TypeSc…
- [colour-types](./src/types/colour-types/colour-types.md) — `colour-types.ts` is the universal type system and Zod schema registry for `@suaveplan/colours`. It defines TypeSc…
- [utils](./src/utils/utils.md) — `utils.ts` provides small, pure mathematical utilities used throughout `@suaveplan/colours`. Every function is determin…
- [utils](./src/utils/utils.md) — `utils.ts` provides small, pure mathematical utilities used throughout `@suaveplan/colours`. Every function is determin…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/colours
```
## Quick Start
```ts
import { Colour } from "@suaveplan/colours";
const c = new Colour("#ff9900");
console.log(c.lighten(0.2).toHex()); // "#ffb34d"
console.log(c.isDark()); // false
console.log(c.contrast("#ffffff")); // WCAG contrast ratio
```
## Modules
- [`src/colour/colour.md`](./src/colour/colour.md) — `Colour` class and instance methods
- [`src/colour/colour-formatters.md`](./src/colour/colour-formatters.md) — Output formatters (hex, rgb, hsl, etc.)
- [`src/colour-factories/colour-factories.md`](./src/colour-factories/colour-factories.md) — Random colours, temperature, blend modes, palettes
- [`src/conversions/conversions.md`](./src/conversions/conversions.md) — Colour-space conversion functions
- [`src/parser/parser.md`](./src/parser/parser.md) — `parseColour` input parser
- [`src/types/colour-types/colour-types.md`](./src/types/colour-types/colour-types.md) — TypeScript interfaces and Zod schemas
- [`src/utils/utils.md`](./src/utils/utils.md) — Clamping and rounding utilities
- [`src/errors/errors.md`](./src/errors/errors.md) — `ColourError` hierarchy and typed factories
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — `COLOUR_CODES` registry
## Subpath Exports
- `@suaveplan/colours/colour` — `Colour` class
- `@suaveplan/colours/parser` — `parseColour`
- `@suaveplan/colours/conversions` — colour-space conversion functions
- `@suaveplan/colours/types` — TypeScript interfaces and Zod schemas
- `@suaveplan/colours/utils` — clamping and rounding utilities
- `@suaveplan/colours/errors` — `ColourError` and factory functions
- `@suaveplan/colours/error-codes` — `COLOUR_CODES` registry

View File

@@ -0,0 +1,68 @@
# colour-factories
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-factories.ts` provides pure, deterministic helpers for generating colours and colour collections. It is completely stateless and has no side effects. All functions return either a single `Colour` instance or an array of `Colour` instances. These helpers extend the core `Colour` class with common creative and programmatic colour tasks.
## Features
- Random colour generation with optional alpha
- Temperature-based black-body approximation (Kelvin to RGB)
- Colour blending with standard Photoshop-style blend modes
- Gradient step generation between two colours
- Palette generation (analogous, monochromatic, triadic, tetradic, complementary, split-complementary)
## Usage: Basic Example
```ts
import { randomColour, steps } from "@suaveplan/colours/colour-factories";
const r = randomColour();
const gradient = steps("#ff0000", "#0000ff", 5);
```
## Usage: Advanced Example
```ts
import { palette, fromTemperature } from "@suaveplan/colours/colour-factories";
const warm = fromTemperature(3200);
const brandPalette = palette("#3b82f6", "analogous", 5);
```
## API Reference
### `randomColour(alpha?: number): Colour`
Returns a pseudo-random colour. If `alpha` is provided it is clamped to `[0,1]`.
### `fromTemperature(kelvin: number): Colour`
Approximates a black-body colour temperature in Kelvin. Valid range is roughly `1000K` to `40000K`. Values outside this range are clamped.
### `blend(bottom: ColourInput, top: ColourInput, mode: BlendMode): Colour`
Applies a standard blend mode to two colours and returns the result. Supported modes include `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`, `color-dodge`, `color-burn`, `difference`, `exclusion`.
### `steps(start: ColourInput, end: ColourInput, count: number): Colour[]`
Generates `count` evenly spaced colours between `start` and `end` in OKLAB space for perceptual uniformity.
### `palette(base: ColourInput, type: PaletteType, count?: number): Colour[]`
Generates a harmonious palette from a base colour. `count` defaults to `5` for most types. Types:
- `analogous` — colours adjacent on the colour wheel
- `monochromatic` — same hue, varying lightness
- `triadic` — evenly spaced three-colour harmony
- `tetradic` — four-colour rectangular harmony
- `complementary` — opposite hue
- `split-complementary` — opposite hue split by a small angle
## Implementation Notes
All factory functions create new `Colour` instances via the constructor; they never mutate existing instances. The `steps` function uses OKLAB interpolation because it preserves perceptual uniformity better than RGB or HSL. Temperature approximation uses the standard black-body polynomial from Tanner Helland.

View File

@@ -0,0 +1,58 @@
# colour-formatters
## Purpose
`colour-formatters.ts` provides deterministic string-formatting functions for every colour representation supported by `@suaveplan/colours`. Each formatter takes a canonical `RGBA` object and returns a human-readable or machine-parseable string. The module is pure, stateless, and side-effect-free.
## Features
- Hex formatters: 6-digit, 3-digit shorthand, and 8-digit with alpha
- CSS functional notation: `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hsv()`
- CIE and OK colour spaces: `lab()`, `lch()`, `oklab()`, `oklch()`
- Device colour: `cmyk()`
- Named colour lookup with fallback to hex
- Unified `formatColour` dispatcher
## Usage: Basic Example
```ts
import { formatHex, formatRgb } from "@suaveplan/colours/colour";
const rgba = { r: 255, g: 128, b: 0, a: 1 };
console.log(formatHex(rgba)); // "#ff8000"
console.log(formatRgb(rgba)); // "rgb(255 128 0)"
```
## Usage: Advanced Example
```ts
import { formatColour } from "@suaveplan/colours/colour";
function exportColour(rgba: RGBA, targetFormat: string) {
return formatColour(rgba, targetFormat as any);
}
```
## API Reference
- `formatHex(rgba: RGBA): string`
- `formatHex3(rgba: RGBA): string | null`
- `formatHex8(rgba: RGBA): string`
- `formatRgb(rgba: RGBA): string`
- `formatRgba(rgba: RGBA): string`
- `formatHsl(rgba: RGBA): string`
- `formatHsla(rgba: RGBA): string`
- `formatHsv(rgba: RGBA): string`
- `formatLab(rgba: RGBA): string`
- `formatLch(rgba: RGBA): string`
- `formatOklab(rgba: RGBA): string`
- `formatOklch(rgba: RGBA): string`
- `formatCmyk(rgba: RGBA): string`
- `formatNamed(rgba: RGBA): string`
- `formatColour(rgba: RGBA, format?: string): string`
## Implementation Notes
All formatters use fixed-precision rounding appropriate to their colour space. Hex formatters pad single-digit components with leading zeros. The `formatHex3` formatter returns `null` when the colour cannot be exactly represented in three-digit shorthand. `formatNamed` iterates the `NAMED_COLOURS` map and falls back to `formatHex` for unknown colours.
Formatters are grouped together so that the `Colour` class can delegate all string output to a single module. This keeps the class focused on manipulation logic while the formatters handle presentation details.

View File

@@ -0,0 +1,88 @@
# colour
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour.ts` implements the `Colour` class — an immutable, chainable colour value object. It wraps an internal `RGBA` state and exposes methods for manipulation, analysis, conversion, and output formatting. The class is the primary public API surface for the package and is used by the `colour-factories` helpers.
## Features
- Immutable chainable manipulation (lighten, darken, saturate, desaturate, rotate, alpha, fade, opaquer)
- Colour-space conversion methods (hex, RGB, HSL, HSV, LAB, LCH, OKLAB, OKLCH, CMYK)
- Analysis methods (luminance, perceived brightness, contrast ratio, isDark, isLight, best contrast, distance)
- Utility methods (negate, grayscale, mix, clone, equals, toString)
- Pure functional implementation with no mutation of internal state
## Usage: Basic Example
```ts
import { Colour } from "@suaveplan/colours/colour";
const c = new Colour("#ff9900");
console.log(c.lighten(0.2).toHex()); // "#ffb34d"
console.log(c.isDark()); // false
```
## Usage: Advanced Example
```ts
import { Colour } from "@suaveplan/colours/colour";
const brand = new Colour("#3b82f6");
const text = brand.bestContrast("#ffffff", "#1f2937");
console.log(text.toHex()); // "#ffffff"
```
## API Reference
### Constructor
- `new Colour(input: ColourInput)` — parses any supported colour input into an immutable `Colour` instance.
### Manipulation
- `lighten(amount: number): Colour`
- `darken(amount: number): Colour`
- `saturate(amount: number): Colour`
- `desaturate(amount: number): Colour`
- `rotate(degrees: number): Colour`
- `alpha(a: number): Colour`
- `fade(amount: number): Colour`
- `opaquer(amount: number): Colour`
- `negate(): Colour`
- `grayscale(): Colour`
- `mix(other: ColourInput, amount?: number): Colour`
### Analysis
- `luminance(): number`
- `perceivedBrightness(): number`
- `contrast(other: ColourInput): number`
- `isDark(): boolean`
- `isLight(): boolean`
- `bestContrast(...candidates: (Colour | string)[]): Colour`
- `distance(other: ColourInput, mode?: DistanceMode): number`
- `equals(other: ColourInput): boolean`
### Output
- `toHex(): string`
- `toHex3(): string`
- `toHex8(): string`
- `toRgb(): RGB`
- `toRgba(): RGBA`
- `toHsl(): HSL`
- `toHsv(): HSV`
- `toLab(): LAB`
- `toLch(): LCH`
- `toOklab(): OKLAB`
- `toOklch(): OKLCH`
- `toCmyk(): CMYK`
- `toString(): string`
## Implementation Notes
The internal `_rgba` state is never mutated. Every chainable method returns a new `Colour` instance. Conversion methods delegate to the `conversions` module. Analysis methods use the standard WCAG 2.1 luminance formula and sRGB gamma. The `bestContrast` helper uses the APCA-like perceived brightness for candidate ranking.

View File

@@ -0,0 +1,76 @@
# conversions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`conversions.ts` provides deterministic, side-effect-free mathematical transforms between colour spaces. Every function takes a tuple of numbers and returns a tuple of numbers. No object mutation, no I/O, no dependency on any other package logic except basic math. These functions form the numerical foundation for the `Colour` class.
## Features
- RGB to HSL/HSLA and back
- RGB to HSV and back
- RGB to XYZ and back
- XYZ to LAB/LCH and back
- RGB to OKLAB/OKLCH and back
- RGB to CMYK
- Alpha passthrough on every conversion
## Usage: Basic Example
```ts
import { rgbToHsl, hslToRgb } from "@suaveplan/colours/conversions";
const hsl = rgbToHsl({ r: 255, g: 128, b: 0 });
const back = hslToRgb(hsl);
```
## Usage: Advanced Example
```ts
import { colourSteps } from "@suaveplan/colours/colour-factories";
const gradient = colourSteps("#ff0000", "#0000ff", 5);
```
## API Reference
### RGB ↔ HSL
- `rgbToHsl(rgb: RGB): HSL`
- `hslToRgb(hsl: HSL): RGBA`
### RGB ↔ HSV
- `rgbToHsv(rgb: RGB): HSV`
- `hsvToRgb(hsv: HSV, alpha?: number): RGBA`
### RGB ↔ XYZ
- `rgbToXyz(rgb: RGB): XYZ`
- `xyzToRgb(xyz: XYZ, alpha?: number): RGBA`
### XYZ ↔ LAB/LCH
- `xyzToLab(xyz: XYZ): LAB`
- `labToXyz(lab: LAB): XYZ`
- `labToLch(lab: LAB): LCH`
- `lchToLab(lch: LCH): LAB`
### RGB ↔ OKLAB/OKLCH
- `rgbToOklab(rgb: RGB): OKLAB`
- `oklabToRgb(oklab: OKLAB, alpha?: number): RGBA`
- `oklabToOklch(oklab: OKLAB): OKLCH`
- `oklchToRgb(oklch: OKLCH): RGBA`
### RGB ↔ CMYK
- `rgbToCmyk(rgb: RGB): CMYK`
- `cmykToRgb(cmyk: CMYK): RGBA`
## Implementation Notes
All transforms use double-precision arithmetic and standard colour-science matrices. RGB values are normalised to `[0,1]` before matrix multiplication and clamped back to `[0,255]` afterwards. OKLAB uses the Bradley coefficients. CMYK uses the simple 100% minus RGB ratio formula. Alpha is never modified by colour-space conversion.

View File

@@ -0,0 +1,61 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` declares the canonical error-code registry for `@suaveplan/colours`. Every failure mode that the colour package can surface — from invalid hex strings to unsupported CSS colour formats — is assigned a stable, namespaced string via `defineCodes` from `@suaveplan/error`. This gives downstream log analysers, retry policies, and problem+json renderers a deterministic contract they can match against without parsing human-readable messages.
## Features
- Three stable codes under the `COLOUR.*` namespace
- Type-safe `ColourCode` union derived from the registry object
- Zero runtime side effects — the registry is a plain constant
## Usage: Basic Example
```ts
import { COLOUR_CODES } from "@suaveplan/colours/error-codes";
function handleColourError(err: { code: string }) {
if (err.code === COLOUR_CODES.INVALID_INPUT) {
return { status: 400, message: "Invalid colour" };
}
}
```
## Usage: Advanced Example
```ts
import { COLOUR_CODES, type ColourCode } from "@suaveplan/colours/error-codes";
const retryableCodes: ColourCode[] = [
COLOUR_CODES.CONVERSION_FAILED,
];
function shouldRetry(code: ColourCode): boolean {
return retryableCodes.includes(code);
}
```
## API Reference
### `COLOUR_CODES`
A frozen object mapping human-readable keys to namespaced string values:
- `INVALID_INPUT` → `"COLOUR.INVALID_INPUT"`
- `UNSUPPORTED_FORMAT` → `"COLOUR.UNSUPPORTED_FORMAT"`
- `CONVERSION_FAILED` → `"COLOUR.CONVERSION_FAILED"`
### `ColourCode`
A TypeScript union of all three code string literals. Useful for function parameters and discriminated unions.
## Implementation Notes
The registry is produced by `defineCodes("COLOUR", [...])`. If the namespace or code list ever changes, every test that asserts against `COLOUR_CODES.*` will fail, which is the desired behaviour — it prevents silent drift in the error contract.
Error codes are intentionally coarse-grained. They group related failure modes under a single code so that retry policies and alerting rules do not need to track an ever-expanding list of micro-codes. When more detail is required, the factory functions attach a context object with the raw input or format string.

View File

@@ -0,0 +1,65 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`errors.ts` provides the typed error hierarchy for `@suaveplan/colours`. `ColourError` extends `SuaveplanError` so that callers receive structured failures with a stable code, a message key, an optional context object, and an optional cause chain. Typed factory functions make it easy to construct correctly-coded errors without hand-typing string literals.
## Features
- `ColourError` class with `code`, `message`, `context`, and `cause`
- Factory functions for every failure mode
- Context carries `input` and `format` for debugging
## Usage: Basic Example
```ts
import { colourInvalid } from "@suaveplan/colours/errors";
function parseUserColour(input: string) {
if (input.length === 0) {
throw colourInvalid(input);
}
}
```
## Usage: Advanced Example
```ts
import { ColourError, COLOUR_CODES } from "@suaveplan/colours/errors";
function enrichError(err: unknown): ColourError {
if (err instanceof ColourError) return err;
return new ColourError(
COLOUR_CODES.CONVERSION_FAILED,
"Unexpected failure",
{ cause: err instanceof Error ? err : undefined }
);
}
```
## API Reference
### `ColourError`
Extends `SuaveplanError<ColourCode>`.
- `constructor(code, messageKey, options?)` — `options` may include `cause` and `context`.
- `context?: ColourErrorContext` — carries `input` and `format`.
### Factory Functions
- `colourInvalid(input?)`
- `colourUnsupportedFormat(format)`
- `colourConversionFailed(message)`
## Implementation Notes
All factories are thin wrappers — they do not duplicate logic. The `ColourErrorContext` interface extends `Record<string, unknown>` so callers can safely add custom fields without losing type safety.
Every factory accepts an optional input or format value and embeds it in the error context. This makes debugging straightforward because the thrown error carries the exact value that caused the failure. Callers should never construct `ColourError` manually; always use the exported factories to guarantee the correct code and context shape.
The error hierarchy follows the suaveplan standard of extending `SuaveplanError` so that all packages in the project throw errors with a consistent shape. This allows top-level error handlers to treat colour errors the same way they treat network, validation, or auth errors.

View File

@@ -0,0 +1,51 @@
# parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parser.ts` converts arbitrary colour input into a canonical `RGBA` object. It supports hex strings (3/4/6/8 digit), modern and legacy CSS `rgb()` and `hsl()` syntax, CSS Color Module Level 4/5 `lab()`, `lch()`, `oklab()`, and `oklch()` syntax, named CSS colours, and plain JavaScript objects in every supported colour space. The parser is pure and side-effect-free.
## Features
- Hex parsing with optional `#` prefix
- Modern space-separated and legacy comma-separated CSS syntax
- Named colour lookup via `NAMED_COLOURS`
- Object dispatch for RGB, HSL, HSV, LAB, OKLAB, and CMYK objects
- Strict validation with typed `ColourError` throws
- Safe parsing via `tryParseColour` with configurable fallback
## Usage: Basic Example
```ts
import { parseColour } from "@suaveplan/colours/parser";
const rgba = parseColour("#ff9900");
console.log(rgba); // { r: 255, g: 153, b: 0, a: 1 }
```
## Usage: Advanced Example
```ts
import { tryParseColour } from "@suaveplan/colours/parser";
const safe = tryParseColour(userInput, { r: 255, g: 255, b: 255, a: 1 });
```
## API Reference
### `parseColour(input: ColourInput): RGBA`
Returns a canonical `RGBA` object. Throws `ColourError` with code `COLOUR.INVALID_INPUT` or `COLOUR.UNSUPPORTED_FORMAT` on failure.
### `tryParseColour(input: ColourInput, fallback?: RGBA): RGBA`
Returns the parsed `RGBA` object, or `fallback` if parsing fails. The default fallback is black with full opacity (`{ r: 0, g: 0, b: 0, a: 1 }`).
## Implementation Notes
Regex patterns are compiled once at module load and reused for every parse call. Object parsing uses property detection (e.g. `'r' in obj`) rather than `instanceof` to avoid cross-realm issues. Alpha values in hex8 are normalised with `roundAlpha` to three decimal places.
The parser is designed to be strict rather than permissive. It does not attempt to guess intent when input is ambiguous; instead it throws a typed `ColourError` immediately. This fail-fast behaviour makes debugging easier for callers and prevents subtle colour drift caused by silently accepted malformed input. The `tryParseColour` variant provides a convenient escape hatch for callers who need graceful degradation rather than exceptions.

View File

@@ -0,0 +1,61 @@
# colour-types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`colour-types.ts` is the universal type system and Zod schema registry for `@suaveplan/colours`. It defines TypeScript interfaces for every colour space supported by the library, validates object inputs via Zod, and provides a lookup table of named CSS colours. Everything in this module is deterministic and side-effect free.
## Features
- TypeScript interfaces for RGB, RGBA, HSL, HSLA, HSV, LAB, LCH, OKLAB, OKLCH, CMYK
- Zod schemas for runtime validation of each colour-space object
- Named colour map with 19 CSS named colours plus transparent
- Union types for `ColourInput`, `MixMode`, `DistanceMode`, `BlendMode`, and `PaletteType`
## Usage: Basic Example
```ts
import { RgbSchema, NAMED_COLOURS } from "@suaveplan/colours/types";
const rgb = RgbSchema.parse({ r: 255, g: 128, b: 0 });
const red = NAMED_COLOURS.get("red");
```
## Usage: Advanced Example
```ts
import { LabSchema, type LAB } from "@suaveplan/colours/types";
function processLab(input: unknown): LAB {
return LabSchema.parse(input);
}
```
## API Reference
### Zod Schemas
- `RgbSchema`, `RgbaSchema`
- `HslSchema`, `HslaSchema`
- `HsvSchema`
- `LabSchema`, `LchSchema`
- `OklabSchema`, `OklchSchema`
- `CmykSchema`
### Types
- `RGB`, `RGBA`, `HSL`, `HSLA`, `HSV`, `LAB`, `LCH`, `OKLAB`, `OKLCH`, `CMYK`
- `ColourInput`, `MixMode`, `DistanceMode`, `BlendMode`, `PaletteType`
### Constants
- `NAMED_COLOURS` — `ReadonlyMap<string, RGBA>`
## Implementation Notes
The Zod schemas use `.int()` and `.min().max()` bounds to match CSS channel constraints. `NAMED_COLOURS` is a native `Map` wrapped in `ReadonlyMap` to prevent accidental mutation while preserving `O(1)` lookups.
Type definitions are kept as simple readonly interfaces so they can be used in both runtime Zod validation and compile-time TypeScript checks without duplication. The union types for modes and palettes are derived from const arrays, which means adding a new mode updates both the type and any Zod schema that references it automatically.

View File

@@ -0,0 +1,56 @@
# utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`utils.ts` provides small, pure mathematical utilities used throughout `@suaveplan/colours`. Every function is deterministic and side-effect-free, making the module safe to import in any JavaScript runtime. These helpers enforce the numeric invariants that keep colour channels, percentages, and alpha values within their valid ranges.
## Features
- `clamp` — restricts a number to a closed interval
- `isFiniteNumber` — type guard rejecting `NaN` and `Infinity`
- `roundChannel` — clamps and rounds to `[0, 255]`
- `roundPercent` — clamps and rounds to `[0, 100]`
- `roundAlpha` — clamps and rounds to three decimal places in `[0, 1]`
## Usage: Basic Example
```ts
import { clamp, roundChannel } from "@suaveplan/colours/utils";
const value = clamp(300, 0, 255);
console.log(value); // 255
const channel = roundChannel(128.6);
console.log(channel); // 129
```
## Usage: Advanced Example
```ts
import { isFiniteNumber, roundAlpha } from "@suaveplan/colours/utils";
function safeAlpha(input: unknown): number {
if (!isFiniteNumber(input)) return 1;
return roundAlpha(input);
}
```
## API Reference
- `clamp(val, min, max): number`
- `isFiniteNumber(v): v is number`
- `roundChannel(v): number`
- `roundPercent(v): number`
- `roundAlpha(v): number`
## Implementation Notes
`roundAlpha` uses `Math.round(v * 1000) / 1000` to cap precision at three decimal places. This matches the CSS `opacity` resolution and prevents floating-point drift in serialised output.
All utilities are implemented as standalone functions with no shared state. They can be imported individually for tree-shaking or used together as a consistent numeric toolkit. Because every helper is pure, they are safe to call inside hot loops such as pixel shaders or animation frames without risk of side effects.
The clamp function uses simple comparison operators rather than Math.min and Math.max for slightly better performance in tight loops. The isFiniteNumber guard explicitly rejects NaN and Infinity, which is stricter than a plain typeof check and prevents invalid values from propagating through conversion chains.

View File

@@ -0,0 +1,65 @@
# @suaveplan/compress-image - Runtime-agnostic image compression utilities: resize, quality reduction, format conversion, and metadata stripping via pluggable adapters
Runtime-agnostic image compression utilities with pluggable adapters for Node.js (Sharp) and testing (MemoryAdapter). Supports resize, quality reduction, format conversion (JPEG, PNG, WebP, AVIF), and metadata stripping. The compressor core is pure TypeScript with zero runtime dependencies; only the Sharp adapter requires the native `sharp` library.
<!-- begin:auto-docs -->
## Documentation tree
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [memory](./src/adapters/memory/memory.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sharp](./src/adapters/sharp/sharp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sharp](./src/adapters/sharp/sharp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compressor](./src/core/compressor/compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compressor](./src/core/compressor/compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/compress-image-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compress-image-error](./src/errors/compress-image-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/compress-options.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [compress-options.schema](./src/schemas/compress-options.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/image-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [image-utils](./src/utils/image-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/compress-image
```
## Quick Start
```ts
import { createCompressor } from "@suaveplan/compress-image/compressor";
import { SharpAdapter } from "@suaveplan/compress-image/adapters/sharp";
import sharp from "sharp";
const compressor = createCompressor(new SharpAdapter(sharp));
const result = await compressor.compress(imageBuffer, {
format: "webp",
quality: 75,
width: 1280,
height: 720,
resizeMode: "cover",
stripMetadata: true,
});
console.log(result.compressionRatio, result.compressedSize);
```
## Modules
- [`src/core/compressor/compressor.md`](./src/core/compressor/compressor.md) — `ImageCompressor` core and `createCompressor`
- [`src/adapters/memory/memory.md`](./src/adapters/memory/memory.md) — `MemoryAdapter` for testing without native deps
- [`src/adapters/sharp/sharp.md`](./src/adapters/sharp/sharp.md) — `SharpAdapter` for Node.js production compression
- [`src/errors/compress-image-error.md`](./src/errors/compress-image-error.md) — `CompressImageError` and error codes
- [`src/schemas/compress-options.schema.md`](./src/schemas/compress-options.schema.md) — Zod schemas for compression options
- [`src/types/compress-image.types.md`](./src/types/compress-image.types.md) — TypeScript interfaces for results and adapters
- [`src/utils/image-utils.md`](./src/utils/image-utils.md) — Pure TypeScript image utilities
## Subpath Exports
- `.` — All public types, errors, utilities, adapters, and the compressor
- `./compressor` — `ImageCompressor`, `createCompressor`
- `./adapters/memory` — `MemoryAdapter`
- `./adapters/sharp` — `SharpAdapter`, `SharpLike`, `SharpInstance`
- `./errors` — `CompressImageError`, `COMPRESS_IMAGE_CODES`

View File

@@ -0,0 +1,81 @@
# memory-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a pure-TypeScript implementation of `ImageAdapter` that operates entirely on raw bytes — no native addons, no WebAssembly, no external codec dependencies. Its primary purpose is to enable 100% test coverage of the `ImageCompressor` class, Zod validation paths, and OTel instrumentation without requiring Sharp or any platform-specific library.
The adapter parses PNG IHDR chunks and JPEG SOF markers to derive real pixel dimensions from test fixtures, and synthesises `CompressResult` metadata (including a simulated `compressionRatio`) from the requested `quality` option.
## Features
- Implements the full `ImageAdapter` interface (`compress`, `getInfo`, `resize`)
- PNG dimension extraction via IHDR chunk parsing
- JPEG dimension extraction via SOF0/SOF2 marker scanning
- Synthetic `compressionRatio` computation: `ratio = 100 / quality`
- Conservative alpha detection: PNG and WebP are flagged as potentially having alpha
- Throws `CompressImageError` with code `UNSUPPORTED_FORMAT` for unrecognized inputs
- Zero external runtime dependencies
## Usage: Basic Example
```ts
import { MemoryAdapter } from '@suaveplan/compress-image/adapters/memory';
import { createCompressor } from '@suaveplan/compress-image/compressor';
const compressor = createCompressor(new MemoryAdapter());
const result = await compressor.compress(pngBuffer, { quality: 60, format: 'webp' });
console.log(result.compressionRatio); // ~1.67
```
## Usage: Advanced Example
```ts
import { MemoryAdapter } from '@suaveplan/compress-image/adapters/memory';
const adapter = new MemoryAdapter();
// getInfo: detect format and dimensions
const info = await adapter.getInfo(pngBuffer);
// { width: 1, height: 1, format: 'png', size: 68, hasAlpha: true }
// compress: returns original bytes with synthetic metadata
const result = await adapter.compress(pngBuffer, {
quality: 50,
format: 'webp',
width: 200,
height: 200,
});
// result.compressionRatio === 2.0
// result.data === pngBuffer (no actual re-encoding)
// resize: passthrough (no real pixel manipulation)
const resized = await adapter.resize(pngBuffer, 100, 100, 'cover');
// resized === pngBuffer
```
## API Reference
### `class MemoryAdapter implements ImageAdapter`
#### `compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult>`
Returns a `CompressResult` where `data` is identical to `input`. The `format` defaults to the detected input format. `compressionRatio` is computed as `100 / quality` (quality defaults to 80, giving ratio 1.25). `width` and `height` in the result reflect `options.width`/`options.height` when supplied, otherwise the parsed dimensions. Throws `CompressImageError(UNSUPPORTED_FORMAT)` for unrecognized input.
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
Returns `{ width, height, format, size, hasAlpha }` derived from magic-byte parsing. Alpha is `true` for PNG and WebP, `false` for JPEG. AVIF and unrecognized formats fall back to `{ width: 1, height: 1 }`. Throws `CompressImageError(UNSUPPORTED_FORMAT)` if no format is detected.
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
Returns `input` unchanged. Validates that the format is recognizable before returning; throws `CompressImageError(UNSUPPORTED_FORMAT)` otherwise.
## Implementation Notes
The simulated `compressionRatio = 100 / quality` formula is a deliberate simplification. Real compressors produce ratios that depend on image content complexity, format, and encoder state. The formula satisfies the test contract that "higher quality = lower ratio", while being deterministic and dependency-free.
`compressedSize` is `Math.max(1, Math.round(originalSize / compressionRatio))` — the `Math.max(1, ...)` guard prevents a zero byte count when the input buffer is a single byte at quality 100.
WebP and AVIF dimension parsing is not implemented because these formats require a full codec to parse the container headers. The fallback `{ width: 1, height: 1 }` is clearly documented so test authors set explicit `width`/`height` options when asserting on those fields.

View File

@@ -0,0 +1,102 @@
# sharp-adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a production-grade `ImageAdapter` implementation backed by the Sharp image processing library. Sharp delivers native-speed JPEG, PNG, WebP, and AVIF encoding through libvips, making it the recommended adapter for server-side workloads where pixel-accurate compression and real resizing are required.
Sharp is deliberately **not** a direct dependency of this package. The caller supplies a Sharp factory function at construction time, which eliminates the native-addon from the `@suaveplan/compress-image` dependency tree and allows the package to be used in environments where Sharp cannot be installed (e.g., edge runtimes, bundled frontends, or CI test runs).
## Features
- Full `ImageAdapter` implementation using the real Sharp pipeline
- JPEG progressive encoding, PNG compression levels, WebP lossless, AVIF effort tuning
- Metadata stripping by default; opt-in preservation via `stripMetadata: false`
- `ResizeMode` mapped to Sharp's `fit` option (cover / contain / fill / inside / outside)
- Caller-supplied Sharp factory for dependency injection
- Wraps all Sharp errors in `CompressImageError` with cause-chain forwarding
## Usage: Basic Example
```ts
import sharp from 'sharp';
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
import { createCompressor } from '@suaveplan/compress-image/compressor';
const compressor = createCompressor(new SharpAdapter(sharp));
const result = await compressor.compress(imageBuffer, {
format: 'webp',
quality: 75,
width: 800,
height: 600,
});
```
## Usage: Advanced Example
```ts
import sharp from 'sharp';
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
const adapter = new SharpAdapter(sharp);
// Get metadata
const info = await adapter.getInfo(jpegBuffer);
console.log(info.width, info.height, info.format, info.hasAlpha);
// Convert JPEG to AVIF with high compression effort
const result = await adapter.compress(jpegBuffer, {
format: 'avif',
quality: 60,
effort: 6,
stripMetadata: true,
});
// Resize to thumbnail keeping aspect ratio (contain mode)
const thumb = await adapter.resize(jpegBuffer, 128, 128, 'contain');
```
## API Reference
### `interface SharpLike`
A minimal factory interface matching the Sharp module export signature. Implement this to inject a mock in tests.
```ts
interface SharpLike {
(input: Buffer | Uint8Array): SharpInstance;
}
```
### `interface SharpInstance`
The fluent pipeline instance returned by `SharpLike`. Covers the methods used by `SharpAdapter`.
### `class SharpAdapter implements ImageAdapter`
#### `constructor(sharp: SharpLike)`
Stores the provided Sharp factory. No I/O occurs at construction time.
#### `compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult>`
Runs the Sharp pipeline with format conversion, quality settings, and optional resize. Returns a `CompressResult` with real output bytes and actual dimensions. Throws `CompressImageError(UNSUPPORTED_FORMAT)` for undetectable inputs; throws `CompressImageError(COMPRESS_FAILED)` for all other Sharp errors.
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
Calls `sharp(input).metadata()` to extract width, height, format, and alpha channel information. Throws `CompressImageError(UNSUPPORTED_FORMAT)` if Sharp returns a format not in the known set.
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
Resizes using `sharp.resize()` with the mapped `fit` option. Returns a `Uint8Array` wrapping the Sharp output `Buffer`. Default mode is `'cover'`.
## Implementation Notes
Format encoding options are mapped per-format: JPEG receives `quality` and `progressive`; WebP receives `quality`, `lossless`, and `effort`; AVIF receives `quality` and `effort`; PNG receives `compressionLevel` (mapped from `effort`) but no quality parameter since PNG is lossless.
A second `.metadata()` pass on the output buffer is used to derive the final `width` and `height` in `CompressResult`. This adds a small overhead but guarantees accuracy after resize operations where the actual output dimensions may differ from the requested values (e.g., when using `inside` or `outside` fit modes).
Sharp's `Buffer` is wrapped in `new Uint8Array(buffer)` to satisfy the `Uint8Array` contract in `CompressResult.data`, ensuring interoperability with environments that do not use Node.js `Buffer`.

View File

@@ -0,0 +1,105 @@
# compressor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ImageCompressor` class is the primary entry point for the `@suaveplan/compress-image` package. It orchestrates all image processing operations through a pluggable `ImageAdapter`, adds Zod-based option validation, dimension range checking, and OpenTelemetry instrumentation, and exposes a convenience `compressIfLarger` method for conditional compression pipelines.
Application code should interact with `ImageCompressor` (or the `createCompressor` factory) rather than calling adapters directly, to benefit from validation and observability.
## Features
- Pluggable adapter pattern: swap `MemoryAdapter` ↔ `SharpAdapter` without changing application code
- Zod option validation with structured `CompressImageError(INVALID_OPTIONS)` on failure
- Dimension range validation (1–32767 px) before delegating to adapter
- OTel counter `compress_image.compress.total{format, outcome}` — incremented on every `compress()` call
- OTel histogram `compress_image.compress.duration_ms{format, outcome}` — recorded per call
- `compressIfLarger` for size-threshold-based conditional compression
- `createCompressor` factory for functional-style initialization
## Usage: Basic Example
```ts
import { createCompressor } from '@suaveplan/compress-image/compressor';
// Default adapter: MemoryAdapter (testing)
const compressor = createCompressor();
const result = await compressor.compress(pngBuffer, { quality: 80 });
console.log(result.compressionRatio, result.format);
```
## Usage: Advanced Example
```ts
import sharp from 'sharp';
import { createCompressor } from '@suaveplan/compress-image/compressor';
import { SharpAdapter } from '@suaveplan/compress-image/adapters/sharp';
const compressor = createCompressor(new SharpAdapter(sharp));
// Compress with resize and format conversion
const result = await compressor.compress(rawBuffer, {
format: 'webp',
quality: 75,
width: 1280,
height: 720,
resizeMode: 'contain',
stripMetadata: true,
progressive: false,
effort: 5,
});
// Conditionally compress: only if > 200 KB
const maybe = await compressor.compressIfLarger(rawBuffer, 200 * 1024, {
format: 'jpeg',
quality: 70,
});
// Get info without compressing
const info = await compressor.getInfo(rawBuffer);
// Resize only
const thumb = await compressor.resize(rawBuffer, 128, 128, 'cover');
```
## API Reference
### `class ImageCompressor`
#### `constructor(adapter?: ImageAdapter)`
Creates a new compressor. Defaults to `MemoryAdapter` when no adapter is provided.
#### `setAdapter(adapter: ImageAdapter): void`
Replaces the active adapter. Useful for switching between testing and production adapters at runtime.
#### `compress(input: Uint8Array, options?: CompressOptions): Promise<CompressResult>`
Validates options with Zod, checks dimension bounds, delegates to the adapter, and records OTel metrics. Returns a `CompressResult`. Throws `CompressImageError(INVALID_OPTIONS)` on bad options; throws `CompressImageError(UNSUPPORTED_FORMAT)` or `CompressImageError(COMPRESS_FAILED)` from the adapter.
#### `getInfo(input: Uint8Array): Promise<ImageInfo>`
Delegates directly to `adapter.getInfo()` — no option validation required.
#### `resize(input: Uint8Array, width: number, height: number, mode?: ResizeMode): Promise<Uint8Array>`
Validates `width` and `height` ranges, then delegates to `adapter.resize()`. Throws `CompressImageError(INVALID_OPTIONS)` for out-of-range dimensions.
#### `compressIfLarger(input: Uint8Array, maxBytes: number, options?: CompressOptions): Promise<CompressResult>`
Returns the original bytes wrapped in a synthetic `CompressResult` (`compressionRatio = 1`) when `input.length <= maxBytes`. Otherwise delegates to `compress()`. Always returns `CompressResult` so downstream code has a consistent return type.
### `createCompressor(adapter?: ImageAdapter): ImageCompressor`
Equivalent to `new ImageCompressor(adapter)`. Returns a configured `ImageCompressor`.
## Implementation Notes
OTel instruments are lazily initialized on first use inside `getCounter()` and `getDuration()`. This avoids registering metrics at module load time, which would interfere with test environments that configure the OTel SDK after imports are resolved.
The `format` OTel attribute defaults to `'unknown'` when `options.format` is not specified, since the detected format is not available until the adapter resolves it. This is a deliberate trade-off: the counter fires unconditionally so monitoring captures 100% of calls.
`compressIfLarger` calls `adapter.getInfo()` for the bypass path to populate `format`, `width`, and `height` in the returned `CompressResult`. This costs one metadata parse but gives callers a usable result regardless of whether compression ran.

View File

@@ -0,0 +1,82 @@
# compress-image-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error class and stable code registry for the `@suaveplan/compress-image` package. All compression failures are surfaced through `CompressImageError`, which extends the project-wide `SuaveplanError` base class to guarantee consistent wire format, cause-chain traversal, OTel span-event integration, and Zod-validated JSON/binary serialization.
Centralising codes in `COMPRESS_IMAGE_CODES` (produced by `defineCodes`) ensures that every error string is unique within the project namespace and will not silently change across package updates.
## Features
- Stable `COMPRESS_IMAGE_CODES` registry with four codes covering all failure surfaces
- `CompressImageError` class with typed `code` property for narrow `instanceof` discrimination
- Full cause-chain forwarding via the `cause` option
- Compatible with the `SuaveplanError` OTel adapter and redaction middleware
- Zero external runtime dependencies beyond `@suaveplan/error`
## Usage: Basic Example
```ts
import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
throw new CompressImageError(
'Unsupported input format: tiff',
COMPRESS_IMAGE_CODES.UNSUPPORTED_FORMAT,
);
```
## Usage: Advanced Example
```ts
import { CompressImageError, COMPRESS_IMAGE_CODES } from '@suaveplan/compress-image/errors';
async function runCompress(input: Uint8Array) {
try {
await adapter.compress(input, { quality: 80 });
} catch (err) {
throw new CompressImageError(
'Compression pipeline failed',
COMPRESS_IMAGE_CODES.COMPRESS_FAILED,
{ cause: err instanceof Error ? err : undefined },
);
}
}
```
## API Reference
### `COMPRESS_IMAGE_CODES`
An object map produced by `defineCodes('COMPRESS_IMAGE', { ... })`. Each value is a stable string code of the form `COMPRESS_IMAGE.<KEY>`.
| Key | Description |
|---|---|
| `UNSUPPORTED_FORMAT` | Input image format is not recognized or not supported by the adapter |
| `COMPRESS_FAILED` | The compression operation itself threw an error |
| `INVALID_OPTIONS` | Zod validation rejected the provided `CompressOptions` |
| `ADAPTER_NOT_SET` | `compress` / `resize` / `getInfo` was called before an adapter was configured |
### `CompressImageError`
```ts
class CompressImageError extends SuaveplanError {
constructor(message: string, code: string, options?: { cause?: Error }): void
}
```
**Parameters**
| Name | Type | Description |
|---|---|---|
| `message` | `string` | Human-readable description of the failure |
| `code` | `string` | A value from `COMPRESS_IMAGE_CODES` |
| `options.cause` | `Error \| undefined` | Originating error for cause-chain traversal |
## Implementation Notes
`SuaveplanError` stores `code` on its instance and enforces the Zod-validated wire format so errors survive cross-process transport. The `name = 'CompressImageError'` assignment enables `instanceof` checks even after deserialization with the error registry.
The constructor signature deviates slightly from `DatetimeError` — it accepts a plain `string` message rather than a messageKey because image compression errors carry enough context inline and do not require i18n resolution.

View File

@@ -0,0 +1,103 @@
# compress-options.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a Zod schema (`CompressOptionsSchema`) that validates and normalises
`CompressOptions` at runtime before any image-processing work begins. Keeping
validation in a dedicated schema file separates concerns cleanly: the
compressor delegates option-checking to this module and never needs to repeat
range guards inline.
## Features
- `format` — optional enum restricted to `"jpeg" | "png" | "webp" | "avif"`
- `quality` — integer `1–100`; rejects floats and out-of-range values
- `width` / `height` — positive integers capped at 32767 (JPEG/PNG spec limit)
- `resizeMode` — optional enum of five Sharp `fit` strategies
- `stripMetadata` / `progressive` / `lossless` — optional booleans
- `effort` — integer `0–6` for compression effort / speed trade-off
- Re-exports `CompressOptionsInput` (pre-parse) and `CompressOptionsParsed` (post-parse) type aliases for strong typing at call sites
## Usage: Basic Example
```ts
import { CompressOptionsSchema } from '@suaveplan/compress-image/schemas';
const result = CompressOptionsSchema.safeParse({ quality: 85, format: 'webp' });
if (!result.success) {
console.error(result.error.issues);
} else {
const opts = result.data; // CompressOptionsParsed
}
```
## Usage: Advanced Example
```ts
import { CompressOptionsSchema } from '@suaveplan/compress-image/schemas';
import type { CompressOptionsInput } from '@suaveplan/compress-image/schemas';
function toCompressOptions(raw: unknown): CompressOptionsInput {
// Strict parse — throws ZodError on invalid input
return CompressOptionsSchema.parse(raw);
}
// Combining with ImageCompressor validation:
// The ImageCompressor calls CompressOptionsSchema.safeParse internally and
// maps ZodError issues into a structured CompressImageError(INVALID_OPTIONS).
// Direct use of this schema is useful in API route handlers that want to
// validate options before constructing an ImageCompressor.
const validated = CompressOptionsSchema.safeParse({
format: 'avif',
quality: 60,
width: 1920,
height: 1080,
resizeMode: 'cover',
effort: 4,
stripMetadata: true,
});
```
## API Reference
### `CompressOptionsSchema`
`ZodObject` schema validating `CompressOptions`. All fields are optional.
| Field | Type | Constraints |
|----------------|-----------------------------------|----------------------------|
| `format` | `"jpeg" \| "png" \| "webp" \| "avif"` | enum |
| `quality` | `number` | int, 1–100 |
| `width` | `number` | int, 1–32767 |
| `height` | `number` | int, 1–32767 |
| `resizeMode` | `ResizeMode` | enum of 5 strategies |
| `stripMetadata`| `boolean` | optional |
| `progressive` | `boolean` | optional |
| `lossless` | `boolean` | optional |
| `effort` | `number` | int, 0–6 |
### `CompressOptionsInput`
`z.input<typeof CompressOptionsSchema>` — the shape of data accepted before parsing (all fields optional).
### `CompressOptionsParsed`
`z.output<typeof CompressOptionsSchema>` — the shape returned after successful parsing.
## Implementation Notes
The schema intentionally mirrors the `CompressOptions` interface defined in
`compress-image.types.ts`. No coercion is applied; callers are expected to
pass the correct JS types. Integer constraints (`z.number().int()`) are
necessary because Sharp interprets quality as a C `int`; passing `80.5` would
produce unpredictable output.
Effort range `0–6` follows the WebP/AVIF encoder convention. Sharp maps this
field directly to the codec's speed parameter, where 0 is fastest (lower
quality at equal bit-rate) and 6 is slowest (best compression).
The schema is intentionally stateless and side-effect-free — it is safe to
import and reuse across workers, edge functions, and test helpers.

View File

@@ -0,0 +1,149 @@
# compress-image.types
## Purpose
Defines all public TypeScript types and interfaces for the
`@suaveplan/compress-image` package. Keeping type definitions in a single
dedicated file avoids circular imports between adapter and compressor modules
and makes it straightforward for consumers to import only the types they need
without pulling in any runtime code.
## Features
- `ImageFormat` — union of four supported output formats (`jpeg`, `png`, `webp`, `avif`)
- `ResizeMode` — five resize strategies mirroring the Sharp `fit` option
- `ColorSpace` — two colour-space choices (`srgb`, `p3`)
- `CompressOptions` — all parameters accepted by `ImageCompressor.compress()`
- `CompressResult` — structured result returned after a compress call
- `ImageInfo` — image metadata returned by `getInfo()` without modifying the image
- `ImageAdapter` — pluggable backend interface implemented by `MemoryAdapter` and `SharpAdapter`
## Usage: Basic Example
```ts
import type {
CompressOptions,
CompressResult,
ImageFormat,
} from '@suaveplan/compress-image';
function makeWebpOptions(quality: number): CompressOptions {
return { format: 'webp' as ImageFormat, quality };
}
async function printResult(result: CompressResult): Promise<void> {
console.log(`Compressed to ${result.compressedSize} bytes (ratio ${result.compressionRatio.toFixed(2)})`);
}
```
## Usage: Advanced Example
```ts
import type { ImageAdapter, CompressOptions, CompressResult, ImageInfo, ResizeMode } from '@suaveplan/compress-image';
// Implement a custom adapter backed by a WASM codec:
class WasmAdapter implements ImageAdapter {
async compress(input: Uint8Array, options: CompressOptions): Promise<CompressResult> {
// delegate to WASM encoder...
return {
data: input,
format: options.format ?? 'jpeg',
width: 100,
height: 100,
originalSize: input.length,
compressedSize: input.length,
compressionRatio: 1,
};
}
async getInfo(input: Uint8Array): Promise<ImageInfo> {
return { width: 100, height: 100, format: 'jpeg', size: input.length, hasAlpha: false };
}
async resize(input: Uint8Array, _w: number, _h: number, _mode?: ResizeMode): Promise<Uint8Array> {
return input;
}
}
```
## API Reference
### `ImageFormat`
```ts
type ImageFormat = "jpeg" | "png" | "webp" | "avif";
```
### `ResizeMode`
```ts
type ResizeMode = "cover" | "contain" | "fill" | "inside" | "outside";
```
Mirrors the Sharp `fit` option. `cover` crops to fill; `contain` letterboxes; `fill` stretches; `inside` shrinks only; `outside` enlarges only.
### `ColorSpace`
```ts
type ColorSpace = "srgb" | "p3";
```
### `CompressOptions`
All fields optional.
| Field | Type | Description |
|-----------------|---------------|----------------------------------------------------------|
| `format` | `ImageFormat` | Target format. Defaults to detected input format. |
| `quality` | `number` | 1–100 quality level. Default 80. |
| `width` | `number` | Target width in pixels. |
| `height` | `number` | Target height in pixels. |
| `resizeMode` | `ResizeMode` | Resize strategy. Default `"cover"`. |
| `stripMetadata` | `boolean` | Strip EXIF and other metadata. Default `true`. |
| `progressive` | `boolean` | Enable progressive JPEG. Default `false`. |
| `lossless` | `boolean` | WebP lossless encoding. Default `false`. |
| `effort` | `number` | Compression effort 0–6. Default 4. |
### `CompressResult`
| Field | Type | Description |
|--------------------|---------------|------------------------------------------------------|
| `data` | `Uint8Array` | Compressed image bytes. |
| `format` | `ImageFormat` | Output format. |
| `width` | `number` | Output width in pixels. |
| `height` | `number` | Output height in pixels. |
| `originalSize` | `number` | Input byte length. |
| `compressedSize` | `number` | Output byte length. |
| `compressionRatio` | `number` | `originalSize / compressedSize` (>1 = effective compression). |
### `ImageInfo`
| Field | Type | Description |
|------------|---------------|------------------------------------------|
| `width` | `number` | Image width in pixels. |
| `height` | `number` | Image height in pixels. |
| `format` | `ImageFormat` | Detected image format. |
| `size` | `number` | File size in bytes. |
| `hasAlpha` | `boolean` | Whether the image has an alpha channel. |
### `ImageAdapter`
Interface for pluggable image-processing backends.
| Method | Signature | Description |
|-------------|---------------------------------------------------------------------------|-----------------------------------|
| `compress` | `(input: Uint8Array, options: CompressOptions) => Promise<CompressResult>`| Compress with given options. |
| `getInfo` | `(input: Uint8Array) => Promise<ImageInfo>` | Extract metadata without encoding.|
| `resize` | `(input: Uint8Array, w: number, h: number, mode?: ResizeMode) => Promise<Uint8Array>` | Resize to target dimensions. |
## Implementation Notes
These types are pure TypeScript interfaces — they emit no runtime code and add
no bundle weight. The `ImageAdapter` interface is the primary extension point:
any backend (Sharp, WebAssembly, Canvas API, remote service) that satisfies
the three-method contract can be plugged into `ImageCompressor` via the
`setAdapter()` method or the `createCompressor()` factory.
`ColorSpace` is defined here for forward compatibility but is not yet consumed
by any built-in adapter. It is available for custom adapters that support
wide-gamut output.

View File

@@ -0,0 +1,89 @@
# image-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides low-level, dependency-free utility functions for working with raw image bytes. These utilities power both the `MemoryAdapter` (testing) and the `SharpAdapter` (production), and are also available to application code for format detection and dimension extraction without instantiating a full adapter.
All functions operate on `Uint8Array` buffers and are designed to be pure and free of side effects, enabling tree-shaking and straightforward unit testing.
## Features
- Magic-byte format detection for JPEG, PNG, WebP, and AVIF
- PNG IHDR chunk dimension parser (big-endian uint32 at fixed offsets)
- JPEG SOF0/SOF2 marker scanner for baseline and progressive dimension extraction
- Aspect ratio calculation using the Euclidean GCD algorithm
- Dimension range validation with descriptive `CompressImageError` on failure
## Usage: Basic Example
```ts
import { detectFormat, parsePngDimensions } from '@suaveplan/compress-image';
const format = detectFormat(buffer); // 'png' | 'jpeg' | 'webp' | 'avif' | null
const dims = parsePngDimensions(buffer);
if (dims) {
console.log(dims.width, dims.height);
}
```
## Usage: Advanced Example
```ts
import {
detectFormat,
parseJpegDimensions,
validateDimensions,
calculateAspectRatio,
} from '@suaveplan/compress-image';
function analyzeImage(data: Uint8Array) {
const format = detectFormat(data);
if (!format) throw new Error('Unknown format');
let dims: { width: number; height: number } | null = null;
if (format === 'jpeg') dims = parseJpegDimensions(data);
else if (format === 'png') dims = parsePngDimensions(data);
if (!dims) throw new Error('Could not determine dimensions');
// Validate target dimensions before a resize
validateDimensions(800, 600);
const ratio = calculateAspectRatio(dims.width, dims.height);
console.log(`Aspect ratio: ${ratio.width}:${ratio.height}`);
}
```
## API Reference
### `detectFormat(data: Uint8Array): ImageFormat | null`
Inspects magic bytes at the beginning of `data` to determine the image format. Returns `null` for unrecognized signatures. Requires at least 4 bytes; WebP and AVIF detection requires 12 bytes.
### `validateDimensions(width?: number, height?: number): void`
Validates that the provided dimensions (when present) are positive integers not exceeding 32767. Throws `CompressImageError` with code `INVALID_OPTIONS` on any violation.
### `calculateAspectRatio(width: number, height: number): { width: number; height: number }`
Returns the reduced aspect ratio using the Euclidean GCD. For example `(1920, 1080)` returns `{ width: 16, height: 9 }`.
### `parsePngDimensions(data: Uint8Array): { width: number; height: number } | null`
Reads the IHDR chunk at the fixed offset in a PNG file (bytes 16–23) and returns the width and height. Returns `null` if the magic bytes are wrong, the buffer is too short, or the IHDR tag is not found.
### `parseJpegDimensions(data: Uint8Array): { width: number; height: number } | null`
Scans the JPEG segment stream for SOF0 (0xFF 0xC0) or SOF2 (0xFF 0xC2) markers and reads the frame header dimensions. Returns `null` if no SOF marker is found or the buffer is too short.
## Implementation Notes
The AVIF detection reads the `ftyp` box major brand field at bytes 8–11. This covers `avif` (still image) and `avis` (AVIF image sequence). Files with compatible brands listed only in the compatible-brands array (not the major brand) are not detected; this is an acceptable limitation for a magic-byte heuristic.
`parseJpegDimensions` handles 0xFF padding bytes and segment-length arithmetic correctly, but does not attempt to parse progressive scans beyond SOF2. DNL (Define Number of Lines) segments used in some streaming encoders are not supported.
Dimension overflow: width and height are read as unsigned 32-bit big-endian integers and immediately checked against 0 to reject pathological inputs before they can propagate.

View File

@@ -0,0 +1,134 @@
# @suaveplan/emblem-solver - Minimal-rectangle SVG converter for pixelated sprite emblems: layered occlusion, overlapping rect optimisation, dual-axis maximal rectangles, strip decomposition, and exhaustive layer ordering
Minimal-rectangle SVG converter for pixelated sprite emblems.
Converts a pixel grid (`RGBA[]`) into the smallest possible set of
axis-aligned coloured rectangles by combining four covering strategies
(dual-axis maximal rectangles + greedy cover, per-island independent
cover, strip decomposition, adjacent-rect merge), pairwise + triple-wise
overlap reductions, and exhaustive z-stacking search across up to 8! layer
orderings. Outputs a deterministic SVG string ready for direct embedding
or further compression.
<!-- begin:auto-docs -->
## Documentation tree
- [converter](./src/converter/converter.md) — This module is the public-facing entry point of `@suaveplan/emblem-solver`. It coordinates the entire pipeline — schema…
- [converter](./src/converter/converter.md) — This module is the public-facing entry point of `@suaveplan/emblem-solver`. It coordinates the entire pipeline — schema…
- [errors](./src/errors/errors.md) — This module exposes the typed error surface for `@suaveplan/emblem-solver`. It defines the stable, namespaced code regi…
- [errors](./src/errors/errors.md) — This module exposes the typed error surface for `@suaveplan/emblem-solver`. It defines the stable, namespaced code regi…
- [fixtures](./src/fixtures/fixtures.md) — This module is the canonical test-fixture surface for the package. Each exported function returns a deterministic `RGBA…
- [fixtures](./src/fixtures/fixtures.md) — This module is the canonical test-fixture surface for the package. Each exported function returns a deterministic `RGBA…
- [analyse](./src/grid/analyse/analyse.md) — This module owns `analyseGrid`, the diagnostic phase of the conversion pipeline. It walks an RGBA grid once, buckets op…
- [analyse](./src/grid/analyse/analyse.md) — This module owns `analyseGrid`, the diagnostic phase of the conversion pipeline. It walks an RGBA grid once, buckets op…
- [helpers](./src/helpers/helpers.md) — This module is the shared kernel of utility functions used by the analyser, the solver strategies, and the optimisers.…
- [helpers](./src/helpers/helpers.md) — This module is the shared kernel of utility functions used by the analyser, the solver strategies, and the optimisers.…
- [parse](./src/io/parse/parse.md) — This module owns the two import paths into the solver: `parseRGBABuffer` turns a raw RGBA `Uint8Array` (e.g. the output…
- [parse](./src/io/parse/parse.md) — This module owns the two import paths into the solver: `parseRGBABuffer` turns a raw RGBA `Uint8Array` (e.g. the output…
- [merge](./src/optimizer/merge/merge.md) — This module owns `mergeAdjacentRects`, the post-processing pass that combines pairs of edge-sharing same-extent rectang…
- [merge](./src/optimizer/merge/merge.md) — This module owns `mergeAdjacentRects`, the post-processing pass that combines pairs of edge-sharing same-extent rectang…
- [overlap](./src/optimizer/overlap/overlap.md) — This module owns `optimiseOverlap`, the deepest cover-reduction pass in the package. It looks for three patterns that t…
- [overlap](./src/optimizer/overlap/overlap.md) — This module owns `optimiseOverlap`, the deepest cover-reduction pass in the package. It looks for three patterns that t…
- [redundancy](./src/optimizer/redundancy/redundancy.md) — This module owns `eliminateRedundant`, the final cross-layer optimisation the converter runs after every layer's cover…
- [redundancy](./src/optimizer/redundancy/redundancy.md) — This module owns `eliminateRedundant`, the final cross-layer optimisation the converter runs after every layer's cover…
- [schemas](./src/schemas/schemas.md) — This module owns the runtime validation surface for `@suaveplan/emblem-solver`. It exposes `ConvertOptionsSchema`, a Zo…
- [schemas](./src/schemas/schemas.md) — This module owns the runtime validation surface for `@suaveplan/emblem-solver`. It exposes `ConvertOptionsSchema`, a Zo…
- [greedy](./src/solver/greedy/greedy.md) — This module provides the greedy selector that drives the primary covering strategy. `enhancedGreedyCover` picks rectang…
- [greedy](./src/solver/greedy/greedy.md) — This module provides the greedy selector that drives the primary covering strategy. `enhancedGreedyCover` picks rectang…
- [histogram](./src/solver/histogram/histogram.md) — This module enumerates every distinct maximal-rectangle candidate inside an opaque-pixel grid. It is the candidate pool…
- [histogram](./src/solver/histogram/histogram.md) — This module enumerates every distinct maximal-rectangle candidate inside an opaque-pixel grid. It is the candidate pool…
- [layer](./src/solver/layer/layer.md) — This module owns `processLayerMultiStrategy`, the function the converter calls once per layer in the current ordering.…
- [layer](./src/solver/layer/layer.md) — This module owns `processLayerMultiStrategy`, the function the converter calls once per layer in the current ordering.…
- [ordering](./src/solver/ordering/ordering.md) — This module provides the layer-ordering machinery the converter consults when it has to decide which colour sits on the…
- [ordering](./src/solver/ordering/ordering.md) — This module provides the layer-ordering machinery the converter consults when it has to decide which colour sits on the…
- [strip](./src/solver/strip/strip.md) — This module implements `stripDecomposition`, a complementary cover strategy that ignores the maximal-rect candidate poo…
- [strip](./src/solver/strip/strip.md) — This module implements `stripDecomposition`, a complementary cover strategy that ignores the maximal-rect candidate poo…
- [build](./src/svg/build/build.md) — This module owns `buildSVG`, the deterministic SVG serialiser the converter uses to produce its final `<svg>` string. I…
- [build](./src/svg/build/build.md) — This module owns `buildSVG`, the deterministic SVG serialiser the converter uses to produce its final `<svg>` string. I…
- [telemetry](./src/telemetry/telemetry.md) — This module owns the OpenTelemetry instruments emitted by `convert()`. It exposes a counter (`emblem_solver.convert.tot…
- [telemetry](./src/telemetry/telemetry.md) — This module owns the OpenTelemetry instruments emitted by `convert()`. It exposes a counter (`emblem_solver.convert.tot…
- [types](./src/types/types.md) — This module is the single source of truth for the public type surface of `@suaveplan/emblem-solver`. It defines the imm…
- [types](./src/types/types.md) — This module is the single source of truth for the public type surface of `@suaveplan/emblem-solver`. It defines the imm…
<!-- end:auto-docs -->
## Install
```sh
bun add @suaveplan/emblem-solver
```
## Quick start
```ts
import { convert, fromIndexedGrid } from "@suaveplan/emblem-solver";
const { pixels, width, height } = fromIndexedGrid(
[
[0, 1, 0],
[1, 1, 1],
[0, 1, 0],
],
new Map([[1, { r: 255, g: 0, b: 0 }]])
);
const { svg, totalRects, ratio } = convert(pixels, width, height);
console.log(svg); // <svg …>…</svg>
console.log(totalRects); // 2 — the cross collapses to two overlapping rects
console.log(ratio.toFixed(1)); // 2.5 — naïve per-pixel would have been 5 rects
```
## API surface
| Symbol | Subpath | Purpose |
|---|---|---|
| `convert` | root | Public orchestrator |
| `analyseGrid` | `/grid` | Per-colour islands, density, fragmentation |
| `parseRGBABuffer`, `fromIndexedGrid` | `/io` | Input adapters |
| `buildSVG` | `/svg` | Standalone serialiser |
| `dualAxisMaximalRects`, `enhancedGreedyCover`, `perIslandCover`, `stripDecomposition`, `processLayerMultiStrategy`, `permutations`, `factorial`, `getStaticOrdering` | `/solver` | Composition primitives |
| `mergeAdjacentRects`, `optimiseOverlap`, `eliminateRedundant` | `/optimizer` | Composition primitives |
| `ConvertOptionsSchema` | `/schemas` | Zod input validation |
| `EmblemSolverError`, `EMBLEM_SOLVER_CODES` | `/errors` | Typed errors |
## Options
```ts
interface ConvertOptions {
/** Default: "auto" */
layerOrder?: "auto" | "area-desc" | "area-asc" | "island-aware" | "perimeter-desc";
/** Default: 40320 (= 8!) */
maxPermutations?: number;
/** Default: Math.random — supply a seeded RNG for deterministic auto-ordering on > 8-colour inputs */
rng?: () => number;
}
```
## Errors
All failures throw `EmblemSolverError` whose `.code` is one of:
- `EMBLEM_SOLVER.INVALID_GRID` — dimensions non-positive or
`pixels.length !== width * height`.
- `EMBLEM_SOLVER.INVALID_OPTIONS` — Zod rejected the options bag.
- `EMBLEM_SOLVER.INVALID_PIXEL` — reserved for future use.
## Observability
`convert()` emits two OpenTelemetry instruments via the meter
`@suaveplan/emblem-solver`:
- Counter `emblem_solver.convert.total`
- Histogram `emblem_solver.convert.duration_ms` (unit: `ms`)
Instruments are registered lazily on first call, so importing the package
has no observability side effects.
## Tier
Universal: zero browser / Node / React dependencies. Runs unchanged on
Bun, Node, Deno, and modern browsers.
## License
MIT — Suaveplan Team.

View File

@@ -0,0 +1,98 @@
# `converter/converter.ts` — public `convert()` orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the public-facing entry point of `@suaveplan/emblem-solver`.
It coordinates the entire pipeline — schema validation, grid validation,
grid analysis, ordering search, per-layer covering, cross-layer redundancy
elimination, SVG serialisation, and OpenTelemetry instrument recording —
into a single deterministic call: `convert(pixels, width, height,
options?)`.
## Features
- Zod-validated options with strict-mode unknown-key rejection.
- Grid validation (`width > 0`, `height > 0`, `pixels.length === w*h`).
- Two orderings paths: exhaustive permutation search when
`factorial(colourCount) ≤ maxPermutations`, otherwise four static
heuristics plus up to 1000 RNG-driven random samples.
- Injectable `rng` for deterministic auto-ordering on > 8-colour inputs.
- OTel counter + histogram recorded around the algorithmic core.
- Early-return for fully-transparent input — emits a viewBox-only SVG and
zero layers.
## Usage: Basic
```ts
import { convert, fromIndexedGrid } from "@suaveplan/emblem-solver";
const { pixels, width, height } = fromIndexedGrid(
[
[0, 1, 0],
[1, 1, 1],
[0, 1, 0],
],
new Map([[1, { r: 255, g: 0, b: 0 }]])
);
const result = convert(pixels, width, height);
console.log(result.totalRects, "rects;", result.svg.length, "bytes");
```
## Usage: Advanced
```ts
import { convert } from "@suaveplan/emblem-solver";
// Seeded RNG → deterministic auto-mode output on >8-colour inputs.
function mulberry32(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const a = convert(pixels, w, h, {
layerOrder: "auto",
maxPermutations: 16,
rng: mulberry32(123),
});
const b = convert(pixels, w, h, {
layerOrder: "auto",
maxPermutations: 16,
rng: mulberry32(123),
});
console.assert(a.svg === b.svg, "deterministic round-trip");
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `convert(pixels, w, h, options?)` | fn | Returns `ConvertResult` |
| Throws | When |
|---|---|
| `EmblemSolverError(INVALID_OPTIONS)` | Zod schema rejects `options` |
| `EmblemSolverError(INVALID_GRID)` | non-positive dims or `pixels.length` mismatch |
## Implementation Notes
The orchestrator uses `Date.now()` for the duration histogram —
millisecond resolution is sufficient for OTel and the clock is universal
across Bun, Node, Deno, and browsers.
The fisher-Yates shuffle uses the injected `rng` directly so a seeded
source is byte-stable across runs. The heuristic branch is deliberate
about its sample budget: `min(maxPermutations - 4, 1000)` because the
four static strategies are evaluated first, and beyond 1000 samples the
upstream measurements showed diminishing returns. `eliminateRedundant`
mutates `bestLayers` in place inside `processOrdering`, which is safe
because `processOrdering` returns a freshly-allocated array per
invocation.

View File

@@ -0,0 +1,83 @@
# `errors/errors.ts` — typed error hierarchy
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module exposes the typed error surface for `@suaveplan/emblem-solver`.
It defines the stable, namespaced code registry `EMBLEM_SOLVER_CODES` and
the `EmblemSolverError` class that extends `SuaveplanError`. Every failure
path inside the package — schema validation, grid validation, IO checks —
funnels through this hierarchy so callers can pattern-match on `error.code`
to distinguish user input mistakes from defensive guards.
## Features
- Codes are produced by `defineCodes("EMBLEM_SOLVER", [...] as const)`,
yielding the namespaced strings `EMBLEM_SOLVER.INVALID_GRID`,
`EMBLEM_SOLVER.INVALID_OPTIONS`, and `EMBLEM_SOLVER.INVALID_PIXEL`.
- The registry is frozen — consumers can rely on identity equality across
the module.
- `EmblemSolverError` extends `SuaveplanError<EmblemSolverCode>`, so
`error.code` narrows the union and `instanceof EmblemSolverError`
discriminates the throw site.
- Supports the full `SuaveplanErrorOptions` bag: `cause`, `context`,
`traceId`, and `clock` for testing.
## Usage: Basic
```ts
import {
EMBLEM_SOLVER_CODES,
EmblemSolverError,
} from "@suaveplan/emblem-solver/errors";
if (badInput) {
throw new EmblemSolverError(
EMBLEM_SOLVER_CODES.INVALID_GRID,
"emblem-solver.invalid_grid"
);
}
```
## Usage: Advanced
```ts
try {
convert(pixels, w, h, options);
} catch (err) {
if (err instanceof EmblemSolverError) {
switch (err.code) {
case EMBLEM_SOLVER_CODES.INVALID_OPTIONS:
return reportInputProblem(err);
case EMBLEM_SOLVER_CODES.INVALID_GRID:
return reportShapeProblem(err);
case EMBLEM_SOLVER_CODES.INVALID_PIXEL:
return reportPixelProblem(err);
}
}
throw err;
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `EMBLEM_SOLVER_CODES` | const | Frozen `{ INVALID_GRID, INVALID_OPTIONS, INVALID_PIXEL }` map |
| `EmblemSolverCode` | type | Union of the namespaced strings |
| `EmblemSolverError` | class | `extends SuaveplanError<EmblemSolverCode>` |
## Implementation Notes
`defineCodes` runs at module load and `Object.freeze`s the registry,
guaranteeing identity stability — consumers that import via the root barrel
or the `./errors` subpath see the same object. The class constructor is
deliberately thin: it forwards to `SuaveplanError`'s `protected` constructor
and sets `name = "EmblemSolverError"` so stack frames disambiguate. The
`INVALID_PIXEL` code is reserved for future use (it appears in the code
registry so consumers can switch on it ahead of any algorithmic change that
introduces an unrepresentable RGBA value); the test suite exercises it via
the round-trip code coverage check.

View File

@@ -0,0 +1,73 @@
# `fixtures/fixtures.ts` — canonical regression grids
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the canonical test-fixture surface for the package. Each
exported function returns a deterministic `RGBA[]` grid with known
algorithmic properties — chessboards, donuts, crosses, T-shapes, stars,
flags, shields, and crests. Together they form the regression suite that
locks the solver's quality numbers in place: any change that regresses
the rect count on one of these fixtures fails the test gate.
## Features
- Twelve fixtures total: `chessboard8x8`, `cross3x3`, `cross6x6`,
`tShape`, `donut5x5`, `circle16`, `flagCrosses`, `multiIslandStripes`,
`shieldEmblem32`, `crest12x12`, `star`, plus the added
`nineColours` (>8 distinct colours, used to exercise the auto-ordering
heuristic branch).
- Every fixture is purely declarative — built from a `number[][]` palette
index map and `fromIndexedGrid`. No randomness, no `Date.now`, no
module-load side effects.
- Index `0` is reserved for transparency; every fixture follows that
convention.
## Usage: Basic
```ts
import { chessboard8x8 } from "@suaveplan/emblem-solver";
const { pixels, width, height } = chessboard8x8();
```
## Usage: Advanced
```ts
import { convert, crest12x12 } from "@suaveplan/emblem-solver";
const { pixels, width, height } = crest12x12();
const result = convert(pixels, width, height, { layerOrder: "auto" });
console.log(`crest compressed to ${result.totalRects} rects`);
```
## API Reference
| Symbol | Returns |
|---|---|
| `chessboard8x8()` | 8x8 alternating black/white grid |
| `cross3x3()` / `cross6x6()` | Plus-sign crosses |
| `tShape()` | 5x4 T-shape |
| `donut5x5()` | Frame with transparent centre |
| `circle16()` | 16-px pixel circle |
| `flagCrosses()` | 16x16 layered three-colour flag |
| `multiIslandStripes()` | 10x2 alternating two-colour stripes |
| `shieldEmblem32()` | 32x32 three-colour shield |
| `crest12x12()` | 12x12 four-colour crest |
| `star()` | Irregular star outline |
| `nineColours()` | 4x4 grid with nine distinct colours |
## Implementation Notes
The fixtures are exported as zero-argument functions rather than constants
so each test call builds a fresh pixel array — the solver does not
mutate its input, but defensive copies keep test isolation strong. The
`nineColours` grid exists specifically to push `factorial(colourCount) >
maxPermutations` so the heuristic + sampling branch in `convert()` is
exercised; without it that branch would be unreachable and coverage would
drop. Fixtures are excluded from the package's coverage threshold
(`coverage.exclude` in `vitest.config.ts`) because they are test
material, not production code.

View File

@@ -0,0 +1,75 @@
# `grid/analyse/analyse.ts` — colour groups, islands, and shape stats
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `analyseGrid`, the diagnostic phase of the conversion
pipeline. It walks an RGBA grid once, buckets opaque pixels by colour, runs
a 4-connected flood-fill on each bucket to recover the islands, and
computes per-colour `density` (`pixels / bbox area`) and `fragmentation`
(`islands / pixels`). The result feeds the layer-ordering heuristics and
the per-island cover strategy in the solver pipeline.
## Features
- Single linear pass over the grid to populate `opaquePixels` and the
colour buckets.
- 4-connected BFS flood-fill — diagonals are treated as separate islands,
matching the upstream reference and the convention used by every
pixel-art tool.
- Density and fragmentation are pure functions of the bucket and its
bounding box; the solver uses both to score the `island-aware` ordering.
- Output is structurally shared with the rest of the package via
`GridAnalysis` — solver kernels consume `colourGroups`/`colourAnalysis`
directly.
## Usage: Basic
```ts
import { analyseGrid } from "@suaveplan/emblem-solver";
const { colourGroups, colourAnalysis } = analyseGrid(pixels, w, h);
console.log("colours:", colourGroups.size);
```
## Usage: Advanced
```ts
import { analyseGrid } from "@suaveplan/emblem-solver";
function pickBackgroundColour(pixels, w, h) {
const a = analyseGrid(pixels, w, h);
let best = "";
let bestScore = -Infinity;
for (const [key, info] of a.colourAnalysis) {
const score = info.totalPixels * info.density;
if (score > bestScore) {
best = key;
bestScore = score;
}
}
return best;
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `analyseGrid(pixels, w, h)` | fn | Returns `GridAnalysis` |
## Implementation Notes
The flood-fill uses an explicit stack (`pop()`) rather than recursion to
avoid blowing the call stack on large emblems. We `add` to `visited`
*before* enqueueing the neighbour, which guarantees each pixel is touched
at most twice (once when discovered, once when its packed index is popped).
The neighbour set is computed as a small inline array of `-1`-sentinel
checks for the four cardinal directions; benchmarks against a `for (const
[dx,dy] …)` form showed the inline form measurably faster in V8 and Bun.
The `Map<string, Set<number>>` colour-group layout is intentional: the
string key is the canonical colour identity, while the packed-index `Set`
gives O(1) `has`/`add`/`delete` for the cover strategies.

View File

@@ -0,0 +1,85 @@
# `helpers/helpers.ts` — packing, keys, and grid primitives
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the shared kernel of utility functions used by the analyser,
the solver strategies, and the optimisers. It owns the canonical
representation of a pixel index (a packed `y * width + x` integer), the
canonical colour key (`"r,g,b"`), the `computeBounds` reduction over a
packed-pixel set, and the boolean-grid builders that drive the
maximal-rectangle sweep and per-layer covering.
## Features
- `pack` / `unpackX` / `unpackY` give the package a single, branch-free way
to convert between `(x, y)` and a single integer. Using a `Set<number>`
of packed indices is dramatically cheaper than a `Set<string>` of
`"x,y"`.
- `colourKey` / `parseKey` round-trip an RGBA without committing to alpha;
the solver does not need alpha for keying.
- `computeBounds` is the only place we touch unpack-loop machinery for
bounds computation; everywhere else delegates here.
- `isFullyAvailable`, `rectCoversAnyRequired`, and `countRequired` are the
three predicates the optimiser relies on; they share an early-exit
pattern that keeps the solver O(area × candidates) rather than
O(area² × candidates).
- `buildAvailableGrid` / `buildRequiredGrid` allocate the 2-D `boolean[][]`
masks used by the cover strategies.
## Usage: Basic
```ts
import { pack, unpackX, unpackY } from "@suaveplan/emblem-solver";
const w = 8;
const idx = pack(3, 2, w); // 19
unpackX(idx, w); // 3
unpackY(idx, w); // 2
```
## Usage: Advanced
```ts
import {
buildAvailableGrid,
buildRequiredGrid,
isFullyAvailable,
} from "@suaveplan/emblem-solver";
const opaque = new Set<number>([0, 1, 2, 3]);
const earlier = new Set<number>();
const avail = buildAvailableGrid(2, 2, opaque, earlier);
const req = buildRequiredGrid(opaque, 2, 2);
isFullyAvailable({ x: 0, y: 0, w: 2, h: 2 }, avail); // true
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `pack(x, y, w)` | fn | Row-major packed index |
| `unpackX(i, w)` | fn | Recover `x` |
| `unpackY(i, w)` | fn | Recover `y` |
| `colourKey(rgba)` | fn | `"r,g,b"` |
| `parseKey(str)` | fn | `RGBA` with `a:1` |
| `computeBounds(set, w)` | fn | Tight bounding `Rect` |
| `isFullyAvailable(rect, grid)` | fn | All cells `true`? |
| `rectCoversAnyRequired(rect, req)` | fn | Any overlap? |
| `countRequired(rect, req)` | fn | Overlap count |
| `buildAvailableGrid(w, h, opaque, earlier)` | fn | 2-D opaque mask minus earlier layers |
| `buildRequiredGrid(set, w, h)` | fn | 2-D mask from packed pixel set |
## Implementation Notes
The functions are deliberately monomorphic over `boolean[][]`/`number` for
predictable JIT performance. The bounding-box reduction uses `±Infinity`
sentinels so it works for any non-empty set. The available-grid builder
skips earlier-layer pixels in O(1) per packed index — important when the
solver iterates orderings exhaustively over a colour set. None of these
helpers allocate beyond the grids they explicitly build; the predicates
return as soon as their early-exit condition is met, so worst-case work is
bounded by `rect.w * rect.h`.

View File

@@ -0,0 +1,71 @@
# `io/parse/parse.ts` — RGBA buffer + indexed-grid IO
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the two import paths into the solver: `parseRGBABuffer`
turns a raw RGBA `Uint8Array` (e.g. the output of `<canvas>.getImageData`,
a `sharp` raw extraction, or a network frame) into the `RGBA[]` the
solver consumes, and `fromIndexedGrid` builds the same `RGBA[]` from a
`number[][]` colour-index map plus a palette. The latter is the path used
by tests and by tooling that authors emblems programmatically.
## Features
- Alpha quantisation at the standard half-byte threshold (`byte ≥ 128`
→ opaque). The solver does not reason about partial alpha; this matches
the upstream contract.
- Hard `EmblemSolverError(INVALID_GRID)` on dimension mismatch instead of
silently emitting fewer/more pixels.
- Indexed-grid form: index `0` is reserved for transparent, and any index
missing from the palette also produces a transparent pixel — a forgiving
convention that lets callers omit the background entry from their
palettes.
- Output is a fresh `RGBA[]` with no shared references to the input.
## Usage: Basic
```ts
import { parseRGBABuffer } from "@suaveplan/emblem-solver";
const pixels = parseRGBABuffer(new Uint8Array(w * h * 4), w, h);
```
## Usage: Advanced
```ts
import { fromIndexedGrid, convert } from "@suaveplan/emblem-solver";
const { pixels, width, height } = fromIndexedGrid(
[
[0, 1, 0],
[1, 1, 1],
[0, 1, 0],
],
new Map([[1, { r: 255, g: 0, b: 0 }]])
);
const result = convert(pixels, width, height);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `parseRGBABuffer(buf, w, h)` | fn | `RGBA[]` of length `w*h`, alpha quantised |
| `fromIndexedGrid(grid, palette)` | fn | `{ pixels, width, height }` |
Both throw `EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID)` on
non-positive dimensions or buffer-length mismatch.
## Implementation Notes
`parseRGBABuffer` does a tight 4-stride loop over the input, reading via
the optional-chaining default of `0` so a truncated buffer is rejected
before it can produce a malformed pixel. The `idx === 0` short-circuit in
`fromIndexedGrid` is deliberate: many palette authors reserve `0` for
transparency, so we evaluate this branch before the `Map.get` lookup. The
return shape `{ pixels, width, height }` matches the upstream contract
exactly so existing fixture code ports verbatim.

View File

@@ -0,0 +1,67 @@
# `optimizer/merge/merge.ts` — adjacent-rect merge pass
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `mergeAdjacentRects`, the post-processing pass that
combines pairs of edge-sharing same-extent rectangles into a single larger
rectangle, provided the merged span lies entirely inside the available
mask. It is the cheapest of the optimiser passes and runs after every
covering strategy in the layer pipeline.
## Features
- Merges along both axes: two rects with the same `y` and `h` merge
horizontally when adjacent; two rects with the same `x` and `w` merge
vertically when adjacent.
- Iterates until a full pass produces no merges, so chains of three or
more rects collapse to one in `O(n²)` per pass and `O(k · n²)` overall
for `k` passes (bounded by the rect count).
- Available-mask check (`isFullyAvailable`) guarantees the merged span
never paints transparent cells.
## Usage: Basic
```ts
import { mergeAdjacentRects } from "@suaveplan/emblem-solver";
const merged = mergeAdjacentRects(
[
{ x: 0, y: 0, w: 1, h: 1 },
{ x: 1, y: 0, w: 1, h: 1 },
],
fullyAvailableGrid
);
// merged === [{ x: 0, y: 0, w: 2, h: 1 }]
```
## Usage: Advanced
```ts
import { mergeAdjacentRects } from "@suaveplan/emblem-solver";
let rects = greedyCover;
rects = mergeAdjacentRects(rects, available);
rects = mergeAdjacentRects(rects, available); // idempotent — no further change
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `mergeAdjacentRects(rects, available)` | fn | Greedily merge edge-sharing same-extent rects |
## Implementation Notes
The inner `tryMerge` first tests the horizontal merge (same `y`, same
`h`, sharing an edge in `x`), then the vertical merge (same `x`, same
`w`, sharing an edge in `y`). Either form checks the combined rectangle
against `available` before accepting; this avoids painting through
transparent pixels. The outer loop terminates when a full pass produces
zero merges, so each call is idempotent. We deliberately do not try
*non*-same-extent merges here — that is the job of `optimiseOverlap`,
which can produce overlapping output and is therefore a strictly stronger
(but more expensive) operation.

View File

@@ -0,0 +1,73 @@
# `optimizer/overlap/overlap.ts` — overlap-based reductions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `optimiseOverlap`, the deepest cover-reduction pass in
the package. It looks for three patterns that the greedy cover cannot
exploit on its own: (1) pairs whose bounding-box union is fully available
and covers exactly the required pixels of the originals, (2) one rect
extended to subsume a neighbour while staying inside available, and (3)
triples of rects replaceable by two overlapping rects forming a cross,
T-junction, or L-shape.
## Features
- Pass 1 — pairwise bbox merge with required-set inclusion check.
- Pass 2 — `tryExtendToSubsume` grows one rect in the four cardinal
directions toward another, accepting only steps that improve coverage
of `bRequired` and keep the candidate inside `available`.
- Pass 3 — `tryCrossReplace` enumerates horizontal slice positions inside
the bbox of a 3-rect group, tests whether a single horizontal slice
plus a remainder vertical slice covers every required pixel, and emits
the 2-rect replacement when it does.
- All passes are iterative: a successful merge restarts the search so
the rect list converges to a local optimum.
## Usage: Basic
```ts
import { optimiseOverlap } from "@suaveplan/emblem-solver";
const reduced = optimiseOverlap(
greedyCover,
requiredGrid,
availableGrid,
rows,
cols
);
```
## Usage: Advanced
```ts
import { optimiseOverlap } from "@suaveplan/emblem-solver";
let rects = greedyCover;
do {
const next = optimiseOverlap(rects, required, available, rows, cols);
if (next.length === rects.length) break;
rects = next;
} while (true);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `optimiseOverlap(rects, required, available, rows, cols)` | fn | Apply pairwise, extend, and cross-replace reductions |
## Implementation Notes
The pass 1 inclusion check uses `Set<number>` of packed pixel indices so
the comparison is O(k) where `k` is the cell count of the union. Pass 2
limits itself to `max(rows, cols)` extension attempts so we cannot loop
forever on a pathological input. Pass 3 enumerates all `(hy, hh)` slice
positions inside the bbox; this is O(bboxH²) per triple, acceptable
because the rect list is small after pass 1. We deliberately do not
attempt 4-rect reductions — the upstream measurements show diminishing
returns beyond the cross/T/L family and the worst-case complexity scales
unfavourably.

View File

@@ -0,0 +1,61 @@
# `optimizer/redundancy/redundancy.ts` — cross-layer redundancy elimination
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `eliminateRedundant`, the final cross-layer optimisation
the converter runs after every layer's cover has been built. It removes
rectangles that produce no visible output: either because higher-z
rectangles of any colour overdraw them, or because sibling rectangles in
the same layer already cover the same cells. The pass operates in place
on the layer list and is the only pass that mutates `layer.rects`.
## Features
- Composites every layer into a per-cell `target` grid first (lower-z to
higher-z), then walks rect by rect deciding whether removal would
change the composited output.
- A rect is removed iff *every* one of its required cells is either
covered by a sibling rect at the same z-index or overdrawn by a higher-z
rect of any colour.
- In-place mutation via `Array.prototype.splice` — the converter calls
this once per ordering, so the in-place form keeps allocations bounded.
## Usage: Basic
```ts
import { eliminateRedundant } from "@suaveplan/emblem-solver";
eliminateRedundant(layers, width, height);
```
## Usage: Advanced
```ts
import { eliminateRedundant } from "@suaveplan/emblem-solver";
const before = layers.reduce((s, l) => s + l.rects.length, 0);
eliminateRedundant(layers, width, height);
const after = layers.reduce((s, l) => s + l.rects.length, 0);
console.log(`pruned ${before - after} redundant rects`);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `eliminateRedundant(layers, width, height)` | fn | In-place prune of fully-occluded rects |
## Implementation Notes
The compositing step builds a `(string | null)[][]` grid keyed by the
canonical colour key — using a string rather than a packed integer makes
the per-cell comparison a single `===` against the layer's fill string,
which V8 deduplicates aggressively. The two-pass design (composite first,
then walk rect by rect) avoids the quadratic blow-up of repeatedly
re-compositing after each removal. The removable indices are pushed in
ascending order and spliced in reverse, so we never break the index
correspondence mid-removal.

View File

@@ -0,0 +1,79 @@
# `schemas/schemas.ts` — Zod options schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the runtime validation surface for `@suaveplan/emblem-solver`.
It exposes `ConvertOptionsSchema`, a Zod schema that mirrors the static
`ConvertOptions` interface, applies sensible defaults, and rejects unknown
keys. `convert()` runs every caller-supplied options object through
`safeParse` so typos or out-of-range values fail loudly with
`EmblemSolverError(INVALID_OPTIONS)` rather than silently degrading the
solver.
## Features
- `layerOrder` defaults to `"auto"` and is validated as a closed enum over
the five supported strategies.
- `maxPermutations` defaults to `40320 = 8!` — the threshold beyond which
exhaustive permutation search is abandoned in favour of heuristics +
random sampling. The schema enforces `int().min(1)` so a zero or negative
value is rejected at the boundary.
- `rng` is an optional `() => number` validated with `z.custom<…>()`. It is
the only injection point for the package — supplying it makes the
heuristic + sampling path fully deterministic.
- `.strict()` rejects unknown keys, so misspelled options never silently
succeed.
## Usage: Basic
```ts
import { ConvertOptionsSchema } from "@suaveplan/emblem-solver/schemas";
const parsed = ConvertOptionsSchema.parse({ layerOrder: "area-desc" });
parsed.maxPermutations; // 40320 (default applied)
```
## Usage: Advanced
```ts
import { ConvertOptionsSchema } from "@suaveplan/emblem-solver/schemas";
import { EMBLEM_SOLVER_CODES, EmblemSolverError } from
"@suaveplan/emblem-solver/errors";
function safeOptions(raw: unknown) {
const r = ConvertOptionsSchema.safeParse(raw);
if (!r.success) {
throw new EmblemSolverError(
EMBLEM_SOLVER_CODES.INVALID_OPTIONS,
`bad options: ${r.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ")}`
);
}
return r.data;
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `ConvertOptionsSchema` | `z.ZodObject` | Strict schema for `ConvertOptions` |
| `ConvertOptionsParsed` | type | Post-defaults shape of the parsed result |
## Implementation Notes
The schema lives in its own module so the rest of the package does not pay
the Zod cost at import time when consumers do not need validation (e.g.
hot-loop callers that constructed options at startup). `convert()` always
runs through this schema, but downstream tooling can call
`ConvertOptionsSchema.parse` ahead of time and pass the parsed result into
its own caching layer. We use `z.custom<() => number>` rather than
`z.function()` because Zod 4 deprecated runtime function validation in
favour of a custom predicate; this matches the catalog `zod ^4.x`. The
`rng` field stays optional (rather than defaulting to `Math.random`) so the
test harness can detect whether a caller injected a deterministic source.

View File

@@ -0,0 +1,72 @@
# `solver/greedy/greedy.ts` — deterministic greedy set-cover
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides the greedy selector that drives the primary covering
strategy. `enhancedGreedyCover` picks rectangles from a candidate pool
using a deterministic three-key score (covered count, efficiency, centroid
proximity). `perIslandCover` runs the same selector independently on each
4-connected island so the algorithm never wastes budget on rectangles that
span the transparent gap between two disjoint islands.
## Features
- Three-key score: `covered * 1000 + efficiency * 100 + proximity * 10`.
The factors are deliberately spaced so a more-covering rect always beats
a more-efficient one, and ties are broken by centroid proximity.
- Falls back to emitting `1×1` rectangles for any residual uncovered
pixels — guarantees the cover is complete even when no candidate
intersects the residual.
- `perIslandCover` filters the candidate pool per island so the selector
never considers a spanning rect; this halves selection work on
multi-island colours.
## Usage: Basic
```ts
import {
dualAxisMaximalRects,
enhancedGreedyCover,
} from "@suaveplan/emblem-solver/solver";
const candidates = dualAxisMaximalRects(available, rows, cols);
const cover = enhancedGreedyCover(candidates, required, cols, rows);
```
## Usage: Advanced
```ts
import {
analyseGrid,
perIslandCover,
dualAxisMaximalRects,
} from "@suaveplan/emblem-solver";
const a = analyseGrid(pixels, w, h);
const info = a.colourAnalysis.get("255,0,0")!;
const candidates = dualAxisMaximalRects(available, h, w);
const cover = perIslandCover(info.islands, candidates, w, h);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `enhancedGreedyCover(candidates, required, w, h)` | fn | Greedy cover with three-key score |
| `perIslandCover(islands, candidates, w, h)` | fn | Independent cover per island |
## Implementation Notes
The centroid term is computed against the running set of *uncovered*
required pixels, not the original set — this keeps selections clustered
even as the residual shrinks. We use a precomputed `maxDist =
sqrt(w² + h²)` so the proximity term is normalised to `[0, 1]`; without
this, the third tiebreaker could dominate the second on large grids.
`perIslandCover` materialises a fresh `boolean[][]` per island; this is
acceptable because the typical island count is small (≤ 10 on the
upstream fixtures) and the allocation cost is dominated by the cover work
inside.

View File

@@ -0,0 +1,67 @@
# `solver/histogram/histogram.ts` — dual-axis maximal rectangles
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module enumerates every distinct maximal-rectangle candidate inside an
opaque-pixel grid. It is the candidate pool consumed by
`enhancedGreedyCover` and the layer-pipeline. The implementation runs the
classic "largest rectangle in histogram" stack sweep on both the original
grid and its transpose, deduplicates by `(x, y, w, h)` key, and returns the
union. Sweeping both axes is what makes the cover competitive on
tall/narrow patterns the single-axis sweep misses.
## Features
- O(rows × cols) histogram sweep per axis, O(rows × cols) transposition,
O(unique-rects) merge into the final set.
- Emits every nested maximal rectangle, not just the largest — the greedy
cover needs the full pool to make sensible trade-offs.
- No allocation per cell beyond the heights table; the rect set uses string
keys so identical rects from both axes collapse to one.
## Usage: Basic
```ts
import { dualAxisMaximalRects } from "@suaveplan/emblem-solver/solver";
const available = [
[true, true, false],
[true, true, false],
];
const rects = dualAxisMaximalRects(available, 2, 3);
```
## Usage: Advanced
```ts
import {
dualAxisMaximalRects,
enhancedGreedyCover,
} from "@suaveplan/emblem-solver/solver";
const candidates = dualAxisMaximalRects(available, rows, cols);
candidates.sort((a, b) => b.w * b.h - a.w * a.h);
const cover = enhancedGreedyCover(candidates, required, cols, rows);
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `dualAxisMaximalRects(available, rows, cols)` | fn | Deduplicated maximal rects across both sweep axes |
## Implementation Notes
The internal `histogramSweep` builds a column-by-column heights table and
then walks each row with a monotonic stack. When the stack pops, every
height ≥ the popped column's height yields a distinct maximal rect of
width `x - leftBound` and height `rectH`. We emit *every* nested height so
the greedy cover can pick a smaller-but-better-positioned rect when the
maximal-tall one would force an awkward residual. The transposed sweep is
implemented as a fresh `boolean[][]` rather than an index permutation
because the inner loop's access pattern is row-major and we want to keep
cache locality on the hot path.

View File

@@ -0,0 +1,84 @@
# `solver/layer/layer.ts` — multi-strategy per-layer cover
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `processLayerMultiStrategy`, the function the converter
calls once per layer in the current ordering. It runs every covering
strategy the package implements (dual-axis histogram + greedy, per-island
cover, strip decomposition, adjacent-rect merge) and returns the result
with the fewest rectangles. Running every strategy is what makes the
upstream algorithm robust — no single strategy wins on every emblem.
## Features
- Strategy A (dual-axis histogram → greedy → overlap optimise) is always
evaluated; it is the workhorse.
- Strategy B (per-island cover) only runs when the colour has more than
one island — pure optimisation, since a single-island colour would
produce the same answer.
- Strategy C (strip decomposition) is only adopted when the strips fully
cover the required pixels; the layer pipeline does not stitch in a
partial strip cover.
- Strategy D (adjacent-rect merge) runs against the best-so-far so we
never reject a free merge.
- The final winner is the smallest of all four (or three when B is
skipped).
## Usage: Basic
```ts
import {
analyseGrid,
processLayerMultiStrategy,
} from "@suaveplan/emblem-solver";
const a = analyseGrid(pixels, w, h);
const ownPixels = a.colourGroups.get("255,0,0")!;
const rects = processLayerMultiStrategy(
"255,0,0",
ownPixels,
a,
new Set<number>()
);
```
## Usage: Advanced
```ts
import { processLayerMultiStrategy } from "@suaveplan/emblem-solver";
const earlier = new Set<number>();
for (const key of orderedKeys) {
const ownPixels = analysis.colourGroups.get(key)!;
const rects = processLayerMultiStrategy(
key,
ownPixels,
analysis,
earlier
);
layers.push({ key, rects /* ... */ });
for (const p of ownPixels) earlier.add(p);
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `processLayerMultiStrategy(key, ownPixels, analysis, earlier)` | fn | Smallest rect cover across all strategies |
## Implementation Notes
The strip strategy only contributes when it fully covers the required
mask. We compute coverage by intersecting each valid strip with the
`required` grid (via `pack`) and comparing the resulting count to a
single pre-tallied `totalRequired`. The adjacent-rect merge pass runs
*after* the strategies have produced their winners; running it earlier
would force a merge on results that would have been pruned anyway. The
final reduce uses a strictly-less-than comparison (`a.length <= b.length`)
so the first result wins on ties — this preserves deterministic ordering
across runs.

View File

@@ -0,0 +1,74 @@
# `solver/ordering/ordering.ts` — layer-ordering search primitives
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module provides the layer-ordering machinery the converter consults
when it has to decide which colour sits on the bottom of the z-stack and
which paints on top. `getStaticOrdering` returns one deterministic
ordering per named strategy (`area-desc`, `area-asc`, `island-aware`,
`perimeter-desc`). `permutations` lazily yields every n! ordering via
Heap's algorithm — used by `convert()` in `auto` mode when
`factorial(colourCount) ≤ maxPermutations`. `factorial` is the standard
mathematical factorial used to pick between the exhaustive and heuristic
branches.
## Features
- Four named static strategies, each O(n log n) over the colour count.
- `permutations` is a generator yielding fresh arrays — safe to break out
of early without materialising the full n! list.
- `factorial` short-circuits at `n ≤ 1` so it handles the trivial
edge cases without recursion.
## Usage: Basic
```ts
import {
getStaticOrdering,
permutations,
factorial,
} from "@suaveplan/emblem-solver/solver";
const order = getStaticOrdering(analysis, "area-desc");
factorial(5); // 120
```
## Usage: Advanced
```ts
import { permutations } from "@suaveplan/emblem-solver/solver";
let best = Infinity;
let bestPerm: string[] | undefined;
for (const perm of permutations(["r", "g", "b"])) {
const score = scoreOrdering(perm);
if (score < best) {
best = score;
bestPerm = [...perm];
}
}
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `getStaticOrdering(analysis, strategy)` | fn | Sorted colour keys for a named strategy |
| `permutations<T>(arr)` | gen | Yields every permutation (Heap's algorithm) |
| `factorial(n)` | fn | `factorial(0) === factorial(1) === 1` |
## Implementation Notes
`islandAwareScore` mixes four signals: coverage (how much of the grid the
colour occupies), occlusion benefit (the visual mass it removes from
higher layers), island penalty (more islands ⇒ more rectangles needed),
and hole ratio (bbox area / pixel count — large hole ratios suggest the
colour sits below another). The coefficients (`2.0`, `3.0`, `1.5`, `0.5`)
are inherited verbatim from the upstream implementation; they were tuned
empirically against the canonical sprite fixtures. `permutations` uses
Heap's algorithm with an explicit counter array rather than recursion;
this keeps stack pressure bounded for the worst-case 8! enumeration.

View File

@@ -0,0 +1,64 @@
# `solver/strip/strip.ts` — horizontal/vertical strip decomposition
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module implements `stripDecomposition`, a complementary cover strategy
that ignores the maximal-rect candidate pool entirely. It finds maximal
horizontal runs of required pixels, merges vertically-adjacent runs that
share both `x` and `length`, and runs the transposed variant (vertical runs
merged horizontally). The smaller of the two orientations wins. This
strategy is what gives `convert()` the right answer on tall thin shapes
that the histogram + greedy combo fragments.
## Features
- O(rows × cols) per axis; tail-call-free `while` loops.
- Two orientations evaluated, smaller wins — deterministic.
- Produces non-overlapping rects (unlike the overlap optimiser); the layer
pipeline only adopts the strip result if it covers the entire required
mask, otherwise it falls back to the greedy cover.
## Usage: Basic
```ts
import { stripDecomposition } from "@suaveplan/emblem-solver/solver";
const required = [
[true, true, true],
[true, true, true],
];
const rects = stripDecomposition(required, 2, 3);
```
## Usage: Advanced
```ts
import {
stripDecomposition,
isFullyAvailable,
} from "@suaveplan/emblem-solver";
const strips = stripDecomposition(required, rows, cols);
const valid = strips.filter((r) => isFullyAvailable(r, available));
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `stripDecomposition(required, rows, cols)` | fn | Smaller of horizontal-strips / vertical-strips |
## Implementation Notes
`mergeRuns` sorts runs by their primary coordinate, then their length, then
their secondary coordinate. The merge loop walks the sorted list once and
collapses consecutive entries that share `(x, length)` (horizontal) or
`(y, length)` (vertical) into a single taller/wider rect. Because the sort
is stable on JavaScript's `Array.prototype.sort` and we break on a length
mismatch, the merge terminates in O(n). The dual-orientation evaluation
ensures we never make the cover worse by picking the wrong axis — the
chess-board test exercises this branch heavily.

View File

@@ -0,0 +1,61 @@
# `svg/build/build.ts` — SVG serialiser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns `buildSVG`, the deterministic SVG serialiser the
converter uses to produce its final `<svg>` string. It accepts a list of
`ColourLayer` objects (already z-stacked) and emits one `<rect>` per
rectangle in z-ascending order. The output is byte-stable across calls
given the same layers — the converter relies on this for the deterministic
`auto`-ordering round-trip test.
## Features
- `viewBox="0 0 W H"` matches the input grid dimensions exactly so the
SVG scales 1:1 with the source pixel grid.
- `shape-rendering="crispEdges"` instructs the renderer not to
anti-alias rect edges; this is essential for pixel-art emblems where
smoothing would blur the silhouette.
- Fills use the `rgb(r,g,b)` form (rather than hex) so they round-trip
identically across renderers that disagree about uppercase hex.
- Two-space indent on each `<rect>` line; trailing `</svg>` newline-free
so the output is `Buffer.from(out).length` bytes regardless of platform
line endings.
## Usage: Basic
```ts
import { buildSVG } from "@suaveplan/emblem-solver";
const svg = buildSVG(layers, width, height);
```
## Usage: Advanced
```ts
import { buildSVG } from "@suaveplan/emblem-solver";
const reordered = layers.map((l, i) => ({ ...l, zIndex: i }));
const svg = buildSVG(reordered, width, height);
console.log(svg.length, "bytes");
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `buildSVG(layers, w, h)` | fn | Serialise z-stacked layers to an SVG string |
## Implementation Notes
The function sorts a copy of the layer list (never the caller's reference)
so callers can pass `result.layers` without losing the original ordering
metadata. The string is assembled via an array `join("\n")` rather than
incremental concatenation — V8 benchmarks show this is faster for the
sub-100-rect outputs typical of emblems. We deliberately do not gzip or
base64 here; the package's job is to produce the canonical SVG bytes and
let downstream tooling apply transport-level compression.

View File

@@ -0,0 +1,69 @@
# `telemetry/telemetry.ts` — OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the OpenTelemetry instruments emitted by `convert()`. It
exposes a counter (`emblem_solver.convert.total`) and a histogram
(`emblem_solver.convert.duration_ms`), both lazily initialised on first
use so importing the package does not register instruments unless they
are actually needed.
## Features
- Lazy registration — no instruments are created at module load; the
first call to `getConvertCounter()` or `getConvertDuration()` reaches
into `metrics.getMeter("@suaveplan/emblem-solver")` and caches the
result.
- Instruments are independent objects so a downstream OTel SDK can
configure views per instrument.
- Test helpers (`__resetTelemetryForTests`, `__peekTelemetryForTests`)
let the test suite assert lazy-registration behaviour without forcing
the production code to expose internals.
- Histogram unit is `ms`; emitting an OTel view that converts to seconds
is the consumer's responsibility.
## Usage: Basic
```ts
import {
getConvertCounter,
getConvertDuration,
} from "@suaveplan/emblem-solver";
getConvertCounter().add(1);
getConvertDuration().record(12.5);
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { getConvertCounter } from "@suaveplan/emblem-solver";
metrics.setGlobalMeterProvider(myProvider);
// the next call lazily creates the counter against `myProvider`'s meter:
getConvertCounter().add(1, { layerOrder: "auto" });
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `getConvertCounter()` | fn | Lazy `Counter` for `emblem_solver.convert.total` |
| `getConvertDuration()` | fn | Lazy `Histogram` for `emblem_solver.convert.duration_ms` |
| `__resetTelemetryForTests()` | fn | Test-only: drop cached instruments |
| `__peekTelemetryForTests()` | fn | Test-only: inspect cached instruments |
## Implementation Notes
The `metrics.getMeter` call is deferred until first use so that a host
that swaps the global meter provider after import still sees its
provider's instruments. Without this, instruments would be bound to the
no-op default and silently discard recordings. The test helpers are
exported from the module (rather than placed in a separate `__internal`
file) because they need to reach the module-scope `let` bindings; keeping
them here avoids exposing those bindings through a different surface.

View File

@@ -0,0 +1,92 @@
# `types/types.ts` — public type contract
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single source of truth for the public type surface of
`@suaveplan/emblem-solver`. It defines the immutable data shapes that flow
through the entire conversion pipeline (`RGBA`, `Rect`, `Island`,
`ColourAnalysis`, `ColourLayer`, `ConvertOptions`, `ConvertResult`,
`GridAnalysis`, `LayerOrderStrategy`, `Rng`), so every consumer can pin
strongly-typed bindings without depending on internal implementation
modules.
Types live in `types/` (per CLAUDE.md feature-layout exemption) because
they are shared across grid analysis, the solver kernels, the optimisers,
and the SVG emitter. Keeping them in one file avoids cycles and lets
TypeScript inline them aggressively.
## Features
- Pure data; zero runtime, zero side effects.
- `RGBA.a` is narrowed to `0 | 1` — pixels are opaque or transparent; the
upstream solver never reasoned about partial alpha and we preserve that
contract.
- `Rect` uses `{x, y, w, h}` (not `width/height`) to match the upstream
on-the-wire layout.
- `Island.pixels` is a `Set<number>` of packed indices to keep flood-fill
inexpensive.
- `ConvertOptions.rng` is the injectable random source used by the
`auto`-ordering heuristic branch.
## Usage: Basic
```ts
import type { RGBA, ConvertOptions } from "@suaveplan/emblem-solver";
const px: RGBA = { r: 255, g: 0, b: 0, a: 1 };
const opts: ConvertOptions = { layerOrder: "area-desc" };
```
## Usage: Advanced
```ts
import type {
ColourAnalysis,
ColourLayer,
GridAnalysis,
Rng,
} from "@suaveplan/emblem-solver";
function pickBottomLayer(analysis: GridAnalysis): ColourAnalysis | undefined {
return [...analysis.colourAnalysis.values()].sort(
(a, b) => b.totalPixels - a.totalPixels
)[0];
}
const seeded: Rng = (() => {
let s = 1;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 0x1_0000_0000;
};
})();
```
## API Reference
| Symbol | Kind | Notes |
|---|---|---|
| `RGBA` | interface | `{ r, g, b, a: 0 \| 1 }` |
| `Rect` | interface | `{ x, y, w, h }` |
| `Island` | interface | `{ pixels: Set<number>, bounds, size }` |
| `ColourAnalysis` | interface | Per-colour analysis (density, fragmentation, islands) |
| `ColourLayer` | interface | A z-stacked layer with `rects` + `analysis` |
| `LayerOrderStrategy` | union | `"auto" \| "area-desc" \| "area-asc" \| "island-aware" \| "perimeter-desc"` |
| `Rng` | type | `() => number` in `[0, 1)` |
| `ConvertOptions` | interface | Public input to `convert()` |
| `ConvertResult` | interface | Public output of `convert()` |
| `GridAnalysis` | interface | Diagnostic data returned by `analyseGrid` |
## Implementation Notes
Every interface marked `readonly` enforces the immutability contract at the
type level — solver internals deliberately mutate the working sets they
allocate; the public types do not advertise mutability. `ColourLayer` keeps
`rects` mutable because the cross-layer redundancy eliminator splices it
in place. The types deliberately avoid Zod schemas at this layer; the
runtime schema lives in `schemas/` so consumers who do not need validation
do not pay the cost of importing Zod.

View File

@@ -0,0 +1,86 @@
# @suaveplan/exif - Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images
Dependency-free, runtime-agnostic EXIF/XMP/IPTC metadata reader, writer, and stripper for JPEG and PNG images. Parses JPEG segment chains to locate and decode the EXIF IFD (TIFF byte-order aware, including GPS and SubIFD), IPTC IIM (caption, keywords, copyright), and XMP Dublin Core (raw XML string extraction). Writes back a revised EXIF IFD without re-encoding the image pixels, and strips all metadata segments for privacy-preserving image export. Includes a `ByteReader`/`ByteWriter` pair with rational arithmetic helpers and CRC-32 for PNG chunks.
<!-- begin:auto-docs -->
## Documentation tree
- [crc32](./src/binary/crc32/crc32.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [crc32](./src/binary/crc32/crc32.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rational](./src/binary/rational/rational.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [rational](./src/binary/rational/rational.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [reader](./src/binary/reader/byte-reader.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [byte-reader](./src/binary/reader/byte-reader.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [writer](./src/binary/writer/byte-writer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [byte-writer](./src/binary/writer/byte-writer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [read-metadata](./src/core/read-metadata.md) — The main entry point for reading image metadata. Orchestrates format detection, JPEG segment parsing, and EXIF/XMP/IPTC…
- [strip-metadata](./src/core/strip-metadata.md) — Removes metadata segments from a JPEG image buffer and returns a clean image with only the pixel data intact. Privacy-s…
- [write-metadata](./src/core/write-metadata.md) — Writes or updates EXIF metadata in a JPEG image buffer. Merges the provided patch over any existing EXIF data and rebui…
- [errors](./src/errors/exif-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [exif-error](./src/errors/exif-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [exif](./src/exif/read-exif.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [read-exif](./src/exif/read-exif.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [format](./src/format/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [detect](./src/format/detect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [ifd](./src/ifd/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parse](./src/ifd/parse.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [iptc](./src/iptc/read-iptc.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [read-iptc](./src/iptc/read-iptc.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [jpeg](./src/jpeg/segments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [segments](./src/jpeg/segments.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [xmp](./src/xmp/read-xmp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [read-xmp](./src/xmp/read-xmp.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/exif
```
## Quick Start
```ts
import { readMetadata, writeMetadata, stripMetadata } from "@suaveplan/exif";
// Read all metadata
const meta = readMetadata(imageBuffer);
console.log(meta.format); // "jpeg"
console.log(meta.exif?.make); // "Canon"
console.log(meta.exif?.gps?.latitude);
// Write / update metadata
const updated = writeMetadata(imageBuffer, { orientation: 1, software: "My App" });
// Strip all metadata for privacy
const clean = stripMetadata(imageBuffer);
```
## Modules
- [`src/core/read-metadata.md`](./src/core/read-metadata.md) — Main entry point for reading image metadata
- [`src/core/write-metadata.md`](./src/core/write-metadata.md) — Write or update EXIF metadata in JPEG buffers
- [`src/core/strip-metadata.md`](./src/core/strip-metadata.md) — Remove metadata segments for privacy-safe export
- [`src/binary/reader/byte-reader.md`](./src/binary/reader/byte-reader.md) — `ByteReader` with endianness support
- [`src/binary/writer/byte-writer.md`](./src/binary/writer/byte-writer.md) — `ByteWriter` for TIFF reconstruction
- [`src/binary/rational/rational.md`](./src/binary/rational/rational.md) — Rational number arithmetic helpers
- [`src/binary/crc32/crc32.md`](./src/binary/crc32/crc32.md) — CRC-32 for PNG chunk validation
- [`src/format/detect.md`](./src/format/detect.md) — Magic-byte image format detection
- [`src/jpeg/segments.md`](./src/jpeg/segments.md) — JPEG segment chain parsing
- [`src/ifd/parse.md`](./src/ifd/parse.md) — TIFF IFD parsing (GPS, SubIFD)
- [`src/exif/read-exif.md`](./src/exif/read-exif.md) — Raw EXIF/TIFF buffer parsing
- [`src/iptc/read-iptc.md`](./src/iptc/read-iptc.md) — IPTC IIM caption/keywords/copyright parsing
- [`src/xmp/read-xmp.md`](./src/xmp/read-xmp.md) — XMP Dublin Core raw XML extraction
- [`src/errors/exif-error.md`](./src/errors/exif-error.md) — `ExifError` and `EXIF_CODES` registry
## Subpath Exports
| Subpath | Description |
| --- | --- |
| `@suaveplan/exif` | Main API: readMetadata, writeMetadata, stripMetadata |
| `@suaveplan/exif/binary` | ByteReader, ByteWriter, rational utilities, CRC-32 |
| `@suaveplan/exif/format` | detectImageFormat — magic byte detection |
| `@suaveplan/exif/exif` | readExifFromTiffBuffer — raw TIFF/EXIF parsing |
| `@suaveplan/exif/iptc` | readIptc — IPTC IIM parsing |
| `@suaveplan/exif/xmp` | readXmp — XMP Dublin Core parsing |
| `@suaveplan/exif/errors` | ExifError, EXIF_CODES |

View File

@@ -0,0 +1,55 @@
# CRC-32
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the standard IEEE 802.3 CRC-32 algorithm (polynomial `0xEDB88320`, reflected) used for PNG chunk integrity verification. The implementation is a pure TypeScript, lookup-table-based computation that works in any JavaScript runtime without native bindings or Node.js built-ins.
## Features
- Single-buffer `crc32()` for straightforward integrity checks
- Multi-chunk `crc32Multi()` that avoids buffer concatenation allocation
- Pre-computed 256-entry lookup table for O(n) throughput
- Returns unsigned 32-bit integer (safe for comparison with `>>>0` normalised values)
## Usage: Basic Example
```ts
import { crc32 } from "@suaveplan/exif/binary";
// Standard check value: crc32("123456789") === 0xCBF43926
const encoder = new TextEncoder();
const checkValue = crc32(encoder.encode("123456789"));
console.log(checkValue.toString(16)); // "cbf43926"
```
## Usage: Advanced Example
```ts
import { crc32Multi } from "@suaveplan/exif/binary";
// PNG chunk CRC: covers chunk type bytes + chunk data bytes
function pngChunkCrc(type: Uint8Array, data: Uint8Array): number {
return crc32Multi([type, data]);
}
const typeBytes = new TextEncoder().encode("IHDR");
const ihdrData = new Uint8Array([0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0]);
const checksum = pngChunkCrc(typeBytes, ihdrData);
```
## API Reference
### `crc32(data: Uint8Array): number`
Computes the CRC-32 of `data`. Returns an unsigned 32-bit integer. The standard test vector is: `crc32(new TextEncoder().encode("123456789")) === 0xCBF43926`.
### `crc32Multi(chunks: Uint8Array[]): number`
Computes the CRC-32 of all `chunks` as if they were concatenated, without allocating a combined buffer. Returns an unsigned 32-bit integer. Equivalent to `crc32(concat(...chunks))`.
## Implementation Notes
The lookup table is built once at module load time using the standard reflected polynomial `0xEDB88320` (the bit-reversal of `0x04C11DB7`). Each byte of input XORs into the low 8 bits of the running CRC, selects a table entry, then is combined with the shifted CRC via XOR. The final `>>> 0` ensures the result is a non-negative JavaScript number even when the high bit is set. `crc32Multi` avoids buffer allocation by maintaining the running CRC across chunk boundaries.

View File

@@ -0,0 +1,67 @@
# Rational
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides conversion and formatting utilities for TIFF RATIONAL values — pairs of integers representing numerator and denominator. EXIF metadata stores many camera settings (exposure time, f-number, focal length, GPS coordinates) as RATIONAL pairs, so correct round-trip conversion between floating-point numbers and rational pairs is essential for accurate metadata reading and writing.
## Features
- `numberToRational` — floating-point to rational via Stern-Brocot tree approximation with configurable max denominator
- `rationalToNumber` — rational to float with zero-denominator guard
- `rationalToString` — human-readable "n/d" formatting
- `simplifyRational` — GCD reduction to lowest terms with sign normalisation
- Handles negative values and zero correctly
## Usage: Basic Example
```ts
import { numberToRational, rationalToNumber, rationalToString } from "@suaveplan/exif/binary";
const r = numberToRational(0.01); // exposure 1/100 s
console.log(rationalToString(r)); // "1/100"
console.log(rationalToNumber(r)); // 0.01
```
## Usage: Advanced Example
```ts
import { numberToRational, simplifyRational, rationalToString } from "@suaveplan/exif/binary";
// Limit denominator for compact storage
const focalLength = numberToRational(50.0, 100);
console.log(rationalToString(focalLength)); // "50/1"
// GPS coordinate degree representation
const degrees = numberToRational(40.7128, 1_000_000);
const simple = simplifyRational(degrees);
console.log(rationalToString(simple));
// Simplify a raw EXIF rational
const raw = { numerator: 6, denominator: 4 };
console.log(rationalToString(simplifyRational(raw))); // "3/2"
```
## API Reference
### `numberToRational(n: number, maxDenominator?: number): Rational`
Converts a floating-point number to the closest `Rational` with denominator ≤ `maxDenominator` (default `1_000_000`). Uses the Stern-Brocot tree / mediant algorithm to guarantee the best approximation for the given denominator bound. Throws `ExifError(INVALID_DATA)` for non-finite inputs.
### `rationalToNumber(r: Rational): number`
Returns `numerator / denominator`. Returns `0` when `denominator === 0` to avoid propagating `NaN` or `Infinity` into metadata fields.
### `rationalToString(r: Rational): string`
Returns the string `"${numerator}/${denominator}"`, e.g. `"1/100"`.
### `simplifyRational(r: Rational): Rational`
Reduces `r` to lowest terms using Euclidean GCD. Normalises negative denominators to the numerator. Returns `{ numerator: 0, denominator: 1 }` when numerator is zero.
## Implementation Notes
The Stern-Brocot tree search is bounded by a maximum of 1000 iterations in addition to the denominator limit, ensuring termination even for pathological inputs. The mediant criterion (`|medVal - abs| < 1e-10`) provides exact representation for most photographic fractions such as `1/100`, `1/250`, `2.8`, `50.0`. The GCD in `simplifyRational` uses the iterative Euclidean algorithm to avoid stack overflow on large inputs.

View File

@@ -0,0 +1,117 @@
# ByteReader
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a low-level, bounds-checked binary reader for TIFF/EXIF byte streams. It wraps a `Uint8Array` with a configurable byte order and exposes typed read methods for all TIFF primitive types. The runtime-agnostic implementation avoids Node.js `Buffer` and browser-specific APIs, relying solely on `DataView` and standard typed arrays.
## Features
- Big-endian and little-endian support with runtime `setByteOrder()` switching
- Full TIFF primitive coverage: uint8/16/32, int16/32, float32/64, rational, signed rational
- Bounds-checked reads that throw `ExifError` with `EXIF.PARSE_ERROR` on overflow
- String reads with null-termination handling (`readString`, `readNullTermString`)
- `slice()` factory that creates a sub-reader sharing the same byte order
## Usage: Basic Example
```ts
import { ByteReader } from "@suaveplan/exif/binary";
const data = new Uint8Array([0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08]);
const reader = new ByteReader(data, "BE");
const magic = reader.readUint16(2); // 0x002A
const ifdOffset = reader.readUint32(4); // 8
```
## Usage: Advanced Example
```ts
import { ByteReader } from "@suaveplan/exif/binary";
// Switch byte order mid-stream (e.g. after reading TIFF header)
const reader = new ByteReader(tiffBuffer, "BE");
const bom = reader.readUint16(0);
if (bom === 0x4949) reader.setByteOrder("LE"); // Intel = little-endian
// Read a RATIONAL (8 bytes: numerator + denominator)
const exposureTime = reader.readRational(offset);
console.log(`${exposureTime.numerator}/${exposureTime.denominator}`);
// Sub-slice for IFD parsing
const ifdReader = reader.slice(ifdOffset, ifdOffset + ifdSize);
const entryCount = ifdReader.readUint16(0);
```
## API Reference
### `constructor(data: Uint8Array, byteOrder?: ByteOrder): ByteReader`
Creates a new reader wrapping `data`. Default `byteOrder` is `"BE"` (big-endian).
### `setByteOrder(order: ByteOrder): void`
Switches the active byte order for all subsequent reads.
### `get length: number`
Returns the total byte length of the underlying buffer.
### `readUint8(offset: number): number`
Reads one unsigned byte at `offset`.
### `readUint16(offset: number): number`
Reads a 2-byte unsigned integer using the active byte order.
### `readUint32(offset: number): number`
Reads a 4-byte unsigned integer using the active byte order.
### `readInt16(offset: number): number`
Reads a 2-byte signed integer using the active byte order.
### `readInt32(offset: number): number`
Reads a 4-byte signed integer using the active byte order.
### `readFloat32(offset: number): number`
Reads a 4-byte IEEE 754 single-precision float using the active byte order.
### `readFloat64(offset: number): number`
Reads an 8-byte IEEE 754 double-precision float using the active byte order.
### `readBytes(offset: number, length: number): Uint8Array`
Returns a copy of `length` bytes starting at `offset`.
### `readString(offset: number, length: number): string`
Reads `length` bytes as a Latin-1 string, stopping at the first null byte.
### `readNullTermString(offset: number): string`
Scans forward from `offset` until a null byte, then reads the preceding bytes as a string.
### `readRational(offset: number): Rational`
Reads an 8-byte TIFF RATIONAL (two unsigned 32-bit integers: numerator then denominator).
### `readSRational(offset: number): Rational`
Reads an 8-byte TIFF SRATIONAL (two signed 32-bit integers).
### `slice(start: number, end: number): ByteReader`
Returns a new `ByteReader` over `data.slice(start, end)` with the same byte order.
## Implementation Notes
All read methods call `assertBounds` before touching `DataView` to ensure clear error messages on malformed input. The `view` is constructed from the array's `buffer`, `byteOffset`, and `byteLength` to correctly handle sliced `Uint8Array` inputs that do not start at offset 0 in their underlying `ArrayBuffer`. The `readString` and `readNullTermString` methods use Latin-1 (one byte per character) which matches TIFF/EXIF ASCII strings; UTF-8 decoding is not attempted here as EXIF ASCII tags are defined as 7-bit ASCII.

View File

@@ -0,0 +1,100 @@
# ByteWriter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a dynamic binary writer for building TIFF/EXIF byte streams from scratch. It maintains an auto-growing `Uint8Array` buffer and exposes typed write methods for all TIFF primitive types. This is the write-side counterpart to `ByteReader`, sharing the same byte-order model and error infrastructure.
## Features
- Auto-growing buffer (doubles capacity on overflow, no pre-allocation required)
- Big-endian and little-endian support with runtime `setByteOrder()` switching
- Full TIFF primitive coverage: uint8/16/32, int16, rational, raw bytes, strings
- Fluent API — all write methods return `this` for chaining
- `toUint8Array()` snapshot of the written bytes at any point
- `ExifError` with `EXIF.WRITE_ERROR` on non-Latin-1 string characters
## Usage: Basic Example
```ts
import { ByteWriter } from "@suaveplan/exif/binary";
const writer = new ByteWriter(64, "BE");
writer.writeUint16(0x4D4D); // big-endian BOM
writer.writeUint16(0x002A); // TIFF magic
writer.writeUint32(8); // IFD0 offset
const bytes = writer.toUint8Array();
// Uint8Array [ 0x4D, 0x4D, 0x00, 0x2A, 0x00, 0x00, 0x00, 0x08 ]
```
## Usage: Advanced Example
```ts
import { ByteWriter } from "@suaveplan/exif/binary";
// Fluent chaining
const exifHeader = new ByteWriter()
.setByteOrder("LE")
.writeBytes(new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) // "Exif\0\0"
.writeUint16(0x4949) // LE BOM
.writeUint16(0x002A)
.writeUint32(8)
.toUint8Array();
// Writing a RATIONAL (e.g. exposure time 1/100)
const writer = new ByteWriter();
writer.writeRational({ numerator: 1, denominator: 100 });
```
## API Reference
### `constructor(initialSize?: number, byteOrder?: ByteOrder): ByteWriter`
Creates a new writer. `initialSize` defaults to 256 bytes. `byteOrder` defaults to `"BE"`.
### `get position: number`
Returns the current write cursor position (total bytes written so far).
### `setByteOrder(order: ByteOrder): this`
Switches the active byte order. Returns `this` for chaining.
### `writeUint8(value: number): this`
Writes one unsigned byte.
### `writeUint16(value: number): this`
Writes a 2-byte unsigned integer using the active byte order.
### `writeUint32(value: number): this`
Writes a 4-byte unsigned integer using the active byte order.
### `writeInt16(value: number): this`
Writes a 2-byte signed integer using the active byte order.
### `writeBytes(bytes: Uint8Array): this`
Appends a raw byte array.
### `writeString(str: string): this`
Writes a Latin-1 string without null termination. Throws `ExifError(WRITE_ERROR)` if any character is outside the Latin-1 range (> U+00FF).
### `writeRational(r: Rational): this`
Writes an 8-byte TIFF RATIONAL (two unsigned 32-bit integers: numerator then denominator).
### `toUint8Array(): Uint8Array`
Returns a copy of all bytes written so far (from position 0 to the current cursor).
## Implementation Notes
The buffer starts at `initialSize` and doubles whenever the next write would overflow. This amortizes allocation cost to O(log n) reallocations for n total bytes written. The `view` is recreated after each reallocation since `DataView` binds to a specific `ArrayBuffer` instance. `toUint8Array()` always returns a fresh slice to prevent the caller from holding a reference to the internal buffer.

View File

@@ -0,0 +1,65 @@
# readMetadata
## Purpose
The main entry point for reading image metadata. Orchestrates format detection, JPEG segment parsing, and EXIF/XMP/IPTC decoding into a unified `ImageMetadata` result. Callers do not need to interact with the lower-level parsers directly — this function handles the full pipeline from raw `Uint8Array` to structured metadata.
## Features
- Auto-detects image format via magic bytes
- For JPEG: parses all APP segments and dispatches to EXIF, XMP, and IPTC readers
- For PNG: reads IHDR chunk dimensions
- `ReadOptions` to skip individual metadata types for performance
- Throws `ExifError(UNSUPPORTED_FORMAT)` for completely unrecognised formats
- Gracefully skips malformed EXIF segments without throwing
## Usage: Basic Example
```ts
import { readMetadata } from "@suaveplan/exif";
const metadata = readMetadata(imageBuffer);
console.log(metadata.format); // "jpeg"
console.log(metadata.exif?.make); // "Canon"
console.log(metadata.exif?.dateTimeOriginal);
```
## Usage: Advanced Example
```ts
import { readMetadata } from "@suaveplan/exif";
// Skip XMP for faster processing
const metadata = readMetadata(imageBuffer, { skipXmp: true });
if (metadata.exif?.gps?.latitude) {
const { degrees, minutes, seconds, ref } = metadata.exif.gps.latitude;
const decimal = (degrees + minutes / 60 + seconds / 3600) * (ref === "S" ? -1 : 1);
console.log("GPS lat:", decimal.toFixed(6));
}
if (metadata.iptc?.keywords) {
console.log("Keywords:", metadata.iptc.keywords.join(", "));
}
```
## API Reference
### `readMetadata(buffer: Uint8Array, options?: ReadOptions): ImageMetadata`
Reads all available metadata from `buffer`.
**Parameters:**
- `buffer` — raw image bytes
- `options.skipExif` — skip EXIF APP1 parsing (default: false)
- `options.skipIptc` — skip IPTC APP13 parsing (default: false)
- `options.skipXmp` — skip XMP APP1 parsing (default: false)
**Returns:** `ImageMetadata` with `format` always set, and optional `exif`, `iptc`, `xmp`, `width`, `height`.
**Throws:** `ExifError(UNSUPPORTED_FORMAT)` if `detectImageFormat` returns `"unknown"`.
## Implementation Notes
The EXIF APP1 segment begins with a 6-byte `"Exif\0\0"` prefix before the TIFF header; this is stripped before calling `readExifFromTiffBuffer`. The XMP APP1 segment begins with the null-terminated namespace URI `"http://ns.adobe.com/xap/1.0/\0"` before the XML payload. Image dimensions are extracted from EXIF (imageWidth/imageHeight fields) when present and from the PNG IHDR chunk as a fallback. Malformed EXIF data is silently skipped to avoid crashing on images with corrupt metadata.

View File

@@ -0,0 +1,60 @@
# stripMetadata
## Purpose
Removes metadata segments from a JPEG image buffer and returns a clean image with only the pixel data intact. Privacy-sensitive metadata (GPS location, camera serial numbers, timestamps) is removed without re-encoding or degrading the image quality. The JPEG stream is rebuilt by concatenating only the non-metadata segments with the original compressed image data.
## Features
- Strips EXIF APP1 segments (camera, GPS, orientation metadata)
- Strips XMP APP1 segments (Adobe editorial metadata)
- Strips IPTC APP13 segments (Photoshop 3.0 news/editorial metadata)
- Each strip operation is independently controllable via `StripOptions`
- Non-destructive: compressed image data (SOS + payload + EOI) is preserved byte-for-byte
- Throws `ExifError(UNSUPPORTED_FORMAT)` for non-JPEG inputs
## Usage: Basic Example
```ts
import { stripMetadata } from "@suaveplan/exif";
// Strip all metadata (default behaviour)
const clean = stripMetadata(jpegBuffer);
```
## Usage: Advanced Example
```ts
import { stripMetadata } from "@suaveplan/exif";
// Keep IPTC (editorial attribution) but remove GPS and XMP
const stripped = stripMetadata(jpegBuffer, {
stripExif: true,
stripXmp: true,
stripIptc: false,
});
// Verify the result is still a valid JPEG
console.log(stripped[0] === 0xFF && stripped[1] === 0xD8); // true
```
## API Reference
### `stripMetadata(buffer: Uint8Array, options?: StripOptions): Uint8Array`
Strips metadata from a JPEG buffer.
**Parameters:**
- `buffer` — raw JPEG image bytes
- `options.stripExif` — strip EXIF APP1 segments (default: `true`)
- `options.stripIptc` — strip IPTC APP13 segments (default: `true`)
- `options.stripXmp` — strip XMP APP1 segments (default: `true`)
**Returns:** New `Uint8Array` with the specified segments removed.
**Throws:** `ExifError(UNSUPPORTED_FORMAT)` if `buffer` is not a JPEG image.
## Implementation Notes
The function calls `parseJpegSegments` to obtain the structural segment list, then rebuilds the JPEG by writing the SOI marker, each non-stripped segment (marker + length + data), and finally the raw SOS-onward bytes from the original buffer. The SOS segment and all subsequent compressed data are appended verbatim — no re-encoding occurs. Default options strip all three metadata types (EXIF, XMP, IPTC) to provide the safest default for privacy-sensitive use cases. Passing `{ stripExif: false, stripIptc: false, stripXmp: false }` is a no-op that returns a structurally identical (though freshly allocated) buffer.

View File

@@ -0,0 +1,64 @@
# writeMetadata
## Purpose
Writes or updates EXIF metadata in a JPEG image buffer. Merges the provided patch over any existing EXIF data and rebuilds the APP1 segment, inserting one if the image does not already have EXIF. The compressed image data is preserved byte-for-byte — no re-encoding occurs.
## Features
- Merges patch fields over existing EXIF data when an APP1 segment is present
- Inserts a new APP1 segment when no EXIF exists
- Writes IFD0 fields: Make, Model, Software, DateTime, Orientation, ImageWidth, ImageHeight
- Uses big-endian (Motorola) TIFF format for maximum compatibility
- Rebuilds valid JPEG stream with correct APP1 marker and length
- Throws `ExifError(UNSUPPORTED_FORMAT)` for non-JPEG inputs
## Usage: Basic Example
```ts
import { writeMetadata } from "@suaveplan/exif";
const updated = writeMetadata(jpegBuffer, {
software: "My App 1.0",
orientation: 1,
});
```
## Usage: Advanced Example
```ts
import { writeMetadata, readMetadata } from "@suaveplan/exif";
// Read → patch → write round-trip
const before = readMetadata(jpegBuffer);
const after = writeMetadata(jpegBuffer, {
...before.exif,
orientation: 6, // rotate 90° CW
software: "Suaveplan Processor",
});
const verify = readMetadata(after);
console.log(verify.exif?.orientation); // 6
console.log(verify.exif?.software); // "Suaveplan Processor"
```
## API Reference
### `writeMetadata(buffer: Uint8Array, patch: Partial<ExifData>): Uint8Array`
Updates EXIF metadata in a JPEG buffer.
**Parameters:**
- `buffer` — raw JPEG image bytes
- `patch` — `ExifData` fields to set. Fields not included in `patch` retain their existing values from the original EXIF (if present).
**Returns:** New `Uint8Array` containing the updated JPEG.
**Throws:** `ExifError(UNSUPPORTED_FORMAT)` if `buffer` is not a JPEG image.
**Supported fields:** `make`, `model`, `software`, `dateTime`, `orientation`, `imageWidth`, `imageHeight`. GPS and ExifIFD sub-IFD writing are not yet supported.
## Implementation Notes
The TIFF stream is always written in big-endian (Motorola, `MM`) format for maximum compatibility with reading software. IFD entries are sorted by tag number as required by the TIFF specification. String values (ASCII type) are null-terminated; values longer than 3 characters are stored in the data area rather than inline. The rebuilt JPEG inserts the new APP1 immediately after the SOI marker (before any other APP segments), which is the canonical position for EXIF. Any existing EXIF APP1 segment is replaced to prevent duplicate segments.

View File

@@ -0,0 +1,99 @@
# ExifError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the stable error code registry and base error class for the `@suaveplan/exif` package. Every error thrown by the package uses `ExifError` with a code from `EXIF_CODES`, enabling programmatic error handling without string matching on error messages.
The design mirrors the `@suaveplan/error` pattern but keeps the package dependency-free: no runtime `@suaveplan/*` imports are needed.
## Features
- Stable code registry via `EXIF_CODES` constant object
- `ExifError` class with `code` and optional `cause` chaining
- TypeScript `ExifErrorCode` union type for exhaustive switch statements
- Four distinct codes covering all error surfaces: format detection, parsing, writing, and data validation
## Usage: Basic Example
```ts
import { ExifError, EXIF_CODES } from "@suaveplan/exif/errors";
throw new ExifError("Buffer is not a JPEG or PNG image", EXIF_CODES.UNSUPPORTED_FORMAT);
```
## Usage: Advanced Example
```ts
import { ExifError, EXIF_CODES, type ExifErrorCode } from "@suaveplan/exif/errors";
function handleExifError(err: ExifError): void {
switch (err.code as ExifErrorCode) {
case "EXIF.UNSUPPORTED_FORMAT":
console.warn("Unsupported image format:", err.message);
break;
case "EXIF.PARSE_ERROR":
console.error("Parsing failed:", err.message, err.cause);
break;
case "EXIF.WRITE_ERROR":
console.error("Write failed:", err.message);
break;
case "EXIF.INVALID_DATA":
console.error("Invalid metadata:", err.message);
break;
}
}
// With cause chaining
try {
const data = parseIfd(reader, 0);
} catch (cause) {
throw new ExifError("IFD parse failed at offset 0", EXIF_CODES.PARSE_ERROR, { cause });
}
```
## API Reference
### `EXIF_CODES`
```ts
const EXIF_CODES: {
UNSUPPORTED_FORMAT: "EXIF.UNSUPPORTED_FORMAT";
PARSE_ERROR: "EXIF.PARSE_ERROR";
WRITE_ERROR: "EXIF.WRITE_ERROR";
INVALID_DATA: "EXIF.INVALID_DATA";
}
```
Stable string code registry. Use these constants rather than literal strings to guard against typos.
### `ExifErrorCode`
```ts
type ExifErrorCode = "EXIF.UNSUPPORTED_FORMAT" | "EXIF.PARSE_ERROR" | "EXIF.WRITE_ERROR" | "EXIF.INVALID_DATA";
```
Union type of all valid codes, suitable for exhaustive switches.
### `ExifError`
```ts
class ExifError extends Error {
readonly code: ExifErrorCode;
constructor(message: string, code: ExifErrorCode, options?: { cause?: unknown }): ExifError;
}
```
- **message** — human-readable description of the error
- **code** — one of the `EXIF_CODES` values for programmatic handling
- **options.cause** — upstream error to chain (accessible via `err.cause`)
The `name` property is always `"ExifError"` for `instanceof`-free type narrowing via `err.name`.
## Implementation Notes
This module is intentionally minimal. It carries no runtime dependencies and exports only what is needed for error handling across the package. The `EXIF_CODES` object is declared `as const` so TypeScript narrows each property to its literal type, enabling the `ExifErrorCode` union to be derived without duplication.
`ExifError` extends `Error` directly rather than `@suaveplan/error`'s base class to keep the package fully dependency-free. This is a deliberate trade-off: the package is a leaf node in the dependency graph and should impose zero peer requirements on consumers.

View File

@@ -0,0 +1,74 @@
# readExifFromTiffBuffer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reads EXIF metadata from a raw TIFF byte buffer. Handles the full TIFF IFD chain: IFD0 for image-level tags, ExifIFD (0x8769) for camera settings, and GPS IFD (0x8825) for geolocation data. Supports both big-endian (Motorola, `MM`) and little-endian (Intel, `II`) TIFF byte orders.
## Features
- Reads both Motorola (big-endian) and Intel (little-endian) TIFF streams
- Maps IFD0 tags: Make, Model, Software, DateTime, Orientation, ImageWidth, ImageHeight
- Follows ExifIFD: ExposureTime, FNumber, ISO, FocalLength, FocalLengthIn35mm, Flash, WhiteBalance, ExposureMode, ExposureProgram, DateTimeOriginal, DateTimeDigitized
- Follows GPS IFD: latitude, longitude, altitude, timestamp, datestamp, speed, direction
- Returns all parsed tags in `rawTags` for custom access
- Throws `ExifError(PARSE_ERROR)` on invalid TIFF header
## Usage: Basic Example
```ts
import { readExifFromTiffBuffer } from "@suaveplan/exif/exif";
// Buffer starts at TIFF header (after "Exif\0\0" prefix is stripped)
const exif = readExifFromTiffBuffer(tiffBuffer);
console.log(exif.make, exif.model);
console.log(exif.exposureTime); // { numerator: 1, denominator: 100 }
```
## Usage: Advanced Example
```ts
import { readExifFromTiffBuffer } from "@suaveplan/exif/exif";
import { rationalToNumber } from "@suaveplan/exif/binary";
const exif = readExifFromTiffBuffer(tiffBuffer);
if (exif.gps?.latitude) {
const { degrees, minutes, seconds, ref } = exif.gps.latitude;
const decimal = degrees + minutes / 60 + seconds / 3600;
const signed = ref === "S" ? -decimal : decimal;
console.log("Latitude:", signed.toFixed(6));
}
if (exif.exposureTime) {
const shutter = rationalToNumber(exif.exposureTime);
console.log(`Shutter: ${shutter}s`);
}
// Access raw tag by number
const customTag = exif.rawTags?.[0x013B]; // Artist tag
```
## API Reference
### `readExifFromTiffBuffer(buffer: Uint8Array): ExifData`
Parses EXIF metadata from a raw TIFF buffer.
**Parameters:**
- `buffer` — `Uint8Array` starting at the TIFF byte-order mark (`MM` or `II`), not at the JPEG `Exif\0\0` prefix
**Returns:** `ExifData` with all recognised tags populated.
**Throws:** `ExifError(PARSE_ERROR)` if:
- The buffer is shorter than 8 bytes
- The byte-order mark is not `0x4D4D` (BE) or `0x4949` (LE)
- The TIFF magic number is not `0x002A`
## Implementation Notes
The 6-byte `"Exif\0\0"` prefix must be stripped before calling this function — the buffer must start at the TIFF header. The `readMetadata` orchestrator handles this stripping automatically. GPS coordinate rationals are expected to be an array of three RATIONAL values (degrees, minutes, seconds) as per the EXIF GPS IFD specification. The GPS timestamp is parsed from three RATIONAL values and formatted as `HH:MM:SS`. All collected IFD entries from both IFD0 and ExifIFD are merged into `rawTags` for consumer access.

View File

@@ -0,0 +1,66 @@
# detectImageFormat
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Identifies the image format of a raw byte buffer by inspecting its leading magic bytes. This is the entry point for all metadata operations in `@suaveplan/exif`: the correct parser is selected based on the detected format, so detection must be reliable before any other processing occurs.
The implementation avoids file extensions and MIME type strings — it reads only the binary signature, which is the only trustworthy signal for format identification.
## Features
- Detects JPEG (3-byte signature), PNG (8-byte signature), WebP (12-byte RIFF signature), and TIFF (both big-endian and little-endian variants)
- Returns `'unknown'` rather than throwing for unrecognised formats
- Zero dependencies — pure TypeScript with no external imports
- Handles buffers shorter than the minimum signature length gracefully
## Usage: Basic Example
```ts
import { detectImageFormat } from "@suaveplan/exif/format";
const jpegBytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0, ...]);
console.log(detectImageFormat(jpegBytes)); // "jpeg"
```
## Usage: Advanced Example
```ts
import { detectImageFormat } from "@suaveplan/exif/format";
import { readMetadata } from "@suaveplan/exif";
async function processImage(buffer: Uint8Array) {
const format = detectImageFormat(buffer);
switch (format) {
case "jpeg":
case "png":
return readMetadata(buffer);
case "webp":
case "tiff":
throw new Error(`Format ${format} metadata reading not yet supported`);
case "unknown":
throw new Error("Unrecognised image format");
}
}
```
## API Reference
### `detectImageFormat(buffer: Uint8Array): ImageFormat`
Inspects the leading bytes of `buffer` and returns one of:
- `"jpeg"` — buffer starts with `FF D8 FF`
- `"png"` — buffer starts with `89 50 4E 47 0D 0A 1A 0A`
- `"webp"` — buffer starts with `RIFF` and bytes 8–11 are `WEBP`
- `"tiff"` — buffer starts with `49 49 2A 00` (LE) or `4D 4D 00 2A` (BE)
- `"unknown"` — none of the above match, or buffer is shorter than 4 bytes
Does not throw. All inputs are handled.
## Implementation Notes
The magic-byte checks are ordered by specificity: JPEG and PNG checks come first because they are the primary supported formats. WebP requires a 12-byte buffer to read both the RIFF header and the WEBP brand. TIFF detection handles both byte orders since TIFF files start with their own byte-order mark. The function returns `"unknown"` for buffers shorter than 4 bytes rather than attempting a partial match, to avoid false positives on truncated data.

View File

@@ -0,0 +1,86 @@
# IFD Parse
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements TIFF IFD (Image File Directory) parsing, the core data structure used by EXIF to store metadata. Each IFD entry is a 12-byte record containing a tag number, data type, count, and either an inline value or an offset to data elsewhere in the TIFF stream. This module handles all 12 standard TIFF data types and provides typed accessor helpers for downstream EXIF, GPS, and sub-IFD readers.
## Features
- Parses all 12 TIFF primitive types: BYTE, ASCII, SHORT, LONG, RATIONAL, SBYTE, UNDEFINED, SSHORT, SLONG, SRATIONAL, FLOAT, DOUBLE
- Inline vs. offset-stored value selection based on total data size
- Graceful handling of malformed entries (skips rather than throwing)
- Typed accessors: `getTagNumber`, `getTagString`, `getTagRational`, `getTagRationalArray`
- Bounds-checked with `ExifError(PARSE_ERROR)` on structural violations
## Usage: Basic Example
```ts
import { ByteReader } from "@suaveplan/exif/binary";
import { parseIfd, getTagString } from "@suaveplan/exif";
const reader = new ByteReader(tiffBuffer, "BE");
const ifd0 = parseIfd(reader, 8);
const make = getTagString(ifd0, 0x010F); // Make tag
const model = getTagString(ifd0, 0x0110); // Model tag
console.log(`${make} ${model}`);
```
## Usage: Advanced Example
```ts
import { ByteReader } from "@suaveplan/exif/binary";
import { parseIfd, getTagNumber, getTagRational } from "@suaveplan/exif";
const reader = new ByteReader(tiffBuffer, "LE");
// Parse IFD0 then follow ExifIFD pointer
const ifd0 = parseIfd(reader, 8);
const exifIfdOffset = getTagNumber(ifd0, 0x8769); // ExifIFD pointer
if (exifIfdOffset !== undefined) {
const exifIfd = parseIfd(reader, exifIfdOffset);
const exposureTime = getTagRational(exifIfd, 0x829A);
const fNumber = getTagRational(exifIfd, 0x829D);
}
```
## API Reference
### `IfdEntry`
```ts
interface IfdEntry {
tag: number;
type: number;
count: number;
value: unknown;
}
```
### `parseIfd(reader: ByteReader, ifdOffset: number): Map<number, IfdEntry>`
Parses the IFD at `ifdOffset` within `reader`. Returns a `Map<tag, IfdEntry>`. Throws `ExifError(PARSE_ERROR)` if the IFD count field extends beyond the buffer. Malformed individual entries are silently skipped.
### `getTagNumber(entries: Map<number, IfdEntry>, tag: number): number | undefined`
Extracts a SHORT or LONG tag as a number.
### `getTagString(entries: Map<number, IfdEntry>, tag: number): string | undefined`
Extracts an ASCII tag as a string.
### `getTagRational(entries: Map<number, IfdEntry>, tag: number): Rational | undefined`
Extracts a single RATIONAL or SRATIONAL tag.
### `getTagRationalArray(entries: Map<number, IfdEntry>, tag: number): Rational[] | undefined`
Extracts an array of RATIONAL values (e.g. GPS coordinate triples where each axis is three RATIONALs for degrees/minutes/seconds).
## Implementation Notes
Inline storage applies when `typeSize * count <= 4`: the value is packed into the 4-byte value/offset field of the IFD entry. For larger values, the field contains a file offset to the actual data. ASCII strings have trailing nulls stripped. The `UNDEFINED` type (7) returns a raw `Uint8Array`. Sign extension for SBYTE uses the left-shift/right-shift trick `<< 24 >> 24`.

View File

@@ -0,0 +1,73 @@
# readIptc
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses IPTC IIM (Information Interchange Model) metadata from a Photoshop 3.0 APP13 JPEG segment payload. IPTC is the editorial metadata standard used by news agencies and photo distributors for fields such as headline, caption, copyright, keywords, and geographic information. The parser traverses the Photoshop 8BIM resource block structure to locate the IPTC-NAA data block (resource ID 0x0404) and then iterates its Record 2 datasets.
## Features
- Parses the Photoshop 3.0 + 8BIM wrapper before accessing IPTC data
- Extracts all standard Record 2 editorial fields: headline, caption, copyright, creator, keywords, city, country code, country name, source
- Handles repeatable fields (keywords) correctly as arrays
- Returns an empty object gracefully if no IPTC data is present
- Zero external dependencies — pure TypeScript byte-level parsing
## Usage: Basic Example
```ts
import { readIptc } from "@suaveplan/exif/iptc";
const iptc = readIptc(app13SegmentData);
console.log(iptc.headline);
console.log(iptc.keywords); // string[]
```
## Usage: Advanced Example
```ts
import { parseJpegSegments, isIptcSegment } from "@suaveplan/exif";
import { readIptc } from "@suaveplan/exif/iptc";
const segments = parseJpegSegments(jpegBuffer);
const iptcSeg = segments.find(isIptcSegment);
if (iptcSeg) {
const iptc = readIptc(iptcSeg.data);
console.log("Headline:", iptc.headline);
console.log("Copyright:", iptc.copyright);
console.log("Creator:", iptc.creator);
console.log("Keywords:", iptc.keywords?.join(", "));
console.log("Location:", [iptc.city, iptc.country].filter(Boolean).join(", "));
}
```
## API Reference
### `readIptc(data: Uint8Array): IptcData`
Parses IPTC data from a raw APP13 segment payload (the full `data` field of a `JpegSegment` with marker `0xFFED`).
**Returns:** `IptcData` object with all recognisable fields populated, or an empty `{}` if the data does not match the Photoshop 3.0 + 8BIM + IPTC structure.
**Does not throw.** Malformed or truncated data results in partial or empty output.
### Dataset mapping
| Dataset code | IptcData field |
|---|---|
| Record 2, DS 5 (0x05) | `headline` |
| Record 2, DS 80 (0x50) | `creator` |
| Record 2, DS 120 (0x78) | `caption` |
| Record 2, DS 25 (0x19) | `keywords[]` |
| Record 2, DS 90 (0x5A) | `city` |
| Record 2, DS 100 (0x64) | `countryCode` |
| Record 2, DS 101 (0x65) | `country` |
| Record 2, DS 116 (0x74) | `copyright` |
| Record 2, DS 115 (0x73) | `source` |
## Implementation Notes
The Photoshop 3.0 wrapper uses Pascal strings for resource names (1-byte length followed by the name, padded to an even byte boundary). The 8BIM block iteration stops on the first non-`8BIM` signature to avoid reading into compressed image data. IPTC record markers are `0x1C` followed by record number and dataset number. Only Record 2 (Application Record) datasets are extracted; Record 1 (Envelope Record) envelope fields are skipped. The string decoder uses Latin-1 (one byte per character); IPTC IIM data is specified as 7-bit ASCII.

View File

@@ -0,0 +1,83 @@
# JPEG Segments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses the segment structure of a JPEG file and identifies metadata segments (EXIF, XMP, IPTC). JPEG is a container format: each segment is introduced by a 2-byte marker (`0xFF` + type), followed by a 2-byte big-endian length and payload. This module provides the structural parser that upstream readers (EXIF, IPTC, XMP) rely on to locate their data.
## Features
- `parseJpegSegments` — iterates all APP/marker segments up to and including SOS
- Handles padding bytes (multiple consecutive `0xFF`) between segments
- `isExifSegment` — identifies APP1 segments with the `Exif\0\0` header
- `isXmpSegment` — identifies APP1 segments with the XMP namespace header
- `isIptcSegment` — identifies APP13 segments with the `Photoshop 3.0\0` header
- Bounds-checking with `ExifError(PARSE_ERROR)` on malformed input
## Usage: Basic Example
```ts
import { parseJpegSegments, isExifSegment } from "@suaveplan/exif";
const segments = parseJpegSegments(jpegBuffer);
const exifSeg = segments.find(isExifSegment);
if (exifSeg) {
console.log("EXIF data found, length:", exifSeg.data.length);
}
```
## Usage: Advanced Example
```ts
import { parseJpegSegments, isExifSegment, isXmpSegment, isIptcSegment } from "@suaveplan/exif";
const segments = parseJpegSegments(jpegBuffer);
for (const seg of segments) {
if (isExifSegment(seg)) {
console.log(`EXIF APP1 at offset ${seg.offset}, ${seg.data.length} bytes`);
} else if (isXmpSegment(seg)) {
console.log(`XMP APP1 at offset ${seg.offset}`);
} else if (isIptcSegment(seg)) {
console.log(`IPTC APP13 at offset ${seg.offset}`);
}
}
```
## API Reference
### `JpegSegment`
```ts
interface JpegSegment {
marker: number; // e.g. 0xFFE1 for APP1
data: Uint8Array; // payload (length field excluded)
offset: number; // byte offset of marker in original buffer
}
```
### `parseJpegSegments(buffer: Uint8Array): JpegSegment[]`
Parses all JPEG segments from `buffer`. Returns segments from SOI through SOS (inclusive). Throws `ExifError(PARSE_ERROR)` if:
- The buffer does not start with `FF D8` (SOI)
- A segment's length field is less than 2
- A segment's data extends beyond the buffer
### `isExifSegment(seg: JpegSegment): boolean`
Returns `true` if `seg` is an APP1 segment (`0xFFE1`) whose payload begins with `Exif\0\0`.
### `isXmpSegment(seg: JpegSegment): boolean`
Returns `true` if `seg` is an APP1 segment whose payload begins with `http://ns.adobe.com/xap/1.0/\0`.
### `isIptcSegment(seg: JpegSegment): boolean`
Returns `true` if `seg` is an APP13 segment (`0xFFED`) whose payload begins with `Photoshop 3.0\0`.
## Implementation Notes
The parser skips padding `0xFF` bytes between markers as permitted by the JFIF specification. Standalone markers (SOI, EOI, RST0–RST7) have no length field and are handled as zero-data segments. Parsing stops at the SOS marker to avoid processing compressed image data as segments. The `data` field of each segment excludes the 2-byte length field, so `data.length === segmentLength - 2`.

View File

@@ -0,0 +1,93 @@
# readXmp
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses XMP (Extensible Metadata Platform) metadata from a raw XMP XML string extracted from a JPEG APP1 segment. XMP is Adobe's XML-based metadata format built on RDF/XML. This module extracts the most commonly used Dublin Core fields (dc:creator, dc:description, dc:rights, dc:subject, dc:title) and XMP Rights fields using pattern-based extraction rather than a full XML parser, trading completeness for zero dependencies.
## Features
- Extracts dc:creator as a string array (from rdf:Seq)
- Extracts dc:description as a language-keyed Record (from rdf:Alt)
- Extracts dc:rights or xmpRights:UsageTerms as a plain string
- Extracts dc:subject as a string array (from rdf:Bag)
- Extracts dc:title as a language-keyed Record (from rdf:Alt)
- Always returns the raw XMP XML string in the `raw` field
- Never throws — partial results on malformed or missing fields
## Usage: Basic Example
```ts
import { readXmp } from "@suaveplan/exif/xmp";
const xml = `<x:xmpmeta><rdf:RDF>
<rdf:Description>
<dc:creator><rdf:Seq><rdf:li>Jane Doe</rdf:li></rdf:Seq></dc:creator>
<dc:rights>© 2024 Jane Doe</dc:rights>
</rdf:Description>
</rdf:RDF></x:xmpmeta>`;
const xmp = readXmp(xml);
console.log(xmp.creator); // ["Jane Doe"]
console.log(xmp.rights); // "© 2024 Jane Doe"
```
## Usage: Advanced Example
```ts
import { readXmp } from "@suaveplan/exif/xmp";
const xml = `<x:xmpmeta>
<rdf:RDF>
<rdf:Description>
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="en">Sunset over mountains</rdf:li>
<rdf:li xml:lang="fr">Coucher de soleil sur les montagnes</rdf:li>
</rdf:Alt>
</dc:title>
<dc:subject>
<rdf:Bag>
<rdf:li>landscape</rdf:li>
<rdf:li>sunset</rdf:li>
<rdf:li>mountains</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>`;
const xmp = readXmp(xml);
console.log(xmp.title?.["en"]); // "Sunset over mountains"
console.log(xmp.title?.["fr"]); // "Coucher de soleil sur les montagnes"
console.log(xmp.subject); // ["landscape", "sunset", "mountains"]
```
## API Reference
### `readXmp(xmlString: string): XmpData`
Parses XMP metadata from `xmlString`.
**Returns:** `XmpData` with all recognised fields populated. Always includes `raw: xmlString`. Fields absent from the XMP are `undefined` in the result.
**Does not throw.** Returns `{ raw: xmlString }` on empty or unrecognised input.
### `XmpData`
```ts
interface XmpData {
raw: string;
creator?: string[];
description?: Record<string, string>;
rights?: string;
subject?: string[];
title?: Record<string, string>;
}
```
## Implementation Notes
The extraction uses three internal helpers: `extractTextNode` for simple inline values, `extractRdfList` for `rdf:Bag`/`rdf:Seq` item arrays, and `extractRdfAlt` for `rdf:Alt` language maps. Pattern matching is tolerant of namespace prefixes (any prefix or no prefix). The `rdf:Alt` extraction captures optional `xml:lang` attributes and defaults to `"x-default"` when absent. This approach handles the most common XMP output from Lightroom, Photoshop, and camera firmware without the complexity and size of a full DOM parser.

View File

@@ -0,0 +1,88 @@
# @suaveplan/image-quantisation - Image complexity analysis and adaptive colour quantisation with pool detection, spatial-aware anti-aliasing cleanup, and multiple quantisation algorithms
Image complexity analysis and adaptive colour quantisation with pool detection, spatial-aware anti-aliasing cleanup, and multiple quantisation algorithms. Designed for emblem generation pipelines where pixel-art inputs need minimal processing while photographic inputs need aggressive reduction.
<!-- begin:auto-docs -->
## Documentation tree
- [classify](./src/analysis/classify/classify.md) — Analyse a raw RGBA pixel buffer and classify its complexity into one of four tiers: pixel-art, vector-clean, vector-aa,…
- [classify](./src/analysis/classify/classify.md) — Analyse a raw RGBA pixel buffer and classify its complexity into one of four tiers: pixel-art, vector-clean, vector-aa,…
- [colour](./src/analysis/colour/colour.md) — Low-level colour distance and keying helpers used by the analysis and spatial-cleanup modules. These functions are pure…
- [colour](./src/analysis/colour/colour.md) — Low-level colour distance and keying helpers used by the analysis and spatial-cleanup modules. These functions are pure…
- [edges](./src/analysis/edges/edges.md) — Analyse colour transitions between horizontally and vertically adjacent pixels to determine whether an image has sharp…
- [edges](./src/analysis/edges/edges.md) — Analyse colour transitions between horizontally and vertically adjacent pixels to determine whether an image has sharp…
- [pools](./src/analysis/pools/pools.md) — Find contiguous regions of identical colour via 4-connected flood fill. Pool size distribution is the primary signal fo…
- [pools](./src/analysis/pools/pools.md) — Find contiguous regions of identical colour via 4-connected flood fill. Pool size distribution is the primary signal fo…
- [errors](./src/errors/image-quantisation-error.md) — Structured error type for all `@suaveplan/image-quantisation` failures. Extends `SuaveplanError` so that callers receiv…
- [image-quantisation-error](./src/errors/image-quantisation-error.md) — Structured error type for all `@suaveplan/image-quantisation` failures. Extends `SuaveplanError` so that callers receiv…
- [pipeline](./src/pipeline/pipeline.md) — Full workflow: analyse complexity → spatial cleanup → quantise. Automatically selects the best strategy based on image…
- [pipeline](./src/pipeline/pipeline.md) — Full workflow: analyse complexity → spatial cleanup → quantise. Automatically selects the best strategy based on image…
- [quantise](./src/quantise/quantise.md) — Reduce an RGBA pixel buffer to a target number of colours using the `image-q` library. This module is the primary quant…
- [quantise](./src/quantise/quantise.md) — Reduce an RGBA pixel buffer to a target number of colours using the `image-q` library. This module is the primary quant…
- [schemas](./src/schemas/options.schema.md) — Zod schemas for runtime validation of quantisation, spatial cleanup, and pipeline options. Every public entry point tha…
- [options.schema](./src/schemas/options.schema.md) — Zod schemas for runtime validation of quantisation, spatial cleanup, and pipeline options. Every public entry point tha…
- [cleanup](./src/spatial/cleanup/cleanup.md) — Merge small anti-aliasing pools into their nearest spatial neighbours by colour distance. This preserves intentional de…
- [cleanup](./src/spatial/cleanup/cleanup.md) — Merge small anti-aliasing pools into their nearest spatial neighbours by colour distance. This preserves intentional de…
- [types](./src/types/common.md) — The `common.ts` module defines the core domain types used across the `@suaveplan/image-quantisation` package. Every pub…
- [common](./src/types/common.md) — The `common.ts` module defines the core domain types used across the `@suaveplan/image-quantisation` package. Every pub…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/image-quantisation
```
## Quick Start
```ts
import {
adaptivePipeline,
analyseComplexity,
quantise,
} from "@suaveplan/image-quantisation";
// Full adaptive pipeline: analyse -> cleanup -> quantise
const result = adaptivePipeline(rgbaPixels, width, height);
console.log(result.analysis.tier); // 'pixel-art' | 'vector-aa' | 'photographic'
console.log(result.analysis.uniqueColours);
console.log(result.finalColours); // colour count after quantisation
// result.pixels is ready for downstream consumers
```
## Modules
- [`src/analysis/classify/classify.md`](src/analysis/classify/classify.md) — Complexity classification
- [`src/analysis/colour/colour.md`](src/analysis/colour/colour.md) — Colour distance and keying utilities
- [`src/analysis/edges/edges.md`](src/analysis/edges/edges.md) — Edge sharpness analysis
- [`src/analysis/pools/pools.md`](src/analysis/pools/pools.md) — Pool detection and distribution
- [`src/errors/image-quantisation-error.md`](src/errors/image-quantisation-error.md) — Structured error types
- [`src/pipeline/pipeline.md`](src/pipeline/pipeline.md) — Adaptive pipeline
- [`src/quantise/quantise.md`](src/quantise/quantise.md) — Colour quantisation
- [`src/schemas/options.schema.md`](src/schemas/options.schema.md) — Zod validation schemas
- [`src/spatial/cleanup/cleanup.md`](src/spatial/cleanup/cleanup.md) — Spatial AA cleanup
- [`src/types/common.md`](src/types/common.md) — Core type definitions
## Features
- Image complexity classification into four tiers: pixel-art, vector-clean, vector-aa, photographic
- Pool detection via 4-connected flood fill for contiguous same-colour regions
- Pool distribution statistics (count, min, max, mean, median, singleton ratio)
- Edge analysis with sharpness ratio and mean colour distance
- Spatial AA cleanup that merges small pools into nearest spatial neighbours
- Three quantisation algorithms: wuquant (Wu), neuquant (neural network), rgbquant (fast)
- Three colour distance formulas: euclidean, manhattan, ciede2000
- Dithering options: none, floyd-steinberg, riemersma
- Full adaptive pipeline: analyse, cleanup, quantise in one call
- Colour utility functions for distance calculation and key generation
- Zod schemas for runtime option validation
- OpenTelemetry counter and histogram instrumentation
- Structured errors with stable codes via `@suaveplan/error`
- Property-based tests with fast-check
- 100% test coverage (Istanbul)
## License
MIT

View File

@@ -0,0 +1,66 @@
# Complexity Classification
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Analyse a raw RGBA pixel buffer and classify its complexity into one of four tiers: pixel-art, vector-clean, vector-aa, or photographic. The tier determines the optimal processing strategy for emblem generation pipelines. This module combines pool detection, edge analysis, and heuristics into a single high-level entry point.
## Features
- Four-tier classification based on pool distribution and edge sharpness
- Automatic palette size recommendation
- Dithering recommendation for photographic content
- Pure TypeScript with zero external dependencies for analysis
## Usage: Basic Example
```ts
import { analyseComplexity } from "@suaveplan/image-quantisation/analysis/classify";
const analysis = analyseComplexity(pixels, 32, 32);
console.log(analysis.tier); // "pixel-art" | "photographic" | ...
console.log(analysis.recommendedColours); // suggested palette size
```
## Usage: Advanced Example
```ts
import { analyseComplexity } from "@suaveplan/image-quantisation/analysis/classify";
const analysis = analyseComplexity(pixels, 128, 128);
const strategy =
analysis.tier === "pixel-art"
? "skip_quantise"
: analysis.tier === "photographic"
? "heavy_dither"
: "light_merge";
```
## API Reference
### `analyseComplexity(pixels, width, height): ComplexityAnalysis`
Runs pool detection, edge analysis, and tier classification in one call.
Returns a `ComplexityAnalysis` object containing:
- `tier` — classification result
- `uniqueColours` — number of distinct colours
- `poolDistribution` — pool size statistics
- `edgeAnalysis` — edge sharpness metrics
- `recommendedColours` — suggested palette size (2–256)
- `recommendDithering` — boolean
## Implementation Notes
Classification heuristics are tuned for emblem-generation workflows:
- Pixel art: ≤32 colours, mean pool size >20
- Photographic: >70% singleton pools, sharpness <0.05
- Vector-AA: bimodal distribution with many tiny edge pools
Recommended colours are clamped to a minimum of 2 to satisfy quantiser constraints. The heuristics were derived from empirical testing on a corpus of pixel-art, vector, and photographic images. They are intentionally simple to keep the analysis fast and deterministic. Future versions may expose the threshold constants as configurable parameters.

View File

@@ -0,0 +1,70 @@
# Colour Utilities
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Low-level colour distance and keying helpers used by the analysis and spatial-cleanup modules. These functions are pure, stateless, and operate on the `RGBA` type. They enable exact colour matching for pool detection, perceptual distance for edge analysis, and canonical string keys for hash-based lookups. All operations are synchronous and allocate no intermediate objects beyond the return value.
## Features
- Euclidean RGB distance
- Perceptually weighted Euclidean distance (ITU-R BT.601)
- Canonical colour key generation for exact matching
- Round-trip colour key parsing
## Usage: Basic Example
```ts
import {
rgbDistance,
colourKey,
} from "@suaveplan/image-quantisation/analysis/colour";
const a = { r: 255, g: 0, b: 0, a: 255 };
const b = { r: 0, g: 255, b: 0, a: 255 };
console.log(rgbDistance(a, b)); // ~360.6
console.log(colourKey(a)); // "255,0,0,255"
```
## Usage: Advanced Example
```ts
import {
weightedDistance,
parseColourKey,
} from "@suaveplan/image-quantisation/analysis/colour";
// Weighted distance accounts for human eye sensitivity
const dist = weightedDistance(
{ r: 100, g: 100, b: 100, a: 255 },
{ r: 100, g: 110, b: 100, a: 255 }
);
const parsed = parseColourKey("100,110,100,255");
```
## API Reference
### `rgbDistance(a, b): number`
Euclidean distance in RGB space. Range 0–441.67.
### `weightedDistance(a, b): number`
ITU-R BT.601 weighted Euclidean distance. Range 0–255.
### `colourKey(c): string`
Returns `"r,g,b,a"`.
### `parseColourKey(key): RGBA`
Parses a comma-separated key back into `RGBA`.
## Implementation Notes
`rgbDistance` ignores the alpha channel. `weightedDistance` uses coefficients 0.299 (R), 0.587 (G), 0.114 (B) to approximate perceptual difference. The alpha channel is included in `colourKey` so that transparent pools are not merged with opaque ones during flood fill. All four functions are pure and have no side effects, making them safe to use in property-based tests and benchmarks. They are also fully deterministic: given the same inputs they always produce the same outputs, which is important for reproducible image processing pipelines.

View File

@@ -0,0 +1,53 @@
# Edge Analysis
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Analyse colour transitions between horizontally and vertically adjacent pixels to determine whether an image has sharp edges (pixel art) or soft edges (photographic). Edge metrics are a critical input to the complexity classifier, providing a signal that is orthogonal to pool distribution. Two images may have the same number of pools but very different edge characteristics.
## Features
- Examines every right and bottom neighbour pair
- Counts hard edges (perceptually distinct) and soft edges (nearly identical)
- Computes sharpness ratio and mean edge distance
- Configurable hard/soft thresholds
## Usage: Basic Example
```ts
import { analyseEdges } from "@suaveplan/image-quantisation/analysis/edges";
const edges = analyseEdges(pixels, 32, 32);
console.log(edges.sharpnessRatio); // 0.0 – 1.0
```
## Usage: Advanced Example
```ts
import { analyseEdges } from "@suaveplan/image-quantisation/analysis/edges";
// Stricter thresholds for high-contrast artwork
const edges = analyseEdges(pixels, 64, 64, 20, 5);
if (edges.sharpnessRatio > 0.5) {
console.log("High-contrast pixel art detected");
}
```
## API Reference
### `analyseEdges(pixels, width, height, hardThreshold?, softThreshold?): EdgeAnalysis`
- `pixels` — RGBA `Uint8Array`
- `width`, `height` — image dimensions
- `hardThreshold` — default 30
- `softThreshold` — default 10
Returns `EdgeAnalysis` with `totalEdges`, `hardEdges`, `softEdges`, `sharpnessRatio`, `meanEdgeDistance`.
## Implementation Notes
The function uses `weightedDistance` (ITU-R BT.601) for perceptually fair comparisons. Pairs are counted once each (right and bottom neighbours only) to avoid double counting. A solid-colour image produces zero hard edges and zero mean distance, while a checkerboard produces a very high sharpness ratio. The thresholds can be adjusted for different content types: stricter thresholds treat more edges as hard, which is useful for high-contrast artwork, while lenient thresholds classify most edges as soft, which is better for smooth gradients.

View File

@@ -0,0 +1,56 @@
# Pool Detection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Find contiguous regions of identical colour via 4-connected flood fill. Pool size distribution is the primary signal for image complexity classification. A pixel-art image may have only a handful of large pools, while a photograph may have thousands of singleton pools. This module provides both detection and statistical summary functions.
## Features
- 4-connected BFS flood fill
- Returns pools sorted by size descending
- Computes distribution statistics
- Zero dependencies beyond colour utilities
## Usage: Basic Example
```ts
import { detectPools } from "@suaveplan/image-quantisation/analysis/pools";
const pools = detectPools(pixels, 32, 32);
console.log(pools.length); // number of distinct colour regions
console.log(pools[0]!.size); // largest region
```
## Usage: Advanced Example
```ts
import {
detectPools,
poolDistribution,
} from "@suaveplan/image-quantisation/analysis/pools";
const pools = detectPools(pixels, 64, 64);
const dist = poolDistribution(pools);
if (dist.singletonRatio > 0.7) {
console.log("Photographic — many 1-pixel pools");
}
```
## API Reference
### `detectPools(pixels, width, height): Pool[]`
Returns an array of `Pool` objects sorted by size descending.
### `poolDistribution(pools): PoolDistribution`
Computes count, min, max, mean, median, p90, singletons, and singletonRatio.
## Implementation Notes
Flood fill uses a stack-based BFS with a `Uint8Array` visited mask for memory efficiency. The `pixels` buffer is read-only. Pools are sorted by size descending so that the dominant colour regions appear first, which is useful for downstream algorithms that only need to inspect the largest pools. The visited mask uses a single byte per pixel, which keeps memory overhead low even for large images. Because every pixel is visited exactly once, the overall time complexity is O(width * height) in the worst case. For images with many small pools, the BFS queue may grow large, but the `Uint8Array` visited mask ensures that each pixel is never enqueued more than once.

View File

@@ -0,0 +1,70 @@
# QuantisationError
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structured error type for all `@suaveplan/image-quantisation` failures. Extends `SuaveplanError` so that callers receive stable codes, i18n-ready message keys, and optional cause chains. This unified error type eliminates the need for consumers to catch multiple different exception classes and enables programmatic branching on the `code` property.
## Features
- Stable `QUANTISATION.*` error codes via `defineCodes`
- Typed `code` property for programmatic discrimination
- i18n-ready message keys with no hard-coded user strings
- OTel span-event compatibility via `toSpanEvent`
## Usage: Basic Example
```ts
import {
QuantisationError,
QUANTISATION_CODES,
} from "@suaveplan/image-quantisation/errors";
throw new QuantisationError(
QUANTISATION_CODES.INVALID_DIMENSIONS,
"quantisation.invalid_dimensions"
);
```
## Usage: Advanced Example
```ts
import {
QuantisationError,
QUANTISATION_CODES,
} from "@suaveplan/image-quantisation/errors";
try {
quantise(pixels, 0, 1);
} catch (err) {
if (err instanceof QuantisationError) {
console.log(err.code); // "QUANTISATION.INVALID_DIMENSIONS"
}
}
```
## API Reference
### `QUANTISATION_CODES`
Constant object exported from `defineCodes("QUANTISATION", [...])`.
- `INVALID_PIXEL_BUFFER`
- `INVALID_COLOR_COUNT`
- `INVALID_DIMENSIONS`
- `PROCESSING_FAILED`
### `QuantisationError`
Constructor: `new QuantisationError(code, messageKey, options?)`
- `code` — one of the `QUANTISATION_CODES` values
- `messageKey` — i18n message resolver key
- `options?.cause` — optional underlying `Error`
## Implementation Notes
Error codes are registered at module load time. The `defineCodes` utility guarantees that every code string is prefixed with the namespace, so collisions across packages are impossible. The `INVALID_COLOR_COUNT` code is reserved for future use when palette-size validation is tightened. Consumers should always test against the exported `QUANTISATION_CODES` constant rather than hard-coding strings, because the exact prefix may change during major version bumps. The `QuantisationError` class is kept in its own feature module so that consumers who only need the error type can import it without pulling in the full quantisation logic.

View File

@@ -0,0 +1,67 @@
# Adaptive Pipeline
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Full workflow: analyse complexity → spatial cleanup → quantise. Automatically selects the best strategy based on image content. This is the recommended high-level entry point for emblem-generation pipelines because it encapsulates the decision logic for when to skip quantisation, when to apply spatial cleanup, and when to use dithering.
## Features
- One-call image preparation for emblem-solver
- Skips quantisation for pixel-art images
- Applies spatial cleanup for vector-aa and vector-clean tiers
- Auto-selects dithering for photographic content
- Zod validation for all options
- OTel counter and histogram instrumentation
## Usage: Basic Example
```ts
import { adaptivePipeline } from "@suaveplan/image-quantisation/pipeline";
const result = adaptivePipeline(pixels, 32, 32);
console.log(result.analysis.tier);
console.log(result.finalColours);
```
## Usage: Advanced Example
```ts
import { adaptivePipeline } from "@suaveplan/image-quantisation/pipeline";
const result = adaptivePipeline(pixels, 64, 64, {
maxColours: 16,
dither: "floyd-steinberg",
spatial: { maxDistance: 25, maxPoolSize: 2 },
skipQuantisationForPixelArt: false,
});
if (result.spatialCleanupApplied) {
console.log("Spatial cleanup merged small AA pools");
}
```
## API Reference
### `adaptivePipeline(pixels, width, height, options?): AdaptivePipelineResult`
- `pixels` — RGBA `Uint8Array`
- `width`, `height` — image dimensions
- `options` — `AdaptivePipelineOptions` (optional)
Returns `AdaptivePipelineResult` with:
- `pixels` — final processed buffer
- `analysis` — complexity analysis of the original
- `quantisation` — quantisation result or `null`
- `spatialCleanupApplied` — boolean
- `finalColours` — unique colour count after processing
Throws `QuantisationError` on invalid input or options.
## Implementation Notes
The pipeline never mutates the input buffer. Each stage creates a new `Uint8Array` when modification is required. OTel instruments record the complexity tier and whether spatial cleanup was applied as attributes. The final colour count is computed by packing each RGBA tuple into a 32-bit integer and counting distinct values in a `Set`. This design makes the pipeline safe to call repeatedly on the same buffer without copying at the call site. The `skipQuantisationForPixelArt` default is true because pixel-art images already have a low colour count and quantisation would only degrade quality.

View File

@@ -0,0 +1,66 @@
# Quantise
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Reduce an RGBA pixel buffer to a target number of colours using the `image-q` library. This module is the primary quantisation entry point, supporting three palette-generation algorithms and three dithering modes. It wraps the low-level `image-q` API with ergonomic options, Zod validation, and OpenTelemetry instrumentation.
## Features
- Wu quant (median-cut, best quality)
- Neuquant (neural network, good for photos)
- RGBQuant (fastest, good for previews)
- Euclidean, Manhattan, and CIEDE2000 distance formulas
- Floyd-Steinberg and Riemersma dithering
- Zod option validation
- OTel counter and histogram instrumentation
## Usage: Basic Example
```ts
import { quantise } from "@suaveplan/image-quantisation/quantise";
const result = quantise(pixels, 32, 32, {
colours: 16,
algorithm: "wuquant",
});
console.log(result.paletteSize); // ≤ 16
```
## Usage: Advanced Example
```ts
import { quantise } from "@suaveplan/image-quantisation/quantise";
const result = quantise(pixels, 64, 64, {
colours: 32,
algorithm: "neuquant",
distance: "ciede2000",
dither: "floyd-steinberg",
});
// result.pixels is a new Uint8Array with quantised colours
// result.palette is the generated colour table
```
## API Reference
### `quantise(pixels, width, height, options?): QuantiseResult`
- `pixels` — RGBA `Uint8Array`
- `width`, `height` — image dimensions
- `options` — `QuantiseOptions` (optional)
Throws `QuantisationError` on invalid input dimensions, mismatched buffer size, or invalid options.
### `quantiseAdaptive(pixels, width, height, recommendedColours, recommendDithering, overrides?): QuantiseResult`
Convenience wrapper that sets algorithm to `"wuquant"` and dither based on the boolean flag. `overrides` can override any individual field.
## Implementation Notes
The function validates inputs with Zod schemas before delegating to `image-q`. OTel instruments are lazily initialised on first use to avoid side effects at module load time. All algorithms run synchronously. The `mapDither` helper translates our dither enum values into the strings expected by `image-q`. Performance varies by algorithm: Wu quant is generally the best balance of quality and speed, Neuquant excels on photographic content, and RGBQuant is fastest but may produce slightly lower quality. Callers should benchmark on their specific image corpus to choose the best algorithm.

View File

@@ -0,0 +1,70 @@
# Option Schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schemas for runtime validation of quantisation, spatial cleanup, and pipeline options. Every public entry point that accepts options validates them through these schemas before processing. This catches misconfigured callers early and produces descriptive error messages that can be forwarded to end users or logged without additional formatting.
## Features
- `QuantiseOptionsSchema` — validates colour count, algorithm, distance formula, and dithering
- `SpatialCleanupOptionsSchema` — validates merge thresholds
- `AdaptivePipelineOptionsSchema` — validates pipeline overrides
- Descriptive error messages suitable for structured logging
## Usage: Basic Example
```ts
import { QuantiseOptionsSchema } from "@suaveplan/image-quantisation/schemas";
const result = QuantiseOptionsSchema.safeParse({ colours: 16 });
if (!result.success) {
console.log(result.error.issues);
}
```
## Usage: Advanced Example
```ts
import {
AdaptivePipelineOptionsSchema,
SpatialCleanupOptionsSchema,
} from "@suaveplan/image-quantisation/schemas";
const raw = {
maxColours: 32,
spatial: { maxDistance: 20 },
};
const parsed = AdaptivePipelineOptionsSchema.parse(raw);
// parsed.spatial is fully typed and defaults are applied by Zod
```
## API Reference
### `QuantiseOptionsSchema`
- `colours` — optional integer 2–256
- `algorithm` — optional enum `"neuquant" | "rgbquant" | "wuquant"`
- `distance` — optional enum `"euclidean" | "manhattan" | "ciede2000"`
- `dither` — optional enum `"none" | "floyd-steinberg" | "riemersma"`
### `SpatialCleanupOptionsSchema`
- `maxDistance` — optional non-negative number
- `maxPoolSize` — optional non-negative integer
### `AdaptivePipelineOptionsSchema`
- `maxColours` — optional integer 2–256
- `dither` — optional dither enum
- `spatial` — optional nested `SpatialCleanupOptionsSchema`
- `skipSpatialCleanup` — optional boolean
- `skipQuantisationForPixelArt` — optional boolean
## Implementation Notes
Schemas use `.optional()` for every field so that callers can supply partial options and the implementation supplies defaults. Validation failures throw `QuantisationError` with code `PROCESSING_FAILED`. The schemas are co-located with the types they validate to reduce drift. The `QuantiseOptionsSchema` enforces a minimum of 2 colours because single-colour palettes are not supported by the underlying `image-q` library. The `AdaptivePipelineOptionsSchema` nests `SpatialCleanupOptionsSchema` so that pipeline callers can override cleanup parameters in a single options object.

View File

@@ -0,0 +1,69 @@
# Spatial Cleanup
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Merge small anti-aliasing pools into their nearest spatial neighbours by colour distance. This preserves intentional detail while removing AA noise. Unlike global k-means quantisation, spatial cleanup only merges pools that are physically adjacent, so a small blue dot next to a red region stays blue while a near-red edge pixel gets absorbed into the red region.
## Features
- Spatially-aware merge (not global k-means)
- Configurable colour distance and pool size thresholds
- Returns a new pixel buffer (non-destructive)
- Fast pixel-to-pool index lookup via `Int32Array`
## Usage: Basic Example
```ts
import {
detectPools,
spatialCleanup,
} from "@suaveplan/image-quantisation";
const pools = detectPools(pixels, 32, 32);
const cleaned = spatialCleanup(pixels, 32, 32, pools);
```
## Usage: Advanced Example
```ts
import {
detectPools,
spatialCleanup,
countUniqueColours,
} from "@suaveplan/image-quantisation";
const pools = detectPools(pixels, 64, 64);
const before = countUniqueColours(pixels, 64, 64);
const cleaned = spatialCleanup(pixels, 64, 64, pools, {
maxDistance: 25,
maxPoolSize: 3,
});
const after = countUniqueColours(cleaned, 64, 64);
console.log(`Removed ${before - after} colours`);
```
## API Reference
### `spatialCleanup(pixels, width, height, pools, options?): Uint8Array`
- `pixels` — RGBA `Uint8Array`
- `width`, `height` — image dimensions
- `pools` — array of `Pool` from `detectPools`
- `options.maxDistance` — default 30
- `options.maxPoolSize` — default 4
Returns a new `Uint8Array` with merged pixels.
### `countUniqueColours(pixels, width, height): number`
Counts distinct colours in the buffer using a 32-bit packed key.
## Implementation Notes
The algorithm builds an adjacency map for pools that are physically adjacent in the image. For each small pool, it finds the neighbour with the minimum `rgbDistance`. If the distance is within `maxDistance`, the pool is recoloured. The pixel-to-pool index is stored in an `Int32Array` initialised to `-1` for fast O(1) lookup during adjacency building.

View File

@@ -0,0 +1,114 @@
# Common Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `common.ts` module defines the core domain types used across the `@suaveplan/image-quantisation` package. Every public function operates on these shapes, so consumers should import them when writing TypeScript wrappers or adapters. Keeping types in a single module ensures consistency between analysis, quantisation, and pipeline stages. The types are designed to be serialisable, tree-shakeable, and free of runtime dependencies.
## Features
- Strongly-typed RGBA pixel representation
- Pool and edge-analysis result shapes
- Quantisation option and result types
- Adaptive pipeline input/output types
## Usage: Basic Example
```ts
import type { RGBA, QuantiseOptions } from "@suaveplan/image-quantisation/types";
const pixel: RGBA = { r: 255, g: 128, b: 64, a: 255 };
const opts: QuantiseOptions = {
colours: 16,
algorithm: "wuquant",
distance: "euclidean",
dither: "none",
};
```
## Usage: Advanced Example
```ts
import type {
AdaptivePipelineOptions,
ComplexityAnalysis,
} from "@suaveplan/image-quantisation/types";
function buildPipelineOpts(
analysis: ComplexityAnalysis
): AdaptivePipelineOptions {
return {
maxColours: analysis.recommendedColours,
dither: analysis.recommendDithering ? "floyd-steinberg" : "none",
spatial: { maxDistance: 25, maxPoolSize: 3 },
skipSpatialCleanup: false,
skipQuantisationForPixelArt: true,
};
}
```
## API Reference
### `RGBA`
Interface with `r`, `g`, `b`, `a` in the range 0–255.
### `Pool`
Represents a contiguous region of identical colour found via 4-connected flood fill.
### `PoolDistribution`
Statistics object: `count`, `min`, `max`, `mean`, `median`, `p90`, `singletons`, `singletonRatio`.
### `EdgeAnalysis`
Metrics: `totalEdges`, `hardEdges`, `softEdges`, `sharpnessRatio`, `meanEdgeDistance`.
### `ComplexityTier`
Union type: `"pixel-art" | "vector-clean" | "vector-aa" | "photographic"`.
### `ComplexityAnalysis`
Full analysis including tier, pools, edges, and recommendations.
### `PaletteAlgorithm`
`"neuquant" | "rgbquant" | "wuquant"`.
### `DistanceFormula`
`"euclidean" | "manhattan" | "ciede2000"`.
### `DitherAlgorithm`
`"none" | "floyd-steinberg" | "riemersma"`.
### `QuantiseOptions`
Optional fields: `colours`, `algorithm`, `distance`, `dither`.
### `QuantiseResult`
Result with `pixels`, `palette`, `paletteSize`, and the resolved `options`.
### `SpatialCleanupOptions`
Optional fields: `maxDistance`, `maxPoolSize`.
### `AdaptivePipelineOptions`
Optional fields: `maxColours`, `dither`, `spatial`, `skipSpatialCleanup`, `skipQuantisationForPixelArt`.
### `AdaptivePipelineResult`
Full pipeline result with analysis, optional quantisation, and final pixel buffer.
## Implementation Notes
All interfaces are pure data shapes with no methods. They are designed to be serialisable and tree-shakeable. The `Pool` interface uses `Set<number>` for pixel indices, which is efficient for BFS traversal but may be replaced with a denser representation in future versions if memory becomes a concern for very large images.

View File

@@ -0,0 +1,59 @@
# @suaveplan/image - Universal image metadata extraction, format detection, resize math, crop validation, and transform pipeline planning
Universal image metadata extraction, format detection, resize math, crop validation, and transform pipeline planning. Zero browser or Node dependencies — runs in Bun, Node, and browsers alike. Provides `ImageProcessor` for planning image transforms, `PipelineBuilder` for fluent composition, and header-based dimension parsers for JPEG, PNG, WebP, GIF, and AVIF.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` declares the canonical error-code registry for `@suaveplan/image`. Every failure mode that the image p…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes.ts` declares the canonical error-code registry for `@suaveplan/image`. Every failure mode that the image p…
- [errors](./src/errors/errors.md) — `errors.ts` provides the typed error hierarchy for `@suaveplan/image`. `ImageError` extends `SuaveplanError` so that ca…
- [errors](./src/errors/errors.md) — `errors.ts` provides the typed error hierarchy for `@suaveplan/image`. `ImageError` extends `SuaveplanError` so that ca…
- [dimensions](./src/processor/dimensions/dimensions.md) — `dimensions.ts` extracts width and height from raw image headers for the four supported raster formats: PNG, JPEG, GIF,…
- [dimensions](./src/processor/dimensions/dimensions.md) — `dimensions.ts` extracts width and height from raw image headers for the four supported raster formats: PNG, JPEG, GIF,…
- [instruments](./src/processor/instruments/instruments.md) — `instruments.ts` lazily initialises the OpenTelemetry counters and histograms used by `ImageProcessor`. Each instrument…
- [instruments](./src/processor/instruments/instruments.md) — `instruments.ts` lazily initialises the OpenTelemetry counters and histograms used by `ImageProcessor`. Each instrument…
- [pipeline-builder](./src/processor/pipeline-builder/pipeline-builder.md) — `pipeline-builder.ts` implements a fluent, chainable API for constructing image transform pipelines. Each method append…
- [pipeline-builder](./src/processor/pipeline-builder/pipeline-builder.md) — `pipeline-builder.ts` implements a fluent, chainable API for constructing image transform pipelines. Each method append…
- [processor](./src/processor/processor/processor.md) — `processor.ts` is the orchestration layer of `@suaveplan/image`. `ImageProcessor` accepts a `Uint8Array` of image bytes…
- [processor](./src/processor/processor/processor.md) — `processor.ts` is the orchestration layer of `@suaveplan/image`. `ImageProcessor` accepts a `Uint8Array` of image bytes…
- [image-detect](./src/types/image-detect/image-detect.md) — `image-detect.ts` provides fast, deterministic format detection from raw binary headers. It inspects the first bytes of…
- [image-detect](./src/types/image-detect/image-detect.md) — `image-detect.ts` provides fast, deterministic format detection from raw binary headers. It inspects the first bytes of…
- [image-types](./src/types/image-types/image-types.md) — `image-types.ts` is the universal type system and pure-function toolkit for image metadata. It defines Zod schemas…
- [image-types](./src/types/image-types/image-types.md) — `image-types.ts` is the universal type system and pure-function toolkit for image metadata. It defines Zod schemas…
<!-- end:auto-docs -->
## Installation
```bash
bun add @suaveplan/image
```
## Quick Start
```ts
import { ImageProcessor } from "@suaveplan/image";
const proc = new ImageProcessor({ maxPixels: 50_000_000 });
const meta = proc.extractMetadata(imageBytes);
console.log(`${meta.format} ${meta.width}x${meta.height}`);
const result = proc.planTransform(meta, {
operations: [
{ type: "resize", options: { width: 400 } },
{ type: "format", format: "webp" },
],
});
```
## Modules
- [`src/processor/processor/processor.md`](./src/processor/processor/processor.md) — `ImageProcessor` and `PipelineBuilder`
- [`src/processor/instruments/instruments.md`](./src/processor/instruments/instruments.md) — OpenTelemetry instrumentation helpers
- [`src/processor/dimensions/dimensions.md`](./src/processor/dimensions/dimensions.md) — Header dimension parsers for JPEG, PNG, WebP, GIF, AVIF
- [`src/processor/pipeline-builder/pipeline-builder.md`](./src/processor/pipeline-builder/pipeline-builder.md) — Fluent transform pipeline builder
- [`src/types/image-types/image-types.md`](./src/types/image-types/image-types.md) — TypeScript interfaces and Zod schemas
- [`src/types/image-detect/image-detect.md`](./src/types/image-detect/image-detect.md) — Format detection types and constants
- [`src/errors/errors.md`](./src/errors/errors.md) — `ImageError` hierarchy and typed factories
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) — Stable `IMAGE.*` error code registry

View File

@@ -0,0 +1,66 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes.ts` declares the canonical error-code registry for `@suaveplan/image`. Every failure mode that the image package can surface — from unsupported formats to dimension violations — is assigned a stable, namespaced string via `defineCodes` from `@suaveplan/error`. This gives downstream log analysers, retry policies, and problem+json renderers a deterministic contract they can match against without parsing human-readable messages.
## Features
- Seven stable codes under the `IMAGE.*` namespace
- Type-safe `ImageCode` union derived from the registry object
- Zero runtime side effects — the registry is a plain constant
## Usage: Basic Example
```ts
import { IMAGE_CODES } from "@suaveplan/image/error-codes";
function handleImageError(err: { code: string }) {
if (err.code === IMAGE_CODES.FORMAT_UNSUPPORTED) {
return { status: 415, message: "Format not supported" };
}
}
```
## Usage: Advanced Example
```ts
import { IMAGE_CODES, type ImageCode } from "@suaveplan/image/error-codes";
const retryableCodes: ImageCode[] = [
IMAGE_CODES.DECODE_FAILED,
IMAGE_CODES.ENCODE_FAILED,
];
function shouldRetry(code: ImageCode): boolean {
return retryableCodes.includes(code);
}
```
## API Reference
### `IMAGE_CODES`
A frozen object mapping human-readable keys to namespaced string values:
- `NOT_FOUND` → `"IMAGE.NOT_FOUND"`
- `DECODE_FAILED` → `"IMAGE.DECODE_FAILED"`
- `ENCODE_FAILED` → `"IMAGE.ENCODE_FAILED"`
- `FORMAT_UNSUPPORTED` → `"IMAGE.FORMAT_UNSUPPORTED"`
- `TOO_LARGE` → `"IMAGE.TOO_LARGE"`
- `PROCESSING_FAILED` → `"IMAGE.PROCESSING_FAILED"`
- `DIMENSIONS_INVALID` → `"IMAGE.DIMENSIONS_INVALID"`
### `ImageCode`
A TypeScript union of all seven code string literals. Useful for function parameters and discriminated unions.
## Implementation Notes
The registry is produced by `defineCodes("IMAGE", [...])`. If the namespace or code list ever changes, every test that asserts against `IMAGE_CODES.*` will fail, which is the desired behaviour — it prevents silent drift in the error contract.
Namespaces matter in a package ecosystem. Without the `IMAGE.` prefix, a generic name like `NOT_FOUND` could collide with `DRIVE.NOT_FOUND` or `CONTACTS.NOT_FOUND`. The `defineCodes` utility enforces a namespace at compile time and produces a frozen object, so consumers cannot accidentally mutate the registry at runtime. When adding a new failure mode, the code must be added to the `defineCodes` array first; only then can it be used in `src/` or tests. This two-step discipline ensures the error surface remains intentional and auditable.

View File

@@ -0,0 +1,75 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`errors.ts` provides the typed error hierarchy for `@suaveplan/image`. `ImageError` extends `SuaveplanError` so that callers receive structured failures with a stable code, a message key, an optional context object, and an optional cause chain. Typed factory functions make it easy to construct correctly-coded errors without hand-typing string literals.
## Features
- `ImageError` class with `code`, `message`, `context`, and `cause`
- Factory functions for every failure mode
- Backwards-compatibility aliases for upstream consumers
## Usage: Basic Example
```ts
import { imageFormatUnsupported } from "@suaveplan/image/errors";
function processImage(data: Uint8Array) {
if (data.length === 0) {
throw imageFormatUnsupported("empty buffer");
}
}
```
## Usage: Advanced Example
```ts
import { ImageError, IMAGE_CODES } from "@suaveplan/image/errors";
function enrichError(err: unknown): ImageError {
if (err instanceof ImageError) return err;
return new ImageError(
IMAGE_CODES.PROCESSING_FAILED,
"Unexpected failure",
{ cause: err instanceof Error ? err : undefined }
);
}
```
## API Reference
### `ImageError`
Extends `SuaveplanError<ImageCode>`.
- `constructor(code, messageKey, options?)` — `options` may include `cause` and `context`.
- `context?: ImageErrorContext` — carries `format`, `width`, `height`, `pixels`, `maxPixels`, `path`.
### Factory Functions
- `imageNotFound(path?)`
- `imageDecodeFailed(format?, cause?)`
- `imageEncodeFailed(format?, cause?)`
- `imageFormatUnsupported(format?)`
- `imageTooLarge(pixels, maxPixels)`
- `imageProcessingFailed(message, cause?)`
- `imageDimensionsInvalid(width, height)`
### Backwards-Compatibility Aliases
- `unknownFormat()` → alias for `imageFormatUnsupported()`
- `invalidDimensions(w, h)` → alias for `imageDimensionsInvalid(w, h)`
- `invalidCrop(msg)` → alias for `imageProcessingFailed("Invalid crop region: " + msg)`
- `metadataParseFailed(cause?)` → alias for `imageDecodeFailed(undefined, cause)`
- `invalidTransform(msg)` → alias for `imageProcessingFailed("Invalid transform: " + msg)`
## Implementation Notes
All aliases are thin wrappers — they do not duplicate logic. The `ImageErrorContext` interface extends `Record<string, unknown>` so callers can safely add custom fields without losing type safety.
Because `ImageError` extends `SuaveplanError`, it inherits cause-chain traversal and OTel span-event adaptation for free. When surfacing an image failure inside an HTTP handler, the error can be passed directly to the logger or telemetry layer without manual unpacking. Canonical factories should be preferred for new code; backwards-compatibility aliases exist only to ease migration from upstream packages that used the old naming convention. Each factory constructs the error with a consistent message key derived from the code name, so telemetry dashboards can aggregate by message key while retry policies discriminate by code.

View File

@@ -0,0 +1,48 @@
# dimensions
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`dimensions.ts` extracts width and height from raw image headers for the four supported raster formats: PNG, JPEG, GIF, and BMP. It is a pure, side-effect-free module consumed by `ImageProcessor`. Short or malformed buffers return `{ width: 0, height: 0 }` rather than throwing, so callers can decide whether zero dimensions are fatal.
## Features
- PNG IHDR dimension parsing
- JPEG SOF0 marker scanning (skips APP segments)
- GIF logical screen dimension parsing
- BMP DIB header dimension parsing (handles negative height for top-down bitmaps)
## Usage: Basic Example
```ts
import { parseDimensions } from "@suaveplan/image/processor";
const dims = parseDimensions(pngBytes, "png");
console.log(`${dims.width}x${dims.height}`);
```
## Usage: Advanced Example
```ts
import { parseDimensions } from "@suaveplan/image/processor";
import { detectFormat } from "@suaveplan/image/types";
function getDimensions(data: Uint8Array) {
const format = detectFormat(data);
if (!format) return null;
return parseDimensions(data, format);
}
```
## API Reference
### `parseDimensions(data, format): { width: number; height: number }`
Dispatches to the appropriate parser based on `format`. Returns `{ width: 0, height: 0 }` for unsupported formats or buffers that are too short.
## Implementation Notes
JPEG parsing walks the segment chain starting at offset 2 (after the SOI marker). It stops on the first SOF0-SOF15 marker (excluding DHT, DAC, and DHP) and reads the 16-bit width and height fields. BMP parsing uses `Math.abs` on the height field because Windows bitmaps can encode top-down images with a negative height value. TIFF is detected at the format level but dimension parsing is intentionally not implemented yet; callers receive `{ width: 0, height: 0 }` for TIFF rather than an exception, which lets `ImageProcessor` decide whether to treat the format as supported for metadata extraction while deferring dimension work. All four parsers validate buffer length before reading every multi-byte field, so truncated or corrupt headers never throw — they simply return zero dimensions. This design keeps `extractMetadata` robust against partial reads from network streams or damaged files.

View File

@@ -0,0 +1,54 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments.ts` lazily initialises the OpenTelemetry counters and histograms used by `ImageProcessor`. Each instrument is created on first access to avoid registering metrics at module-load time, keeping the package `sideEffects: false`. A matching tracer helper provides span creation for `extractMetadata` and `planTransform`.
## Features
- Lazy OTel counter and histogram creation
- Four instruments: two counters + two histograms
- Tracer helper for span creation
## Usage: Basic Example
This module is internal — consumers do not interact with it directly. `ImageProcessor` calls the helpers automatically.
## Usage: Advanced Example
If you need to override the global meter provider in tests, install a custom provider via `@opentelemetry/api` before constructing `ImageProcessor`:
```ts
import { metrics } from "@opentelemetry/api";
metrics.setGlobalMeterProvider(myTestProvider);
```
## API Reference
### `getExtractCounter(): Counter`
Returns the `image.metadata.extracted_total` counter, creating it on first call.
### `getExtractDuration(): Histogram`
Returns the `image.metadata.extract_duration_ms` histogram.
### `getPlanCounter(): Counter`
Returns the `image.transform.planned_total` counter.
### `getPlanDuration(): Histogram`
Returns the `image.transform.plan_duration_ms` histogram.
### `getTracer(): Tracer`
Returns the `@suaveplan/image` tracer from the global tracer provider.
## Implementation Notes
The module-level caches mean that switching meter providers after the first instrument has been created has no effect. In practice this is fine because the global provider is set once at application startup. Tests that need deterministic instrumentation should set the provider before the first `ImageProcessor` call.

View File

@@ -0,0 +1,63 @@
# pipeline-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`pipeline-builder.ts` implements a fluent, chainable API for constructing image transform pipelines. Each method appends an operation to an internal list and returns `this`, so callers can compose resize, crop, rotate, flip, quality, and format changes in a single expression. The builder validates dimensions at call time via Zod-derived guards, surfacing structured `ImageError`s immediately rather than deferring failures to execution.
## Features
- Fluent chaining API
- Runtime validation of resize and crop dimensions
- Quality clamping to `[1, 100]`
- Immutable pipeline output via spread copy
## Usage: Basic Example
```ts
import { ImageProcessor } from "@suaveplan/image/processor";
const proc = new ImageProcessor();
const pipeline = proc
.createPipeline()
.resize({ width: 400 })
.toFormat("webp")
.quality(75)
.build();
```
## Usage: Advanced Example
```ts
import { ImageProcessor } from "@suaveplan/image/processor";
const proc = new ImageProcessor();
const result = proc
.createPipeline()
.resize({ width: 800, height: 600, fit: "cover" })
.crop({ x: 0, y: 0, width: 400, height: 300 })
.rotate(90)
.apply(metadata, proc);
```
## API Reference
### `PipelineBuilder`
- `resize(options: ResizeOptions): this` — validates width/height are positive.
- `crop(region: CropRegion): this` — validates width/height are positive.
- `rotate(degrees: number): this`
- `flip(direction: "horizontal" | "vertical"): this`
- `quality(value: number): this` — clamps to `[1, 100]`.
- `toFormat(format: ImageFormat): this`
- `build(): TransformPipeline` — returns a frozen snapshot of operations.
- `apply(metadata, processor): TransformResult` — delegates to `processor.planTransform`.
## Implementation Notes
Negative or zero dimensions in `resize` and `crop` throw `IMAGE_CODES.DIMENSIONS_INVALID` synchronously. The `quality` clamping is a defensive convenience — passing `150` silently becomes `100`, while passing `0` becomes `1`. This avoids silent truncation bugs in downstream pipelines.
The internal `ops` array is a plain JavaScript array of discriminated objects. Each fluent method pushes a new object and returns `this`, so multiple calls on the same builder accumulate operations in call order. `build()` spreads the array into a new object, so the returned `TransformPipeline` is immutable — further mutations to the builder do not affect previously built pipelines. The `apply` method is sugar for calling `build()` followed by `processor.planTransform(metadata, pipeline)`, making it easy to use the builder in one-shot expressions without storing the intermediate pipeline object.

Some files were not shown because too many files have changed in this diff Show More