Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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 aspe… |
| [`@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()`, `… |
| [`@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 —… |
| [`@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,131 @@
{
"name": "@suaveplan/caddy-imgproxy",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./parse": {
"import": {
"types": "./dist/parse/index.d.ts",
"default": "./dist/parse/index.js"
}
},
"./dimensions": {
"import": {
"types": "./dist/dimensions/index.d.ts",
"default": "./dist/dimensions/index.js"
}
},
"./gravity": {
"import": {
"types": "./dist/gravity/index.d.ts",
"default": "./dist/gravity/index.js"
}
},
"./operation": {
"import": {
"types": "./dist/operation/index.d.ts",
"default": "./dist/operation/index.js"
}
},
"./signing": {
"import": {
"types": "./dist/signing/index.d.ts",
"default": "./dist/signing/index.js"
}
},
"./url-builder": {
"import": {
"types": "./dist/url-builder/index.d.ts",
"default": "./dist/url-builder/index.js"
}
},
"./domain": {
"import": {
"types": "./dist/domain/index.d.ts",
"default": "./dist/domain/index.js"
}
},
"./handler": {
"import": {
"types": "./dist/handler/index.d.ts",
"default": "./dist/handler/index.js"
}
},
"./config": {
"import": {
"types": "./dist/config/index.d.ts",
"default": "./dist/config/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./telemetry": {
"import": {
"types": "./dist/telemetry/index.d.ts",
"default": "./dist/telemetry/index.js"
}
},
"./types": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-hash": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,95 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CADDY_IMGPROXY_CODES,
type CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { parseConfig } from "./config.js";
describe("parseConfig", () => {
it("applies defaults when fields are omitted", { timeout: 5000 }, () => {
const cfg = parseConfig({});
expect(cfg.upstream).toBe("localhost:8080");
expect(cfg.s3Bucket).toBe("images");
expect(cfg.defaultWidth).toBe(800);
expect(cfg.key).toBeUndefined();
expect(cfg.salt).toBeUndefined();
});
it("accepts a fully-specified config", { timeout: 5000 }, () => {
const cfg = parseConfig({
upstream: "imgproxy:8080",
s3Bucket: "media",
defaultWidth: 1200,
key: "aa11",
salt: "bbcc",
});
expect(cfg.upstream).toBe("imgproxy:8080");
expect(cfg.s3Bucket).toBe("media");
expect(cfg.defaultWidth).toBe(1200);
expect(cfg.key).toBe("aa11");
expect(cfg.salt).toBe("bbcc");
});
it("raises INVALID_OPTIONS when defaultWidth is zero", {
timeout: 5000,
}, () => {
try {
parseConfig({ defaultWidth: 0 });
expect.fail("expected INVALID_OPTIONS");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_OPTIONS
);
}
});
it("raises INVALID_OPTIONS when upstream is empty", {
timeout: 5000,
}, () => {
try {
parseConfig({ upstream: "" });
expect.fail("expected INVALID_OPTIONS");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_OPTIONS
);
}
});
it("raises INVALID_KEY when key is malformed hex", {
timeout: 5000,
}, () => {
try {
parseConfig({ key: "xyz" });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_KEY when key is odd length", { timeout: 5000 }, () => {
try {
parseConfig({ key: "abc" });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_SALT when salt is malformed hex", {
timeout: 5000,
}, () => {
try {
parseConfig({ salt: "zz" });
expect.fail("expected INVALID_SALT");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_SALT
);
}
});
});

View File

@@ -0,0 +1,71 @@
import z from "zod";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import type { ImgproxyConfig } from "../types/types.js";
const HEX_RE = /^[0-9a-f]*$/u;
const HexStringSchema = z
.string()
.min(2, "must be a non-empty even-length lower-hex string")
.refine(
(value) => value.length % 2 === 0 && HEX_RE.test(value),
"must be even-length lower-hex"
);
/**
* Public Zod schema for `ImgproxyConfig`. Defaults mirror the Go
* module: upstream `localhost:8080`, bucket `images`, default width
* 800. `key` / `salt` are validated as even-length lower-hex when
* supplied; absence flips the rewriter into `/unsafe` mode.
*/
export const ImgproxyConfigSchema = z.object({
upstream: z.string().min(1).default("localhost:8080"),
s3Bucket: z.string().min(1).default("images"),
defaultWidth: z
.number()
.int("defaultWidth must be an integer")
.positive("defaultWidth must be > 0")
.default(800),
key: HexStringSchema.optional(),
salt: HexStringSchema.optional(),
});
/**
* Parses and validates a raw config object into an immutable
* `ImgproxyConfig`. Raises `CaddyImgproxyError(INVALID_KEY)` when the
* key is malformed hex, `INVALID_SALT` for the salt, and
* `INVALID_OPTIONS` for everything else.
*/
export function parseConfig(raw: unknown): ImgproxyConfig {
const result = ImgproxyConfigSchema.safeParse(raw);
if (!result.success) {
const firstIssue = result.error.issues[0] as {
path: PropertyKey[];
message: string;
};
const path = firstIssue.path.join(".");
if (path === "key") {
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_KEY,
"key must be a non-empty even-length lower-hex string",
{ context: { path } }
);
}
if (path === "salt") {
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_SALT,
"salt must be a non-empty even-length lower-hex string",
{ context: { path } }
);
}
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_OPTIONS,
`ImgproxyConfig validation failed: ${firstIssue.message}`,
{ context: { path } }
);
}
return Object.freeze(result.data);
}

View File

@@ -0,0 +1 @@
export { ImgproxyConfigSchema, parseConfig } from "./config.js";

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,125 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { ParsedImgproxyUrl } from "../types/types.js";
import { calculateDimensions } from "./calculate-dimensions.js";
function parsed(overrides: Partial<ParsedImgproxyUrl>): ParsedImgproxyUrl {
return {
signed: false,
operation: "",
gravity: "c",
aspectW: 0,
aspectH: 0,
pixelW: 0,
pixelH: 0,
hintType: "",
hintValue: 0,
quality: 0,
path: "x",
format: "png",
isAspect: false,
hasDims: false,
...overrides,
};
}
describe("calculateDimensions", () => {
it("returns zero when hasDims is false", { timeout: 5000 }, () => {
const r = calculateDimensions(parsed({}), 800);
expect(r).toEqual({ width: 0, height: 0 });
});
it("returns pixel dimensions unchanged", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({ pixelW: 800, pixelH: 600, hasDims: true }),
1200
);
expect(r).toEqual({ width: 800, height: 600 });
});
it("resolves 16:9 with default width", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 16,
aspectH: 9,
isAspect: true,
hasDims: true,
}),
800
);
expect(r).toEqual({ width: 800, height: 450 });
});
it("resolves 9:16 with default width", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 9,
aspectH: 16,
isAspect: true,
hasDims: true,
}),
800
);
expect(r).toEqual({ width: 800, height: 1422 });
});
it("resolves 16:9 with width hint", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 16,
aspectH: 9,
isAspect: true,
hasDims: true,
hintType: "w",
hintValue: 1920,
}),
800
);
expect(r).toEqual({ width: 1920, height: 1080 });
});
it("resolves 9:16 with height hint", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 9,
aspectH: 16,
isAspect: true,
hasDims: true,
hintType: "h",
hintValue: 1080,
}),
800
);
expect(r).toEqual({ width: 607, height: 1080 });
});
it("resolves 1:1 square aspect", { timeout: 5000 }, () => {
const r = calculateDimensions(
parsed({
aspectW: 1,
aspectH: 1,
isAspect: true,
hasDims: true,
}),
500
);
expect(r).toEqual({ width: 500, height: 500 });
});
it("h-hint with zero value falls back to default-width path", {
timeout: 5000,
}, () => {
// hintType = "h" but hintValue = 0 — should hit the default width branch
const r = calculateDimensions(
parsed({
aspectW: 1,
aspectH: 1,
isAspect: true,
hasDims: true,
hintType: "h",
hintValue: 0,
}),
500
);
expect(r).toEqual({ width: 500, height: 500 });
});
});

View File

@@ -0,0 +1,37 @@
import type { ImgproxyDimensions, ParsedImgproxyUrl } from "../types/types.js";
/**
* Resolves a `ParsedImgproxyUrl` plus the configured `defaultWidth`
* into concrete integer pixel dimensions.
*
* Mirrors the Go `calculateDimensions` precedence:
* - No dimensions present → `{ 0, 0 }` (caller passes through).
* - Pixel dimensions present → return them verbatim.
* - Aspect ratio + `h` hint → height drives, width derived.
* - Aspect ratio + `w` hint OR no hint → width drives (or default), height derived.
*
* All derived values use `Math.trunc` so the integer truncation
* exactly matches Go's `int` division.
*/
export function calculateDimensions(
parsed: ParsedImgproxyUrl,
defaultWidth: number
): ImgproxyDimensions {
if (!parsed.hasDims) {
return { width: 0, height: 0 };
}
if (!parsed.isAspect) {
return { width: parsed.pixelW, height: parsed.pixelH };
}
if (parsed.hintType === "h" && parsed.hintValue > 0) {
const height = parsed.hintValue;
const width = Math.trunc((height * parsed.aspectW) / parsed.aspectH);
return { width, height };
}
const width = parsed.hintValue > 0 ? parsed.hintValue : defaultWidth;
const height = Math.trunc((width * parsed.aspectH) / parsed.aspectW);
return { width, height };
}

View File

@@ -0,0 +1 @@
export { calculateDimensions } from "./calculate-dimensions.js";

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,23 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { extractDomain } from "./extract-domain.js";
describe("extractDomain", () => {
it("strips port and drops the images. subdomain", { timeout: 5000 }, () => {
expect(extractDomain("images.example.com:8080")).toBe("example.com");
expect(extractDomain("images.example.com")).toBe("example.com");
});
it("returns hosts without images. unchanged", { timeout: 5000 }, () => {
expect(extractDomain("example.com")).toBe("example.com");
expect(extractDomain("cdn.example.com")).toBe("cdn.example.com");
});
it("returns short hosts unchanged", { timeout: 5000 }, () => {
expect(extractDomain("localhost")).toBe("localhost");
expect(extractDomain("localhost:8080")).toBe("localhost");
});
it("handles images. with two-label host", { timeout: 5000 }, () => {
expect(extractDomain("images.co.uk")).toBe("co.uk");
});
});

View File

@@ -0,0 +1,24 @@
/**
* Strips any `:<port>` suffix and drops a leading `images.` subdomain
* label. Mirrors the Go `extractDomain` helper byte-for-byte:
*
* - `images.example.com:8080` → `example.com`
* - `images.example.com` → `example.com`
* - `example.com` → `example.com`
* - `localhost:8080` → `localhost`
* - `images.co.uk` → `co.uk`
*
* Hosts shorter than two labels after port removal are returned
* unchanged. The function is total — it never throws — so callers
* can safely apply it to any `Host` header value.
*/
export function extractDomain(host: string): string {
const colonIdx = host.indexOf(":");
const withoutPort = colonIdx >= 0 ? host.slice(0, colonIdx) : host;
const parts = withoutPort.split(".");
if (parts.length >= 2 && parts[0] === "images") {
return parts.slice(1).join(".");
}
return withoutPort;
}

View File

@@ -0,0 +1 @@
export { extractDomain } from "./extract-domain.js";

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,61 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";
describe("CADDY_IMGPROXY_CODES", () => {
it("namespaces every code under CADDY_IMGPROXY.", { timeout: 5000 }, () => {
for (const value of Object.values(CADDY_IMGPROXY_CODES)) {
expect(value.startsWith("CADDY_IMGPROXY.")).toBe(true);
}
});
it("exposes the expected leaf names", { timeout: 5000 }, () => {
expect(Object.keys(CADDY_IMGPROXY_CODES).sort()).toEqual([
"INVALID_HOST",
"INVALID_KEY",
"INVALID_OPTIONS",
"INVALID_SALT",
"INVALID_URL",
"MISSING_DIMENSIONS",
]);
});
it("carries the supplied code, message, cause, and context", {
timeout: 5000,
}, () => {
const cause = new Error("upstream");
const err = new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_URL,
"bad url",
{ cause, context: { path: "/x" } }
);
expect(err).toBeInstanceOf(CaddyImgproxyError);
expect(err.code).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
expect(err.message).toBe("bad url");
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ path: "/x" });
expect(err.name).toBe("CaddyImgproxyError");
});
it("every registered code is thrown from at least one call site", {
timeout: 5000,
}, async () => {
// INVALID_URL — from parse-url
// MISSING_DIMENSIONS — from parse-url
// INVALID_OPTIONS — from config
// INVALID_KEY — from config / signing
// INVALID_SALT — from config / signing
// INVALID_HOST — from handler
// Each is exercised by its own test file; this reachability
// gate just confirms the registry has the expected closure.
const expected = new Set([
"CADDY_IMGPROXY.INVALID_URL",
"CADDY_IMGPROXY.MISSING_DIMENSIONS",
"CADDY_IMGPROXY.INVALID_OPTIONS",
"CADDY_IMGPROXY.INVALID_KEY",
"CADDY_IMGPROXY.INVALID_SALT",
"CADDY_IMGPROXY.INVALID_HOST",
]);
const actual = new Set(Object.values(CADDY_IMGPROXY_CODES));
expect(actual).toEqual(expected);
});
});

View File

@@ -0,0 +1,66 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/caddy-imgproxy`.
*
* Every failure path in the package surfaces a `CaddyImgproxyError`
* whose `code` is drawn from this registry. Consumers SHOULD
* discriminate on `error.code` rather than `instanceof` so the
* namespace survives module boundaries and runtime serialisation
* (see `@suaveplan/error`).
*
* - `INVALID_URL` — the input path does not match the imgproxy
* grammar regex (missing prefix, bad quality, malformed dims, …).
* - `MISSING_DIMENSIONS` — the parsed URL named an operation
* (`v|c|f|s|r`) without dimensions.
* - `INVALID_OPTIONS` — Zod validation of `ImgproxyConfig` failed.
* - `INVALID_KEY` — `config.key` is not even-length lower-hex.
* - `INVALID_SALT` — `config.salt` is not even-length lower-hex.
* - `INVALID_HOST` — the rewriter received a host that could not be
* processed by `extractDomain` (reserved for future use; raised
* when host is empty).
*/
export const CADDY_IMGPROXY_CODES = defineCodes("CADDY_IMGPROXY", [
"INVALID_URL",
"MISSING_DIMENSIONS",
"INVALID_OPTIONS",
"INVALID_KEY",
"INVALID_SALT",
"INVALID_HOST",
] as const);
/** Discriminating union literal for {@link CaddyImgproxyError.code}. */
export type CaddyImgproxyCode =
(typeof CADDY_IMGPROXY_CODES)[keyof typeof CADDY_IMGPROXY_CODES];
/**
* Typed error for every `@suaveplan/caddy-imgproxy` failure path.
*
* Extends `SuaveplanError<CaddyImgproxyCode>` so `code` narrows
* against the literal union; consumers get exhaustiveness checking
* on `switch (err.code)` blocks. Carries a public constructor so
* `new CaddyImgproxyError(...)` works at the call site.
*
* @example
* ```ts
* import { CADDY_IMGPROXY_CODES, CaddyImgproxyError, parseImgproxyUrl } from "@suaveplan/caddy-imgproxy";
*
* try {
* parseImgproxyUrl("/garbage");
* } catch (err) {
* if (err instanceof CaddyImgproxyError && err.code === CADDY_IMGPROXY_CODES.INVALID_URL) {
* console.warn("not an imgproxy URL", err.context);
* }
* }
* ```
*/
export class CaddyImgproxyError extends SuaveplanError<CaddyImgproxyCode> {
public constructor(
code: CaddyImgproxyCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
super(code, message, options);
this.name = "CaddyImgproxyError";
}
}

View File

@@ -0,0 +1,2 @@
export type { CaddyImgproxyCode } from "./error-codes.js";
export { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";

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,27 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";
describe("gravityToImgproxy", () => {
it("translates every direction to imgproxy tokens", {
timeout: 5000,
}, () => {
expect(gravityToImgproxy("t")).toBe("no");
expect(gravityToImgproxy("b")).toBe("so");
expect(gravityToImgproxy("l")).toBe("we");
expect(gravityToImgproxy("r")).toBe("ea");
expect(gravityToImgproxy("c")).toBe("ce");
expect(gravityToImgproxy("tl")).toBe("nowe");
expect(gravityToImgproxy("tr")).toBe("noea");
expect(gravityToImgproxy("bl")).toBe("sowe");
expect(gravityToImgproxy("br")).toBe("soea");
});
it("defaults unknown codes to ce", { timeout: 5000 }, () => {
expect(gravityToImgproxy("")).toBe("ce");
expect(gravityToImgproxy("xx")).toBe("ce");
});
it("GRAVITY_MAP is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(GRAVITY_MAP)).toBe(true);
});
});

View File

@@ -0,0 +1,39 @@
import type { GravityCode } from "../types/types.js";
/**
* Bidirectional grammar → imgproxy gravity token map. Mirrors the
* upstream Go `gravityToImgproxy` switch statement exactly.
*
* Imgproxy gravity tokens follow the compass-direction convention:
* - `no` north (top)
* - `so` south (bottom)
* - `we` west (left)
* - `ea` east (right)
* - `nowe`/`noea`/`sowe`/`soea` for the four corners
* - `ce` center (default)
*/
export const GRAVITY_MAP: Readonly<Record<GravityCode, string>> = Object.freeze(
{
t: "no",
b: "so",
l: "we",
r: "ea",
c: "ce",
tl: "nowe",
tr: "noea",
bl: "sowe",
br: "soea",
}
);
/**
* Translates a single-character grammar gravity code into its
* imgproxy token. Unknown / empty input falls back to `"ce"`
* (center) — matching the Go default branch.
*/
export function gravityToImgproxy(code: string): string {
if (code === "" || code === undefined) return "ce";
const token = (GRAVITY_MAP as Record<string, string>)[code];
if (token === undefined) return "ce";
return token;
}

View File

@@ -0,0 +1 @@
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";

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,153 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Clock, LoggerLike } from "@suaveplan/types";
import { parseConfig } from "../config/config.js";
import { CADDY_IMGPROXY_CODES } from "../error-codes/error-codes.js";
import { ImgproxyHandler, NOOP_LOGGER } from "./handler.js";
function makeLogger(): {
logger: LoggerLike;
warns: Array<{
message: string;
context?: Readonly<Record<string, unknown>>;
}>;
} {
const warns: Array<{
message: string;
context?: Readonly<Record<string, unknown>>;
}> = [];
return {
warns,
logger: {
debug: () => undefined,
info: () => undefined,
warn: (message, context) => {
warns.push({ message, context });
},
error: () => undefined,
},
};
}
describe("ImgproxyHandler", () => {
it("rewrites a valid imgproxy URL", { timeout: 5000 }, () => {
const config = parseConfig({
defaultWidth: 800,
key: "aa11bbcc",
salt: "deadbeef",
});
const handler = new ImgproxyHandler({ config });
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "images.example.com",
});
if (!result.rewritten) {
throw new Error("expected rewritten=true");
}
expect(result.operation).toBe("cover");
expect(result.targetPath.startsWith("/")).toBe(true);
expect(result.targetPath).toContain("rs:fill:800:450");
expect(result.targetPath).toContain(
"plain/s3://images/example.com/avatar@png"
);
});
it("records caddy_imgproxy_rewrite_duration_ms via the injected clock", {
timeout: 5000,
}, () => {
let tick = 1000;
const clock: Clock = () => {
tick += 5;
return tick;
};
const config = parseConfig({});
const handler = new ImgproxyHandler({ config, clock });
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "images.example.com",
});
expect(result.rewritten).toBe(true);
// No exception means the clock-driven duration was recorded.
});
it("reports passthrough for /unsafe paths", { timeout: 5000 }, () => {
const config = parseConfig({});
const handler = new ImgproxyHandler({ config });
const result = handler.rewrite({
path: "/unsafe/foo/bar.png",
host: "example.com",
});
expect(result).toEqual({
rewritten: false,
reason: "passthrough",
});
});
it("reports passthrough for /health paths", { timeout: 5000 }, () => {
const config = parseConfig({});
const handler = new ImgproxyHandler({ config });
const result = handler.rewrite({
path: "/health",
host: "example.com",
});
expect(result).toEqual({
rewritten: false,
reason: "passthrough",
});
});
it("reports invalid for unparsable paths and warns the logger", {
timeout: 5000,
}, () => {
const { logger, warns } = makeLogger();
const config = parseConfig({});
const handler = new ImgproxyHandler({ config, logger });
const result = handler.rewrite({
path: "/garbage",
host: "example.com",
});
if (result.rewritten !== false || result.reason !== "invalid") {
throw new Error("expected invalid result");
}
expect(result.errorCode).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
expect(result.errorMessage.length).toBeGreaterThan(0);
expect(warns.length).toBe(1);
expect(warns[0]?.context?.code).toBe(CADDY_IMGPROXY_CODES.INVALID_URL);
});
it("raises INVALID_HOST when host is empty", { timeout: 5000 }, () => {
const { logger, warns } = makeLogger();
const config = parseConfig({});
const handler = new ImgproxyHandler({ config, logger });
const result = handler.rewrite({
path: "/uv16:9/avatar.png",
host: "",
});
if (result.rewritten !== false || result.reason !== "invalid") {
throw new Error("expected invalid result");
}
expect(result.errorCode).toBe(CADDY_IMGPROXY_CODES.INVALID_HOST);
expect(warns.length).toBe(1);
});
it("uses the default no-op logger when not supplied", {
timeout: 5000,
}, () => {
const config = parseConfig({});
const handler = new ImgproxyHandler({ config });
// No logger provided — default no-op must absorb the warn call.
const result = handler.rewrite({
path: "/garbage",
host: "example.com",
});
expect(result.rewritten).toBe(false);
});
it("NOOP_LOGGER every method is callable and returns undefined", {
timeout: 5000,
}, () => {
expect(NOOP_LOGGER.debug("msg")).toBeUndefined();
expect(NOOP_LOGGER.info("msg")).toBeUndefined();
expect(NOOP_LOGGER.warn("msg")).toBeUndefined();
expect(NOOP_LOGGER.error("msg")).toBeUndefined();
});
});

View File

@@ -0,0 +1,136 @@
import type { Clock, LoggerLike } from "@suaveplan/types";
import { extractDomain } from "../domain/extract-domain.js";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { parseImgproxyUrl } from "../parse/parse-url.js";
import type { SignerHashLike } from "../signing/sign-path.js";
import {
recordRewriteDurationMs,
recordRewriteOutcome,
} from "../telemetry/telemetry.js";
import type { ImgproxyConfig, RewriteResult } from "../types/types.js";
import { buildImgproxyUrl } from "../url-builder/url-builder.js";
/** Inputs for {@link ImgproxyHandler.rewrite}. */
export interface RewriteRequest {
/** Incoming URL path (e.g. `/uv16:9/avatar.png` or `/unsafe/...`). */
readonly path: string;
/** Incoming Host header (used to derive the S3 sub-path). */
readonly host: string;
}
/** Constructor options for {@link ImgproxyHandler}. */
export interface ImgproxyHandlerOptions {
readonly config: ImgproxyConfig;
/** Wall-clock for histogram timing; defaults to `Date.now`. */
readonly clock?: Clock | undefined;
/** Structured logger for parse warnings; defaults to a silent no-op. */
readonly logger?: LoggerLike | undefined;
/** Optional injectable signer hash. */
readonly hash?: SignerHashLike | undefined;
}
/** Exported for testability — every method is a no-op. */
export const NOOP_LOGGER: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const defaultClock: Clock = () => Date.now();
/**
* High-level orchestrator that wraps `parseImgproxyUrl` +
* `extractDomain` + `buildImgproxyUrl` into a single
* `rewrite(request)` call, emitting telemetry and structured logs
* along the way.
*
* Returns a discriminated `RewriteResult`:
* - `{ rewritten: true; targetPath; operation }` on success.
* - `{ rewritten: false; reason: "passthrough" }` when the input
* path is a direct imgproxy path (`/unsafe/...` or `/health*`)
* that should bypass rewriting.
* - `{ rewritten: false; reason: "invalid"; errorCode; errorMessage }`
* when the path does not match the imgproxy grammar.
*/
export class ImgproxyHandler {
private readonly config: ImgproxyConfig;
private readonly clock: Clock;
private readonly logger: LoggerLike;
private readonly hash?: SignerHashLike | undefined;
public constructor(options: ImgproxyHandlerOptions) {
this.config = options.config;
this.clock = options.clock ?? defaultClock;
this.logger = options.logger ?? NOOP_LOGGER;
this.hash = options.hash;
}
public rewrite(request: RewriteRequest): RewriteResult {
const start = this.clock();
const result = this.executeRewrite(request);
const duration = this.clock() - start;
recordRewriteDurationMs(duration);
return result;
}
private executeRewrite(request: RewriteRequest): RewriteResult {
if (request.host.length === 0) {
const err = new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_HOST,
"host header is required",
{ context: { path: request.path } }
);
this.logger.warn("imgproxy host missing", {
code: err.code,
});
recordRewriteOutcome("invalid", "");
return {
rewritten: false,
reason: "invalid",
errorCode: err.code,
errorMessage: err.message,
};
}
try {
const parsed = parseImgproxyUrl(request.path);
const domain = extractDomain(request.host);
const targetPath = buildImgproxyUrl({
parsed,
domain,
config: this.config,
hash: this.hash,
});
recordRewriteOutcome("rewritten", parsed.operation);
return {
rewritten: true,
targetPath,
operation: parsed.operation,
};
} catch (err) {
const caddyErr = err as CaddyImgproxyError;
if (
request.path.startsWith("/unsafe/") ||
request.path.startsWith("/health")
) {
recordRewriteOutcome("passthrough", "");
return { rewritten: false, reason: "passthrough" };
}
this.logger.warn("imgproxy parse failed", {
path: request.path,
code: caddyErr.code,
});
recordRewriteOutcome("invalid", "");
return {
rewritten: false,
reason: "invalid",
errorCode: caddyErr.code,
errorMessage: caddyErr.message,
};
}
}
}

View File

@@ -0,0 +1,2 @@
export type { ImgproxyHandlerOptions, RewriteRequest } from "./handler.js";
export { ImgproxyHandler } from "./handler.js";

View File

@@ -0,0 +1,50 @@
export { ImgproxyConfigSchema, parseConfig } from "./config/config.js";
export { calculateDimensions } from "./dimensions/calculate-dimensions.js";
export { extractDomain } from "./domain/extract-domain.js";
export type { CaddyImgproxyCode } from "./error-codes/error-codes.js";
export {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "./error-codes/error-codes.js";
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity/gravity.js";
export type {
ImgproxyHandlerOptions,
RewriteRequest,
} from "./handler/handler.js";
export { ImgproxyHandler } from "./handler/handler.js";
export { operationToImgproxy } from "./operation/operation.js";
export { parseImgproxyUrl } from "./parse/parse-url.js";
export type {
SignerHashLike,
SignImgproxyPathOptions,
} from "./signing/sign-path.js";
export { signImgproxyPath } from "./signing/sign-path.js";
export type {
ParseOutcome,
RewriteOutcome,
SignMode,
} from "./telemetry/telemetry.js";
export {
parseTotalCounter,
recordParseOutcome,
recordRewriteDurationMs,
recordRewriteOutcome,
recordSignMode,
rewriteDurationHistogram,
rewriteTotalCounter,
signTotalCounter,
} from "./telemetry/telemetry.js";
export type {
GravityCode,
HintType,
ImgproxyConfig,
ImgproxyDimensions,
ImgproxyOperation,
ParsedImgproxyUrl,
RewriteInvalidResult,
RewritePassthroughResult,
RewriteResult,
RewriteRewrittenResult,
} from "./types/types.js";
export type { BuildImgproxyUrlOptions } from "./url-builder/url-builder.js";
export { buildImgproxyUrl } from "./url-builder/url-builder.js";

View File

@@ -0,0 +1 @@
export { operationToImgproxy } from "./operation.js";

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,79 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { operationToImgproxy } from "./operation.js";
describe("operationToImgproxy", () => {
it("cover with both dims emits gravity-prefixed fill", {
timeout: 5000,
}, () => {
expect(operationToImgproxy("cover", 1920, 1080, "t")).toBe(
"g:no/rs:fill:1920:1080"
);
});
it("cover with width only emits g:<g>/w:W", { timeout: 5000 }, () => {
expect(operationToImgproxy("cover", 800, 0, "c")).toBe("g:ce/w:800");
});
it("cover with height only emits g:<g>/h:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("cover", 0, 600, "c")).toBe("g:ce/h:600");
});
it("cover with both dims zero emits empty string", {
timeout: 5000,
}, () => {
expect(operationToImgproxy("cover", 0, 0, "c")).toBe("");
});
it("fit emits rs:fit:W:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 800, 600, "c")).toBe(
"rs:fit:800:600"
);
});
it("fit with width only emits w:W", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 800, 0, "c")).toBe("w:800");
});
it("fit with height only emits h:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 0, 600, "c")).toBe("h:600");
});
it("fit with no dims emits empty string", { timeout: 5000 }, () => {
expect(operationToImgproxy("fit", 0, 0, "c")).toBe("");
});
it("crop emits g:<g>/c:W:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("crop", 800, 600, "tl")).toBe(
"g:nowe/c:800:600"
);
});
it("crop without both dims emits empty", { timeout: 5000 }, () => {
expect(operationToImgproxy("crop", 800, 0, "c")).toBe("");
});
it("stretch emits rs:force:W:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("stretch", 800, 600, "c")).toBe(
"rs:force:800:600"
);
});
it("stretch without both dims emits empty", { timeout: 5000 }, () => {
expect(operationToImgproxy("stretch", 800, 0, "c")).toBe("");
});
it("resize emits w:W or h:H", { timeout: 5000 }, () => {
expect(operationToImgproxy("resize", 800, 0, "c")).toBe("w:800");
expect(operationToImgproxy("resize", 0, 600, "c")).toBe("h:600");
});
it("resize without dims emits empty", { timeout: 5000 }, () => {
expect(operationToImgproxy("resize", 0, 0, "c")).toBe("");
});
it("passthrough and unknown operations emit empty string", {
timeout: 5000,
}, () => {
expect(operationToImgproxy("", 800, 600, "c")).toBe("");
});
});

View File

@@ -0,0 +1,60 @@
import { gravityToImgproxy } from "../gravity/gravity.js";
import type { ImgproxyOperation } from "../types/types.js";
/**
* Converts a resolved operation + dimensions + gravity into the
* imgproxy processing-string segment.
*
* Mirrors the upstream Go `operationToImgproxy` switch:
* - `cover` → `g:<g>/rs:fill:W:H` when both dims present;
* otherwise `g:<g>/w:W` or `g:<g>/h:H`. Gravity MUST come
* before the resize directive for it to take effect during
* cropping.
* - `fit` → `rs:fit:W:H` (gravity does not apply); or `w:W` /
* `h:H` for one-axis fits.
* - `crop` → `g:<g>/c:W:H`. Gravity prefix required to anchor.
* - `stretch` → `rs:force:W:H`.
* - `resize` → `w:W` or `h:H`.
* - `""` (passthrough) → empty string.
*/
export function operationToImgproxy(
op: ImgproxyOperation,
width: number,
height: number,
gravity: string
): string {
const g = gravityToImgproxy(gravity);
switch (op) {
case "cover":
if (width > 0 && height > 0) {
return `g:${g}/rs:fill:${width}:${height}`;
}
if (width > 0) return `g:${g}/w:${width}`;
if (height > 0) return `g:${g}/h:${height}`;
return "";
case "fit":
if (width > 0 && height > 0) {
return `rs:fit:${width}:${height}`;
}
if (width > 0) return `w:${width}`;
if (height > 0) return `h:${height}`;
return "";
case "crop":
if (width > 0 && height > 0) {
return `g:${g}/c:${width}:${height}`;
}
return "";
case "stretch":
if (width > 0 && height > 0) {
return `rs:force:${width}:${height}`;
}
return "";
case "resize":
if (width > 0) return `w:${width}`;
if (height > 0) return `h:${height}`;
return "";
case "":
return "";
}
}

View File

@@ -0,0 +1 @@
export { parseImgproxyUrl } from "./parse-url.js";

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,233 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { parseImgproxyUrl } from "./parse-url.js";
describe("parseImgproxyUrl", () => {
it("parses passthrough unsigned URL", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/u/photo.png");
expect(result.signed).toBe(false);
expect(result.operation).toBe("");
expect(result.gravity).toBe("c");
expect(result.path).toBe("photo");
expect(result.format).toBe("png");
expect(result.hasDims).toBe(false);
});
it("parses passthrough signed URL", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/s/photo.png");
expect(result.signed).toBe(true);
expect(result.operation).toBe("");
expect(result.path).toBe("photo");
expect(result.format).toBe("png");
});
it("parses cover with aspect ratio, top gravity, height hint", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uvt9:16h1080/photos/hero.webp");
expect(result.operation).toBe("cover");
expect(result.gravity).toBe("t");
expect(result.aspectW).toBe(9);
expect(result.aspectH).toBe(16);
expect(result.hintType).toBe("h");
expect(result.hintValue).toBe(1080);
expect(result.path).toBe("photos/hero");
expect(result.format).toBe("webp");
expect(result.isAspect).toBe(true);
});
it("parses cover with aspect ratio, center gravity default", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uv16:9/avatar.png");
expect(result.operation).toBe("cover");
expect(result.gravity).toBe("c");
expect(result.aspectW).toBe(16);
expect(result.aspectH).toBe(9);
expect(result.isAspect).toBe(true);
});
it("parses fit with pixel dimensions both axes", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf100x100/avatar.jpg");
expect(result.operation).toBe("fit");
expect(result.pixelW).toBe(100);
expect(result.pixelH).toBe(100);
expect(result.isAspect).toBe(false);
});
it("parses fit with width only (auto height)", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf800x0/banner.webp");
expect(result.pixelW).toBe(800);
expect(result.pixelH).toBe(0);
});
it("parses fit with height only (auto width)", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf0x600/portrait.jpg");
expect(result.pixelW).toBe(0);
expect(result.pixelH).toBe(600);
});
it("parses crop with aspect ratio and bottom-left gravity", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/ucbl4:3w1920/photo.png");
expect(result.operation).toBe("crop");
expect(result.gravity).toBe("bl");
expect(result.aspectW).toBe(4);
expect(result.aspectH).toBe(3);
expect(result.hintType).toBe("w");
expect(result.hintValue).toBe(1920);
});
it("parses stretch with pixel dimensions", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/us800x600/photo.jpg");
expect(result.operation).toBe("stretch");
expect(result.pixelW).toBe(800);
expect(result.pixelH).toBe(600);
});
it("parses resize with width only", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/ur800x0/photo.jpg");
expect(result.operation).toBe("resize");
expect(result.pixelW).toBe(800);
});
it("parses cover with top-right gravity", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uvtr16:9/photo.png");
expect(result.gravity).toBe("tr");
});
it("parses cover with bottom-right gravity", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uvbr16:9/photo.png");
expect(result.gravity).toBe("br");
});
it("parses cover with width hint", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv16:9w1920/photo.png");
expect(result.hintType).toBe("w");
expect(result.hintValue).toBe(1920);
});
it("parses fit with quality", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf800x600q85/photo.jpg");
expect(result.quality).toBe(85);
});
it("parses cover with quality 100", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv16:9q100/photo.jpg");
expect(result.quality).toBe(100);
});
it("parses square shorthand - cover 128", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv128/avatar.png");
expect(result.pixelW).toBe(128);
expect(result.pixelH).toBe(128);
expect(result.isAspect).toBe(false);
expect(result.hasDims).toBe(true);
});
it("parses square shorthand - fit 64 with quality", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uf64q90/avatar.jpg");
expect(result.pixelW).toBe(64);
expect(result.pixelH).toBe(64);
expect(result.quality).toBe(90);
});
it("parses square shorthand - cover with gravity", {
timeout: 5000,
}, () => {
const result = parseImgproxyUrl("/uvtr128/avatar.png");
expect(result.gravity).toBe("tr");
expect(result.pixelW).toBe(128);
});
it("parses deep path", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv16:9/folder/subfolder/photo.png");
expect(result.path).toBe("folder/subfolder/photo");
});
it("parses quality before width hint", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf800x0q85w1920/avatar.jpg");
expect(result.quality).toBe(85);
expect(result.hintType).toBe("w");
expect(result.hintValue).toBe(1920);
});
it("parses quality before height hint", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uv9:16q85h1080/photo.jpg");
expect(result.quality).toBe(85);
expect(result.hintType).toBe("h");
expect(result.hintValue).toBe(1080);
});
it("parses pixel dims with quality first", { timeout: 5000 }, () => {
const result = parseImgproxyUrl("/uf100x100q75/photo.jpg");
expect(result.quality).toBe(75);
expect(result.pixelW).toBe(100);
});
it("raises MISSING_DIMENSIONS when operation is specified without dimensions", {
timeout: 5000,
}, () => {
try {
parseImgproxyUrl("/uv/photo.png");
expect.fail("expected MISSING_DIMENSIONS");
} catch (err) {
expect(err).toBeInstanceOf(CaddyImgproxyError);
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS
);
}
});
it("raises INVALID_URL on missing prefix", { timeout: 5000 }, () => {
try {
parseImgproxyUrl("/photo.png");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
it("raises INVALID_URL on quality zero", { timeout: 5000 }, () => {
try {
parseImgproxyUrl("/uf800x600q0/photo.jpg");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
it("raises INVALID_URL on quality over 100", { timeout: 5000 }, () => {
try {
parseImgproxyUrl("/uf800x600q101/photo.jpg");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
it("raises INVALID_URL on aspect ratio with zero", {
timeout: 5000,
}, () => {
try {
parseImgproxyUrl("/uv0:9/photo.png");
expect.fail("expected INVALID_URL");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_URL
);
}
});
});

View File

@@ -0,0 +1,195 @@
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { recordParseOutcome } from "../telemetry/telemetry.js";
import type {
GravityCode,
HintType,
ImgproxyOperation,
ParsedImgproxyUrl,
} from "../types/types.js";
/**
* Matches the compact imgproxy URL grammar. Capture groups:
* 1: signed/unsigned (`s`|`u`)
* 2: operation (`v`|`c`|`f`|`s`|`r`) — optional
* 3: gravity (`tl`|`tr`|`bl`|`br`|`t`|`b`|`l`|`r`|`c`) — optional
* 4: aspect W (>=1)
* 5: aspect H (>=1)
* 6: pixel W (>=1, when followed by xH)
* 7: pixel H (>=0)
* 8: pixel W (=0)
* 9: pixel H (>=1, when W=0)
* 10: square dim
* 11: modifiers
* 12: path
* 13: format
*/
const URL_PATTERN =
/^\/([su])([vcfsr])?(tl|tr|bl|br|[tblrc])?(?:([1-9]\d*):([1-9]\d*)|([1-9]\d*)x(\d+)|(0)x([1-9]\d*)|([1-9]\d*))?((?:[wh][1-9]\d*|q(?:100|[1-9]\d?))*)\/(.+)\.([a-z]+)$/;
/** Extracts individual `w<int>`, `h<int>`, `q<int>` modifiers. */
const MODIFIER_PATTERN = /([whq])(\d+)/g;
const OP_MAP: Record<string, ImgproxyOperation> = {
v: "cover",
c: "crop",
f: "fit",
s: "stretch",
r: "resize",
};
/**
* Parses an imgproxy URL into its typed components.
*
* Verbatim port of the upstream Go parseURL. Returns a frozen
* ParsedImgproxyUrl on success and throws CaddyImgproxyError on
* every failure. Increments caddy_imgproxy_parse_total once per call.
*/
export function parseImgproxyUrl(path: string): ParsedImgproxyUrl {
const matches = URL_PATTERN.exec(path);
if (matches === null) {
recordParseOutcome("invalid");
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.INVALID_URL,
"imgproxy URL does not match the expected grammar",
{ context: { path } }
);
}
const opToken = matches[2] ?? "";
const operation =
opToken === "" ? "" : (OP_MAP[opToken] as ImgproxyOperation);
const gravity = resolveGravity(matches[3]);
const { aspectW, aspectH, pixelW, pixelH, isAspect, hasDims } =
resolveDimensions(matches);
const { hintType, hintValue, quality } = parseModifiers(
matches[11] as string
);
if (operation !== "" && !hasDims) {
recordParseOutcome("missing_dims");
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES.MISSING_DIMENSIONS,
`dimensions required for operation: ${operation}`,
{ context: { path, operation } }
);
}
recordParseOutcome("ok");
return Object.freeze({
signed: matches[1] === "s",
operation,
gravity,
aspectW,
aspectH,
pixelW,
pixelH,
hintType,
hintValue,
quality,
path: matches[12] as string,
format: matches[13] as string,
isAspect,
hasDims,
});
}
function resolveGravity(token: string | undefined): GravityCode {
if (token === undefined || token === "") return "c";
return token as GravityCode;
}
interface DimensionParts {
readonly aspectW: number;
readonly aspectH: number;
readonly pixelW: number;
readonly pixelH: number;
readonly isAspect: boolean;
readonly hasDims: boolean;
}
function resolveDimensions(matches: RegExpExecArray): DimensionParts {
if (matches[4] !== undefined && matches[5] !== undefined) {
return {
aspectW: Number.parseInt(matches[4], 10),
aspectH: Number.parseInt(matches[5], 10),
pixelW: 0,
pixelH: 0,
isAspect: true,
hasDims: true,
};
}
if (matches[6] !== undefined) {
return {
aspectW: 0,
aspectH: 0,
pixelW: Number.parseInt(matches[6], 10),
pixelH: Number.parseInt(matches[7] as string, 10),
isAspect: false,
hasDims: true,
};
}
if (matches[8] !== undefined) {
return {
aspectW: 0,
aspectH: 0,
pixelW: 0,
pixelH: Number.parseInt(matches[9] as string, 10),
isAspect: false,
hasDims: true,
};
}
if (matches[10] !== undefined) {
const dim = Number.parseInt(matches[10], 10);
return {
aspectW: 0,
aspectH: 0,
pixelW: dim,
pixelH: dim,
isAspect: false,
hasDims: true,
};
}
return {
aspectW: 0,
aspectH: 0,
pixelW: 0,
pixelH: 0,
isAspect: false,
hasDims: false,
};
}
interface ModifierParts {
readonly hintType: HintType;
readonly hintValue: number;
readonly quality: number;
}
function parseModifiers(token: string): ModifierParts {
let hintType: HintType = "";
let hintValue = 0;
let quality = 0;
if (token === "") {
return { hintType, hintValue, quality };
}
MODIFIER_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = MODIFIER_PATTERN.exec(token);
while (match !== null) {
const kind = match[1];
const value = Number.parseInt(match[2] as string, 10);
if (kind === "w") {
hintType = "w";
hintValue = value;
} else if (kind === "h") {
hintType = "h";
hintValue = value;
} else {
quality = value;
}
match = MODIFIER_PATTERN.exec(token);
}
return { hintType, hintValue, quality };
}

View File

@@ -0,0 +1,2 @@
export type { SignerHashLike, SignImgproxyPathOptions } from "./sign-path.js";
export { signImgproxyPath } from "./sign-path.js";

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,86 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CADDY_IMGPROXY_CODES,
type CaddyImgproxyError,
} from "../error-codes/error-codes.js";
import { type SignerHashLike, signImgproxyPath } from "./sign-path.js";
const KEY = "aa11bbcc";
const SALT = "deadbeef";
const PATH = "/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png";
const EXPECTED = "E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U";
describe("signImgproxyPath", () => {
it("matches the known-answer signature vector", { timeout: 5000 }, () => {
const result = signImgproxyPath({
key: KEY,
salt: SALT,
path: PATH,
});
expect(result).toBe(EXPECTED);
});
it("raises INVALID_KEY for malformed hex key", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: "xyz", salt: SALT, path: PATH });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_KEY for odd-length key", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: "abc", salt: SALT, path: PATH });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_KEY for empty key", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: "", salt: SALT, path: PATH });
expect.fail("expected INVALID_KEY");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_KEY
);
}
});
it("raises INVALID_SALT for malformed hex salt", { timeout: 5000 }, () => {
try {
signImgproxyPath({ key: KEY, salt: "zzz", path: PATH });
expect.fail("expected INVALID_SALT");
} catch (err) {
expect((err as CaddyImgproxyError).code).toBe(
CADDY_IMGPROXY_CODES.INVALID_SALT
);
}
});
it("uses the injected hash implementation", { timeout: 5000 }, () => {
let invoked = false;
const hash: SignerHashLike = {
hmac: (algorithm, _key, _data) => {
invoked = true;
expect(algorithm).toBe("SHA-256");
return new Uint8Array([1, 2, 3, 4]);
},
};
const result = signImgproxyPath({
key: KEY,
salt: SALT,
path: PATH,
hash,
});
expect(invoked).toBe(true);
// toBase64Url of [1,2,3,4] = "AQIDBA"
expect(result).toBe("AQIDBA");
});
});

View File

@@ -0,0 +1,76 @@
import { fromHex, hmac, toBase64Url } from "@suaveplan/crypto-hash";
import {
CADDY_IMGPROXY_CODES,
CaddyImgproxyError,
} from "../error-codes/error-codes.js";
/**
* Pluggable HMAC implementation. Allows a host to swap in a native
* implementation, an HSM-backed signer, or a deterministic stub for
* tests. The default uses `@suaveplan/crypto-hash`'s `hmac`.
*/
export interface SignerHashLike {
hmac(algorithm: "SHA-256", key: Uint8Array, data: Uint8Array): Uint8Array;
}
const defaultHash: SignerHashLike = {
hmac: (algorithm, key, data) => hmac(algorithm, key, data),
};
/** Inputs for {@link signImgproxyPath}. */
export interface SignImgproxyPathOptions {
/** Lower-hex HMAC key (even length). */
readonly key: string;
/** Lower-hex salt (even length). */
readonly salt: string;
/** Imgproxy path being signed (including leading slash + segments). */
readonly path: string;
/** Optional injectable hash implementation. */
readonly hash?: SignerHashLike | undefined;
}
const HEX_RE = /^[0-9a-f]*$/u;
const utf8 = new TextEncoder();
/**
* Decodes a hex string into bytes, raising the supplied
* caddy-imgproxy code on failure. The hex validation is stricter than
* `crypto-hash.fromHex` (lower-case only) to keep wire-format
* round-trips deterministic.
*/
function decodeHex(
hex: string,
code: "INVALID_KEY" | "INVALID_SALT",
field: string
): Uint8Array {
if (hex.length === 0 || hex.length % 2 !== 0 || !HEX_RE.test(hex)) {
throw new CaddyImgproxyError(
CADDY_IMGPROXY_CODES[code],
`${field} must be a non-empty even-length lower-hex string`,
{ context: { field } }
);
}
return fromHex(hex);
}
/**
* Computes the imgproxy URL signature.
*
* The signature is `HMAC-SHA256(keyBytes, saltBytes || pathBytes)`,
* base64url encoded without padding. Matches the upstream Go
* `signPath` byte-for-byte so any URL signed by the Go module
* verifies against the TypeScript port and vice-versa.
*/
export function signImgproxyPath(options: SignImgproxyPathOptions): string {
const keyBytes = decodeHex(options.key, "INVALID_KEY", "key");
const saltBytes = decodeHex(options.salt, "INVALID_SALT", "salt");
const pathBytes = utf8.encode(options.path);
const data = new Uint8Array(saltBytes.length + pathBytes.length);
data.set(saltBytes, 0);
data.set(pathBytes, saltBytes.length);
const hash = options.hash ?? defaultHash;
const digest = hash.hmac("SHA-256", keyBytes, data);
return toBase64Url(digest);
}

View File

@@ -0,0 +1,11 @@
export type { ParseOutcome, RewriteOutcome, SignMode } from "./telemetry.js";
export {
parseTotalCounter,
recordParseOutcome,
recordRewriteDurationMs,
recordRewriteOutcome,
recordSignMode,
rewriteDurationHistogram,
rewriteTotalCounter,
signTotalCounter,
} from "./telemetry.js";

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,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
parseTotalCounter,
recordParseOutcome,
recordRewriteDurationMs,
recordRewriteOutcome,
recordSignMode,
rewriteDurationHistogram,
rewriteTotalCounter,
signTotalCounter,
} from "./telemetry.js";
describe("caddy-imgproxy telemetry", () => {
it("exposes counters + histogram", { timeout: 5000 }, () => {
expect(parseTotalCounter).toBeDefined();
expect(rewriteTotalCounter).toBeDefined();
expect(signTotalCounter).toBeDefined();
expect(rewriteDurationHistogram).toBeDefined();
});
it("records parse outcomes", { timeout: 5000 }, () => {
expect(() => recordParseOutcome("ok")).not.toThrow();
expect(() => recordParseOutcome("invalid")).not.toThrow();
expect(() => recordParseOutcome("missing_dims")).not.toThrow();
});
it("records rewrite outcomes and durations", { timeout: 5000 }, () => {
expect(() => recordRewriteOutcome("rewritten", "cover")).not.toThrow();
expect(() => recordRewriteOutcome("passthrough", "")).not.toThrow();
expect(() => recordRewriteOutcome("invalid", "")).not.toThrow();
expect(() => recordRewriteDurationMs(42)).not.toThrow();
});
it("records sign modes", { timeout: 5000 }, () => {
expect(() => recordSignMode("signed")).not.toThrow();
expect(() => recordSignMode("unsafe")).not.toThrow();
});
});

View File

@@ -0,0 +1,80 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
const METER_NAME = "@suaveplan/caddy-imgproxy";
/**
* Lazy-bound meter — resolves the globally-registered `MeterProvider`
* on first access. When no provider is registered the OTel API returns
* a no-op meter, so the instruments are safe to import in production
* code that does not opt into telemetry.
*/
const meter = metrics.getMeter(METER_NAME);
/** Counter for parse-call outcomes. */
export const parseTotalCounter: Counter = meter.createCounter(
"caddy_imgproxy_parse_total",
{
description:
"Total parseImgproxyUrl calls, tagged with outcome (ok|invalid|missing_dims)",
}
);
/** Counter for rewrite-call outcomes. */
export const rewriteTotalCounter: Counter = meter.createCounter(
"caddy_imgproxy_rewrite_total",
{
description:
"Total ImgproxyHandler.rewrite calls, tagged with outcome (rewritten|passthrough|invalid) and operation",
}
);
/** Counter for sign / unsafe modes. */
export const signTotalCounter: Counter = meter.createCounter(
"caddy_imgproxy_sign_total",
{
description:
"Total buildImgproxyUrl calls, tagged with mode (signed|unsafe)",
}
);
/** Histogram for ImgproxyHandler.rewrite end-to-end duration in milliseconds. */
export const rewriteDurationHistogram: Histogram = meter.createHistogram(
"caddy_imgproxy_rewrite_duration_ms",
{
description:
"ImgproxyHandler.rewrite end-to-end duration in milliseconds",
unit: "ms",
}
);
/** Parse outcome tag — one tick per `parseImgproxyUrl` call. */
export type ParseOutcome = "ok" | "invalid" | "missing_dims";
/** Rewrite outcome tag. */
export type RewriteOutcome = "rewritten" | "passthrough" | "invalid";
/** Sign mode tag. */
export type SignMode = "signed" | "unsafe";
/** Records a `caddy_imgproxy_parse_total` data point. */
export function recordParseOutcome(outcome: ParseOutcome): void {
parseTotalCounter.add(1, { outcome });
}
/** Records a `caddy_imgproxy_rewrite_total` data point with operation tag. */
export function recordRewriteOutcome(
outcome: RewriteOutcome,
operation: string
): void {
rewriteTotalCounter.add(1, { outcome, operation });
}
/** Records a `caddy_imgproxy_sign_total` data point. */
export function recordSignMode(mode: SignMode): void {
signTotalCounter.add(1, { mode });
}
/** Records a duration sample on the rewrite histogram. */
export function recordRewriteDurationMs(durationMs: number): void {
rewriteDurationHistogram.record(durationMs);
}

View File

@@ -0,0 +1,12 @@
export type {
GravityCode,
HintType,
ImgproxyConfig,
ImgproxyDimensions,
ImgproxyOperation,
ParsedImgproxyUrl,
RewriteInvalidResult,
RewritePassthroughResult,
RewriteResult,
RewriteRewrittenResult,
} from "./types.js";

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,113 @@
/**
* Shared, runtime-free type definitions for the @suaveplan/caddy-imgproxy
* package. These are the cross-feature contracts that flow between
* `parse`, `dimensions`, `url-builder`, `handler`, and `config`.
*/
/** Imgproxy operation codes — the empty string is "passthrough" (no resize). */
export type ImgproxyOperation =
| ""
| "cover"
| "crop"
| "fit"
| "stretch"
| "resize";
/** Gravity / anchor grammar — 9 directions plus center default. */
export type GravityCode =
| "t"
| "b"
| "l"
| "r"
| "c"
| "tl"
| "tr"
| "bl"
| "br";
/** Modifier hint type — either width-driven (`w`), height-driven (`h`), or none (`""`). */
export type HintType = "" | "w" | "h";
/**
* Parsed representation of an imgproxy URL — the output of
* `parseImgproxyUrl`. Mirrors the Go `ParsedURL` struct field-for-field
* so the TypeScript and Go implementations can be tested against the
* same fixture corpus.
*/
export interface ParsedImgproxyUrl {
/** `true` when the URL had the `s` prefix (signed), `false` for `u`. */
readonly signed: boolean;
/** Resolved operation; empty string means "passthrough". */
readonly operation: ImgproxyOperation;
/** Resolved gravity anchor; defaults to `"c"` (center) when omitted. */
readonly gravity: GravityCode;
/** Aspect ratio numerator (e.g. `16` in `16:9`); `0` when not aspect mode. */
readonly aspectW: number;
/** Aspect ratio denominator (e.g. `9` in `16:9`); `0` when not aspect mode. */
readonly aspectH: number;
/** Explicit pixel width; `0` for auto. */
readonly pixelW: number;
/** Explicit pixel height; `0` for auto. */
readonly pixelH: number;
/** Hint axis — width (`w`), height (`h`), or none (`""`). */
readonly hintType: HintType;
/** Hint magnitude in pixels. */
readonly hintValue: number;
/** JPEG-style quality 1..100; `0` means "default / unspecified". */
readonly quality: number;
/** Image path after the prefix and before the extension. */
readonly path: string;
/** Output format extension (e.g. `png`, `webp`). */
readonly format: string;
/** `true` when dimensions came from an aspect ratio expression. */
readonly isAspect: boolean;
/** `true` when any dimension was supplied. */
readonly hasDims: boolean;
}
/**
* Public configuration for the imgproxy URL rewriter. Validated by
* `ImgproxyConfigSchema` / `parseConfig`. `key` and `salt` are
* lower-hex strings; their absence flips the rewriter into `/unsafe`
* mode (matching the Go module's behaviour).
*/
export interface ImgproxyConfig {
readonly upstream: string;
readonly s3Bucket: string;
readonly defaultWidth: number;
readonly key?: string | undefined;
readonly salt?: string | undefined;
}
/** Resolved pixel dimensions — the output of `calculateDimensions`. */
export interface ImgproxyDimensions {
readonly width: number;
readonly height: number;
}
/** Result of `ImgproxyHandler.rewrite`. Discriminated by `rewritten`. */
export type RewriteResult =
| RewriteRewrittenResult
| RewritePassthroughResult
| RewriteInvalidResult;
/** A URL that successfully rewrote into an imgproxy target. */
export interface RewriteRewrittenResult {
readonly rewritten: true;
readonly targetPath: string;
readonly operation: ImgproxyOperation;
}
/** A URL that should bypass rewriting (e.g. `/unsafe/...` or `/health`). */
export interface RewritePassthroughResult {
readonly rewritten: false;
readonly reason: "passthrough";
}
/** A URL that failed to parse — surfaced for logging / error handling. */
export interface RewriteInvalidResult {
readonly rewritten: false;
readonly reason: "invalid";
readonly errorCode: string;
readonly errorMessage: string;
}

View File

@@ -0,0 +1,2 @@
export type { BuildImgproxyUrlOptions } from "./url-builder.js";
export { buildImgproxyUrl } from "./url-builder.js";

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,140 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseConfig } from "../config/config.js";
import { parseImgproxyUrl } from "../parse/parse-url.js";
import { buildImgproxyUrl } from "./url-builder.js";
describe("buildImgproxyUrl", () => {
it("composes /unsafe URL when no key/salt", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const config = parseConfig({ defaultWidth: 800 });
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
expect(result).toBe(
"/unsafe/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
);
});
it("composes signed URL for cover with aspect ratio", {
timeout: 5000,
}, () => {
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const config = parseConfig({
defaultWidth: 800,
key: "aa11bbcc",
salt: "deadbeef",
});
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
expect(result).toBe(
"/E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U/g:ce/rs:fill:800:450/plain/s3://images/example.com/avatar@png"
);
});
it("inserts q:<quality> when parsed.quality > 0", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/uf800x600q85/photo.jpg");
const config = parseConfig({});
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
expect(result).toContain("/q:85/");
});
it("emits passthrough URL when operation is empty", {
timeout: 5000,
}, () => {
const parsed = parseImgproxyUrl("/u/photo.png");
const config = parseConfig({});
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config,
});
// No processing segment since operation === ""
expect(result).toBe("/unsafe/plain/s3://images/example.com/photo@png");
});
it("records caddy_imgproxy_sign_total for signed and unsafe modes", {
timeout: 5000,
}, () => {
const parsed = parseImgproxyUrl("/uv16:9/avatar.png");
const unsafeCfg = parseConfig({});
const signedCfg = parseConfig({
key: "aa11bbcc",
salt: "deadbeef",
});
const a = buildImgproxyUrl({
parsed,
domain: "example.com",
config: unsafeCfg,
});
const b = buildImgproxyUrl({
parsed,
domain: "example.com",
config: signedCfg,
});
expect(a.startsWith("/unsafe/")).toBe(true);
expect(
b.startsWith("/E3ytdnTM6THWihFdTWqY4YGQIV-6K969S-8jb4AaE4U/")
).toBe(true);
});
it("uses /unsafe when key is set but salt missing", {
timeout: 5000,
}, () => {
// Manually construct a config with key but no salt to hit the
// branch where only one half is set.
const parsed = parseImgproxyUrl("/u/photo.png");
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config: {
upstream: "localhost:8080",
s3Bucket: "images",
defaultWidth: 800,
key: "aa11",
salt: undefined,
},
});
expect(result.startsWith("/unsafe/")).toBe(true);
});
it("treats empty-string key as unsafe mode", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/u/photo.png");
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config: {
upstream: "localhost:8080",
s3Bucket: "images",
defaultWidth: 800,
key: "",
salt: "deadbeef",
},
});
expect(result.startsWith("/unsafe/")).toBe(true);
});
it("treats empty-string salt as unsafe mode", { timeout: 5000 }, () => {
const parsed = parseImgproxyUrl("/u/photo.png");
const result = buildImgproxyUrl({
parsed,
domain: "example.com",
config: {
upstream: "localhost:8080",
s3Bucket: "images",
defaultWidth: 800,
key: "aa11",
salt: "",
},
});
expect(result.startsWith("/unsafe/")).toBe(true);
});
});

View File

@@ -0,0 +1,68 @@
import { calculateDimensions } from "../dimensions/calculate-dimensions.js";
import { operationToImgproxy } from "../operation/operation.js";
import { type SignerHashLike, signImgproxyPath } from "../signing/sign-path.js";
import { recordSignMode } from "../telemetry/telemetry.js";
import type { ImgproxyConfig, ParsedImgproxyUrl } from "../types/types.js";
/** Inputs for {@link buildImgproxyUrl}. */
export interface BuildImgproxyUrlOptions {
/** A `ParsedImgproxyUrl` previously produced by `parseImgproxyUrl`. */
readonly parsed: ParsedImgproxyUrl;
/** Tenant / virtual-host segment to interpolate into the S3 path. */
readonly domain: string;
/** Validated imgproxy configuration (defaults + optional key/salt). */
readonly config: ImgproxyConfig;
/** Optional injectable hash implementation for the signer. */
readonly hash?: SignerHashLike | undefined;
}
/**
* Composes the final imgproxy URL path from a parsed grammar
* representation. Re-implements the Go `buildImgproxyURL` semantics:
*
* 1. Resolve `(width, height)` from the parsed dims + default width.
* 2. Resolve the processing string for the operation + dims + gravity.
* 3. Append `q:<quality>` when `parsed.quality > 0`.
* 4. Append `plain/s3://<bucket>/<domain>/<path>@<format>`.
* 5. Prefix with `<signature>` (signed mode) or `/unsafe`.
*
* Records `caddy_imgproxy_sign_total` exactly once per call.
*/
export function buildImgproxyUrl(options: BuildImgproxyUrlOptions): string {
const { parsed, domain, config } = options;
const { width, height } = calculateDimensions(parsed, config.defaultWidth);
const processing = operationToImgproxy(
parsed.operation,
width,
height,
parsed.gravity
);
const s3Path = `s3://${config.s3Bucket}/${domain}/${parsed.path}`;
const segments: string[] = [];
if (processing !== "") segments.push(processing);
if (parsed.quality > 0) segments.push(`q:${parsed.quality}`);
segments.push("plain", `${s3Path}@${parsed.format}`);
const innerPath = `/${segments.join("/")}`;
if (
config.key !== undefined &&
config.salt !== undefined &&
config.key.length > 0 &&
config.salt.length > 0
) {
const signature = signImgproxyPath({
key: config.key,
salt: config.salt,
path: innerPath,
hash: options.hash,
});
recordSignMode("signed");
return `/${signature}${innerPath}`;
}
recordSignMode("unsafe");
return `/unsafe${innerPath}`;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,55 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/media-image-caddy-imgproxy",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"parse/index": "src/parse/index.ts",
"dimensions/index": "src/dimensions/index.ts",
"gravity/index": "src/gravity/index.ts",
"operation/index": "src/operation/index.ts",
"signing/index": "src/signing/index.ts",
"url-builder/index": "src/url-builder/index.ts",
"domain/index": "src/domain/index.ts",
"handler/index": "src/handler/index.ts",
"config/index": "src/config/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"telemetry/index": "src/telemetry/index.ts",
"types/index": "src/types/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@opentelemetry/api",
"@suaveplan/crypto-hash",
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/index.ts",
"src/types/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
hookTimeout: 5000,
},
});

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,44 @@
{
"name": "@suaveplan/colour-convert-adapt",
"version": "0.1.0",
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./adapt/adapt": {
"import": {
"types": "./dist/adapt/adapt.d.ts",
"default": "./dist/adapt/adapt.js"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"tslib": "^2.8.1"
},
"devDependencies": {
"@suaveplan/testing": "workspace:*",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-dts": "^4.5.3",
"vitest": "^4.1.5"
}
}

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,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { bradfordAdapt, ILLUMINANTS } from "./adapt.js";
describe("colour-convert-adapt", () => {
it("D65 to D65 is identity", { timeout: 5000 }, () => {
const xyz = { x: 50, y: 50, z: 50 };
const result = bradfordAdapt(xyz, "D65", "D65");
expect(result.x).toBeCloseTo(xyz.x, 3);
expect(result.y).toBeCloseTo(xyz.y, 3);
expect(result.z).toBeCloseTo(xyz.z, 3);
});
it("adapts D65 to D50", { timeout: 5000 }, () => {
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "D50");
expect(result.x).not.toBeCloseTo(50, 1);
});
it("adapts D50 to D65", { timeout: 5000 }, () => {
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D50", "D65");
expect(result.x).not.toBeCloseTo(50, 1);
});
it("adapts all named illuminants", { timeout: 5000 }, () => {
const names = Object.keys(ILLUMINANTS);
for (const src of names) {
for (const dst of names) {
if (src === dst) continue;
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, src, dst);
expect(Number.isFinite(result.x)).toBe(true);
expect(Number.isFinite(result.y)).toBe(true);
expect(Number.isFinite(result.z)).toBe(true);
}
}
});
it("accepts custom XYZ illuminants", { timeout: 5000 }, () => {
const custom = { x: 95, y: 100, z: 108 };
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, custom, custom);
expect(result.x).toBeCloseTo(50, 3);
expect(result.y).toBeCloseTo(50, 3);
expect(result.z).toBeCloseTo(50, 3);
});
it("defaults destination to D65", { timeout: 5000 }, () => {
const result = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D50");
const explicit = bradfordAdapt({ x: 50, y: 50, z: 50 }, "D50", "D65");
expect(result.x).toBeCloseTo(explicit.x, 5);
expect(result.y).toBeCloseTo(explicit.y, 5);
expect(result.z).toBeCloseTo(explicit.z, 5);
});
it("throws on invalid illuminant string", { timeout: 5000 }, () => {
expect(() =>
bradfordAdapt({ x: 50, y: 50, z: 50 }, "XYZ", "D65")
).toThrow("Invalid illuminant");
});
it("throws on invalid destination string", { timeout: 5000 }, () => {
expect(() =>
bradfordAdapt({ x: 50, y: 50, z: 50 }, "D65", "INVALID")
).toThrow("Invalid illuminant");
});
});

View File

@@ -0,0 +1,76 @@
export interface XYZ {
readonly x: number;
readonly y: number;
readonly z: number;
}
const BRADFORD_MA: [number[], number[], number[]] = [
[0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[0.0389, -0.0685, 1.0296],
];
const BRADFORD_MA_INV: [number[], number[], number[]] = [
[0.986993, -0.147054, 0.159963],
[0.432305, 0.51836, 0.049291],
[-0.008529, 0.040043, 0.968487],
];
export const ILLUMINANTS: Record<string, XYZ> = {
A: { x: 109.85, y: 100.0, z: 35.585 },
B: { x: 99.0927, y: 100.0, z: 85.313 },
C: { x: 98.074, y: 100.0, z: 118.232 },
D50: { x: 96.4212, y: 100.0, z: 82.5188 },
D55: { x: 95.6797, y: 100.0, z: 92.1481 },
D65: { x: 95.047, y: 100.0, z: 108.883 },
D75: { x: 94.972, y: 100.0, z: 122.638 },
E: { x: 100.0, y: 100.0, z: 100.0 },
F2: { x: 99.187, y: 100.0, z: 67.395 },
F7: { x: 95.044, y: 100.0, z: 108.755 },
F11: { x: 100.966, y: 100.0, z: 64.37 },
};
function matVec3(
m: [number[], number[], number[]],
v: { x: number; y: number; z: number }
): { x: number; y: number; z: number } {
const r0 = m[0]!;
const r1 = m[1]!;
const r2 = m[2]!;
return {
x: r0[0]! * v.x + r0[1]! * v.y + r0[2]! * v.z,
y: r1[0]! * v.x + r1[1]! * v.y + r1[2]! * v.z,
z: r2[0]! * v.x + r2[1]! * v.y + r2[2]! * v.z,
};
}
export function bradfordAdapt(
xyz: XYZ,
sourceIlluminant: string | XYZ,
destIlluminant: string | XYZ = "D65"
): XYZ {
const src =
typeof sourceIlluminant === "string"
? ILLUMINANTS[sourceIlluminant]
: sourceIlluminant;
const dst =
typeof destIlluminant === "string"
? ILLUMINANTS[destIlluminant]
: destIlluminant;
if (!src || !dst) {
throw new Error("Invalid illuminant");
}
const srcLms = matVec3(BRADFORD_MA, src);
const dstLms = matVec3(BRADFORD_MA, dst);
const xyzLms = matVec3(BRADFORD_MA, xyz);
const scaled = {
x: xyzLms.x * (dstLms.x / srcLms.x),
y: xyzLms.y * (dstLms.y / srcLms.y),
z: xyzLms.z * (dstLms.z / srcLms.z),
};
return matVec3(BRADFORD_MA_INV, scaled);
}

View File

@@ -0,0 +1,2 @@
export type { XYZ } from "./adapt/adapt.js";
export { bradfordAdapt, ILLUMINANTS } from "./adapt/adapt.js";

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json";
describe("dep-graph", () => {
it("only depends on allowed @suaveplan packages", () => {
const deps = Object.keys(pkg.dependencies ?? {});
const allowed = ["@suaveplan/colours"];
for (const dep of deps) {
if (dep.startsWith("@suaveplan/")) {
expect(allowed).toContain(dep);
}
}
});
});

View File

@@ -0,0 +1 @@
// no polyfills needed

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json";
describe("tree-shake", () => {
it("has sideEffects false", () => {
expect(pkg.sideEffects).toBe(false);
});
it("has at least one subpath export", () => {
const exports = Object.keys(pkg.exports ?? {});
expect(exports.length).toBeGreaterThan(1);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"adapt/adapt": "./src/adapt/adapt.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "tslib"],
},
},
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
setupFiles: ["./tests/setup/polyfills.ts"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/**/index.ts"],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
});

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,61 @@
{
"name": "@suaveplan/colour-convert-cieluv",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "CIELUV, xyY, LMS, and Bradford chromatic adaptation for professional colour workflows",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./colour-convert-cieluv/colour-convert-cieluv": {
"import": {
"types": "./dist/colour-convert-cieluv/colour-convert-cieluv.d.ts",
"default": "./dist/colour-convert-cieluv/colour-convert-cieluv.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/colours": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
bradfordAdapt,
ILLUMINANTS,
lmsToRgb,
lmsToXyz,
luvToRgb,
luvToXyz,
rgbToLms,
rgbToLuv,
rgbToxyY,
xyYToRgb,
xyYToXyz,
xyzToLms,
xyzToLuv,
xyzToxyY,
} from "./colour-convert-cieluv.js";
describe("colour-convert-cieluv", () => {
it("rgb to luv round-trip", { timeout: 5000 }, () => {
const rgb = { r: 128, g: 64, b: 32 };
const luv = rgbToLuv(rgb);
const back = luvToRgb(luv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("xyz to luv round-trip", { timeout: 5000 }, () => {
const xyz = { x: 50, y: 30, z: 20 };
const luv = xyzToLuv(xyz);
const back = luvToXyz(luv);
expect(back.x).toBeCloseTo(xyz.x, 0);
expect(back.y).toBeCloseTo(xyz.y, 0);
expect(back.z).toBeCloseTo(xyz.z, 0);
});
it("xyz to xyY round-trip", { timeout: 5000 }, () => {
const xyz = { x: 50, y: 30, z: 20 };
const xyy = xyzToxyY(xyz);
const back = xyYToXyz(xyy);
expect(back.x).toBeCloseTo(xyz.x, 0);
expect(back.y).toBeCloseTo(xyz.y, 0);
expect(back.z).toBeCloseTo(xyz.z, 0);
});
it("rgb to xyY round-trip", { timeout: 5000 }, () => {
const rgb = { r: 128, g: 64, b: 32 };
const xyy = rgbToxyY(rgb);
const back = xyYToRgb(xyy);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("xyz to lms round-trip", { timeout: 5000 }, () => {
const xyz = { x: 50, y: 30, z: 20 };
const lms = xyzToLms(xyz);
const back = lmsToXyz(lms);
expect(back.x).toBeCloseTo(xyz.x, 0);
expect(back.y).toBeCloseTo(xyz.y, 0);
expect(back.z).toBeCloseTo(xyz.z, 0);
});
it("rgb to lms round-trip", { timeout: 5000 }, () => {
const rgb = { r: 128, g: 64, b: 32 };
const lms = rgbToLms(rgb);
const back = lmsToRgb(lms);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("bradford adapts D65 to D50", { timeout: 5000 }, () => {
const adapted = bradfordAdapt(
{ x: 95.047, y: 100, z: 108.883 },
"D65",
"D50"
);
expect(adapted.x).toBeCloseTo(96.421, 0);
expect(adapted.y).toBeCloseTo(100, 0);
expect(adapted.z).toBeCloseTo(82.519, 0);
});
it("bradford accepts custom XYZ illuminants", { timeout: 5000 }, () => {
const adapted = bradfordAdapt(
{ x: 95.047, y: 100, z: 108.883 },
{ x: 95.047, y: 100, z: 108.883 },
{ x: 96.421, y: 100, z: 82.519 }
);
expect(adapted.x).toBeCloseTo(96.421, 0);
});
it("ILLUMINANTS contains D65", { timeout: 5000 }, () => {
expect(ILLUMINANTS.D65).toBeDefined();
});
it("handles black xyz to xyY", { timeout: 5000 }, () => {
const xyy = xyzToxyY({ x: 0, y: 0, z: 0 });
expect(xyy.x).toBe(0);
expect(xyy.y).toBe(0);
});
it("handles bright luv round-trip", { timeout: 5000 }, () => {
const rgb = { r: 255, g: 255, b: 255 };
const luv = rgbToLuv(rgb);
const back = luvToRgb(luv);
expect(back.r).toBeCloseTo(rgb.r, -1);
});
it("handles black luv to xyz", { timeout: 5000 }, () => {
const xyz = luvToXyz({ l: 0, u: 0, v: 0 });
expect(xyz.x).toBe(0);
expect(xyz.y).toBe(0);
expect(xyz.z).toBe(0);
});
it("handles black xyz to luv", { timeout: 5000 }, () => {
const luv = xyzToLuv({ x: 0, y: 0, z: 0 });
expect(luv.l).toBe(0);
});
it("handles xyY with y=0", { timeout: 5000 }, () => {
const xyz = xyYToXyz({ x: 0.3, y: 0, Y: 0 });
expect(xyz.x).toBe(0);
expect(xyz.y).toBe(0);
expect(xyz.z).toBe(0);
});
it("throws on invalid illuminant", { timeout: 5000 }, () => {
expect(() =>
bradfordAdapt({ x: 50, y: 50, z: 50 }, "INVALID", "D65")
).toThrow();
});
it("handles dark rgb to luv", { timeout: 5000 }, () => {
const luv = rgbToLuv({ r: 5, g: 5, b: 5 });
expect(luv.l).toBeGreaterThan(0);
});
it("handles dark rgb to xyY", { timeout: 5000 }, () => {
const xyy = rgbToxyY({ r: 5, g: 5, b: 5 });
expect(xyy.Y).toBeGreaterThan(0);
});
it("handles dark luv to rgb", { timeout: 5000 }, () => {
const rgb = luvToRgb({ l: 0.01, u: 0, v: 0 });
expect(rgb.r).toBeGreaterThanOrEqual(0);
});
it("bradford uses default dest illuminant", { timeout: 5000 }, () => {
const adapted = bradfordAdapt({ x: 95.047, y: 100, z: 108.883 }, "D65");
expect(adapted.x).toBeCloseTo(95.047, 0);
});
});

View File

@@ -0,0 +1,228 @@
import type { RGB, RGBA } from "@suaveplan/colours";
export interface XYZ {
readonly x: number;
readonly y: number;
readonly z: number;
}
export interface LUV {
readonly l: number;
readonly u: number;
readonly v: number;
}
export interface LUVA extends LUV {
readonly a: number;
}
export interface xyY {
readonly x: number;
readonly y: number;
readonly Y: number;
}
export interface LMS {
readonly l: number;
readonly m: number;
readonly s: number;
}
const D65: XYZ = { x: 95.047, y: 100.0, z: 108.883 };
function xyzToLuvInternal(xyz: XYZ, ref: XYZ): LUV {
const yr = xyz.y / ref.y;
const l = yr > 0.008856 ? 116 * Math.cbrt(yr) - 16 : 903.3 * yr;
const up =
xyz.x + 15 * xyz.y + 3 * xyz.z === 0
? 0
: (4 * xyz.x) / (xyz.x + 15 * xyz.y + 3 * xyz.z);
const vp =
xyz.x + 15 * xyz.y + 3 * xyz.z === 0
? 0
: (9 * xyz.y) / (xyz.x + 15 * xyz.y + 3 * xyz.z);
const ur = (4 * ref.x) / (ref.x + 15 * ref.y + 3 * ref.z);
const vr = (9 * ref.y) / (ref.x + 15 * ref.y + 3 * ref.z);
return {
l,
u: 13 * l * (up - ur),
v: 13 * l * (vp - vr),
};
}
function luvToXyzInternal(luv: LUV, ref: XYZ): XYZ {
if (luv.l === 0) return { x: 0, y: 0, z: 0 };
const ur = (4 * ref.x) / (ref.x + 15 * ref.y + 3 * ref.z);
const vr = (9 * ref.y) / (ref.x + 15 * ref.y + 3 * ref.z);
const up = luv.u / (13 * luv.l) + ur;
const vp = luv.v / (13 * luv.l) + vr;
const y =
luv.l > 8 ? ref.y * ((luv.l + 16) / 116) ** 3 : (ref.y * luv.l) / 903.3;
const x = y * ((9 * up) / (4 * vp));
const z = y * ((12 - 3 * up - 20 * vp) / (4 * vp));
return { x, y, z };
}
export function xyzToLuv(xyz: XYZ): LUV {
return xyzToLuvInternal(xyz, D65);
}
export function luvToXyz(luv: LUV): XYZ {
return luvToXyzInternal(luv, D65);
}
export function rgbToLuv(rgb: RGB): LUV {
const xyz = rgbToXyz(rgb);
return xyzToLuv(xyz);
}
export function luvToRgb(luv: LUV): RGBA {
const xyz = luvToXyz(luv);
return { ...xyzToRgb(xyz), a: 1 };
}
export function xyzToxyY(xyz: XYZ): xyY {
const sum = xyz.x + xyz.y + xyz.z;
if (sum === 0) return { x: 0, y: 0, Y: 0 };
return { x: xyz.x / sum, y: xyz.y / sum, Y: xyz.y };
}
export function xyYToXyz(xy: xyY): XYZ {
if (xy.y === 0) return { x: 0, y: 0, z: 0 };
const x = (xy.x * xy.Y) / xy.y;
const y = xy.Y;
const z = ((1 - xy.x - xy.y) * xy.Y) / xy.y;
return { x, y, z };
}
export function rgbToxyY(rgb: RGB): xyY {
return xyzToxyY(rgbToXyz(rgb));
}
export function xyYToRgb(xy: xyY): RGBA {
return { ...xyzToRgb(xyYToXyz(xy)), a: 1 };
}
export function xyzToLms(xyz: XYZ): LMS {
return {
l: xyz.x * 0.7328 + xyz.y * 0.4296 + xyz.z * -0.1624,
m: xyz.x * -0.7036 + xyz.y * 1.6975 + xyz.z * 0.0061,
s: xyz.x * 0.003 + xyz.y * 0.0136 + xyz.z * 0.9834,
};
}
export function lmsToXyz(lms: LMS): XYZ {
return {
x: lms.l * 1.0961 + lms.m * -0.2789 + lms.s * 0.1827,
y: lms.l * 0.4544 + lms.m * 0.4735 + lms.s * 0.0721,
z: lms.l * -0.0096 + lms.m * -0.0057 + lms.s * 1.0153,
};
}
export function rgbToLms(rgb: RGB): LMS {
return xyzToLms(rgbToXyz(rgb));
}
export function lmsToRgb(lms: LMS): RGBA {
return { ...xyzToRgb(lmsToXyz(lms)), a: 1 };
}
const BRADFORD_MA: [number[], number[], number[]] = [
[0.8951, 0.2664, -0.1614],
[-0.7502, 1.7135, 0.0367],
[0.0389, -0.0685, 1.0296],
];
const BRADFORD_MA_INV: [number[], number[], number[]] = [
[0.986993, -0.147054, 0.159963],
[0.432305, 0.51836, 0.049291],
[-0.008529, 0.040043, 0.968487],
];
export const ILLUMINANTS: Record<string, XYZ> = {
A: { x: 109.85, y: 100.0, z: 35.585 },
B: { x: 99.0927, y: 100.0, z: 85.313 },
C: { x: 98.074, y: 100.0, z: 118.232 },
D50: { x: 96.4212, y: 100.0, z: 82.5188 },
D55: { x: 95.6797, y: 100.0, z: 92.1481 },
D65: { x: 95.047, y: 100.0, z: 108.883 },
D75: { x: 94.972, y: 100.0, z: 122.638 },
E: { x: 100.0, y: 100.0, z: 100.0 },
F2: { x: 99.187, y: 100.0, z: 67.395 },
F7: { x: 95.044, y: 100.0, z: 108.755 },
F11: { x: 100.966, y: 100.0, z: 64.37 },
};
function matVec3(
m: [number[], number[], number[]],
v: { x: number; y: number; z: number }
): { x: number; y: number; z: number } {
const r0 = m[0]!;
const r1 = m[1]!;
const r2 = m[2]!;
return {
x: r0[0]! * v.x + r0[1]! * v.y + r0[2]! * v.z,
y: r1[0]! * v.x + r1[1]! * v.y + r1[2]! * v.z,
z: r2[0]! * v.x + r2[1]! * v.y + r2[2]! * v.z,
};
}
export function bradfordAdapt(
xyz: XYZ,
sourceIlluminant: string | XYZ,
destIlluminant: string | XYZ = "D65"
): XYZ {
const src =
typeof sourceIlluminant === "string"
? ILLUMINANTS[sourceIlluminant]
: sourceIlluminant;
const dst =
typeof destIlluminant === "string"
? ILLUMINANTS[destIlluminant]
: destIlluminant;
if (!src || !dst) {
throw new Error("Invalid illuminant");
}
const srcLms = matVec3(BRADFORD_MA, src);
const dstLms = matVec3(BRADFORD_MA, dst);
const xyzLms = matVec3(BRADFORD_MA, xyz);
const scaled = {
x: xyzLms.x * (dstLms.x / srcLms.x),
y: xyzLms.y * (dstLms.y / srcLms.y),
z: xyzLms.z * (dstLms.z / srcLms.z),
};
return matVec3(BRADFORD_MA_INV, scaled);
}
// Re-export core conversions from @suaveplan/colours for convenience
function rgbToXyz({ r, g, b }: RGB): XYZ {
const toLinear = (c: number) => {
c /= 255;
return c > 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92;
};
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return {
x: R * 0.4124564 + G * 0.3575761 + B * 0.1804375,
y: R * 0.2126729 + G * 0.7151522 + B * 0.072175,
z: R * 0.0193339 + G * 0.119192 + B * 0.9503041,
};
}
function xyzToRgb({ x, y, z }: XYZ): RGB {
const R = x * 3.2404542 + y * -1.5371385 + z * -0.4985314;
const G = x * -0.969266 + y * 1.8760108 + z * 0.041556;
const B = x * 0.0556434 + y * -0.2040259 + z * 1.0572252;
const fromLinear = (c: number) => {
c = c > 0.0031308 ? 1.055 * c ** (1 / 2.4) - 0.055 : 12.92 * c;
return Math.round(Math.max(0, Math.min(255, c * 255)));
};
return { r: fromLinear(R), g: fromLinear(G), b: fromLinear(B) };
}

View File

@@ -0,0 +1,23 @@
export type {
LMS,
LUV,
LUVA,
XYZ,
xyY,
} from "./colour-convert-cieluv.js";
export {
bradfordAdapt,
ILLUMINANTS,
lmsToRgb,
lmsToXyz,
luvToRgb,
luvToXyz,
rgbToLms,
rgbToLuv,
rgbToxyY,
xyYToRgb,
xyYToXyz,
xyzToLms,
xyzToLuv,
xyzToxyY,
} from "./colour-convert-cieluv.js";

View File

@@ -0,0 +1,17 @@
export type { LMS, LUV, LUVA, xyY } from "./colour-convert-cieluv/index.js";
export {
bradfordAdapt,
ILLUMINANTS,
lmsToRgb,
lmsToXyz,
luvToRgb,
luvToXyz,
rgbToLms,
rgbToLuv,
rgbToxyY,
xyYToRgb,
xyYToXyz,
xyzToLms,
xyzToLuv,
xyzToxyY,
} from "./colour-convert-cieluv/index.js";

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("only depends on allowed @suaveplan packages", {
timeout: 5000,
}, async () => {
const pkg = await import("../package.json");
const deps = Object.keys(pkg.default.dependencies || {});
const allowed = ["@suaveplan/colours"];
for (const dep of deps) {
if (dep.startsWith("@suaveplan/")) {
expect(allowed).toContain(dep);
}
}
});
});

View File

@@ -0,0 +1 @@
// no polyfills needed

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("has sideEffects false", { timeout: 5000 }, async () => {
const pkg = await import("../package.json");
expect(pkg.default.sideEffects).toBe(false);
});
it("has at least one subpath export", { timeout: 5000 }, async () => {
const pkg = await import("../package.json");
const exports = Object.keys(pkg.default.exports || {});
expect(exports.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"colour-convert-cieluv/colour-convert-cieluv": "./src/colour-convert-cieluv/index.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "tslib"],
},
},
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 5000,
},
});

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,45 @@
{
"name": "@suaveplan/colour-convert-cmyk",
"version": "0.1.0",
"sideEffects": false,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./cmyk/cmyk": {
"import": {
"types": "./dist/cmyk/cmyk.d.ts",
"default": "./dist/cmyk/cmyk.js"
}
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/colours": "workspace:*",
"tslib": "^2.8.1"
},
"devDependencies": {
"@suaveplan/testing": "workspace:*",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-dts": "^4.5.3",
"vitest": "^4.1.5"
}
}

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,63 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { cmykToRgb, rgbToCmyk } from "./cmyk.js";
describe("colour-convert-cmyk", () => {
it("rgb to cmyk round-trips", { timeout: 5000 }, () => {
const original = { r: 128, g: 64, b: 32 };
const cmyk = rgbToCmyk(original);
const back = cmykToRgb(cmyk);
expect(back.r).toBeCloseTo(original.r, -1);
expect(back.g).toBeCloseTo(original.g, -1);
expect(back.b).toBeCloseTo(original.b, -1);
});
it("white is zero cmyk", { timeout: 5000 }, () => {
const cmyk = rgbToCmyk({ r: 255, g: 255, b: 255 });
expect(cmyk.c).toBe(0);
expect(cmyk.m).toBe(0);
expect(cmyk.y).toBe(0);
expect(cmyk.k).toBe(0);
});
it("black is full k", { timeout: 5000 }, () => {
const cmyk = rgbToCmyk({ r: 0, g: 0, b: 0 });
expect(cmyk.c).toBe(0);
expect(cmyk.m).toBe(0);
expect(cmyk.y).toBe(0);
expect(cmyk.k).toBe(100);
});
it("pure red is magenta+yellow", { timeout: 5000 }, () => {
const cmyk = rgbToCmyk({ r: 255, g: 0, b: 0 });
expect(cmyk.c).toBe(0);
expect(cmyk.m).toBe(100);
expect(cmyk.y).toBe(100);
expect(cmyk.k).toBe(0);
});
it("pure green is cyan+yellow", { timeout: 5000 }, () => {
const cmyk = rgbToCmyk({ r: 0, g: 255, b: 0 });
expect(cmyk.c).toBe(100);
expect(cmyk.m).toBe(0);
expect(cmyk.y).toBe(100);
expect(cmyk.k).toBe(0);
});
it("pure blue is cyan+magenta", { timeout: 5000 }, () => {
const cmyk = rgbToCmyk({ r: 0, g: 0, b: 255 });
expect(cmyk.c).toBe(100);
expect(cmyk.m).toBe(100);
expect(cmyk.y).toBe(0);
expect(cmyk.k).toBe(0);
});
it("cmyk to rgb produces valid colours", { timeout: 5000 }, () => {
const rgb = cmykToRgb({ c: 50, m: 25, y: 75, k: 10 });
expect(rgb.r).toBeGreaterThanOrEqual(0);
expect(rgb.r).toBeLessThanOrEqual(255);
expect(rgb.g).toBeGreaterThanOrEqual(0);
expect(rgb.g).toBeLessThanOrEqual(255);
expect(rgb.b).toBeGreaterThanOrEqual(0);
expect(rgb.b).toBeLessThanOrEqual(255);
});
});

View File

@@ -0,0 +1,40 @@
import type { RGB, RGBA } from "@suaveplan/colours";
export interface CMYK {
readonly c: number;
readonly m: number;
readonly y: number;
readonly k: number;
}
export function rgbToCmyk(rgb: RGB): CMYK {
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const k = 1 - Math.max(r, g, b);
const c = k === 1 ? 0 : (1 - r - k) / (1 - k);
const m = k === 1 ? 0 : (1 - g - k) / (1 - k);
const y = k === 1 ? 0 : (1 - b - k) / (1 - k);
return {
c: Math.round(c * 100),
m: Math.round(m * 100),
y: Math.round(y * 100),
k: Math.round(k * 100),
};
}
export function cmykToRgb(cmyk: CMYK): RGBA {
const c = cmyk.c / 100;
const m = cmyk.m / 100;
const y = cmyk.y / 100;
const k = cmyk.k / 100;
const r = 255 * (1 - c) * (1 - k);
const g = 255 * (1 - m) * (1 - k);
const b = 255 * (1 - y) * (1 - k);
return {
r: Math.round(r),
g: Math.round(g),
b: Math.round(b),
a: 1,
};
}

View File

@@ -0,0 +1,2 @@
export type { CMYK } from "./cmyk/cmyk.js";
export { cmykToRgb, rgbToCmyk } from "./cmyk/cmyk.js";

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json";
describe("dep-graph", () => {
it("only depends on allowed @suaveplan packages", () => {
const deps = Object.keys(pkg.dependencies ?? {});
const allowed = ["@suaveplan/colours"];
for (const dep of deps) {
if (dep.startsWith("@suaveplan/")) {
expect(allowed).toContain(dep);
}
}
});
});

View File

@@ -0,0 +1 @@
// no polyfills needed

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json";
describe("tree-shake", () => {
it("has sideEffects false", () => {
expect(pkg.sideEffects).toBe(false);
});
it("has at least one subpath export", () => {
const exports = Object.keys(pkg.exports ?? {});
expect(exports.length).toBeGreaterThan(1);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: {
index: "./src/index.ts",
"cmyk/cmyk": "./src/cmyk/cmyk.ts",
},
formats: ["es"],
},
sourcemap: true,
rollupOptions: {
external: [/^@suaveplan\//, "tslib"],
},
},
plugins: [dts({ rollupTypes: false, include: ["src/**/*.ts"], tsconfigPath: "./tsconfig.lib.json" })],
});

View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
setupFiles: ["./tests/setup/polyfills.ts"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: ["src/**/*.test.ts", "src/**/index.ts"],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
},
});

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,61 @@
{
"name": "@suaveplan/colour-convert-hsluv",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "HSLuv perceptually uniform colour space: conversions and harmony generators",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./colour-convert-hsluv/colour-convert-hsluv": {
"import": {
"types": "./dist/colour-convert-hsluv/colour-convert-hsluv.d.ts",
"default": "./dist/colour-convert-hsluv/colour-convert-hsluv.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/colours": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,114 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
hpluvToRgb,
hsluvToRgb,
rgbToHpluv,
rgbToHsluv,
uniformComplementary,
uniformTetrad,
uniformTriad,
} from "./colour-convert-hsluv.js";
describe("colour-convert-hsluv", () => {
it("round-trips red", { timeout: 5000 }, () => {
const rgb = { r: 255, g: 0, b: 0 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("round-trips green", { timeout: 5000 }, () => {
const rgb = { r: 0, g: 255, b: 0 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("round-trips blue", { timeout: 5000 }, () => {
const rgb = { r: 0, g: 0, b: 255 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("round-trips gray", { timeout: 5000 }, () => {
const rgb = { r: 128, g: 128, b: 128 };
const hsluv = rgbToHsluv(rgb);
const back = hsluvToRgb(hsluv);
expect(back.r).toBeCloseTo(rgb.r, -1);
expect(back.g).toBeCloseTo(rgb.g, -1);
expect(back.b).toBeCloseTo(rgb.b, -1);
});
it("uniformComplementary returns 2 colours", { timeout: 5000 }, () => {
const colours = uniformComplementary({ r: 255, g: 0, b: 0 });
expect(colours).toHaveLength(2);
});
it("uniformTriad returns 3 colours", { timeout: 5000 }, () => {
const colours = uniformTriad({ r: 255, g: 0, b: 0 });
expect(colours).toHaveLength(3);
});
it("uniformTetrad returns 4 colours", { timeout: 5000 }, () => {
const colours = uniformTetrad({ r: 255, g: 0, b: 0 });
expect(colours).toHaveLength(4);
});
it("handles black", { timeout: 5000 }, () => {
const hsluv = rgbToHsluv({ r: 0, g: 0, b: 0 });
expect(hsluv.l).toBeCloseTo(0, 0);
});
it("handles white", { timeout: 5000 }, () => {
const hsluv = rgbToHsluv({ r: 255, g: 255, b: 255 });
expect(hsluv.l).toBeCloseTo(100, 0);
});
it("round-trips hpluv red", { timeout: 5000 }, () => {
const original = { r: 255, g: 0, b: 0 };
const hpluv = rgbToHpluv(original);
const back = hpluvToRgb(hpluv);
expect(back.r).toBeCloseTo(original.r, -1);
expect(back.g).toBeCloseTo(original.g, -1);
expect(back.b).toBeCloseTo(original.b, -1);
});
it("hpluv handles black", { timeout: 5000 }, () => {
const hpluv = rgbToHpluv({ r: 0, g: 0, b: 0 });
expect(hpluv.l).toBeCloseTo(0, 0);
});
it("hpluv handles white", { timeout: 5000 }, () => {
const hpluv = rgbToHpluv({ r: 255, g: 255, b: 255 });
expect(hpluv.l).toBeCloseTo(100, 0);
});
it("handles hsluv black (l=0)", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 0, s: 0, l: 0 });
expect(rgb.r).toBe(0);
expect(rgb.g).toBe(0);
expect(rgb.b).toBe(0);
});
it("handles hsluv dark grey (l=5)", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 0, s: 0, l: 5 });
expect(rgb.r).toBeGreaterThan(0);
});
it("handles hsluv hue zero", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 0, s: 100, l: 50 });
expect(rgb.r).toBeGreaterThan(0);
});
it("handles denom zero hue", { timeout: 5000 }, () => {
const rgb = hsluvToRgb({ h: 149.5479321378151, s: 100, l: 50 });
expect(rgb.g).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,220 @@
import type { RGB, RGBA } from "@suaveplan/colours";
const INVERSE_SRGB_XYZ: [number[], number[], number[]] = [
[3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[0.0557, -0.204, 1.057],
];
const EPSILON = 0.008856;
const KAPPA = 903.3;
export interface HSLUV {
readonly h: number;
readonly s: number;
readonly l: number;
}
export interface HPLUV {
readonly h: number;
readonly s: number;
readonly l: number;
}
function toRad(deg: number): number {
return (deg * Math.PI) / 180;
}
function maxChromaForLh(L: number, hue: number): number {
if (L > 99.9999999) return 0;
if (L < 0.00000001) return 0;
const s1 = (L + 16) / 116;
const s2 = s1 > 6 / 29 ? s1 * s1 * s1 : L / KAPPA;
const m = INVERSE_SRGB_XYZ;
const rays: { m: number; b: number }[] = [];
for (let c = 0; c < 3; c++) {
const row = m[c]!;
const m1 = row[0]!;
const m2 = row[1]!;
const m3 = row[2]!;
for (let t = 0; t < 2; t++) {
const top1 = (284517 * m1 - 94839 * m3) * s2;
const top2 =
(838422 * m3 + 769860 * m2 + 731718 * m1) * L * s2 -
769860 * t * L;
const bottom = (632260 * m3 - 126452 * m2) * s2 + 126452 * t;
rays.push({ m: top1 / bottom, b: top2 / bottom });
}
}
let min = Number.MAX_VALUE;
const hrad = toRad(hue);
for (const ray of rays) {
const denom = Math.sin(hrad) - ray.m * Math.cos(hrad);
if (denom !== 0) {
const length = ray.b / denom;
if (length >= 0 && length < min) {
min = length;
}
}
}
return min;
}
function xyzToLuv({ x, y, z }: { x: number; y: number; z: number }): {
l: number;
u: number;
v: number;
} {
const refY = 1.0;
const refU = 0.19783;
const refV = 0.46832;
const yr = y / refY;
const l = yr > EPSILON ? 116 * Math.cbrt(yr) - 16 : KAPPA * yr;
const up = x + 15 * y + 3 * z === 0 ? 0 : (4 * x) / (x + 15 * y + 3 * z);
const vp = x + 15 * y + 3 * z === 0 ? 0 : (9 * y) / (x + 15 * y + 3 * z);
return {
l,
u: 13 * l * (up - refU),
v: 13 * l * (vp - refV),
};
}
function luvToXyz({ l, u, v }: { l: number; u: number; v: number }): {
x: number;
y: number;
z: number;
} {
if (l === 0) return { x: 0, y: 0, z: 0 };
const refY = 1.0;
const refU = 0.19783;
const refV = 0.46832;
const up = u / (13 * l) + refU;
const vp = v / (13 * l) + refV;
const y = l > 8 ? refY * ((l + 16) / 116) ** 3 : (refY * l) / KAPPA;
const x = y * ((9 * up) / (4 * vp));
const z = y * ((12 - 3 * up - 20 * vp) / (4 * vp));
return { x, y, z };
}
function lchToLuv({ l, c, h }: { l: number; c: number; h: number }): {
l: number;
u: number;
v: number;
} {
const hr = toRad(h);
return { l, u: c * Math.cos(hr), v: c * Math.sin(hr) };
}
function luvToLch({ l, u, v }: { l: number; u: number; v: number }): {
l: number;
c: number;
h: number;
} {
const c = Math.sqrt(u * u + v * v);
let h = (Math.atan2(v, u) * 180) / Math.PI;
if (h < 0) h += 360;
return { l, c, h };
}
function xyzToRgb({ x, y, z }: { x: number; y: number; z: number }): RGB {
const R = x * 3.2406 + y * -1.5372 + z * -0.4986;
const G = x * -0.9689 + y * 1.8758 + z * 0.0415;
const B = x * 0.0557 + y * -0.204 + z * 1.057;
const fromLinear = (c: number) => {
c = c > 0.0031308 ? 1.055 * c ** (1 / 2.4) - 0.055 : 12.92 * c;
return Math.round(Math.max(0, Math.min(255, c * 255)));
};
return { r: fromLinear(R), g: fromLinear(G), b: fromLinear(B) };
}
function rgbToXyz({ r, g, b }: RGB): { x: number; y: number; z: number } {
const toLinear = (c: number) => {
c /= 255;
return c > 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92;
};
const R = toLinear(r);
const G = toLinear(g);
const B = toLinear(b);
return {
x: R * 0.4124 + G * 0.3576 + B * 0.1805,
y: R * 0.2126 + G * 0.7152 + B * 0.0722,
z: R * 0.0193 + G * 0.1192 + B * 0.9505,
};
}
export function hsluvToRgb(hsluv: HSLUV): RGBA {
const max = maxChromaForLh(hsluv.l, hsluv.h);
const lch = {
l: hsluv.l,
c: (max * hsluv.s) / 100,
h: hsluv.h,
};
const luv = lchToLuv(lch);
const xyz = luvToXyz(luv);
return { ...xyzToRgb(xyz), a: 1 };
}
export function rgbToHsluv(rgb: RGB): HSLUV {
const xyz = rgbToXyz(rgb);
const luv = xyzToLuv(xyz);
const lch = luvToLch(luv);
const max = maxChromaForLh(lch.l, lch.h);
return {
h: lch.h,
s: (lch.c / max) * 100,
l: lch.l,
};
}
export function hpluvToRgb(hpluv: HPLUV): RGBA {
const max = maxChromaForLh(50, hpluv.h);
const lch = {
l: hpluv.l,
c: (max * hpluv.s) / 100,
h: hpluv.h,
};
const luv = lchToLuv(lch);
const xyz = luvToXyz(luv);
return { ...xyzToRgb(xyz), a: 1 };
}
export function rgbToHpluv(rgb: RGB): HPLUV {
const xyz = rgbToXyz(rgb);
const luv = xyzToLuv(xyz);
const lch = luvToLch(luv);
const max = maxChromaForLh(50, lch.h);
return {
h: lch.h,
s: (lch.c / max) * 100,
l: lch.l,
};
}
export function uniformComplementary(rgb: RGB): RGB[] {
const hsluv = rgbToHsluv(rgb);
return [hsluvToRgb({ ...hsluv, h: (hsluv.h + 180) % 360 }), rgb];
}
export function uniformTriad(rgb: RGB): RGB[] {
const hsluv = rgbToHsluv(rgb);
return [
rgb,
hsluvToRgb({ ...hsluv, h: (hsluv.h + 120) % 360 }),
hsluvToRgb({ ...hsluv, h: (hsluv.h + 240) % 360 }),
];
}
export function uniformTetrad(rgb: RGB): RGB[] {
const hsluv = rgbToHsluv(rgb);
return [
rgb,
hsluvToRgb({ ...hsluv, h: (hsluv.h + 90) % 360 }),
hsluvToRgb({ ...hsluv, h: (hsluv.h + 180) % 360 }),
hsluvToRgb({ ...hsluv, h: (hsluv.h + 270) % 360 }),
];
}

View File

@@ -0,0 +1,13 @@
export type {
HPLUV,
HSLUV,
} from "./colour-convert-hsluv.js";
export {
hpluvToRgb,
hsluvToRgb,
rgbToHpluv,
rgbToHsluv,
uniformComplementary,
uniformTetrad,
uniformTriad,
} from "./colour-convert-hsluv.js";

View File

@@ -0,0 +1,10 @@
export type { HPLUV, HSLUV } from "./colour-convert-hsluv/index.js";
export {
hpluvToRgb,
hsluvToRgb,
rgbToHpluv,
rgbToHsluv,
uniformComplementary,
uniformTetrad,
uniformTriad,
} from "./colour-convert-hsluv/index.js";

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