Initial Commit
This commit is contained in:
27
packages/media/image/README.md
Normal file
27
packages/media/image/README.md
Normal 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 -->
|
||||
1
packages/media/image/_intro.md
Normal file
1
packages/media/image/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Image compression, colour conversion, EXIF handling, and optimisation.
|
||||
101
packages/media/image/caddy-imgproxy/README.md
Normal file
101
packages/media/image/caddy-imgproxy/README.md
Normal 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`.
|
||||
|
||||
131
packages/media/image/caddy-imgproxy/package.json
Normal file
131
packages/media/image/caddy-imgproxy/package.json
Normal 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"]
|
||||
}
|
||||
86
packages/media/image/caddy-imgproxy/src/config/config.md
Normal file
86
packages/media/image/caddy-imgproxy/src/config/config.md
Normal 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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
71
packages/media/image/caddy-imgproxy/src/config/config.ts
Normal file
71
packages/media/image/caddy-imgproxy/src/config/config.ts
Normal 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);
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/config/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/config/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ImgproxyConfigSchema, parseConfig } from "./config.js";
|
||||
@@ -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).
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { calculateDimensions } from "./calculate-dimensions.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/domain/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { extractDomain } from "./extract-domain.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { CaddyImgproxyCode } from "./error-codes.js";
|
||||
export { CADDY_IMGPROXY_CODES, CaddyImgproxyError } from "./error-codes.js";
|
||||
74
packages/media/image/caddy-imgproxy/src/gravity/gravity.md
Normal file
74
packages/media/image/caddy-imgproxy/src/gravity/gravity.md
Normal 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`).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
39
packages/media/image/caddy-imgproxy/src/gravity/gravity.ts
Normal file
39
packages/media/image/caddy-imgproxy/src/gravity/gravity.ts
Normal 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;
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/gravity/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/gravity/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GRAVITY_MAP, gravityToImgproxy } from "./gravity.js";
|
||||
121
packages/media/image/caddy-imgproxy/src/handler/handler.md
Normal file
121
packages/media/image/caddy-imgproxy/src/handler/handler.md
Normal 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.
|
||||
153
packages/media/image/caddy-imgproxy/src/handler/handler.test.ts
Normal file
153
packages/media/image/caddy-imgproxy/src/handler/handler.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
136
packages/media/image/caddy-imgproxy/src/handler/handler.ts
Normal file
136
packages/media/image/caddy-imgproxy/src/handler/handler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
2
packages/media/image/caddy-imgproxy/src/handler/index.ts
Normal file
2
packages/media/image/caddy-imgproxy/src/handler/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { ImgproxyHandlerOptions, RewriteRequest } from "./handler.js";
|
||||
export { ImgproxyHandler } from "./handler.js";
|
||||
50
packages/media/image/caddy-imgproxy/src/index.ts
Normal file
50
packages/media/image/caddy-imgproxy/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1 @@
|
||||
export { operationToImgproxy } from "./operation.js";
|
||||
@@ -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.
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
1
packages/media/image/caddy-imgproxy/src/parse/index.ts
Normal file
1
packages/media/image/caddy-imgproxy/src/parse/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { parseImgproxyUrl } from "./parse-url.js";
|
||||
98
packages/media/image/caddy-imgproxy/src/parse/parse-url.md
Normal file
98
packages/media/image/caddy-imgproxy/src/parse/parse-url.md
Normal 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"`).
|
||||
233
packages/media/image/caddy-imgproxy/src/parse/parse-url.test.ts
Normal file
233
packages/media/image/caddy-imgproxy/src/parse/parse-url.test.ts
Normal 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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
195
packages/media/image/caddy-imgproxy/src/parse/parse-url.ts
Normal file
195
packages/media/image/caddy-imgproxy/src/parse/parse-url.ts
Normal 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 };
|
||||
}
|
||||
2
packages/media/image/caddy-imgproxy/src/signing/index.ts
Normal file
2
packages/media/image/caddy-imgproxy/src/signing/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { SignerHashLike, SignImgproxyPathOptions } from "./sign-path.js";
|
||||
export { signImgproxyPath } from "./sign-path.js";
|
||||
95
packages/media/image/caddy-imgproxy/src/signing/sign-path.md
Normal file
95
packages/media/image/caddy-imgproxy/src/signing/sign-path.md
Normal 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).
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
76
packages/media/image/caddy-imgproxy/src/signing/sign-path.ts
Normal file
76
packages/media/image/caddy-imgproxy/src/signing/sign-path.ts
Normal 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);
|
||||
}
|
||||
11
packages/media/image/caddy-imgproxy/src/telemetry/index.ts
Normal file
11
packages/media/image/caddy-imgproxy/src/telemetry/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
12
packages/media/image/caddy-imgproxy/src/types/index.ts
Normal file
12
packages/media/image/caddy-imgproxy/src/types/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
GravityCode,
|
||||
HintType,
|
||||
ImgproxyConfig,
|
||||
ImgproxyDimensions,
|
||||
ImgproxyOperation,
|
||||
ParsedImgproxyUrl,
|
||||
RewriteInvalidResult,
|
||||
RewritePassthroughResult,
|
||||
RewriteResult,
|
||||
RewriteRewrittenResult,
|
||||
} from "./types.js";
|
||||
98
packages/media/image/caddy-imgproxy/src/types/types.md
Normal file
98
packages/media/image/caddy-imgproxy/src/types/types.md
Normal 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`.
|
||||
113
packages/media/image/caddy-imgproxy/src/types/types.ts
Normal file
113
packages/media/image/caddy-imgproxy/src/types/types.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BuildImgproxyUrlOptions } from "./url-builder.js";
|
||||
export { buildImgproxyUrl } from "./url-builder.js";
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
12
packages/media/image/caddy-imgproxy/tsconfig.lib.json
Normal file
12
packages/media/image/caddy-imgproxy/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
55
packages/media/image/caddy-imgproxy/vite.config.ts
Normal file
55
packages/media/image/caddy-imgproxy/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/media/image/caddy-imgproxy/vitest.config.ts
Normal file
33
packages/media/image/caddy-imgproxy/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
29
packages/media/image/colour-convert-adapt/README.md
Normal file
29
packages/media/image/colour-convert-adapt/README.md
Normal 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
|
||||
44
packages/media/image/colour-convert-adapt/package.json
Normal file
44
packages/media/image/colour-convert-adapt/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
48
packages/media/image/colour-convert-adapt/src/adapt/adapt.md
Normal file
48
packages/media/image/colour-convert-adapt/src/adapt/adapt.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
76
packages/media/image/colour-convert-adapt/src/adapt/adapt.ts
Normal file
76
packages/media/image/colour-convert-adapt/src/adapt/adapt.ts
Normal 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);
|
||||
}
|
||||
2
packages/media/image/colour-convert-adapt/src/index.ts
Normal file
2
packages/media/image/colour-convert-adapt/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { XYZ } from "./adapt/adapt.js";
|
||||
export { bradfordAdapt, ILLUMINANTS } from "./adapt/adapt.js";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
// no polyfills needed
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
15
packages/media/image/colour-convert-adapt/tsconfig.lib.json
Normal file
15
packages/media/image/colour-convert-adapt/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
19
packages/media/image/colour-convert-adapt/vite.config.ts
Normal file
19
packages/media/image/colour-convert-adapt/vite.config.ts
Normal 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" })],
|
||||
});
|
||||
21
packages/media/image/colour-convert-adapt/vitest.config.ts
Normal file
21
packages/media/image/colour-convert-adapt/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/media/image/colour-convert-cieluv/README.md
Normal file
32
packages/media/image/colour-convert-cieluv/README.md
Normal 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
|
||||
61
packages/media/image/colour-convert-cieluv/package.json
Normal file
61
packages/media/image/colour-convert-cieluv/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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";
|
||||
17
packages/media/image/colour-convert-cieluv/src/index.ts
Normal file
17
packages/media/image/colour-convert-cieluv/src/index.ts
Normal 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";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
// no polyfills needed
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
15
packages/media/image/colour-convert-cieluv/tsconfig.lib.json
Normal file
15
packages/media/image/colour-convert-cieluv/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
19
packages/media/image/colour-convert-cieluv/vite.config.ts
Normal file
19
packages/media/image/colour-convert-cieluv/vite.config.ts
Normal 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" })],
|
||||
});
|
||||
27
packages/media/image/colour-convert-cieluv/vitest.config.ts
Normal file
27
packages/media/image/colour-convert-cieluv/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
32
packages/media/image/colour-convert-cmyk/README.md
Normal file
32
packages/media/image/colour-convert-cmyk/README.md
Normal 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
|
||||
45
packages/media/image/colour-convert-cmyk/package.json
Normal file
45
packages/media/image/colour-convert-cmyk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
47
packages/media/image/colour-convert-cmyk/src/cmyk/cmyk.md
Normal file
47
packages/media/image/colour-convert-cmyk/src/cmyk/cmyk.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
40
packages/media/image/colour-convert-cmyk/src/cmyk/cmyk.ts
Normal file
40
packages/media/image/colour-convert-cmyk/src/cmyk/cmyk.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
2
packages/media/image/colour-convert-cmyk/src/index.ts
Normal file
2
packages/media/image/colour-convert-cmyk/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { CMYK } from "./cmyk/cmyk.js";
|
||||
export { cmykToRgb, rgbToCmyk } from "./cmyk/cmyk.js";
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
// no polyfills needed
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
15
packages/media/image/colour-convert-cmyk/tsconfig.lib.json
Normal file
15
packages/media/image/colour-convert-cmyk/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
19
packages/media/image/colour-convert-cmyk/vite.config.ts
Normal file
19
packages/media/image/colour-convert-cmyk/vite.config.ts
Normal 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" })],
|
||||
});
|
||||
21
packages/media/image/colour-convert-cmyk/vitest.config.ts
Normal file
21
packages/media/image/colour-convert-cmyk/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
32
packages/media/image/colour-convert-hsluv/README.md
Normal file
32
packages/media/image/colour-convert-hsluv/README.md
Normal 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
|
||||
61
packages/media/image/colour-convert-hsluv/package.json
Normal file
61
packages/media/image/colour-convert-hsluv/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
10
packages/media/image/colour-convert-hsluv/src/index.ts
Normal file
10
packages/media/image/colour-convert-hsluv/src/index.ts
Normal 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
Reference in New Issue
Block a user