From 662634830ee247d90b63ebf3b7957187f5380b24 Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 20 Aug 2026 17:02:33 -0500 Subject: [PATCH 1/8] fix(core,storage,seo,pwa): Bun.Image replaces the hand-rolled JPEG and PNG codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/core/src/image/` was a from-scratch codec — Huffman tables, DCT, scanline filters, a Lanczos resampler. 1,679 source lines and 1,292 test lines, deleted. `pipeline.ts:20` gave the reason it existed: Bun ships no image API and the contract forbids `sharp`, so WebP and AVIF are probed and served, never synthesised here. That stopped being true in Bun 1.3.14 — the version already pinned. `Bun.Image` is statically-linked libjpeg-turbo / libspng / libwebp with SIMD resize kernels. Still zero dependencies. WebP encodes now, so `@ultimat3/storage`'s default variant format and its default `.webp` key extension finally agree: a `srcset` entry can be served, not merely named. `Bun.Image.backend` is pinned to `'bun'` on every call. That forces the static codecs and Highway geometry everywhere, which is what `variantKey`'s content-addressed cache requires — a platform-dependent variant is a cache that never hits. The cost is that AVIF and HEIC are refused on every platform rather than working on two of them, which is one behaviour instead of two. `blurDataUrl` is `Bun.Image.placeholder()` — a deterministic ThumbHash at most 32px on its long edge. `BLUR_PLACEHOLDER_WIDTH` is deleted; there is no width to name. ThumbHash quantises the aspect ratio, and `responsiveImage()` paints the LQIP as `background-size: cover` inside a box sized from the real dimensions, so nothing shifts. With no `format`, the SOURCE format is kept. The old rule guessed from the pixels and turned an opaque PNG into a JPEG; a heuristic that can flatten a logo eventually does. `png-pixels.ts` (183 lines) survives as a raw-pixel seam, and writing its test first found a real bug: unfiltering into a `Uint8ClampedArray` CLAMPS where PNG filters are mod-256, so `255 + 1` gave 255 instead of 0. Invisible against our own filter-0 writer, wrong against every adaptive one — it forced alpha to 255, flattening every transparent PWA icon. `Bun.Image` has no compositor and no raw-pixel terminal, and the PWA maskable safe zone needs both, which is why the seam exists at all. Bun agrees with Pillow byte-for-byte on five reference decodes (RGBA, greyscale, grey+alpha, 16-bit, palette+tRNS). Determinism is asserted on both the fast and composite paths, since the content-addressed cache depends on it. Refs #252 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KgsU1WBJMAjnLaazvfuJmD --- packages/core/README.md | 60 ++- packages/core/src/image/canvas.test.ts | 249 +++++++++++ packages/core/src/image/canvas.ts | 170 +++++++ packages/core/src/image/errors.test.ts | 54 +++ packages/core/src/image/errors.ts | 38 +- packages/core/src/image/jpeg-decode.test.ts | 287 ------------ packages/core/src/image/jpeg-decode.ts | 283 ------------ packages/core/src/image/jpeg-encode.test.ts | 279 ------------ packages/core/src/image/jpeg-encode.ts | 463 -------------------- packages/core/src/image/jpeg-headers.ts | 267 ----------- packages/core/src/image/jpeg-huffman.ts | 202 --------- packages/core/src/image/jpeg-tables.ts | 117 ----- packages/core/src/image/pipeline.test.ts | 400 ++++++++++------- packages/core/src/image/pipeline.ts | 161 ++++--- packages/core/src/image/png-pixels.test.ts | 112 +++++ packages/core/src/image/png-pixels.ts | 183 ++++++++ packages/core/src/image/png.test.ts | 397 ----------------- packages/core/src/image/png.ts | 433 ------------------ packages/core/src/image/resize.test.ts | 329 -------------- packages/core/src/image/resize.ts | 320 -------------- packages/core/src/index.ts | 13 +- packages/pwa/src/icons.ts | 7 +- packages/seo/src/image-driver.test.ts | 29 +- packages/seo/src/image-driver.ts | 10 +- packages/storage/README.md | 19 +- packages/storage/src/image.test.ts | 47 +- packages/storage/src/image.ts | 19 +- packages/storage/src/index.ts | 1 - 28 files changed, 1267 insertions(+), 3682 deletions(-) create mode 100644 packages/core/src/image/canvas.test.ts create mode 100644 packages/core/src/image/canvas.ts delete mode 100644 packages/core/src/image/jpeg-decode.test.ts delete mode 100644 packages/core/src/image/jpeg-decode.ts delete mode 100644 packages/core/src/image/jpeg-encode.test.ts delete mode 100644 packages/core/src/image/jpeg-encode.ts delete mode 100644 packages/core/src/image/jpeg-headers.ts delete mode 100644 packages/core/src/image/jpeg-huffman.ts delete mode 100644 packages/core/src/image/jpeg-tables.ts create mode 100644 packages/core/src/image/png-pixels.test.ts create mode 100644 packages/core/src/image/png-pixels.ts delete mode 100644 packages/core/src/image/png.test.ts delete mode 100644 packages/core/src/image/png.ts delete mode 100644 packages/core/src/image/resize.test.ts delete mode 100644 packages/core/src/image/resize.ts diff --git a/packages/core/README.md b/packages/core/README.md index a9a61e76..b82eaf93 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -38,7 +38,7 @@ Zero dependencies, zero `@ultimat3/*` imports. | the sockets this process opened, so a self-request is not egress | `listeners.ts` | | `defineService('orgs', …)` → `ctx.orgs`, rebuilt per actor | `service.ts` | | the registrar table one same-tier package reaches another through | `registrar.ts` | -| decode → resize → encode, the one image pipeline | `image/` | +| decode → resize → encode, the one image pipeline (over `Bun.Image`) | `image/` | | `assertNever`, `invariant` | `assert.ts` | ## Errors are instructions @@ -466,31 +466,55 @@ than in `@ultimat3/time` because `@ultimat3/money` needs it too and tier 1 may n ```ts probeImage(bytes); // { format, width, height, mimeType } -transformImageBytes(bytes, { width: 640, format: 'jpeg', quality: 80 }); -blurDataUrl(bytes); // 16px PNG data: URI, the LQIP +await transformImageBytes(bytes, { width: 640, format: 'webp', quality: 80 }); +await blurDataUrl(bytes); // ThumbHash PNG data: URI, the LQIP ``` `storage` variants, `seo` `` sources and `pwa` icons are the same three steps — decode, resize, encode — with different numbers, so there is one implementation and no second -scaler for an icon to grow a halo in. Zero dependencies: no `sharp`, no native module. +scaler for an icon to grow a halo in. The codecs are **`Bun.Image`** — statically-linked +libjpeg-turbo / libspng / libwebp with SIMD resize kernels, in the runtime. Still zero +dependencies: no `sharp`, no native module. + +Every terminal is `async`, because the pipeline runs on a worker thread. `probeImage` stays +synchronous: it reads a header and never decodes, which is also why it measures SVG and AVIF that +no codec here reads. | | | |---|---| -| Decode / encode | PNG and JPEG. `canDecode()` / `canEncode()` publish the real list | -| Probe only | WebP, AVIF, GIF, SVG — measured from the header so `width`/`height` still inline and CLS stays 0 | +| Decode | PNG, JPEG, WebP, GIF. `canDecode()` publishes the real list | +| Encode | PNG, JPEG, WebP. `canEncode()` publishes the real list | +| Probe only | AVIF and SVG — measured from the header so `width`/`height` still inline and CLS stays 0 | | Anything else | `X_IMAGE_UNSUPPORTED`, naming the format and pointing at an `ImageTransformDriver` | -| Ceiling | `MAX_IMAGE_PIXELS` (64MP), checked from the header **before** a byte is allocated | -| Determinism | same bytes + same spec → same output bytes. No clock, no randomness | - -Adding a format is a decoder plus an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` — never a -second dispatch. An unencodable `format` is refused from the spec alone, before the source is -decoded, so a request nothing can write never expands 64 megapixels first. - -`image/` is the one place in core allowed past the 200-line target, and only there: a JPEG or PNG -codec is a single algorithm that does not split into smaller responsibilities without inventing -seams. Nothing else in it qualifies, which is why the segment headers (`jpeg-headers.ts`), the SVG -text parse (`probe-svg.ts`) and the colour grammar (`color.ts`) are their own files. The 500-line -hard ceiling applies to all of them. +| Ceiling | `MAX_IMAGE_PIXELS` (64MP), passed to the decoder as `maxPixels` and refused from the header **before** a byte is allocated | +| Determinism | same bytes + same spec → same output bytes, **on every platform** | + +**AVIF and HEIC are refused everywhere, deliberately.** `Bun.Image` can reach them through an OS +codec (ImageIO on macOS, WIC on Windows), and this pipeline sets `Bun.Image.backend = 'bun'` on +every call to forbid exactly that: the static codecs and the Highway geometry kernels are what make +a laptop and a Linux node produce the same bytes, and `variantKey` is content-addressed. A variant +that re-encoded differently per platform is a cache that never hits. Producing AVIF means a CDN or +a custom `ImageTransformDriver`. + +`transformImageBytes` has two paths and picks by geometry. When the resampled artwork IS the output +box it is one `Bun.Image` call, source bytes to encoded bytes. When it is not — a letterbox, a +`padding`, a `cover` crop — the artwork comes back as PNG and `canvas.ts` composites it, because +`Bun.Image` resamples but has no compositor and the PWA maskable safe zone is a composite. +`png-pixels.ts` is the raw-pixel seam that hop needs, 8-bit RGBA only; anything else is +`X_IMAGE_UNSUPPORTED` naming `transformImageBytes`. + +Adding a format is an entry in `DECODABLE_FORMATS` / `ENCODABLE_FORMATS` and a branch in +`withFormat` — never a second dispatch. An unencodable `format` is refused from the spec alone, +before the source is decoded, so a request nothing can write never expands 64 megapixels first. + +`Bun.Image` rejects with `ERR_IMAGE_*` on `error.code`. `imageFromBunError` is the ONE place that +is read, mapping it onto `X_IMAGE_UNSUPPORTED` / `X_IMAGE_TOO_LARGE` / `X_IMAGE_DECODE_FAILED`; no +caller branches on a Bun code. + +Two files in `image/` are past the 200-line target and neither splits without inventing a seam: +`probe.ts` is one algorithm per format over header bytes, and `fixtures.ts` is data. The 500-line +hard ceiling applies to both. Everything else in `image/` is under the target — deleting the +hand-rolled JPEG and PNG codecs is what put it there. `image/fixtures.ts` is byte-exact output from Pillow and ffmpeg on purpose: a codec that only round trips against itself proves nothing. Never regenerate a fixture with our own encoder. diff --git a/packages/core/src/image/canvas.test.ts b/packages/core/src/image/canvas.test.ts new file mode 100644 index 00000000..fb2b9d93 --- /dev/null +++ b/packages/core/src/image/canvas.test.ts @@ -0,0 +1,249 @@ +// Single responsibility: proves the half of a transform `Bun.Image` does not do — the box maths +// every caller reserves layout from, and the composite that letterboxes, pads and crops. Pure +// pixels and pure arithmetic, no codec: a wrong number here is a PWA icon with a clipped logo. + +import { describe, expect, test } from 'bun:test'; +import { composeOnto, fitBox, layOut, scaledToFit } from './canvas'; +import { parseColor } from './color'; +import { ImageUnsupportedError } from './errors'; +import { createRaster, type Raster, rasterFrom } from './raster'; + +type Rgba = readonly [number, number, number, number]; + +const codeOf = (run: () => unknown): string => { + try { + run(); + return 'no-throw'; + } catch (error) { + return error instanceof ImageUnsupportedError ? error.code : `unexpected: ${String(error)}`; + } +}; + +const solid = (width: number, height: number, color: Rgba): Raster => { + const raster = createRaster(width, height, 'test'); + for (let i = 0; i < raster.pixels.length; i += 4) { + raster.pixels[i] = color[0]; + raster.pixels[i + 1] = color[1]; + raster.pixels[i + 2] = color[2]; + raster.pixels[i + 3] = color[3]; + } + return raster; +}; + +/** Builds a raster from `width * height` pixels in row-major order. */ +const gridOf = (width: number, height: number, cells: readonly Rgba[]): Raster => { + const pixels = new Uint8ClampedArray(width * height * 4); + cells.forEach((cell, index) => { + pixels[index * 4] = cell[0]; + pixels[index * 4 + 1] = cell[1]; + pixels[index * 4 + 2] = cell[2]; + pixels[index * 4 + 3] = cell[3]; + }); + return rasterFrom(width, height, pixels); +}; + +const at = (raster: Raster, x: number, y: number): Rgba => { + const i = (y * raster.width + x) * 4; + const p = raster.pixels; + return [p[i] ?? 0, p[i + 1] ?? 0, p[i + 2] ?? 0, p[i + 3] ?? 0]; +}; + +const RED: Rgba = [255, 0, 0, 255]; +const BLUE: Rgba = [0, 0, 255, 255]; +const CLEAR: Rgba = [0, 0, 0, 0]; + +describe('fitBox', () => { + const source = { width: 400, height: 200 }; + + test('keeps the source when neither axis is requested', () => { + expect(fitBox(source, {})).toEqual({ width: 400, height: 200 }); + }); + + test('derives the height from a width request', () => { + expect(fitBox(source, { width: 100 })).toEqual({ width: 100, height: 50 }); + }); + + test('derives the width from a height request', () => { + expect(fitBox(source, { height: 50 })).toEqual({ width: 100, height: 50 }); + }); + + test('never upscales on a single-axis request', () => { + expect(fitBox(source, { width: 4000 })).toEqual({ width: 400, height: 200 }); + expect(fitBox(source, { height: 2000 })).toEqual({ width: 400, height: 200 }); + }); + + test('returns exactly the box when both axes are given, upscale or not', () => { + expect(fitBox(source, { width: 4000, height: 7 })).toEqual({ width: 4000, height: 7 }); + }); + + test('never rounds a derived edge below one pixel', () => { + expect(fitBox({ width: 1000, height: 3 }, { width: 10 })).toEqual({ width: 10, height: 1 }); + }); + + test('rejects a non-integer, zero or negative dimension, naming the field', () => { + expect(codeOf(() => fitBox(source, { width: 10.5 }))).toBe('X_IMAGE_UNSUPPORTED'); + expect(codeOf(() => fitBox(source, { width: 0 }))).toBe('X_IMAGE_UNSUPPORTED'); + expect(codeOf(() => fitBox(source, { height: -4 }))).toBe('X_IMAGE_UNSUPPORTED'); + expect(() => fitBox(source, { height: -4 })).toThrow(/height/); + }); +}); + +describe('scaledToFit', () => { + const source = { width: 400, height: 200 }; + + test('cover fills the box, overflowing the short axis', () => { + expect(scaledToFit(source, { width: 100, height: 100 }, 'cover')).toEqual({ + width: 200, + height: 100, + }); + }); + + test('contain fits inside the box, leaving the long axis short', () => { + expect(scaledToFit(source, { width: 100, height: 100 }, 'contain')).toEqual({ + width: 100, + height: 50, + }); + }); + + test('upscales when the box is bigger — a 128px source asked for a 512px icon', () => { + expect( + scaledToFit({ width: 128, height: 128 }, { width: 512, height: 512 }, 'contain'), + ).toEqual({ width: 512, height: 512 }); + }); + + test('never returns a zero edge', () => { + expect(scaledToFit({ width: 1000, height: 2 }, { width: 4, height: 4 }, 'contain')).toEqual({ + width: 4, + height: 1, + }); + }); +}); + +describe('layOut', () => { + const square = { width: 1024, height: 1024 }; + + test('padding reserves a border and shrinks what the artwork is drawn at', () => { + const layout = layOut(square, { width: 100, height: 100, padding: 0.1 }); + expect(layout.box).toEqual({ width: 100, height: 100 }); + expect(layout.pad).toBe(10); + expect(layout.inner).toEqual({ width: 80, height: 80 }); + expect(layout.drawn).toEqual({ width: 80, height: 80 }); + expect(layout.needsCanvas).toBe(true); + }); + + test('a full-bleed box with no background needs no canvas — the resampler IS the answer', () => { + expect(layOut(square, { width: 192, height: 192 }).needsCanvas).toBe(false); + expect(layOut(square, { width: 192 }).needsCanvas).toBe(false); + expect(layOut(square, {}).needsCanvas).toBe(false); + }); + + test('an OPAQUE background needs the canvas even at full bleed — alpha shows through it', () => { + expect(layOut(square, { width: 8, height: 8, background: 'transparent' }).needsCanvas).toBe( + false, + ); + expect(layOut(square, { width: 8, height: 8, background: '#ff000000' }).needsCanvas).toBe( + false, + ); + expect(layOut(square, { width: 8, height: 8, background: '#ff0000' }).needsCanvas).toBe(true); + }); + + test('the colour is parsed even when the geometry would hide it', () => { + // Otherwise a typo is refused for a 100x50 source and accepted for a 100x100 one. + expect(codeOf(() => layOut(square, { width: 8, height: 8, background: 'chartreuse' }))).toBe( + 'X_IMAGE_UNSUPPORTED', + ); + }); + + test('rejects a padding outside [0, 0.5)', () => { + expect(codeOf(() => layOut(square, { width: 4, padding: 0.6 }))).toBe('X_IMAGE_UNSUPPORTED'); + expect(codeOf(() => layOut(square, { width: 4, padding: -0.1 }))).toBe('X_IMAGE_UNSUPPORTED'); + expect(codeOf(() => layOut(square, { width: 4, padding: 0.5 }))).toBe('X_IMAGE_UNSUPPORTED'); + }); + + test('rejects a padding that leaves no room at the requested size', () => { + expect( + codeOf(() => layOut({ width: 4, height: 4 }, { width: 2, height: 2, padding: 0.4 })), + ).toBe('X_IMAGE_UNSUPPORTED'); + }); +}); + +describe('composeOnto', () => { + test('contain letterboxes with exactly the background that was asked for', () => { + const layout = layOut({ width: 4, height: 2 }, { width: 2, height: 4, background: '#0000ff' }); + expect(layout.drawn).toEqual({ width: 2, height: 1 }); + expect(parseColor('#0000ff')).toEqual(BLUE); + const out = composeOnto(solid(2, 1, RED), layout); + expect(at(out, 0, 0)).toEqual(BLUE); + expect(at(out, 1, 1)).toEqual(BLUE); + expect(at(out, 0, 2)).toEqual(RED); + expect(at(out, 1, 2)).toEqual(RED); + expect(at(out, 0, 3)).toEqual(BLUE); + }); + + test('cover centre-crops: the drawn artwork overflows and the edges are clipped away', () => { + const layout = layOut({ width: 4, height: 2 }, { width: 2, height: 2, fit: 'cover' }); + expect(layout.drawn).toEqual({ width: 4, height: 2 }); + const stripes = gridOf(4, 2, [ + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + [10, 0, 0, 255], + [20, 0, 0, 255], + [30, 0, 0, 255], + [40, 0, 0, 255], + ]); + const out = composeOnto(stripes, layout); + expect([out.width, out.height]).toEqual([2, 2]); + expect(at(out, 0, 0)[0]).toBe(20); + expect(at(out, 1, 0)[0]).toBe(30); + expect(at(out, 0, 1)[0]).toBe(20); + expect(at(out, 1, 1)[0]).toBe(30); + }); + + test('padding leaves an empty border and centres the artwork in what is left', () => { + const layout = layOut({ width: 100, height: 100 }, { width: 100, height: 100, padding: 0.1 }); + const out = composeOnto(solid(80, 80, RED), layout); + for (let i = 0; i < 10; i += 1) { + expect(at(out, i, i)).toEqual(CLEAR); + expect(at(out, 99 - i, 99 - i)).toEqual(CLEAR); + expect(at(out, i, 50)).toEqual(CLEAR); + expect(at(out, 50, i)).toEqual(CLEAR); + } + expect(at(out, 10, 10)).toEqual(RED); + expect(at(out, 89, 89)).toEqual(RED); + expect(at(out, 50, 50)).toEqual(RED); + }); + + test('an opaque background makes every output pixel opaque, and lightens what is over it', () => { + const halfRed: Rgba = [255, 0, 0, 128]; + const layout = layOut({ width: 4, height: 4 }, { width: 8, height: 4, background: '#ffffff' }); + const out = composeOnto(solid(layout.drawn.width, layout.drawn.height, halfRed), layout); + for (let i = 3; i < out.pixels.length; i += 4) expect(out.pixels[i]).toBe(255); + // Half-transparent red over white lightens toward pink, it does not stay pure red. + const [r, g, b] = at(out, 4, 2); + expect(r).toBe(255); + expect(g).toBeGreaterThan(100); + expect(b).toBeGreaterThan(100); + }); + + test('a transparent background preserves the source alpha untouched', () => { + const halfRed: Rgba = [255, 0, 0, 128]; + const layout = layOut({ width: 4, height: 4 }, { width: 2, height: 2, padding: 0 }); + const out = composeOnto(solid(2, 2, halfRed), layout); + for (let y = 0; y < 2; y += 1) { + for (let x = 0; x < 2; x += 1) expect(at(out, x, y)).toEqual(halfRed); + } + }); + + test('a fully transparent background leaves the letterbox at zero, colour included', () => { + // '#ff000000' and 'transparent' must not produce different bytes. + const layout = layOut( + { width: 4, height: 2 }, + { width: 2, height: 4, background: '#ff000000' }, + ); + const out = composeOnto(solid(2, 1, RED), layout); + expect(at(out, 0, 0)).toEqual(CLEAR); + expect(at(out, 0, 2)).toEqual(RED); + }); +}); diff --git a/packages/core/src/image/canvas.ts b/packages/core/src/image/canvas.ts new file mode 100644 index 00000000..53ad910f --- /dev/null +++ b/packages/core/src/image/canvas.ts @@ -0,0 +1,170 @@ +// Single responsibility: the GEOMETRY of a resize — output box, padded inner area, drawn size — +// and the source-over composite that places the drawn artwork on it. `Bun.Image` resamples but +// cannot letterbox, pad or crop, so this is the half of a transform it does not do; keeping the +// arithmetic here is also what lets a caller ask for the box before any pixel exists. + +import { parseColor } from './color'; +import { imageUnsupported } from './errors'; +import { assertPixelBudget, createRaster, type ImageSize, type Raster } from './raster'; + +export type ImageFit = 'cover' | 'contain'; + +export interface ResizeSpec { + readonly width?: number | undefined; + readonly height?: number | undefined; + /** Default 'contain'. */ + readonly fit?: ImageFit | undefined; + /** Fraction of the shorter OUTPUT edge left empty on every side. `0 <= padding < 0.5`. */ + readonly padding?: number | undefined; + /** '#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'. Default transparent. */ + readonly background?: string | undefined; +} + +function assertDimension(value: number, field: string): void { + if (!Number.isInteger(value) || value < 1) { + throw imageUnsupported( + `resize ${field} is ${value}, which is not a whole number of pixels above zero`, + `pass an integer ${field} of 1 or more, or omit it to derive it from the source`, + { field, value }, + ); + } +} + +/** + * The output CANVAS size. A single-axis request clamps to the source: asking for `width: 2000` + * of a 400px original must not invent 1600 pixels of blur, it must hand back the 400. + */ +export function fitBox(source: ImageSize, spec: ResizeSpec): ImageSize { + const { width, height } = spec; + if (width !== undefined) assertDimension(width, 'width'); + if (height !== undefined) assertDimension(height, 'height'); + if (width !== undefined && height !== undefined) return { width, height }; + if (width !== undefined) { + const w = Math.min(width, source.width); + return { width: w, height: Math.max(1, Math.round((w * source.height) / source.width)) }; + } + if (height !== undefined) { + const h = Math.min(height, source.height); + return { width: Math.max(1, Math.round((h * source.width) / source.height)), height: h }; + } + return { width: source.width, height: source.height }; +} + +/** The size the source is DRAWN at inside `box` — no letterbox, no crop maths. May upscale. */ +export function scaledToFit(source: ImageSize, box: ImageSize, fit: ImageFit): ImageSize { + const x = box.width / source.width; + const y = box.height / source.height; + const scale = fit === 'cover' ? Math.max(x, y) : Math.min(x, y); + return { + width: Math.max(1, Math.round(source.width * scale)), + height: Math.max(1, Math.round(source.height * scale)), + }; +} + +/** The whole plan for one transform, decided before a pixel is touched. */ +export interface Layout { + /** The output canvas. */ + readonly box: ImageSize; + /** Pixels of padding on each edge. */ + readonly pad: number; + /** The area inside the padding the artwork may occupy. */ + readonly inner: ImageSize; + /** What the source is resampled to before it is placed. */ + readonly drawn: ImageSize; + /** Parsed once, here, so an unspellable colour is refused before any pixel is produced. */ + readonly background: readonly [number, number, number, number]; + /** False when `drawn` IS the box and nothing shows through — the resampler's output is the answer. */ + readonly needsCanvas: boolean; +} + +export function layOut(source: ImageSize, spec: ResizeSpec): Layout { + const box = fitBox(source, spec); + assertPixelBudget(box.width, box.height, 'resize'); + const padding = spec.padding ?? 0; + if (!Number.isFinite(padding) || padding < 0 || padding >= 0.5) { + throw imageUnsupported( + `resize padding is ${padding}, outside the 0 <= padding < 0.5 range`, + 'pass a fraction of the shorter output edge, e.g. 0.1 for a 10% border on every side', + { padding }, + ); + } + const pad = Math.round(Math.min(box.width, box.height) * padding); + const inner = { width: box.width - 2 * pad, height: box.height - 2 * pad }; + if (inner.width < 1 || inner.height < 1) { + throw imageUnsupported( + `padding ${padding} leaves no room inside a ${box.width}x${box.height} output`, + 'lower the padding or raise the requested width and height', + { padding, pad, width: box.width, height: box.height }, + ); + } + const drawn = scaledToFit(source, inner, spec.fit ?? 'contain'); + // Parsed even when the fast path will not use it: 'chartreuse' must be refused whether or not + // the geometry happens to hide the colour, or the rejection depends on the source's dimensions. + const background = parseColor(spec.background ?? 'transparent'); + // An opaque background still shows THROUGH a source with alpha, so it needs the canvas even at + // full bleed. A transparent one does not, and skipping it keeps a plain `srcset` variant out of + // the RGBA round trip entirely. + const needsCanvas = + drawn.width !== box.width || drawn.height !== box.height || background[3] !== 0; + return { box, pad, inner, drawn, background, needsCanvas }; +} + +function fill(canvas: Raster, color: readonly [number, number, number, number]): void { + const [r, g, b, a] = color; + // A zero-alpha background is canonicalised to all-zero, matching the composite's own + // `outA === 0 -> outC = 0`: '#ff000000' and 'transparent' must not produce different bytes. + if (a === 0) return; + const { pixels } = canvas; + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = a; + } +} + +/** Source-over. `outA === 0` means every contributor was transparent — the colour is nothing. */ +function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: number): void { + const sa = s[p + 3] ?? 0; + if (sa === 0) return; + const da = dst[d + 3] ?? 0; + if (sa === 255 || da === 0) { + dst[d] = s[p] ?? 0; + dst[d + 1] = s[p + 1] ?? 0; + dst[d + 2] = s[p + 2] ?? 0; + dst[d + 3] = sa; + return; + } + const sf = sa / 255; + const df = (da / 255) * (1 - sf); + const outA = sf + df; + dst[d] = ((s[p] ?? 0) * sf + (dst[d] ?? 0) * df) / outA; + dst[d + 1] = ((s[p + 1] ?? 0) * sf + (dst[d + 1] ?? 0) * df) / outA; + dst[d + 2] = ((s[p + 2] ?? 0) * sf + (dst[d + 2] ?? 0) * df) / outA; + dst[d + 3] = outA * 255; +} + +/** + * The artwork, centred in the inner area and clipped to it — that clip is exactly the `cover` + * crop, so one blit serves both fits. + */ +export function composeOnto(art: Raster, layout: Layout): Raster { + const { box, pad, inner } = layout; + const canvas = createRaster(box.width, box.height, 'resize'); + fill(canvas, layout.background); + const ox = pad + Math.round((inner.width - art.width) / 2); + const oy = pad + Math.round((inner.height - art.height) / 2); + const x1 = Math.min(pad + inner.width, ox + art.width); + const y1 = Math.min(pad + inner.height, oy + art.height); + for (let y = Math.max(pad, oy); y < y1; y += 1) { + for (let x = Math.max(pad, ox); x < x1; x += 1) { + blend( + canvas.pixels, + (y * canvas.width + x) * 4, + art.pixels, + ((y - oy) * art.width + (x - ox)) * 4, + ); + } + } + return canvas; +} diff --git a/packages/core/src/image/errors.test.ts b/packages/core/src/image/errors.test.ts index 67ef0b90..b1c1110a 100644 --- a/packages/core/src/image/errors.test.ts +++ b/packages/core/src/image/errors.test.ts @@ -10,6 +10,7 @@ import { ImageTooLargeError, ImageUnsupportedError, imageDecodeFailed, + imageFromBunError, imageTooLarge, imageUnsupported, } from './errors'; @@ -93,3 +94,56 @@ describe('every image error', () => { }); }); }); + +describe('imageFromBunError', () => { + /** The shape `Bun.Image` rejects with: a plain Error carrying a stable `code`. */ + const bunError = (code: string): unknown => Object.assign(new Error(`Image: ${code}`), { code }); + + test.each([ + ['ERR_IMAGE_FORMAT_UNSUPPORTED', 'X_IMAGE_UNSUPPORTED'], + ['ERR_IMAGE_UNKNOWN_FORMAT', 'X_IMAGE_UNSUPPORTED'], + ['ERR_IMAGE_TOO_MANY_PIXELS', 'X_IMAGE_TOO_LARGE'], + ['ERR_IMAGE_DECODE_FAILED', 'X_IMAGE_DECODE_FAILED'], + ['ERR_IMAGE_ENCODE_FAILED', 'X_IMAGE_DECODE_FAILED'], + ['ERR_INVALID_STATE', 'X_IMAGE_DECODE_FAILED'], + ])('maps %s onto %s', (bunCode, code) => { + const error = imageFromBunError(bunError(bunCode), 'transforming the image'); + expect(error.code).toBe(code); + expect(error.meta).toMatchObject({ bunCode }); + expect(error.cause).toContain('transforming the image'); + }); + + test('the format refusal names the three portable formats and the way to the others', () => { + const error = imageFromBunError(bunError('ERR_IMAGE_FORMAT_UNSUPPORTED'), 'encoding'); + expect(error.fix).toContain('webp'); + expect(error.fix).toContain('ImageTransformDriver'); + }); + + test('a code nobody mapped is still a coded error, never a bare one', () => { + // A syscall code (ENOENT, EACCES) reaches this for a file-backed input. + const error = imageFromBunError( + Object.assign(new Error('boom'), { code: 'ENOENT' }), + 'reading', + ); + expect(error.code).toBe('X_IMAGE_DECODE_FAILED'); + expect(error).toBeInstanceOf(ImageDecodeFailedError); + }); + + test('a value that fights being read still renders, and does not throw doing it', () => { + // A rejection crossing a worker arrives as a plain object, and reading `.code` off one is a + // getter call on a value the framework did not build. `new Error` here is INPUT, never a + // verdict — the assertions below are what report. + const fights = new Error('this value fights being read'); + const hostile = new Proxy( + {}, + { + get() { + throw fights; + }, + }, + ); + const error = imageFromBunError(hostile, 'transforming the image'); + expect(error.code).toBe('X_IMAGE_DECODE_FAILED'); + expect(error.cause).toContain('transforming the image'); + }); +}); diff --git a/packages/core/src/image/errors.ts b/packages/core/src/image/errors.ts index 745f2dc5..b5114ad8 100644 --- a/packages/core/src/image/errors.ts +++ b/packages/core/src/image/errors.ts @@ -1,7 +1,9 @@ -// Single responsibility: the three failure modes of the image pipeline, as coded errors. -// Every one names the format AND a runnable way forward, because an agent that hits -// "unsupported" needs to know which format to ask for instead, not that it lost. +// Single responsibility: the three failure modes of the image pipeline, as coded errors, and the +// one translation of `Bun.Image`'s `ERR_IMAGE_*` rejections into them. Every one names the format +// AND a runnable way forward, because an agent that hits "unsupported" needs to know which format +// to ask for instead, not that it lost. +import { renderThrowable, stringField } from '../error-render'; import { UltimateError } from '../errors'; export class ImageUnsupportedError extends UltimateError { @@ -56,3 +58,33 @@ export const imageTooLarge = ( 'downscale the source before it reaches the pipeline, or raise MAX_IMAGE_PIXELS deliberately', meta, ); + +/** + * `Bun.Image` rejects with a plain `Error` carrying a stable `error.code`. This is the ONE place + * that code is read: a caller branching on `ERR_IMAGE_*` would be a second vocabulary for the same + * three failures, and `X_IMAGE_*` is the one the rest of the framework, the wiki and `x errors + * explain` already know. Unknown codes land on decode-failed rather than on a bare `Error`. + */ +const UNSUPPORTED_FIX = + "request 'png', 'jpeg' or 'webp' — AVIF and HEIC need an OS codec the portable backend never " + + 'uses, so route those through an ImageTransformDriver (a CDN or an external encoder)'; + +const UNKNOWN_FORMAT_FIX = + 're-export the source as PNG, JPEG or WebP: `file ` reports what these bytes actually are'; + +export function imageFromBunError(value: unknown, doing: string): UltimateError { + // `renderThrowable`, never `${value}` — the rejection is Bun's value, not ours, and a cause that + // throws while formatting itself replaces the refusal with a TypeError nothing catches by code. + const cause = `${doing}: ${renderThrowable(value)}`; + const code = stringField(value, 'code'); + if (code === 'ERR_IMAGE_FORMAT_UNSUPPORTED') { + return new ImageUnsupportedError(cause, UNSUPPORTED_FIX, { bunCode: code }); + } + if (code === 'ERR_IMAGE_UNKNOWN_FORMAT') { + return new ImageUnsupportedError(cause, UNKNOWN_FORMAT_FIX, { bunCode: code }); + } + if (code === 'ERR_IMAGE_TOO_MANY_PIXELS') { + return imageTooLarge(cause, { bunCode: code }); + } + return imageDecodeFailed(cause, code === undefined ? {} : { bunCode: code }); +} diff --git a/packages/core/src/image/jpeg-decode.test.ts b/packages/core/src/image/jpeg-decode.test.ts deleted file mode 100644 index dbac4cb3..00000000 --- a/packages/core/src/image/jpeg-decode.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -// Single responsibility: proving the JPEG decoder against bytes an independent encoder produced. -// JPEG is lossy, so every assertion is closeness to the formula the fixture was drawn from — an -// exact-equality test would either be impossible or would only prove the codec agrees with itself. - -import { describe, expect, test } from 'bun:test'; -import { isUltimateError, type UltimateError } from '../errors'; -import { - fixtureBytes, - type ImageFixture, - JPEG_420_16X16, - JPEG_420_ODD_33X17, - JPEG_444_16X16, - JPEG_GRAY_16X16, - JPEG_PROGRESSIVE_16X16, - jpegPixel, - oddJpegPixel, -} from './fixtures'; -import { decodeJpeg } from './jpeg-decode'; -import { rgbToYcbcr } from './jpeg-tables'; -import type { Raster } from './raster'; - -type Formula = (x: number, y: number) => readonly [number, number, number]; - -const luma = (r: number, g: number, b: number): number => 0.299 * r + 0.587 * g + 0.114 * b; - -const at = (raster: Raster, x: number, y: number): readonly [number, number, number, number] => { - const o = (y * raster.width + x) * 4; - return [ - raster.pixels[o] ?? 0, - raster.pixels[o + 1] ?? 0, - raster.pixels[o + 2] ?? 0, - raster.pixels[o + 3] ?? 0, - ]; -}; - -/** Mean absolute error per channel, and the worst single channel, against the source formula. */ -function channelError(raster: Raster, formula: Formula): { mean: number; max: number } { - let sum = 0; - let max = 0; - for (let y = 0; y < raster.height; y += 1) { - for (let x = 0; x < raster.width; x += 1) { - const got = at(raster, x, y); - const want = formula(x, y); - for (let c = 0; c < 3; c += 1) { - const delta = Math.abs((got[c] ?? 0) - (want[c] ?? 0)); - sum += delta; - max = Math.max(max, delta); - } - } - } - return { mean: sum / (raster.width * raster.height * 3), max }; -} - -/** Luma only — chroma is half resolution under 4:2:0, so per-channel bounds there prove nothing. */ -function lumaError(raster: Raster, formula: Formula): number { - let sum = 0; - for (let y = 0; y < raster.height; y += 1) { - for (let x = 0; x < raster.width; x += 1) { - const [r, g, b] = at(raster, x, y); - const [wr, wg, wb] = formula(x, y); - sum += Math.abs(luma(r, g, b) - luma(wr, wg, wb)); - } - } - return sum / (raster.width * raster.height); -} - -/** Returns the coded error a call threw, or undefined — never a bare `throw` of its own. */ -function failure(run: () => unknown): UltimateError | undefined { - try { - run(); - } catch (error) { - return isUltimateError(error) ? error : undefined; - } - return undefined; -} - -const segment = (marker: number, body: readonly number[]): readonly number[] => [ - 0xff, - marker, - ((body.length + 2) >> 8) & 0xff, - (body.length + 2) & 0xff, - ...body, -]; - -const jpegOf = (...parts: ReadonlyArray): Uint8Array => - Uint8Array.from([0xff, 0xd8, ...parts.flat(), 0xff, 0xd9]); - -const frame = (marker: number, w: number, h: number, precision = 8, components = 1) => - segment(marker, [ - precision, - (h >> 8) & 0xff, - h & 0xff, - (w >> 8) & 0xff, - w & 0xff, - components, - ...Array.from({ length: components }, (_, i) => [i + 1, 0x11, 0]).flat(), - ]); - -const SOS_ONE_COMPONENT = segment(0xda, [1, 1, 0x00, 0, 63, 0]); -const DQT_FLAT = segment(0xdb, [0, ...new Array(64).fill(16)]); - -/** - * Re-emits every DQT table in its 16-bit form with identical values. A decoder that ignores the - * precision nibble reads the high zero byte of each entry as the whole coefficient and produces a - * flat grey image, so decoding this to the same pixels is the only proof the nibble is honoured. - */ -function widenQuantTables(bytes: Uint8Array): Uint8Array { - const out: number[] = []; - let cursor = 0; - while (cursor < bytes.length) { - if (bytes[cursor] === 0xff && bytes[cursor + 1] === 0xda) break; // entropy data follows - if (bytes[cursor] !== 0xff || bytes[cursor + 1] !== 0xdb) { - out.push(bytes[cursor] ?? 0); - cursor += 1; - continue; - } - const length = ((bytes[cursor + 2] ?? 0) << 8) | (bytes[cursor + 3] ?? 0); - const body = bytes.subarray(cursor + 4, cursor + 2 + length); - const wide: number[] = []; - let i = 0; - while (i < body.length) { - wide.push(0x10 | ((body[i] ?? 0) & 15)); - i += 1; - for (let k = 0; k < 64; k += 1, i += 1) wide.push(0, body[i] ?? 0); - } - out.push(...segment(0xdb, wide)); - cursor += 2 + length; - } - return Uint8Array.from([...out, ...bytes.subarray(cursor)]); -} - -/** Splices an APP14 `Adobe` marker in behind SOI, which is where a real one sits. */ -const withAdobe = (bytes: Uint8Array, transform: number): Uint8Array => - Uint8Array.from([ - 0xff, - 0xd8, - ...segment(0xee, [0x41, 0x64, 0x6f, 0x62, 0x65, 0, 100, 0, 0, 0, 0, transform]), - ...bytes.subarray(2), - ]); - -const decode = (fixture: ImageFixture): Raster => decodeJpeg(fixtureBytes(fixture)); - -describe('decodeJpeg', () => { - test('decodes a 4:4:4 baseline JPEG close to the pixels it was drawn from', () => { - const raster = decode(JPEG_444_16X16); - expect([raster.width, raster.height]).toEqual([16, 16]); - expect(raster.pixels.length).toBe(16 * 16 * 4); - const { mean, max } = channelError(raster, jpegPixel); - expect(max).toBeLessThanOrEqual(12); - expect(mean).toBeLessThan(4); - }); - - test('every pixel of the 4:4:4 fixture is opaque', () => { - const raster = decode(JPEG_444_16X16); - for (let i = 3; i < raster.pixels.length; i += 4) expect(raster.pixels[i]).toBe(255); - }); - - test('upsamples 4:2:0 chroma to full resolution', () => { - const raster = decode(JPEG_420_16X16); - expect([raster.width, raster.height]).toEqual([16, 16]); - expect(lumaError(raster, jpegPixel)).toBeLessThan(5); - }); - - test('crops the MCU padding off odd dimensions under 4:2:0', () => { - const raster = decode(JPEG_420_ODD_33X17); - expect([raster.width, raster.height]).toEqual([33, 17]); - expect(raster.pixels.length).toBe(33 * 17 * 4); - expect(lumaError(raster, oddJpegPixel)).toBeLessThan(8); - }); - - test('replicates a single luma component across R, G and B', () => { - const raster = decode(JPEG_GRAY_16X16); - expect([raster.width, raster.height]).toEqual([16, 16]); - for (let y = 0; y < 16; y += 1) { - for (let x = 0; x < 16; x += 1) { - const [r, g, b, a] = at(raster, x, y); - expect(g).toBe(r); - expect(b).toBe(r); - expect(a).toBe(255); - } - } - // A greyscale decode that lost its DC term would be uniform; the source is a gradient. - expect(at(raster, 15, 15)[0]).toBeGreaterThan(at(raster, 0, 0)[0]); - }); - - test('reads 16-bit precision quantisation tables', () => { - const wide = decodeJpeg(widenQuantTables(fixtureBytes(JPEG_444_16X16))); - const narrow = decode(JPEG_444_16X16); - expect([wide.width, wide.height]).toEqual([16, 16]); - expect(Array.from(wide.pixels)).toEqual(Array.from(narrow.pixels)); - }); - - test('an Adobe APP14 transform of 0 means the samples are already RGB', () => { - const ycc = decode(JPEG_444_16X16); - const asRgb = decodeJpeg(withAdobe(fixtureBytes(JPEG_444_16X16), 0)); - let worst = 0; - for (let y = 0; y < 16; y += 1) { - for (let x = 0; x < 16; x += 1) { - const [r, g, b] = at(ycc, x, y); - const raw = rgbToYcbcr(r, g, b); - const got = at(asRgb, x, y); - for (let c = 0; c < 3; c += 1) { - worst = Math.max(worst, Math.abs((got[c] ?? 0) - (raw[c] ?? 0))); - } - } - } - expect(worst).toBeLessThanOrEqual(2); - // …and transform 1 (YCbCr) leaves the default conversion in place. - const explicitYcc = decodeJpeg(withAdobe(fixtureBytes(JPEG_444_16X16), 1)); - expect(Array.from(explicitYcc.pixels)).toEqual(Array.from(ycc.pixels)); - }); - - test('refuses a progressive JPEG by name instead of decoding noise', () => { - const error = failure(() => decode(JPEG_PROGRESSIVE_16X16)); - expect(error?.code).toBe('X_IMAGE_UNSUPPORTED'); - expect(error?.message).toMatch(/progressive/i); - expect(error?.message).toMatch(/SOF2/); - expect(error?.fix).toContain('convert in.jpg -interlace none baseline.jpg'); - }); - - test.each([ - ['a spectral band narrower than the whole 0-63', [1, 1, 0x00, 1, 5, 0x00], /coefficients 1-5/], - ['a successive-approximation refinement', [1, 1, 0x00, 0, 63, 0x21], /approximation 2\/1/], - ])('refuses a sequential frame whose scan declares %s', (_name, sos, pattern) => { - // SOF0 promises sequential coding, so the scan must code every coefficient at full precision. - // A progressive scan's shape behind a baseline frame header would have `decodeBlock` read - // partial coefficients as whole ones — a plausible, wrong image instead of a named refusal. - const bytes = jpegOf(DQT_FLAT, frame(0xc0, 8, 8), segment(0xda, sos), [0x00, 0x00]); - const error = failure(() => decodeJpeg(bytes)); - expect(error?.code).toBe('X_IMAGE_UNSUPPORTED'); - expect(error?.message).toMatch(pattern); - expect(error?.fix).toContain('convert in.jpg -interlace none baseline.jpg'); - }); - - test.each([ - ['SOF9 arithmetic', jpegOf(frame(0xc9, 16, 16)), /SOF9/], - ['SOF3 lossless', jpegOf(frame(0xc3, 16, 16)), /SOF3/], - ['SOF11 arithmetic lossless', jpegOf(frame(0xcb, 16, 16)), /SOF11/], - ['12-bit precision', jpegOf(frame(0xc0, 16, 16, 12)), /12-bit/], - ['4-component CMYK', jpegOf(frame(0xc0, 16, 16, 8, 4)), /CMYK/], - ])('refuses %s with a coded error', (_name, bytes, pattern) => { - const error = failure(() => decodeJpeg(bytes)); - expect(error?.code).toBe('X_IMAGE_UNSUPPORTED'); - expect(error?.message).toMatch(pattern); - }); - - test('refuses bytes that are not a JPEG at all', () => { - const error = failure(() => decodeJpeg(Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]))); - expect(error?.code).toBe('X_IMAGE_DECODE_FAILED'); - expect(error?.message).toMatch(/SOI/); - }); - - test('refuses a truncated scan rather than padding it with grey', () => { - const bytes = fixtureBytes(JPEG_444_16X16); - for (const cut of [40, 200, bytes.length - 60, bytes.length - 10]) { - const error = failure(() => decodeJpeg(bytes.slice(0, cut))); - expect(error?.code).toBe('X_IMAGE_DECODE_FAILED'); - } - }); - - test('refuses a scan that selects a Huffman table no DHT defined', () => { - const error = failure(() => - decodeJpeg(jpegOf(DQT_FLAT, frame(0xc0, 8, 8), SOS_ONE_COMPONENT, [0x00, 0x00])), - ); - expect(error?.code).toBe('X_IMAGE_DECODE_FAILED'); - expect(error?.message).toMatch(/DC Huffman table 0/); - }); - - test('refuses a scan that selects a quantisation table no DQT defined', () => { - const error = failure(() => - decodeJpeg(jpegOf(frame(0xc0, 8, 8), SOS_ONE_COMPONENT, [0x00, 0x00])), - ); - expect(error?.code).toBe('X_IMAGE_DECODE_FAILED'); - expect(error?.message).toMatch(/quantisation table 0/); - }); - - test('refuses a header that declares more pixels than the budget allows', () => { - const error = failure(() => decodeJpeg(jpegOf(frame(0xc0, 30000, 30000)))); - expect(error?.code).toBe('X_IMAGE_TOO_LARGE'); - expect(error?.message).toMatch(/30000x30000/); - }); - - test('refuses a segment whose declared length runs past the file', () => { - const error = failure(() => decodeJpeg(Uint8Array.from([0xff, 0xd8, 0xff, 0xdb, 0x7f, 0xff]))); - expect(error?.code).toBe('X_IMAGE_DECODE_FAILED'); - }); -}); diff --git a/packages/core/src/image/jpeg-decode.ts b/packages/core/src/image/jpeg-decode.ts deleted file mode 100644 index 202bcf3d..00000000 --- a/packages/core/src/image/jpeg-decode.ts +++ /dev/null @@ -1,283 +0,0 @@ -// Single responsibility: turning a baseline or extended sequential Huffman JPEG (SOF0/SOF1) into -// RGBA — the marker walk, the entropy-coded scan, the inverse DCT and the sample planes. What each -// segment DECLARES, and which codings are refused by name, is `jpeg-headers.ts`; this file is the -// algorithm those declarations describe. - -import { imageDecodeFailed } from './errors'; -import { - assertSupportedCoding, - type Component, - type Frame, - hex, - isAdobe, - readFrame, - readQuantTables, - readScanHeader, - readU16, - type ScanComponent, -} from './jpeg-headers'; -import { type HuffmanTable, JpegBitReader, readHuffmanTables } from './jpeg-huffman'; -import { ycbcrToRgb, ZIGZAG } from './jpeg-tables'; -import { type Raster, rasterFrom } from './raster'; - -const SQRT2 = Math.SQRT2; - -/** Reused across every block of every image: the decoder is synchronous and single-threaded. */ -const COEF = new Int32Array(64); -const WORK = new Float32Array(64); - -/** libjpeg's AAN float butterfly, in place over the 8 samples `step` apart from `base`. */ -function idct1d(v: Float32Array, base: number, step: number): void { - const s0 = v[base] ?? 0; - const s1 = v[base + step] ?? 0; - const s2 = v[base + step * 2] ?? 0; - const s3 = v[base + step * 3] ?? 0; - const s4 = v[base + step * 4] ?? 0; - const s5 = v[base + step * 5] ?? 0; - const s6 = v[base + step * 6] ?? 0; - const s7 = v[base + step * 7] ?? 0; - const e10 = s0 + s4; - const e11 = s0 - s4; - const e13 = s2 + s6; - const e12 = (s2 - s6) * SQRT2 - e13; - const t0 = e10 + e13; - const t3 = e10 - e13; - const t1 = e11 + e12; - const t2 = e11 - e12; - const z13 = s5 + s3; - const z10 = s5 - s3; - const z11 = s1 + s7; - const z12 = s1 - s7; - const t7 = z11 + z13; - const t11 = (z11 - z13) * SQRT2; - const z5 = (z10 + z12) * 1.847759065; - const t10 = 1.0823922 * z12 - z5; - const t12 = -2.61312593 * z10 + z5; - const t6 = t12 - t7; - const t5 = t11 - t6; - const t4 = t10 + t5; - v[base] = t0 + t7; - v[base + step * 7] = t0 - t7; - v[base + step] = t1 + t6; - v[base + step * 6] = t1 - t6; - v[base + step * 2] = t2 + t5; - v[base + step * 5] = t2 - t5; - v[base + step * 4] = t3 + t4; - v[base + step * 3] = t3 - t4; -} - -/** `Uint8ClampedArray` is the level shift: it rounds and clamps to 0-255 on every store. */ -function writeBlock(comp: Component, at: number, flat: boolean): void { - const { samples, stride } = comp; - if (flat) { - const value = (COEF[0] ?? 0) * (comp.dequant[0] ?? 0) + 128; - for (let r = 0; r < 8; r += 1) samples.fill(value, at + r * stride, at + r * stride + 8); - return; - } - for (let i = 0; i < 64; i += 1) WORK[i] = (COEF[i] ?? 0) * (comp.dequant[i] ?? 0); - for (let c = 0; c < 8; c += 1) idct1d(WORK, c, 8); - for (let r = 0; r < 8; r += 1) idct1d(WORK, r * 8, 1); - for (let r = 0; r < 8; r += 1) { - const row = at + r * stride; - for (let c = 0; c < 8; c += 1) samples[row + c] = (WORK[r * 8 + c] ?? 0) + 128; - } -} - -function decodeBlock(reader: JpegBitReader, scan: ScanComponent, row: number, col: number): void { - const { comp } = scan; - if (row >= comp.blocksPerColumn || col >= comp.blocksPerLine) { - throw imageDecodeFailed(`block ${col},${row} falls outside component ${comp.id}`, { row, col }); - } - COEF.fill(0); - const size = reader.decode(scan.dc); - if (size > 15) { - throw imageDecodeFailed(`a DC coefficient claims ${size} magnitude bits, over the 15 allowed`); - } - comp.pred += reader.receiveAndExtend(size); - COEF[0] = comp.pred; - let k = 1; - let last = 0; - while (k < 64) { - const rs = reader.decode(scan.ac); - const bits = rs & 15; - const run = rs >> 4; - if (bits === 0) { - if (run !== 15) break; // 0x00 is end-of-block; 0xF0 is a run of 16 zeros - k += 16; - continue; - } - k += run; - if (k > 63) { - throw imageDecodeFailed(`an AC run overruns block ${col},${row} of component ${comp.id}`, { - row, - col, - }); - } - COEF[ZIGZAG[k] ?? 0] = reader.receiveAndExtend(bits); - last = k; - k += 1; - } - writeBlock(comp, row * 8 * comp.stride + col * 8, last === 0); -} - -/** Leaves the walk at the next marker, or at the end when the file carries no EOI. */ -function skipToMarker(bytes: Uint8Array, from: number): number { - for (let at = from; at + 1 < bytes.length; at += 1) { - if ((bytes[at] ?? 0) === 0xff && (bytes[at + 1] ?? 0) !== 0x00) return at; - } - return bytes.length; -} - -/** One scan's entropy-coded data, from the byte after its header to the marker that ends it. */ -function decodeScan( - bytes: Uint8Array, - start: number, - seg: Uint8Array, - frame: Frame, - quant: ReadonlyArray, - dcTables: ReadonlyArray, - acTables: ReadonlyArray, - restartInterval: number, -): number { - const scan: readonly ScanComponent[] = readScanHeader(seg, frame, quant, dcTables, acTables); - const reader = new JpegBitReader(bytes, start); - const single = scan.length === 1 ? scan[0] : undefined; - // A non-interleaved scan walks the component's own blocks, which for a subsampled component is - // fewer than its MCU-padded plane holds; an interleaved one walks whole MCUs. - const perLine = - single === undefined - ? frame.mcusPerLine - : Math.ceil(Math.ceil((frame.width * single.comp.h) / frame.maxH) / 8); - const perColumn = - single === undefined - ? frame.mcusPerColumn - : Math.ceil(Math.ceil((frame.height * single.comp.v) / frame.maxV) / 8); - for (let n = 0; n < perLine * perColumn; n += 1) { - if (restartInterval > 0 && n > 0 && n % restartInterval === 0) { - if (!reader.restart()) { - throw imageDecodeFailed(`the scan omits the restart marker due after ${n} units`, { n }); - } - for (const entry of scan) entry.comp.pred = 0; - } - const row = (n / perLine) | 0; - const col = n % perLine; - if (single !== undefined) { - decodeBlock(reader, single, row, col); - continue; - } - for (const entry of scan) { - for (let v = 0; v < entry.comp.v; v += 1) { - for (let h = 0; h < entry.comp.h; h += 1) { - decodeBlock(reader, entry, row * entry.comp.v + v, col * entry.comp.h + h); - } - } - } - } - return skipToMarker(bytes, reader.position); -} - -/** - * Sample planes to RGBA, cropped to the declared size: the MCU-padded edge columns and rows exist - * only so the last block is whole, and a decoder that returns them reports the wrong dimensions. - * Chroma is upsampled by replication, which is what `h`/`v` below the maxima mean. - */ -function toRaster(frame: Frame, adobeTransform: number): Raster { - const { width, height, components, maxH, maxV } = frame; - const pixels = new Uint8ClampedArray(width * height * 4); - const luma = components[0]; - if (luma === undefined) throw imageDecodeFailed('the frame declares no components'); - const cb = components[1]; - const cr = components[2]; - // Adobe transform 0 over three components means the samples already ARE R, G and B. - const alreadyRgb = adobeTransform === 0; - for (let y = 0; y < height; y += 1) { - const lumaRow = (((y * luma.v) / maxV) | 0) * luma.stride; - let out = y * width * 4; - if (cb === undefined || cr === undefined) { - for (let x = 0; x < width; x += 1) { - const grey = luma.samples[lumaRow + (((x * luma.h) / maxH) | 0)] ?? 0; - pixels[out] = grey; - pixels[out + 1] = grey; - pixels[out + 2] = grey; - pixels[out + 3] = 255; - out += 4; - } - continue; - } - const cbRow = (((y * cb.v) / maxV) | 0) * cb.stride; - const crRow = (((y * cr.v) / maxV) | 0) * cr.stride; - for (let x = 0; x < width; x += 1) { - const a = luma.samples[lumaRow + (((x * luma.h) / maxH) | 0)] ?? 0; - const b = cb.samples[cbRow + (((x * cb.h) / maxH) | 0)] ?? 0; - const c = cr.samples[crRow + (((x * cr.h) / maxH) | 0)] ?? 0; - if (alreadyRgb) { - pixels[out] = a; - pixels[out + 1] = b; - pixels[out + 2] = c; - } else { - const [r, g, blue] = ycbcrToRgb(a, b, c); - pixels[out] = r; - pixels[out + 1] = g; - pixels[out + 2] = blue; - } - pixels[out + 3] = 255; - out += 4; - } - } - return rasterFrom(width, height, pixels); -} - -/** JPEG bytes to RGBA. Baseline and extended sequential only; everything else is named and refused. */ -export function decodeJpeg(bytes: Uint8Array): Raster { - if ((bytes[0] ?? 0) !== 0xff || (bytes[1] ?? 0) !== 0xd8) { - throw imageDecodeFailed('the bytes do not open with a JPEG SOI marker (FF D8)', { - first: `${hex(bytes[0])} ${hex(bytes[1])}`, - }); - } - const quant: Array = []; - const dcTables: Array = []; - const acTables: Array = []; - let frame: Frame | undefined; - let restartInterval = 0; - let adobeTransform = -1; - let offset = 2; - while (offset + 1 < bytes.length) { - if ((bytes[offset] ?? 0) !== 0xff) { - throw imageDecodeFailed( - `expected a marker at byte ${offset}, found 0x${hex(bytes[offset])}`, - { - offset, - }, - ); - } - while (bytes[offset + 1] === 0xff) offset += 1; // fill bytes between segments - const marker = bytes[offset + 1]; - if (marker === undefined) throw imageDecodeFailed('the file ends inside a marker'); - offset += 2; - if (marker === 0xd9) break; // EOI - if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue; // no payload - assertSupportedCoding(marker); - const length = readU16(bytes, offset); - if (length < 2 || offset + length > bytes.length) { - throw imageDecodeFailed( - `segment FF${hex(marker)} declares ${length} bytes but ${bytes.length - offset} remain`, - { marker: `FF${hex(marker)}`, length }, - ); - } - const seg = bytes.subarray(offset + 2, offset + length); - offset += length; - if (marker === 0xdb) readQuantTables(seg, quant); - else if (marker === 0xc4) readHuffmanTables(seg, dcTables, acTables); - else if (marker === 0xc0 || marker === 0xc1) frame = readFrame(seg, marker); - else if (marker === 0xdd) restartInterval = readU16(seg, 0); - else if (marker === 0xee && isAdobe(seg)) adobeTransform = seg[11] ?? adobeTransform; - else if (marker === 0xda) { - if (frame === undefined) { - throw imageDecodeFailed('a scan (SOS) arrives before any frame header (SOF)'); - } - offset = decodeScan(bytes, offset, seg, frame, quant, dcTables, acTables, restartInterval); - } - } - if (frame === undefined) throw imageDecodeFailed('the file carries no frame header (SOF)'); - return toRaster(frame, adobeTransform); -} diff --git a/packages/core/src/image/jpeg-encode.test.ts b/packages/core/src/image/jpeg-encode.test.ts deleted file mode 100644 index 169f45a4..00000000 --- a/packages/core/src/image/jpeg-encode.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -// Single responsibility: proving the baseline JPEG encoder emits a stream any conforming decoder -// reads — the exact segment sequence, stuffed entropy bytes, edge MCUs that do not darken, and a -// round trip whose error stays where 4:2:0 says it should. - -import { describe, expect, test } from 'bun:test'; -import { fixtureBytes, JPEG_420_16X16, jpegPixel } from './fixtures'; -import { decodeJpeg } from './jpeg-decode'; -import { encodeJpeg } from './jpeg-encode'; -import { STD_LUMINANCE_QUANT, scaleQuantTable, ZIGZAG } from './jpeg-tables'; -import { type Raster, rasterFrom } from './raster'; - -type Formula = (x: number, y: number) => readonly [number, number, number]; - -function rasterOf(width: number, height: number, formula: Formula, alpha = 255): Raster { - const pixels = new Uint8ClampedArray(width * height * 4); - for (let y = 0; y < height; y += 1) { - for (let x = 0; x < width; x += 1) { - const [r, g, b] = formula(x, y); - const i = (y * width + x) * 4; - pixels[i] = r; - pixels[i + 1] = g; - pixels[i + 2] = b; - pixels[i + 3] = alpha; - } - } - return rasterFrom(width, height, pixels); -} - -function meanAbsoluteError(decoded: Raster, formula: Formula): number { - let total = 0; - let samples = 0; - for (let y = 0; y < decoded.height; y += 1) { - for (let x = 0; x < decoded.width; x += 1) { - const expected = formula(x, y); - const i = (y * decoded.width + x) * 4; - for (let c = 0; c < 3; c += 1) { - total += Math.abs((decoded.pixels[i + c] ?? 0) - (expected[c] ?? 0)); - samples += 1; - } - } - } - return total / samples; -} - -interface Segment { - readonly marker: number; - readonly payload: Uint8Array; -} - -interface ParsedJpeg { - readonly markers: readonly number[]; - readonly segments: readonly Segment[]; - readonly entropyStart: number; - readonly entropyEnd: number; -} - -/** Walks the container the way a decoder does, so a wrong length or a missing stuff byte trips. */ -function parseJpeg(bytes: Uint8Array): ParsedJpeg { - const segments: Segment[] = []; - let entropyStart = -1; - let entropyEnd = -1; - let i = 0; - while (i + 1 < bytes.length && bytes[i] === 0xff) { - const marker = bytes[i + 1] ?? 0; - i += 2; - if (marker === 0xd8 || marker === 0xd9) { - segments.push({ marker, payload: new Uint8Array(0) }); - if (marker === 0xd9) break; - continue; - } - const length = ((bytes[i] ?? 0) << 8) | (bytes[i + 1] ?? 0); - segments.push({ marker, payload: bytes.subarray(i + 2, i + length) }); - i += length; - if (marker === 0xda) { - entropyStart = i; - while (i < bytes.length && !(bytes[i] === 0xff && bytes[i + 1] !== 0x00)) { - i += bytes[i] === 0xff ? 2 : 1; - } - entropyEnd = i; - } - } - return { markers: segments.map((s) => s.marker), segments, entropyStart, entropyEnd }; -} - -const payloadOf = (parsed: ParsedJpeg, marker: number): Uint8Array => - parsed.segments.find((s) => s.marker === marker)?.payload ?? new Uint8Array(0); - -const SOI = 0xd8; -const EOI = 0xd9; -const SOF0 = 0xc0; -const DHT = 0xc4; -const SOS = 0xda; -const DQT = 0xdb; -const APP0 = 0xe0; - -describe('encodeJpeg container', () => { - const bytes = encodeJpeg(rasterOf(37, 21, jpegPixel), 80); - const parsed = parseJpeg(bytes); - - test('emits the baseline segment sequence, and nothing else', () => { - expect(parsed.markers).toEqual([SOI, APP0, DQT, DQT, SOF0, DHT, DHT, DHT, DHT, SOS, EOI]); - }); - - test('APP0 is a JFIF 1.1 header with no thumbnail', () => { - expect([...payloadOf(parsed, APP0)]).toEqual([ - 0x4a, 0x46, 0x49, 0x46, 0x00, 1, 1, 0, 0, 1, 0, 1, 0, 0, - ]); - }); - - test('DQT carries both 8-bit tables in zig-zag order', () => { - const tables = parsed.segments.filter((s) => s.marker === DQT).map((s) => s.payload); - expect(tables.map((t) => t[0])).toEqual([0x00, 0x01]); // id 0/1, high nibble 0 == 8-bit - expect(tables.every((t) => t.length === 65)).toBe(true); - const luma = scaleQuantTable(STD_LUMINANCE_QUANT, 80); - const zigzagged = Array.from({ length: 64 }, (_, k) => luma[ZIGZAG[k] ?? 0]); - expect([...(tables[0] ?? new Uint8Array()).subarray(1)]).toEqual(zigzagged as number[]); - }); - - test('SOF0 declares the size, three components and 4:2:0 sampling', () => { - const sof = payloadOf(parsed, SOF0); - expect(sof[0]).toBe(8); // 8-bit precision - expect(((sof[1] ?? 0) << 8) | (sof[2] ?? 0)).toBe(21); // height - expect(((sof[3] ?? 0) << 8) | (sof[4] ?? 0)).toBe(37); // width - expect(sof[5]).toBe(3); - expect([...sof.subarray(6)]).toEqual([1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1]); - }); - - test('DHT ships all four standard tables, DC and AC for both classes', () => { - const tables = parsed.segments.filter((s) => s.marker === DHT); - expect(tables.map((t) => t.payload[0])).toEqual([0x00, 0x10, 0x01, 0x11]); - for (const { payload } of tables) { - const counts = [...payload.subarray(1, 17)].reduce((a, b) => a + b, 0); - expect(payload.length).toBe(17 + counts); - } - }); - - test('SOS selects both Huffman classes over the whole spectral band', () => { - expect([...payloadOf(parsed, SOS)]).toEqual([3, 1, 0x00, 2, 0x11, 3, 0x11, 0, 63, 0]); - }); - - test('every FF in the entropy stream is stuffed, and EOI closes it', () => { - expect(parsed.entropyStart).toBeGreaterThan(0); - expect(parsed.entropyEnd).toBeGreaterThan(parsed.entropyStart); - for (let i = parsed.entropyStart; i < parsed.entropyEnd; i += 1) { - if (bytes[i] === 0xff) expect(bytes[i + 1]).toBe(0x00); - } - expect(bytes[parsed.entropyEnd]).toBe(0xff); - expect(bytes[parsed.entropyEnd + 1]).toBe(EOI); - expect(bytes.length).toBe(parsed.entropyEnd + 2); - }); -}); - -describe('encodeJpeg round trip', () => { - // 4:2:0 halves both chroma planes, so a source with a hard 240-level wrap in R and B — which - // `jpegPixel` has at x=16 and at x+y=32 — cannot come back under ~6 through any encoder. - // Measured through this same decoder: ours 6.29, the reference encoder's own 4:2:0 6.35. - const TOLERANCE = 7; - - test('a 32x32 raster survives quality 92', () => { - const decoded = decodeJpeg(encodeJpeg(rasterOf(32, 32, jpegPixel), 92)); - expect(decoded.width).toBe(32); - expect(decoded.height).toBe(32); - expect(meanAbsoluteError(decoded, jpegPixel)).toBeLessThan(TOLERANCE); - }); - - test('an odd 33x17 raster keeps its exact size through MCU padding', () => { - const decoded = decodeJpeg(encodeJpeg(rasterOf(33, 17, jpegPixel), 92)); - expect(decoded.width).toBe(33); - expect(decoded.height).toBe(17); - expect(meanAbsoluteError(decoded, jpegPixel)).toBeLessThan(TOLERANCE); - }); - - test('the padded edge replicates instead of darkening', () => { - // A flat mid-grey: any zero-filled MCU padding bleeds a dark rim back into the last real - // column and row, which is exactly what a uniform source makes impossible to miss. - const decoded = decodeJpeg( - encodeJpeg( - rasterOf(19, 19, () => [128, 128, 128]), - 90, - ), - ); - for (let y = 0; y < 19; y += 1) { - for (let x = 0; x < 19; x += 1) { - expect(decoded.pixels[(y * 19 + x) * 4]).toBeGreaterThan(120); - } - } - }); - - test('matches the reference encoder on the same pixels, size and error', () => { - // `JPEG_420_16X16` is an external reference encoder's 4:2:0 quality-90 take on `jpegPixel`. - // Both sides go through the same decoder here, so the comparison is about the encoder only. - const reference = fixtureBytes(JPEG_420_16X16); - const ours = encodeJpeg(rasterOf(16, 16, jpegPixel), 90); - const referenceError = meanAbsoluteError(decodeJpeg(reference), jpegPixel); - expect(meanAbsoluteError(decodeJpeg(ours), jpegPixel)).toBeLessThanOrEqual( - referenceError + 0.15, - ); - expect(ours.length).toBeLessThanOrEqual(Math.round(reference.length * 1.05)); - }); - - test('a 1x1 raster encodes and decodes back to 1x1', () => { - const decoded = decodeJpeg( - encodeJpeg( - rasterOf(1, 1, () => [200, 100, 50]), - 80, - ), - ); - expect([decoded.width, decoded.height]).toEqual([1, 1]); - expect([...decoded.pixels]).toEqual([200, 100, 50, 255]); - }); -}); - -describe('encodeJpeg alpha', () => { - test('a fully transparent raster composites to white, never to black', () => { - const decoded = decodeJpeg( - encodeJpeg( - rasterOf(24, 24, () => [0, 0, 0], 0), - 90, - ), - ); - for (let i = 0; i < decoded.pixels.length; i += 4) { - expect(decoded.pixels[i]).toBeGreaterThan(240); - expect(decoded.pixels[i + 1]).toBeGreaterThan(240); - expect(decoded.pixels[i + 2]).toBeGreaterThan(240); - expect(decoded.pixels[i + 3]).toBe(255); - } - }); - - test('a half-transparent black composites part way to white', () => { - // 0*a + 255*(1-a) with a = 64/255 is 191: visibly grey, and unmistakably not 0. - const decoded = decodeJpeg( - encodeJpeg( - rasterOf(24, 24, () => [0, 0, 0], 64), - 90, - ), - ); - expect(decoded.pixels[0] ?? 0).toBeGreaterThan(185); - expect(decoded.pixels[0] ?? 0).toBeLessThan(197); - }); -}); - -describe('encodeJpeg quality', () => { - const raster = rasterOf(32, 32, jpegPixel); - - test('a lower quality produces fewer bytes', () => { - expect(encodeJpeg(raster, 30).length).toBeLessThan(encodeJpeg(raster, 95).length); - }); - - test('a higher quality round-trips with strictly less error', () => { - const coarse = meanAbsoluteError(decodeJpeg(encodeJpeg(raster, 30)), jpegPixel); - const fine = meanAbsoluteError(decodeJpeg(encodeJpeg(raster, 95)), jpegPixel); - expect(fine).toBeLessThan(coarse); - }); - - test('quality is clamped to 1-100 rather than producing a broken table', () => { - for (const quality of [-40, 0, 1, 100, 480, Number.NaN]) { - const decoded = decodeJpeg(encodeJpeg(raster, quality)); - expect([decoded.width, decoded.height]).toEqual([32, 32]); - } - expect(encodeJpeg(raster, 480)).toEqual(encodeJpeg(raster, 100)); - expect(encodeJpeg(raster, -40)).toEqual(encodeJpeg(raster, 1)); - }); - - test('the default quality is used when none is passed', () => { - expect(encodeJpeg(raster)).toEqual(encodeJpeg(raster, 80)); - }); -}); - -describe('encodeJpeg determinism', () => { - test('the same raster and quality produce byte-identical output', () => { - const raster = rasterOf(41, 29, jpegPixel); - expect(encodeJpeg(raster, 77)).toEqual(encodeJpeg(raster, 77)); - }); - - test('a different quality produces different bytes', () => { - const raster = rasterOf(41, 29, jpegPixel); - expect(encodeJpeg(raster, 77)).not.toEqual(encodeJpeg(raster, 78)); - }); -}); diff --git a/packages/core/src/image/jpeg-encode.ts b/packages/core/src/image/jpeg-encode.ts deleted file mode 100644 index 4fd20177..00000000 --- a/packages/core/src/image/jpeg-encode.ts +++ /dev/null @@ -1,463 +0,0 @@ -// Single responsibility: writing a raster as a baseline sequential JPEG — SOF0, Huffman-coded, -// 4:2:0, Annex K tables. The subsampling is the whole reason to emit a JPEG instead of a PNG: -// two of the three planes shrink 4x on data the eye cannot resolve, and PNG can never give -// that back. Reads `jpeg-tables.ts` so the encoder and the decoder cannot drift apart. - -import { - AAN_SCALE, - DEFAULT_JPEG_QUALITY, - rgbToYcbcr, - STD_AC_CHROMINANCE_BITS, - STD_AC_CHROMINANCE_VALUES, - STD_AC_LUMINANCE_BITS, - STD_AC_LUMINANCE_VALUES, - STD_CHROMINANCE_QUANT, - STD_DC_CHROMINANCE_BITS, - STD_DC_CHROMINANCE_VALUES, - STD_DC_LUMINANCE_BITS, - STD_DC_LUMINANCE_VALUES, - STD_LUMINANCE_QUANT, - scaleQuantTable, - ZIGZAG, -} from './jpeg-tables'; -import type { Raster } from './raster'; - -const MARKER = { - soi: 0xd8, - eoi: 0xd9, - sof0: 0xc0, - dht: 0xc4, - sos: 0xda, - dqt: 0xdb, - app0: 0xe0, -} as const; - -/** A 4:2:0 MCU is 16x16 source pixels: four luma blocks over one Cb and one Cr block. */ -const MCU_SIZE = 16; - -/** - * The standard tables have no symbol for an 11-bit AC magnitude or a 12-bit DC difference, so a - * coefficient past this is unencodable rather than merely unusual — reachable only by a - * synthetic ±128 checkerboard at quality 100. libjpeg fails the encode there; costing one unit - * on that block keeps every stream we emit decodable. - */ -const MAX_COEFFICIENT = 1023; - -/** Symbol -> code, the mirror of the decoder's MINCODE/MAXCODE form in `jpeg-huffman.ts`. */ -interface HuffmanEncoder { - readonly codes: Int32Array; - readonly lengths: Int32Array; -} - -/** T.81 Annex C: codes are assigned shortest-first, in ascending order within each length. */ -function buildEncoder(bits: readonly number[], values: readonly number[]): HuffmanEncoder { - const codes = new Int32Array(256); - const lengths = new Int32Array(256); - let code = 0; - let k = 0; - for (let length = 1; length <= 16; length += 1) { - for (let n = bits[length - 1] ?? 0; n > 0; n -= 1) { - const symbol = values[k] ?? 0; - codes[symbol] = code; - lengths[symbol] = length; - code += 1; - k += 1; - } - code <<= 1; - } - return { codes, lengths }; -} - -const DC_LUMA = buildEncoder(STD_DC_LUMINANCE_BITS, STD_DC_LUMINANCE_VALUES); -const AC_LUMA = buildEncoder(STD_AC_LUMINANCE_BITS, STD_AC_LUMINANCE_VALUES); -const DC_CHROMA = buildEncoder(STD_DC_CHROMINANCE_BITS, STD_DC_CHROMINANCE_VALUES); -const AC_CHROMA = buildEncoder(STD_AC_CHROMINANCE_BITS, STD_AC_CHROMINANCE_VALUES); - -const C4 = Math.SQRT1_2; -const C6 = 0.382683433; -const C2_SUB_C6 = 0.5411961; -const C2_ADD_C6 = 1.306562965; - -/** Folds AAN's leftover scaling into the quantiser, so quantising stays one multiply. */ -function buildDivisors(quant: Uint8Array): Float64Array { - const divisors = new Float64Array(64); - for (let row = 0; row < 8; row += 1) { - for (let col = 0; col < 8; col += 1) { - const i = row * 8 + col; - divisors[i] = 1 / ((quant[i] ?? 1) * (AAN_SCALE[row] ?? 1) * (AAN_SCALE[col] ?? 1) * 8); - } - } - return divisors; -} - -/** - * One strided 8-point AAN butterfly (Arai/Agui/Nakajima): 5 multiplies instead of the 64 a - * literal cosine sum costs, and rows and columns share it by varying `step`. - */ -function fdct1d(data: Float64Array, base: number, step: number): void { - const s0 = data[base] ?? 0; - const s1 = data[base + step] ?? 0; - const s2 = data[base + step * 2] ?? 0; - const s3 = data[base + step * 3] ?? 0; - const s4 = data[base + step * 4] ?? 0; - const s5 = data[base + step * 5] ?? 0; - const s6 = data[base + step * 6] ?? 0; - const s7 = data[base + step * 7] ?? 0; - - const t0 = s0 + s7; - const t7 = s0 - s7; - const t1 = s1 + s6; - const t6 = s1 - s6; - const t2 = s2 + s5; - const t5 = s2 - s5; - const t3 = s3 + s4; - const t4 = s3 - s4; - - const e0 = t0 + t3; - const e3 = t0 - t3; - const e1 = t1 + t2; - const e2 = t1 - t2; - const z1 = (e2 + e3) * C4; - data[base] = e0 + e1; - data[base + step * 4] = e0 - e1; - data[base + step * 2] = e3 + z1; - data[base + step * 6] = e3 - z1; - - const o0 = t4 + t5; - const o1 = t5 + t6; - const o2 = t6 + t7; - const z5 = (o0 - o2) * C6; - const z2 = C2_SUB_C6 * o0 + z5; - const z4 = C2_ADD_C6 * o2 + z5; - const z3 = o1 * C4; - data[base + step * 5] = t7 - z3 + z2; - data[base + step * 3] = t7 - z3 - z2; - data[base + step] = t7 + z3 + z4; - data[base + step * 7] = t7 + z3 - z4; -} - -function forwardDct(data: Float64Array): void { - for (let row = 0; row < 8; row += 1) fdct1d(data, row * 8, 1); - for (let col = 0; col < 8; col += 1) fdct1d(data, col, 8); -} - -class JpegSink { - private buffer: Uint8Array; - private length = 0; - private bits = 0; - private bitCount = 0; - - constructor(capacity: number) { - this.buffer = new Uint8Array(Math.max(1024, capacity)); - } - - byte(value: number): void { - if (this.length === this.buffer.length) { - const grown = new Uint8Array(this.buffer.length * 2); - grown.set(this.buffer); - this.buffer = grown; - } - this.buffer[this.length] = value; - this.length += 1; - } - - bytes(values: readonly number[]): void { - for (const value of values) this.byte(value); - } - - word(value: number): void { - this.byte((value >> 8) & 0xff); - this.byte(value & 0xff); - } - - marker(code: number): void { - this.byte(0xff); - this.byte(code); - } - - /** A raw 0xFF in the scan would read as a marker, so T.81 stuffs a 0x00 behind every one. */ - writeBits(code: number, length: number): void { - this.bits = (this.bits << length) | (code & ((1 << length) - 1)); - this.bitCount += length; - while (this.bitCount >= 8) { - this.bitCount -= 8; - const value = (this.bits >>> this.bitCount) & 0xff; - this.byte(value); - if (value === 0xff) this.byte(0x00); - } - this.bits &= (1 << this.bitCount) - 1; - } - - /** Pad with 1-bits: a 0-pad can spell a real Huffman code and grow the last block a symbol. */ - flushBits(): void { - if (this.bitCount > 0) this.writeBits(0xff, 8 - this.bitCount); - } - - finish(): Uint8Array { - return this.buffer.slice(0, this.length); - } -} - -type Plane = Uint8ClampedArray | Float32Array; - -interface Planes { - readonly luma: Uint8ClampedArray; - readonly cb: Float32Array; - readonly cr: Float32Array; - readonly lumaWidth: number; - readonly chromaWidth: number; -} - -/** - * JPEG carries no alpha, so a transparent pixel still has to become some colour. Compositing - * over opaque white (`out = src*a + 255*(1-a)`) is why a transparent logo arrives white-backed - * instead of as the black box that dropping the alpha channel outright would produce. - * Padding replicates the last real row/column: zero-fill would put a hard step to black inside - * the edge MCU, and the DCT spreads that step back across visible pixels as a dark rim. - */ -function buildPlanes(raster: Raster, mcusX: number, mcusY: number): Planes { - const { width, height, pixels } = raster; - const lumaWidth = mcusX * MCU_SIZE; - const lumaHeight = mcusY * MCU_SIZE; - const chromaWidth = mcusX * 8; - const luma = new Uint8ClampedArray(lumaWidth * lumaHeight); - const cb = new Float32Array(chromaWidth * mcusY * 8); - const cr = new Float32Array(chromaWidth * mcusY * 8); - for (let y = 0; y < lumaHeight; y += 1) { - const sourceRow = (y < height ? y : height - 1) * width; - const chromaRow = (y >> 1) * chromaWidth; - const lumaRow = y * lumaWidth; - for (let x = 0; x < lumaWidth; x += 1) { - const p = (sourceRow + (x < width ? x : width - 1)) * 4; - const alpha = (pixels[p + 3] ?? 255) / 255; - const over = 255 * (1 - alpha); - const [yy, cbValue, crValue] = rgbToYcbcr( - (pixels[p] ?? 0) * alpha + over, - (pixels[p + 1] ?? 0) * alpha + over, - (pixels[p + 2] ?? 0) * alpha + over, - ); - luma[lumaRow + x] = yy; - const ci = chromaRow + (x >> 1); - cb[ci] = (cb[ci] ?? 0) + cbValue; - cr[ci] = (cr[ci] ?? 0) + crValue; - } - } - // Box-average, not point-sample: every chroma sample sees all four pixels it stands in for. - for (let i = 0; i < cb.length; i += 1) { - cb[i] = (cb[i] ?? 0) / 4; - cr[i] = (cr[i] ?? 0) / 4; - } - return { luma, cb, cr, lumaWidth, chromaWidth }; -} - -/** Copies an 8x8 block out of a plane, level-shifted to the DCT's signed range. */ -function extractBlock( - plane: Plane, - planeWidth: number, - blockX: number, - blockY: number, - out: Float64Array, -): void { - for (let row = 0; row < 8; row += 1) { - const source = (blockY * 8 + row) * planeWidth + blockX * 8; - for (let col = 0; col < 8; col += 1) { - out[row * 8 + col] = (plane[source + col] ?? 0) - 128; - } - } -} - -function quantise(block: Float64Array, divisors: Float64Array, out: Int32Array): void { - for (let i = 0; i < 64; i += 1) { - const value = Math.round((block[i] ?? 0) * (divisors[i] ?? 0)); - out[i] = Math.max(-MAX_COEFFICIENT, Math.min(MAX_COEFFICIENT, value)); - } -} - -function magnitude(value: number): number { - let bits = 0; - let rest = value < 0 ? -value : value; - while (rest > 0) { - bits += 1; - rest >>= 1; - } - return bits; -} - -/** T.81 F.1.2.1: a negative value travels as the low `size` bits of `value - 1`. */ -function writeValue(sink: JpegSink, value: number, size: number): void { - sink.writeBits(value < 0 ? value + (1 << size) - 1 : value, size); -} - -/** Returns this block's DC, which is the predictor for the next block of the same component. */ -function writeBlock( - sink: JpegSink, - coefficients: Int32Array, - dc: HuffmanEncoder, - ac: HuffmanEncoder, - previousDc: number, -): number { - const dcValue = coefficients[0] ?? 0; - const diff = dcValue - previousDc; - const dcSize = magnitude(diff); - sink.writeBits(dc.codes[dcSize] ?? 0, dc.lengths[dcSize] ?? 0); - writeValue(sink, diff, dcSize); - - let last = 0; - for (let k = 63; k >= 1; k -= 1) { - if ((coefficients[ZIGZAG[k] ?? 0] ?? 0) !== 0) { - last = k; - break; - } - } - let run = 0; - for (let k = 1; k <= last; k += 1) { - const value = coefficients[ZIGZAG[k] ?? 0] ?? 0; - if (value === 0) { - run += 1; - continue; - } - while (run >= 16) { - sink.writeBits(ac.codes[0xf0] ?? 0, ac.lengths[0xf0] ?? 0); - run -= 16; - } - const size = magnitude(value); - const symbol = (run << 4) | size; - sink.writeBits(ac.codes[symbol] ?? 0, ac.lengths[symbol] ?? 0); - writeValue(sink, value, size); - run = 0; - } - if (last < 63) sink.writeBits(ac.codes[0] ?? 0, ac.lengths[0] ?? 0); - return dcValue; -} - -interface QuantTables { - readonly luma: Uint8Array; - readonly chroma: Uint8Array; -} - -function writeQuantTable(sink: JpegSink, id: number, table: Uint8Array): void { - sink.marker(MARKER.dqt); - sink.word(67); - sink.byte(id); // High nibble 0 == 8-bit precision. - for (let k = 0; k < 64; k += 1) sink.byte(table[ZIGZAG[k] ?? 0] ?? 1); -} - -function writeHuffmanTable( - sink: JpegSink, - id: number, - bits: readonly number[], - values: readonly number[], -): void { - sink.marker(MARKER.dht); - sink.word(19 + values.length); - sink.byte(id); - for (let i = 0; i < 16; i += 1) sink.byte(bits[i] ?? 0); - for (const value of values) sink.byte(value); -} - -function writeHeaders(sink: JpegSink, raster: Raster, quant: QuantTables): void { - sink.marker(MARKER.soi); - sink.marker(MARKER.app0); - sink.word(16); - sink.bytes([0x4a, 0x46, 0x49, 0x46, 0x00, 1, 1, 0]); // 'JFIF\0', version 1.1, no density unit - sink.bytes([0, 1, 0, 1, 0, 0]); // 1:1 pixel aspect, no thumbnail - writeQuantTable(sink, 0, quant.luma); - writeQuantTable(sink, 1, quant.chroma); - sink.marker(MARKER.sof0); - sink.word(17); - sink.byte(8); - sink.word(raster.height); - sink.word(raster.width); - sink.byte(3); - sink.bytes([1, 0x22, 0]); // Y: h=2, v=2 — the 4:2:0 that halves both chroma planes - sink.bytes([2, 0x11, 1]); - sink.bytes([3, 0x11, 1]); - writeHuffmanTable(sink, 0x00, STD_DC_LUMINANCE_BITS, STD_DC_LUMINANCE_VALUES); - writeHuffmanTable(sink, 0x10, STD_AC_LUMINANCE_BITS, STD_AC_LUMINANCE_VALUES); - writeHuffmanTable(sink, 0x01, STD_DC_CHROMINANCE_BITS, STD_DC_CHROMINANCE_VALUES); - writeHuffmanTable(sink, 0x11, STD_AC_CHROMINANCE_BITS, STD_AC_CHROMINANCE_VALUES); - sink.marker(MARKER.sos); - sink.word(12); - sink.byte(3); - sink.bytes([1, 0x00, 2, 0x11, 3, 0x11]); - sink.bytes([0, 63, 0]); // Baseline: the whole spectral band, no successive approximation -} - -interface Component { - readonly plane: Plane; - readonly planeWidth: number; - readonly divisors: Float64Array; - readonly dc: HuffmanEncoder; - readonly ac: HuffmanEncoder; - /** Index into the DC predictor table — DC is differential per component across the scan. */ - readonly slot: number; -} - -function writeScan( - sink: JpegSink, - planes: Planes, - quant: QuantTables, - mcusX: number, - mcusY: number, -): void { - const shared = { planeWidth: planes.chromaWidth, dc: DC_CHROMA, ac: AC_CHROMA } as const; - const chromaDivisors = buildDivisors(quant.chroma); - const y: Component = { - plane: planes.luma, - planeWidth: planes.lumaWidth, - divisors: buildDivisors(quant.luma), - dc: DC_LUMA, - ac: AC_LUMA, - slot: 0, - }; - const cb: Component = { plane: planes.cb, divisors: chromaDivisors, slot: 1, ...shared }; - const cr: Component = { plane: planes.cr, divisors: chromaDivisors, slot: 2, ...shared }; - - const block = new Float64Array(64); - const coefficients = new Int32Array(64); - const predictors = new Int32Array(3); - const encodeOne = ( - { plane, planeWidth, divisors, dc, ac, slot }: Component, - bx: number, - by: number, - ): void => { - extractBlock(plane, planeWidth, bx, by, block); - forwardDct(block); - quantise(block, divisors, coefficients); - predictors[slot] = writeBlock(sink, coefficients, dc, ac, predictors[slot] ?? 0); - }; - for (let my = 0; my < mcusY; my += 1) { - for (let mx = 0; mx < mcusX; mx += 1) { - // Interleaved, in MCU order: four Y blocks, then the single Cb and Cr they share. - for (let b = 0; b < 4; b += 1) encodeOne(y, mx * 2 + (b & 1), my * 2 + (b >> 1)); - encodeOne(cb, mx, my); - encodeOne(cr, mx, my); - } - } - sink.flushBits(); -} - -/** 1-100. A non-finite quality is a caller bug we absorb rather than a reason to fail an encode. */ -function clampQuality(quality: number): number { - if (!Number.isFinite(quality)) return DEFAULT_JPEG_QUALITY; - return Math.min(100, Math.max(1, Math.round(quality))); -} - -/** - * The raster as baseline JPEG bytes. Deterministic: same raster and quality, same bytes, always - * — which is what lets a build cache a derived image by hashing its inputs. - */ -export function encodeJpeg(raster: Raster, quality: number = DEFAULT_JPEG_QUALITY): Uint8Array { - const scaled = clampQuality(quality); - const mcusX = Math.ceil(raster.width / MCU_SIZE); - const mcusY = Math.ceil(raster.height / MCU_SIZE); - const quant: QuantTables = { - luma: scaleQuantTable(STD_LUMINANCE_QUANT, scaled), - chroma: scaleQuantTable(STD_CHROMINANCE_QUANT, scaled), - }; - const sink = new JpegSink(Math.min(1 << 22, raster.width * raster.height) + 1024); - writeHeaders(sink, raster, quant); - writeScan(sink, buildPlanes(raster, mcusX, mcusY), quant, mcusX, mcusY); - sink.marker(MARKER.eoi); - return sink.finish(); -} diff --git a/packages/core/src/image/jpeg-headers.ts b/packages/core/src/image/jpeg-headers.ts deleted file mode 100644 index 203fcea4..00000000 --- a/packages/core/src/image/jpeg-headers.ts +++ /dev/null @@ -1,267 +0,0 @@ -// Single responsibility: what a JPEG's descriptive segments DECLARE, and what they are refused for -// declaring — the DQT tables, the SOF geometry, the SOS component selection, and every coding -// flavour this decoder names rather than guesses at. No entropy decoding and no pixels: that half -// is `jpeg-decode.ts`, and keeping the two apart is what keeps either inside one file's worth of job. - -import { imageDecodeFailed, imageUnsupported } from './errors'; -import type { HuffmanTable } from './jpeg-huffman'; -import { AAN_SCALE, ZIGZAG } from './jpeg-tables'; -import { assertPixelBudget } from './raster'; - -export const BASELINE_FIX = - 're-encode to baseline JPEG: `convert in.jpg -interlace none baseline.jpg`'; - -export const readU16 = (bytes: Uint8Array, at: number): number => - ((bytes[at] ?? 0) << 8) | (bytes[at + 1] ?? 0); - -export const hex = (value: number | undefined): string => - (value ?? 0).toString(16).toUpperCase().padStart(2, '0'); - -/** Every SOF flavour this decoder refuses, named the way a re-encoding tool names it. */ -const REFUSED_SOF: Readonly> = { - 194: 'SOF2 progressive DCT', - 195: 'SOF3 lossless', - 197: 'SOF5 differential sequential DCT', - 198: 'SOF6 differential progressive DCT', - 199: 'SOF7 differential lossless', - 201: 'SOF9 extended sequential DCT, arithmetic coding', - 202: 'SOF10 progressive DCT, arithmetic coding', - 203: 'SOF11 lossless, arithmetic coding', - 204: 'DAC arithmetic coding conditioning', - 205: 'SOF13 differential sequential DCT, arithmetic coding', - 206: 'SOF14 differential progressive DCT, arithmetic coding', - 207: 'SOF15 differential lossless, arithmetic coding', -}; - -/** Refused at the marker, before a single coefficient is read: the name is the whole point. */ -export function assertSupportedCoding(marker: number): void { - const refused = REFUSED_SOF[marker]; - if (refused === undefined) return; - throw imageUnsupported(`this JPEG is ${refused}, which is not baseline`, BASELINE_FIX, { - marker: `FF${hex(marker)}`, - }); -} - -/** APP14 payload `Adobe` + version + two flag words, then the colour transform at byte 11. */ -const ADOBE = [0x41, 0x64, 0x6f, 0x62, 0x65] as const; -export const isAdobe = (seg: Uint8Array): boolean => - seg.length >= 12 && ADOBE.every((byte, i) => seg[i] === byte); - -export interface Component { - readonly id: number; - readonly h: number; - readonly v: number; - readonly quantId: number; - /** MCU-padded sample plane; `stride` exceeds the image width whenever `h < maxH`. */ - readonly samples: Uint8ClampedArray; - readonly stride: number; - readonly blocksPerLine: number; - readonly blocksPerColumn: number; - dequant: Float32Array; - /** The running DC predictor, reset at every restart interval and at every scan. */ - pred: number; -} - -export interface Frame { - readonly width: number; - readonly height: number; - readonly components: readonly Component[]; - readonly maxH: number; - readonly maxV: number; - readonly mcusPerLine: number; - readonly mcusPerColumn: number; -} - -/** One component as this scan selects it — resolved once, so no block decode re-checks a table. */ -export interface ScanComponent { - readonly comp: Component; - readonly dc: HuffmanTable; - readonly ac: HuffmanTable; -} - -/** - * The shared AAN scale factors, folded into the quantisation table at DQT time along with the 1/8 - * the two-dimensional inverse owes. That is what lets `idct1d` be 11 multiplies per row instead of - * the 64 a direct evaluation costs, without a single scaling step in the per-block hot path. - * `Float32Array` because every value it multiplies is float32 — the widths must match, not the - * numbers only. - */ -const AAN = Float32Array.from(AAN_SCALE); - -/** DQT carries any number of tables; 16-bit precision doubles each entry. */ -export function readQuantTables(seg: Uint8Array, quant: Array): void { - let at = 0; - while (at < seg.length) { - const spec = seg[at] ?? 0; - at += 1; - const precision = spec >> 4; - const id = spec & 15; - if (precision > 1 || id > 3) { - throw imageDecodeFailed(`DQT declares table ${id} with precision code ${precision}`, { - id, - precision, - }); - } - const size = precision === 1 ? 2 : 1; - if (at + 64 * size > seg.length) { - throw imageDecodeFailed(`DQT table ${id} is truncated: it needs ${64 * size} more bytes`, { - id, - }); - } - const table = new Float32Array(64); - for (let k = 0; k < 64; k += 1) { - const value = size === 2 ? readU16(seg, at) : (seg[at] ?? 0); - at += size; - const natural = ZIGZAG[k] ?? 0; - table[natural] = value * (AAN[natural >> 3] ?? 1) * (AAN[natural & 7] ?? 1) * 0.125; - } - quant[id] = table; - } -} - -export function readFrame(seg: Uint8Array, marker: number): Frame { - if (seg.length < 6) throw imageDecodeFailed('the frame header (SOF) is shorter than its fields'); - const precision = seg[0] ?? 0; - if (precision !== 8) { - throw imageUnsupported( - `this JPEG codes ${precision}-bit samples; only 8-bit precision is decoded`, - BASELINE_FIX, - { precision, marker: `FF${hex(marker)}` }, - ); - } - const height = readU16(seg, 1); - const width = readU16(seg, 3); - assertPixelBudget(width, height, 'jpeg'); - const count = seg[5] ?? 0; - if (count !== 1 && count !== 3) { - const model = count === 4 ? 'CMYK or YCCK' : 'an unknown colour model'; - throw imageUnsupported( - `this JPEG has ${count} components (${model}); only 1-component greyscale and ` + - '3-component YCbCr are decoded', - BASELINE_FIX, - { components: count }, - ); - } - if (seg.length < 6 + count * 3) { - throw imageDecodeFailed(`the frame header ends before its ${count} component descriptors`); - } - const specs: Array = []; - let maxH = 1; - let maxV = 1; - for (let i = 0; i < count; i += 1) { - const at = 6 + i * 3; - const sampling = seg[at + 1] ?? 0; - const h = sampling >> 4; - const v = sampling & 15; - if (h < 1 || h > 4 || v < 1 || v > 4) { - throw imageDecodeFailed(`component ${i} declares ${h}x${v} sampling factors, outside 1-4`, { - h, - v, - }); - } - maxH = Math.max(maxH, h); - maxV = Math.max(maxV, v); - specs.push([seg[at] ?? 0, h, v, seg[at + 2] ?? 0]); - } - const mcusPerLine = Math.ceil(width / (8 * maxH)); - const mcusPerColumn = Math.ceil(height / (8 * maxV)); - const components = specs.map(([id, h, v, quantId]) => { - const blocksPerLine = mcusPerLine * h; - const blocksPerColumn = mcusPerColumn * v; - const stride = blocksPerLine * 8; - return { - id, - h, - v, - quantId, - samples: new Uint8ClampedArray(stride * blocksPerColumn * 8), - stride, - blocksPerLine, - blocksPerColumn, - dequant: new Float32Array(64), - pred: 0, - }; - }); - return { width, height, components, maxH, maxV, mcusPerLine, mcusPerColumn }; -} - -const pickTable = ( - tables: ReadonlyArray, - id: number, - kind: string, - component: number, -): HuffmanTable => { - const table = tables[id]; - if (table === undefined) { - throw imageDecodeFailed( - `component ${component} selects ${kind} Huffman table ${id}, which no DHT segment defined`, - { component, kind, table: id }, - ); - } - return table; -}; - -/** - * The last three SOS bytes, which a sequential scan is only allowed one value of: the whole band - * (Ss=0, Se=63) at full precision (Ah=Al=0). Anything else is a progressive scan's shape wearing a - * baseline frame header — `decodeBlock` would read its coefficients as complete ones and emit a - * plausible, wrong image, which is exactly what this decoder refuses to do. - */ -function assertWholeBand(seg: Uint8Array, count: number): void { - const ss = seg[1 + count * 2] ?? 0; - const se = seg[2 + count * 2] ?? 0; - const approx = seg[3 + count * 2] ?? 0; - const ah = approx >> 4; - const al = approx & 15; - if (ss === 0 && se === 63 && ah === 0 && al === 0) return; - throw imageUnsupported( - `this JPEG's scan codes coefficients ${ss}-${se} at successive approximation ${ah}/${al}; a ` + - 'sequential scan must carry the whole 0-63 band at 0/0', - BASELINE_FIX, - { ss, se, ah, al }, - ); -} - -/** - * SOS: the components this scan codes, bound to the tables each one selects. Resolving them once, - * here, is what leaves the per-block hot path with no lookups and no validation of its own. - */ -export function readScanHeader( - seg: Uint8Array, - frame: Frame, - quant: ReadonlyArray, - dcTables: ReadonlyArray, - acTables: ReadonlyArray, -): ScanComponent[] { - const count = seg[0] ?? 0; - if (count < 1 || seg.length < 1 + count * 2 + 3) { - throw imageDecodeFailed(`the scan header (SOS) declares ${count} components but is too short`); - } - assertWholeBand(seg, count); - const scan: ScanComponent[] = []; - for (let i = 0; i < count; i += 1) { - const id = seg[1 + i * 2] ?? 0; - const selector = seg[2 + i * 2] ?? 0; - const comp = frame.components.find((candidate) => candidate.id === id); - if (comp === undefined) { - throw imageDecodeFailed(`the scan names component ${id}, which the frame never declared`, { - component: id, - }); - } - const table = quant[comp.quantId]; - if (table === undefined) { - throw imageDecodeFailed( - `component ${id} selects quantisation table ${comp.quantId}, which no DQT segment defined`, - { component: id, table: comp.quantId }, - ); - } - comp.dequant = table; - comp.pred = 0; - scan.push({ - comp, - dc: pickTable(dcTables, selector >> 4, 'DC', id), - ac: pickTable(acTables, selector & 15, 'AC', id), - }); - } - return scan; -} diff --git a/packages/core/src/image/jpeg-huffman.ts b/packages/core/src/image/jpeg-huffman.ts deleted file mode 100644 index 8926337d..00000000 --- a/packages/core/src/image/jpeg-huffman.ts +++ /dev/null @@ -1,202 +0,0 @@ -// Single responsibility: JPEG's entropy layer — the canonical Huffman decode table derived from a -// DHT segment's bits/values, and the MSB-first bit reader that unstuffs `FF 00` and refuses to read -// through a marker. Split out of the decoder because it knows nothing of blocks, colour or frames. - -import { imageDecodeFailed } from './errors'; - -/** - * T.81 Annex F's `MINCODE`/`MAXCODE`/`VALPTR` form rather than a code tree: three flat arrays - * indexed by code length decode a symbol in one comparison per bit, with no pointer chasing and - * no allocation per block. `maxcode[l]` is -1 when the table has no code of length `l`. - */ -export interface HuffmanTable { - readonly mincode: Int32Array; - readonly maxcode: Int32Array; - readonly valptr: Int32Array; - readonly values: Uint8Array; -} - -/** `bits[i]` counts the codes of length `i + 1`; `values` lists the symbols in code order. */ -export function buildHuffmanTable(bits: Uint8Array, values: Uint8Array): HuffmanTable { - const mincode = new Int32Array(17); - const maxcode = new Int32Array(17).fill(-1); - const valptr = new Int32Array(17); - let code = 0; - let assigned = 0; - for (let length = 1; length <= 16; length += 1) { - const count = bits[length - 1] ?? 0; - if (count > 0) { - valptr[length] = assigned; - mincode[length] = code; - assigned += count; - code += count; - maxcode[length] = code - 1; - } - // More codes than the length can hold means the table is over-subscribed: some code would - // be a prefix of another and the scan would decode into a different image than it encodes. - if (code > 1 << length) { - throw imageDecodeFailed( - `a DHT table is over-subscribed at code length ${length}: it needs more codes than ` + - `${1 << length} distinct ones`, - { length, codes: code }, - ); - } - code <<= 1; - } - if (assigned !== values.length) { - throw imageDecodeFailed( - `a DHT table declares ${assigned} codes but carries ${values.length} symbols`, - { codes: assigned, symbols: values.length }, - ); - } - return { mincode, maxcode, valptr, values }; -} - -/** A DHT segment packs several tables, each a class/id byte then 16 counts then the symbols. */ -export function readHuffmanTables( - seg: Uint8Array, - dc: Array, - ac: Array, -): void { - let at = 0; - while (at < seg.length) { - const spec = seg[at] ?? 0; - at += 1; - const kind = spec >> 4; - const id = spec & 15; - if (kind > 1 || id > 3) { - throw imageDecodeFailed(`DHT declares class ${kind} table ${id}, outside class 0-1 id 0-3`, { - kind, - id, - }); - } - if (at + 16 > seg.length) { - throw imageDecodeFailed(`DHT table ${id} is truncated before its 16 code-length counts`, { - id, - }); - } - const bits = seg.subarray(at, at + 16); - at += 16; - let total = 0; - for (let i = 0; i < 16; i += 1) total += bits[i] ?? 0; - if (at + total > seg.length) { - throw imageDecodeFailed( - `DHT table ${id} declares ${total} symbols but the segment holds ${seg.length - at}`, - { id, symbols: total }, - ); - } - (kind === 0 ? dc : ac)[id] = buildHuffmanTable(bits, seg.subarray(at, at + total)); - at += total; - } -} - -/** - * Walks entropy-coded data one bit at a time, most significant first. Every failure is a coded - * error rather than a zero bit, because padding a truncated scan with zeros yields a plausible - * grey image and no signal that anything went wrong. - */ -export class JpegBitReader { - private readonly bytes: Uint8Array; - private at: number; - private buffer = 0; - private count = 0; - - constructor(bytes: Uint8Array, start: number) { - this.bytes = bytes; - this.at = start; - } - - /** Where the next unread byte begins — the marker walk resumes the file from here. */ - get position(): number { - return this.at; - } - - private nextByte(): number { - if (this.at >= this.bytes.length) { - throw imageDecodeFailed('the entropy-coded data ends before the scan does (truncated JPEG)', { - at: this.at, - }); - } - const byte = this.bytes[this.at] ?? 0; - this.at += 1; - if (byte !== 0xff) return byte; - let next = this.bytes[this.at]; - while (next === 0xff) { - this.at += 1; // repeated 0xFF ahead of a marker is fill, and carries no bits - next = this.bytes[this.at]; - } - if (next === 0x00) { - this.at += 1; // `FF 00` is a stuffed literal 0xFF sample byte - return 0xff; - } - this.at -= 1; - throw imageDecodeFailed( - next === undefined - ? 'the entropy-coded data ends inside a marker (truncated JPEG)' - : `marker FF${next.toString(16).toUpperCase().padStart(2, '0')} interrupts the scan ` + - 'before its last block was decoded', - { at: this.at, marker: next ?? null }, - ); - } - - readBit(): number { - if (this.count === 0) { - this.buffer = this.nextByte(); - this.count = 8; - } - this.count -= 1; - return (this.buffer >> this.count) & 1; - } - - receive(length: number): number { - let value = 0; - for (let i = 0; i < length; i += 1) value = (value << 1) | this.readBit(); - return value; - } - - /** T.81's EXTEND: an `length`-bit magnitude whose top bit is 0 is the negative half of the range. */ - receiveAndExtend(length: number): number { - if (length === 0) return 0; - const value = this.receive(length); - return value < 1 << (length - 1) ? value - (1 << length) + 1 : value; - } - - decode(table: HuffmanTable): number { - let code = this.readBit(); - for (let length = 1; length <= 16; length += 1) { - const max = table.maxcode[length] ?? -1; - if (max >= 0 && code <= max) { - const symbol = - table.values[(table.valptr[length] ?? 0) + code - (table.mincode[length] ?? 0)]; - if (symbol === undefined) { - throw imageDecodeFailed( - `a ${length}-bit Huffman code resolves outside the symbols its table defines`, - { length, code }, - ); - } - return symbol; - } - if (length < 16) code = (code << 1) | this.readBit(); - } - throw imageDecodeFailed('no Huffman code of 16 bits or fewer matches the entropy data', { - code, - }); - } - - /** - * Byte-aligns and swallows one `RSTn` marker, reporting whether it was there. The bit buffer - * dies with it: a restart interval exists precisely so a decoder can resynchronise mid-scan. - */ - restart(): boolean { - this.count = 0; - this.buffer = 0; - let at = this.at; - while (this.bytes[at] === 0xff && this.bytes[at + 1] === 0xff) at += 1; - const marker = this.bytes[at] === 0xff ? this.bytes[at + 1] : undefined; - if (marker !== undefined && marker >= 0xd0 && marker <= 0xd7) { - this.at = at + 2; - return true; - } - return false; - } -} diff --git a/packages/core/src/image/jpeg-tables.ts b/packages/core/src/image/jpeg-tables.ts deleted file mode 100644 index c67cd7d1..00000000 --- a/packages/core/src/image/jpeg-tables.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Single responsibility: the constant tables JPEG's decoder and encoder must agree on — -// zig-zag order, the Annex K quantisation tables and the Annex K Huffman code lengths. -// They live here, once, so the two halves of the codec structurally cannot drift apart. - -/** Zig-zag index -> natural (row-major) index within an 8x8 block. */ -export const ZIGZAG: readonly number[] = Object.freeze([ - 0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27, 20, - 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51, 58, 59, 52, - 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63, -]); - -/** - * cos(k*PI/16) * sqrt(2) — the scaling an AAN butterfly leaves in its output. The encoder folds it - * into the quantiser and the decoder into the dequantiser, so both halves read the same 8 numbers: - * a decoder scaled by anything else than the encoder assumed reads every coefficient wrong. - */ -export const AAN_SCALE: readonly number[] = Object.freeze([ - 1, 1.387039845, 1.306562965, 1.175875602, 1, 0.785694958, 0.5411961, 0.275899379, -]); - -/** ITU T.81 Annex K.1, luminance, in NATURAL order. */ -export const STD_LUMINANCE_QUANT: readonly number[] = Object.freeze([ - 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, - 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68, 109, 103, 77, 24, 35, 55, 64, 81, 104, 113, - 92, 49, 64, 78, 87, 103, 121, 120, 101, 72, 92, 95, 98, 112, 100, 103, 99, -]); - -/** ITU T.81 Annex K.1, chrominance, in NATURAL order. */ -export const STD_CHROMINANCE_QUANT: readonly number[] = Object.freeze([ - 17, 18, 24, 47, 99, 99, 99, 99, 18, 21, 26, 66, 99, 99, 99, 99, 24, 26, 56, 99, 99, 99, 99, 99, - 47, 66, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, - 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, 99, -]); - -/** Annex K.3 code-length counts (index 0 == codes of length 1) and the symbols they map to. */ -export const STD_DC_LUMINANCE_BITS: readonly number[] = Object.freeze([ - 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, -]); -export const STD_DC_LUMINANCE_VALUES: readonly number[] = Object.freeze([ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, -]); - -export const STD_DC_CHROMINANCE_BITS: readonly number[] = Object.freeze([ - 0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, -]); -export const STD_DC_CHROMINANCE_VALUES: readonly number[] = Object.freeze([ - 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, -]); - -export const STD_AC_LUMINANCE_BITS: readonly number[] = Object.freeze([ - 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7d, -]); -export const STD_AC_LUMINANCE_VALUES: readonly number[] = Object.freeze([ - 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, - 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, - 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, - 0x29, 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, - 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, - 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, - 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, - 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, - 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, - 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, - 0xf9, 0xfa, -]); - -export const STD_AC_CHROMINANCE_BITS: readonly number[] = Object.freeze([ - 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77, -]); -export const STD_AC_CHROMINANCE_VALUES: readonly number[] = Object.freeze([ - 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, - 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, - 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, - 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, - 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, - 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, - 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, - 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, - 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, - 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, - 0xf9, 0xfa, -]); - -export const DEFAULT_JPEG_QUALITY = 80; - -/** - * Annex K's quality scaling, the same curve libjpeg uses, so a `quality: 80` here is the - * `quality: 80` every other tool means. Returns a table in NATURAL order. - */ -export function scaleQuantTable(base: readonly number[], quality: number): Uint8Array { - const clamped = Math.min(100, Math.max(1, Math.round(quality))); - const scale = clamped < 50 ? Math.floor(5000 / clamped) : 200 - clamped * 2; - const table = new Uint8Array(64); - for (let i = 0; i < 64; i += 1) { - const value = Math.floor((((base[i] ?? 1) * scale + 50) / 100) | 0); - table[i] = Math.min(255, Math.max(1, value)); - } - return table; -} - -/** BT.601 full-range, the transform every baseline JFIF file is written in. */ -export function rgbToYcbcr(r: number, g: number, b: number): readonly [number, number, number] { - return [ - 0.299 * r + 0.587 * g + 0.114 * b, - 128 - 0.168736 * r - 0.331264 * g + 0.5 * b, - 128 + 0.5 * r - 0.418688 * g - 0.081312 * b, - ]; -} - -/** The exact inverse of `rgbToYcbcr`; both halves must round the same way. */ -export function ycbcrToRgb(y: number, cb: number, cr: number): readonly [number, number, number] { - return [ - y + 1.402 * (cr - 128), - y - 0.344136 * (cb - 128) - 0.714136 * (cr - 128), - y + 1.772 * (cb - 128), - ]; -} diff --git a/packages/core/src/image/pipeline.test.ts b/packages/core/src/image/pipeline.test.ts index 515e0301..0a70ab32 100644 --- a/packages/core/src/image/pipeline.test.ts +++ b/packages/core/src/image/pipeline.test.ts @@ -1,45 +1,61 @@ -// Single responsibility: proves the ONE entry point's contract — that decode dispatches on the -// magic bytes, that the published capability lists are the truth, and that `transformImageBytes` -// is literally decode+resize+encode composed. The last one is what stops a second copy of the -// pipeline appearing in `storage`, `seo` or `pwa` the first time one of them needs a variant. +// Single responsibility: proves the ONE entry point's contract — that the published capability +// lists are the truth, that a transform lands on the box it promised, and that the same bytes and +// spec produce the same OUTPUT BYTES. The last one is not a nicety: `variantKey` is content- +// addressed, so a re-encode that differed per run or per platform is a cache that never hits. import { describe, expect, test } from 'bun:test'; -import { ImageDecodeFailedError, ImageUnsupportedError } from './errors'; +import { ImageDecodeFailedError, ImageTooLargeError, ImageUnsupportedError } from './errors'; import { AVIF_12X16, fixtureBytes, GIF_5X7, + gradientPixel, + type ImageFixture, + JPEG_420_16X16, + JPEG_420_ODD_33X17, JPEG_444_16X16, + jpegPixel, + oddJpegPixel, PNG_GRADIENT_32X24, + PNG_GRAY_2X2, + PNG_GRAY_ALPHA_2X2, + PNG_GRAY16_2X2, + PNG_PALETTE_4X1, PNG_RGB_3X2, PNG_RGBA_4X4, SVG_120X45, WEBP_9X11, } from './fixtures'; import { - BLUR_PLACEHOLDER_WIDTH, blurDataUrl, canDecode, canEncode, DECODABLE_FORMATS, dataUrl, - decodeImage, - defaultFormatFor, ENCODABLE_FORMATS, - encodeImage, transformImageBytes, } from './pipeline'; -import { encodePng } from './png'; -import { IMAGE_FORMATS, type ImageFormat, probeImage } from './probe'; -import { createRaster, type Raster } from './raster'; -import { resizeRaster } from './resize'; - -const thrown = (run: () => unknown): { code: string; cause: string; fix: string } => { +import { crc32, writeU32 } from './png-bytes'; +import { decodeImage, encodeImage } from './png-pixels'; +import { IMAGE_FORMATS, probeImage } from './probe'; +import { createRaster, MAX_IMAGE_PIXELS, type Raster } from './raster'; + +interface Failure { + readonly code: string; + readonly cause: string; + readonly fix: string; +} + +const thrown = async (run: () => Promise): Promise => { try { - run(); - return { code: 'no-throw', cause: 'no-throw', fix: 'no-throw' }; + await run(); + return { code: 'no-throw', cause: '', fix: '' }; } catch (error) { - if (error instanceof ImageUnsupportedError || error instanceof ImageDecodeFailedError) { + if ( + error instanceof ImageUnsupportedError || + error instanceof ImageDecodeFailedError || + error instanceof ImageTooLargeError + ) { return { code: error.code, cause: error.cause, fix: error.fix }; } return { code: `unexpected: ${String(error)}`, cause: '', fix: '' }; @@ -68,161 +84,196 @@ const noise = (width: number, height: number): Raster => { return raster; }; +type Formula = (x: number, y: number) => readonly [number, number, number]; + +const luma = (r: number, g: number, b: number): number => 0.299 * r + 0.587 * g + 0.114 * b; + +/** Mean absolute luma error against the formula the fixture's encoder was fed. */ +function lumaError(raster: Raster, formula: Formula): number { + let sum = 0; + for (let y = 0; y < raster.height; y += 1) { + for (let x = 0; x < raster.width; x += 1) { + const o = (y * raster.width + x) * 4; + const want = formula(x, y); + sum += Math.abs( + luma(raster.pixels[o] ?? 0, raster.pixels[o + 1] ?? 0, raster.pixels[o + 2] ?? 0) - + luma(want[0], want[1], want[2]), + ); + } + } + return sum / (raster.width * raster.height); +} + const bytesOfDataUrl = (uri: string): Uint8Array => { const base64 = uri.slice(uri.indexOf(',') + 1); return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); }; +/** A real PNG whose IHDR claims more pixels than the ceiling, CRC repaired so it parses. */ +const bombHeader = (): Uint8Array => { + const bytes = Uint8Array.from(encodeImage(solid(2, 2, 255))); + writeU32(bytes, 16, 0xffff); + writeU32(bytes, 20, 0xffff); + writeU32(bytes, 29, crc32(bytes, 12, 29)); + return bytes; +}; + describe('capability lists', () => { test('every format the pipeline claims to handle is a format it can identify', () => { const known: readonly string[] = IMAGE_FORMATS; expect([...DECODABLE_FORMATS, ...ENCODABLE_FORMATS].every((f) => known.includes(f))).toBe(true); }); - test('canDecode and canEncode answer for the whole union, not just the built-in half', () => { - const decodable = IMAGE_FORMATS.filter(canDecode); - const encodable = IMAGE_FORMATS.filter(canEncode); - expect(decodable).toEqual([...DECODABLE_FORMATS]); - expect(encodable).toEqual([...ENCODABLE_FORMATS]); + test('canDecode and canEncode answer for the whole union, not just the encodable half', () => { + expect(IMAGE_FORMATS.filter(canDecode)).toEqual([...DECODABLE_FORMATS]); + expect(IMAGE_FORMATS.filter(canEncode)).toEqual([...ENCODABLE_FORMATS]); }); - test('webp and avif are probeable but never encodable — the list must say so', () => { - expect(canEncode('webp')).toBe(false); + test('webp joined the encodable list; avif and svg did not, and the list must say so', () => { + expect(canEncode('webp')).toBe(true); + // AVIF needs an OS codec the portable backend never uses, so it is refused on EVERY platform + // rather than working on a laptop and failing on the node that serves the variant. expect(canEncode('avif')).toBe(false); - expect(IMAGE_FORMATS.includes('webp')).toBe(true); + expect(canEncode('svg')).toBe(false); + expect(canDecode('svg')).toBe(false); }); }); -describe('decodeImage', () => { - test('dispatches on the magic bytes: PNG', () => { - const raster = decodeImage(fixtureBytes(PNG_RGBA_4X4)); - expect([raster.width, raster.height]).toEqual([4, 4]); - expect([...raster.pixels.slice(0, 4)]).toEqual([0, 0, 0, 255]); - }); - - test('dispatches on the magic bytes: JPEG', () => { - const raster = decodeImage(fixtureBytes(JPEG_444_16X16)); - expect([raster.width, raster.height]).toEqual([16, 16]); +describe('decoding, against an independent encoder', () => { + // Pillow and ffmpeg wrote these bytes and the expected pixels; the pipeline agreeing with them + // is what makes "the codec changed" a failure rather than a mutually agreed hallucination. + test.each([ + ['truecolour + alpha', PNG_RGBA_4X4], + ['greyscale', PNG_GRAY_2X2], + ['greyscale + alpha', PNG_GRAY_ALPHA_2X2], + ['16 bits per channel', PNG_GRAY16_2X2], + ['indexed colour with tRNS', PNG_PALETTE_4X1], + ])('decodes %s to the reference pixels', async (_label, fixture: ImageFixture) => { + const out = await transformImageBytes(fixtureBytes(fixture), { format: 'png' }); + expect([...decodeImage(out).pixels]).toEqual([...(fixture.pixels ?? [])]); }); test.each([ ['webp', WEBP_9X11], - ['avif', AVIF_12X16], ['gif', GIF_5X7], - ['svg', SVG_120X45], - ])('%s is identified and then refused, never silently decoded', (format, fixture) => { - const failure = thrown(() => decodeImage(fixtureBytes(fixture))); - expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); - // Naming the format it recognised is the difference between "convert your AVIF" and a shrug. - expect(failure.cause).toContain(format); - expect(failure.fix).toContain('PNG or JPEG'); - }); - - test('bytes matching no format at all name that, not a codec', () => { - const failure = thrown(() => decodeImage(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))); - expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); - expect(failure.fix).toContain('ImageTransformDriver'); - }); - - test('a truncated PNG is a decode failure, not a black image', () => { - const truncated = fixtureBytes(PNG_GRADIENT_32X24).subarray(0, 30); - expect(thrown(() => decodeImage(truncated)).code).toBe('X_IMAGE_DECODE_FAILED'); - }); -}); - -describe('encodeImage', () => { - test('PNG round trips exactly — it is the lossless half of the pipeline', () => { - const source = decodeImage(fixtureBytes(PNG_RGBA_4X4)); - const decoded = decodeImage(encodeImage(source, 'png')); - expect([...decoded.pixels]).toEqual([...source.pixels]); - }); - - test('JPEG produces a JPEG of the same size', () => { - const source = decodeImage(fixtureBytes(PNG_GRADIENT_32X24)); - expect(probeImage(encodeImage(source, 'jpeg'))).toMatchObject({ - format: 'jpeg', - width: 32, - height: 24, + ])('%s is decodable now — it was probe-only before Bun.Image', async (_label, fixture) => { + const out = await transformImageBytes(fixtureBytes(fixture), { format: 'png' }); + expect(probeImage(out)).toMatchObject({ + format: 'png', + width: fixture.width, + height: fixture.height, }); }); - test('quality is honoured — a lower number is fewer bytes', () => { - const source = decodeImage(fixtureBytes(PNG_GRADIENT_32X24)); - expect(encodeImage(source, 'jpeg', 30).length).toBeLessThan( - encodeImage(source, 'jpeg', 95).length, + test('a filtered PNG decodes to the formula its reference encoder was fed', async () => { + // PNG_GRADIENT_32X24 is big enough that Pillow picked a different row filter per scanline. + const raster = decodeImage( + await transformImageBytes(fixtureBytes(PNG_GRADIENT_32X24), { format: 'png' }), ); + for (const [x, y] of [ + [0, 0], + [31, 23], + [17, 9], + ] as const) { + const at = (y * 32 + x) * 4; + expect([...raster.pixels.slice(at, at + 4)]).toEqual([...gradientPixel(x, y), 255]); + } }); - test('quality is ignored by PNG rather than changing the bytes', () => { - const source = decodeImage(fixtureBytes(PNG_RGBA_4X4)); - expect([...encodeImage(source, 'png', 10)]).toEqual([...encodeImage(source, 'png', 90)]); - }); - - test.each(['webp', 'avif', 'gif', 'svg'] as const)( - 'encoding %s is refused with a fix that names the way out', - (format: ImageFormat) => { - const failure = thrown(() => encodeImage(solid(2, 2, 255), format)); - expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); - expect(failure.fix).toContain('ImageTransformDriver'); + test.each([ + ['4:4:4', JPEG_444_16X16, jpegPixel, 5], + ['4:2:0', JPEG_420_16X16, jpegPixel, 5], + ['4:2:0 with odd dimensions', JPEG_420_ODD_33X17, oddJpegPixel, 8], + ])( + 'a %s JPEG decodes to within a lossy tolerance of the reference formula', + async (_label, fixture: ImageFixture, formula: Formula, tolerance) => { + const raster = decodeImage( + await transformImageBytes(fixtureBytes(fixture), { format: 'png' }), + ); + // Mean luma error, not a per-pixel channel max: chroma is quantised hard at 4:2:0 and a + // single-pixel bound would only be satisfiable by a tolerance that asserts nothing. + expect([raster.width, raster.height]).toEqual([fixture.width, fixture.height]); + expect(lumaError(raster, formula)).toBeLessThan(tolerance); + // The odd case pads to whole MCUs and the padding must be CROPPED, not returned. Scoring a + // SHIFTED reference is what makes alignment observable at all: a decode off by two columns + // would score the shifted formula BETTER, and the strict ordering below is the other way. + expect(lumaError(raster, (x, y) => formula(x + 2, y))).toBeGreaterThan( + lumaError(raster, formula), + ); }, ); -}); -describe('defaultFormatFor', () => { - test('a raster with any transparency stays PNG', () => { - expect(defaultFormatFor(solid(2, 2, 128))).toBe('png'); + test('bytes matching no format at all are refused with a runnable way forward', async () => { + const failure = await thrown(() => transformImageBytes(new Uint8Array(32).fill(3))); + expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); + expect(failure.fix).toContain('file '); }); - test('a fully opaque raster becomes JPEG', () => { - expect(defaultFormatFor(solid(2, 2, 255))).toBe('jpeg'); + test('SVG is markup, not pixels — it is measured by probeImage and refused here', async () => { + expect(probeImage(fixtureBytes(SVG_120X45))).toMatchObject({ width: 120, height: 45 }); + expect((await thrown(() => transformImageBytes(fixtureBytes(SVG_120X45)))).code).toBe( + 'X_IMAGE_UNSUPPORTED', + ); }); - test('a single non-opaque pixel is enough — a logo never grows a black background', () => { - const raster = solid(4, 4, 255); - raster.pixels[15] = 254; - expect(defaultFormatFor(raster)).toBe('png'); + test('a truncated PNG is a decode failure, not a black image', async () => { + const truncated = fixtureBytes(PNG_GRADIENT_32X24).subarray(0, 30); + expect((await thrown(() => transformImageBytes(truncated))).code).toBe('X_IMAGE_DECODE_FAILED'); }); -}); -describe('transformImageBytes', () => { - test('is exactly decode + resize + encode, byte for byte', () => { - const bytes = fixtureBytes(PNG_GRADIENT_32X24); - const manual = encodePng(resizeRaster(decodeImage(bytes), { width: 8 })); - expect([...transformImageBytes(bytes, { width: 8, format: 'png' })]).toEqual([...manual]); + test('the decompression-bomb ceiling is refused from the header, before any allocation', async () => { + const failure = await thrown(() => transformImageBytes(bombHeader())); + expect(failure.code).toBe('X_IMAGE_TOO_LARGE'); + expect(failure.fix).toContain('MAX_IMAGE_PIXELS'); + expect(0xffff * 0xffff).toBeGreaterThan(MAX_IMAGE_PIXELS); }); +}); - test('resizes to the requested width and reports it back through the header', () => { - const out = transformImageBytes(fixtureBytes(PNG_GRADIENT_32X24), { +describe('transformImageBytes', () => { + test('resizes to the requested width and reports it back through the header', async () => { + const out = await transformImageBytes(fixtureBytes(PNG_GRADIENT_32X24), { width: 16, format: 'jpeg', }); expect(probeImage(out)).toMatchObject({ format: 'jpeg', width: 16, height: 12 }); }); - test('never upscales: a width above the intrinsic one clamps to the source', () => { - const out = transformImageBytes(fixtureBytes(PNG_RGB_3X2), { width: 800, format: 'png' }); + test('never upscales: a width above the intrinsic one clamps to the source', async () => { + const out = await transformImageBytes(fixtureBytes(PNG_RGB_3X2), { width: 800, format: 'png' }); expect(probeImage(out)).toMatchObject({ width: 3, height: 2 }); }); - test('with no spec at all it re-encodes at the source size in a source-derived format', () => { - const out = transformImageBytes(fixtureBytes(PNG_RGBA_4X4)); - expect(probeImage(out)).toMatchObject({ format: 'png', width: 4, height: 4 }); + test('with no spec at all it re-encodes at the source size, in the source format', async () => { + expect(probeImage(await transformImageBytes(fixtureBytes(PNG_RGBA_4X4)))).toMatchObject({ + format: 'png', + width: 4, + height: 4, + }); + expect(probeImage(await transformImageBytes(fixtureBytes(JPEG_444_16X16))).format).toBe('jpeg'); }); - test('an opaque source with no format asked for comes back as JPEG', () => { - expect(probeImage(transformImageBytes(fixtureBytes(PNG_RGB_3X2))).format).toBe('jpeg'); + test('a source format the pipeline cannot WRITE falls back to PNG, never to a refusal', async () => { + // GIF decodes and does not encode. Keeping the source format would refuse a legal transform. + expect(probeImage(await transformImageBytes(fixtureBytes(GIF_5X7))).format).toBe('png'); }); - test('the format decision is made on the RESIZED pixels, not the source', () => { - // Resampling a hard alpha edge can only ever produce more partial alpha, never less, so a - // source with transparency must still be PNG after the scaler has run. - expect(probeImage(transformImageBytes(fixtureBytes(PNG_RGBA_4X4), { width: 2 })).format).toBe( - 'png', - ); + test('webp is a real output now, at the size that was asked for', async () => { + const out = await transformImageBytes(fixtureBytes(PNG_GRADIENT_32X24), { + width: 8, + format: 'webp', + }); + expect(probeImage(out)).toMatchObject({ format: 'webp', width: 8, height: 6 }); }); - test('padding and background reach the scaler', () => { - const out = transformImageBytes(fixtureBytes(PNG_RGB_3X2), { + test('quality is honoured — a lower number is fewer bytes', async () => { + const bytes = encodeImage(noise(64, 64)); + const low = await transformImageBytes(bytes, { format: 'jpeg', quality: 20 }); + const high = await transformImageBytes(bytes, { format: 'jpeg', quality: 95 }); + expect(low.length).toBeLessThan(high.length); + }); + + test('padding and background reach the canvas', async () => { + const out = await transformImageBytes(fixtureBytes(PNG_RGB_3X2), { width: 10, height: 10, padding: 0.2, @@ -234,32 +285,83 @@ describe('transformImageBytes', () => { expect([...raster.pixels.slice(0, 4)]).toEqual([255, 0, 0, 255]); }); - test('a format the pipeline cannot write fails before the source is even decoded', () => { - const failure = thrown(() => - transformImageBytes(fixtureBytes(PNG_RGB_3X2), { width: 2, format: 'webp' }), + test('a transparent padding survives the composite as real alpha, not as black', async () => { + const out = await transformImageBytes(encodeImage(solid(8, 8, 255)), { + width: 20, + height: 20, + padding: 0.25, + format: 'png', + }); + const raster = decodeImage(out); + expect([...raster.pixels.slice(0, 4)]).toEqual([0, 0, 0, 0]); + expect(raster.pixels[(10 * 20 + 10) * 4 + 3]).toBe(255); + }); + + test('the composite path is WRITTEN by libspng, not by the raw seam it transports through', async () => { + const out = await transformImageBytes(encodeImage(solid(64, 64, 255)), { + width: 64, + height: 64, + padding: 0.1, + format: 'png', + }); + // Same pixels through `png-pixels.ts` (filter 0) are strictly more bytes. Returning the + // transport encoding would ship every PWA icon 1.2-1.8x larger than it needs to be. + expect(out.length).toBeLessThan(encodeImage(decodeImage(out)).length); + }); + + test('backend is pinned to the static codecs, so the bytes do not depend on the OS', async () => { + // Set on every call, never once at import: the property is process-global and writable, and + // an app flipping it to 'system' would otherwise mint macOS-only bytes under a shared key. + Bun.Image.backend = 'system'; + await transformImageBytes(fixtureBytes(PNG_RGB_3X2), { width: 2, format: 'png' }); + // Read through a call: after the assignment above, TS narrows the property to the literal + // 'system' for the rest of the block — which is precisely the claim under test, so comparing + // the narrowed reference would be a type error over a correct assertion. + const backend = (): string => Bun.Image.backend; + expect(backend()).toBe('bun'); + }); + + test('a format the pipeline cannot write fails before the source is even decoded', async () => { + const failure = await thrown(() => + transformImageBytes(fixtureBytes(PNG_RGB_3X2), { width: 2, format: 'avif' }), ); expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); - expect(failure.cause).toContain('webp'); + expect(failure.cause).toContain('avif'); // Bytes that cannot decode at all are what makes the early exit observable: reaching the // format refusal instead of X_IMAGE_DECODE_FAILED proves the spec was answered first, before // 64 megapixels were expanded and resampled for an encoder that was never going to run. - const undecodable = thrown(() => + const undecodable = await thrown(() => transformImageBytes(fixtureBytes(PNG_GRADIENT_32X24).subarray(0, 30), { format: 'avif' }), ); expect(undecodable.code).toBe('X_IMAGE_UNSUPPORTED'); expect(undecodable.cause).toContain('encoding avif'); + expect(undecodable.fix).toContain('ImageTransformDriver'); }); - test('is deterministic — the same bytes and spec produce the same output', () => { + test.each([ + ['the fast path', { width: 12, format: 'jpeg', quality: 70 } as const], + ['the composite path', { width: 12, height: 12, padding: 0.1, format: 'png' } as const], + ])('is deterministic on %s — the same bytes and spec, the same output', async (_label, spec) => { const bytes = fixtureBytes(PNG_GRADIENT_32X24); - const spec = { width: 12, format: 'jpeg', quality: 70 } as const; - expect([...transformImageBytes(bytes, spec)]).toEqual([...transformImageBytes(bytes, spec)]); + const [first, second] = await Promise.all([ + transformImageBytes(bytes, spec), + transformImageBytes(bytes, spec), + ]); + expect([...first]).toEqual([...second]); + }); + + test('the same source re-encoded twice hashes identically, which is what variantKey assumes', async () => { + const bytes = fixtureBytes(JPEG_444_16X16); + const spec = { width: 8, format: 'webp' } as const; + const hash = async (): Promise => + Bun.SHA256.hash(await transformImageBytes(bytes, spec), 'hex'); + expect(await hash()).toBe(await hash()); }); }); describe('dataUrl', () => { test('carries the format mime type and round trips the bytes', () => { - const bytes = encodePng(solid(2, 2, 255)); + const bytes = encodeImage(solid(2, 2, 255)); const uri = dataUrl(bytes, 'png'); expect(uri.startsWith('data:image/png;base64,')).toBe(true); expect([...bytesOfDataUrl(uri)]).toEqual([...bytes]); @@ -269,41 +371,43 @@ describe('dataUrl', () => { // The chunked base64 exists for exactly this: `String.fromCharCode(...bytes)` spread over a // whole photograph throws RangeError, and a photograph is the normal case. Noise, because a // flat colour deflates to a couple of kilobytes and would never reach the chunk boundary. - const bytes = encodePng(noise(200, 200)); + const bytes = encodeImage(noise(400, 400)); expect(bytes.length).toBeGreaterThan(0x8000); expect([...bytesOfDataUrl(dataUrl(bytes, 'png'))]).toEqual([...bytes]); }); }); describe('blurDataUrl', () => { - test('is a 16px-wide PNG data URI by default', () => { - const uri = blurDataUrl(fixtureBytes(PNG_GRADIENT_32X24)); + test('is a PNG data URI at most 32px on its long edge', async () => { + const uri = await blurDataUrl(fixtureBytes(PNG_GRADIENT_32X24)); expect(uri.startsWith('data:image/png;base64,')).toBe(true); - expect(probeImage(bytesOfDataUrl(uri))).toMatchObject({ - format: 'png', - width: BLUR_PLACEHOLDER_WIDTH, - height: 12, - }); + const info = probeImage(bytesOfDataUrl(uri)); + expect(info.format).toBe('png'); + expect(Math.max(info.width, info.height)).toBeLessThanOrEqual(32); + }); + + test('keeps the source aspect ratio, so the placeholder reserves the right box', async () => { + const info = probeImage(bytesOfDataUrl(await blurDataUrl(encodeImage(solid(300, 900, 255))))); + expect(info.height).toBeGreaterThan(info.width * 2); }); - test('is PNG even for a JPEG source — at 16px the JPEG headers cost more than the pixels', () => { - const uri = blurDataUrl(fixtureBytes(JPEG_444_16X16)); + test('is PNG even for a JPEG source — at this size the JPEG headers cost more than the pixels', async () => { + const uri = await blurDataUrl(fixtureBytes(JPEG_444_16X16)); expect(probeImage(bytesOfDataUrl(uri)).format).toBe('png'); }); - test('honours an explicit width', () => { - const uri = blurDataUrl(fixtureBytes(PNG_GRADIENT_32X24), 8); - expect(probeImage(bytesOfDataUrl(uri))).toMatchObject({ width: 8, height: 6 }); + test('stays small enough to inline in a document head', async () => { + expect((await blurDataUrl(fixtureBytes(PNG_GRADIENT_32X24))).length).toBeLessThan(2048); }); - test('keeps alpha, so the placeholder behind a logo is not a black square', () => { - const raster = decodeImage(bytesOfDataUrl(blurDataUrl(fixtureBytes(PNG_RGBA_4X4), 4))); - expect([...raster.pixels.slice(0, 16)].filter((_, i) => i % 4 === 3)).not.toEqual([ - 255, 255, 255, 255, - ]); + test('is deterministic — an LQIP inlined in HTML must not change the page hash per render', async () => { + const bytes = fixtureBytes(PNG_GRADIENT_32X24); + expect(await blurDataUrl(bytes)).toBe(await blurDataUrl(bytes)); }); - test('stays small enough to inline in a document head', () => { - expect(blurDataUrl(fixtureBytes(PNG_GRADIENT_32X24)).length).toBeLessThan(2048); + test('an undecodable source rejects with a code, never a bare Error', async () => { + expect((await thrown(() => blurDataUrl(fixtureBytes(AVIF_12X16)))).code).toBe( + 'X_IMAGE_UNSUPPORTED', + ); }); }); diff --git a/packages/core/src/image/pipeline.ts b/packages/core/src/image/pipeline.ts index 542e7eec..b75c04de 100644 --- a/packages/core/src/image/pipeline.ts +++ b/packages/core/src/image/pipeline.ts @@ -1,94 +1,117 @@ -// Single responsibility: THE image pipeline. Decode -> resize -> encode, one entry point, -// one capability list. `storage`, `seo` and `pwa` all call this file and none of them owns a -// second copy: responsive variants, blur placeholders and PWA icons are the same three steps -// with different numbers, and a framework that generated them twice would drift twice. - -import { imageUnsupported } from './errors'; -import { decodeJpeg } from './jpeg-decode'; -import { encodeJpeg } from './jpeg-encode'; -import { DEFAULT_JPEG_QUALITY } from './jpeg-tables'; -import { decodePng, encodePng } from './png'; -import { IMAGE_MIME_TYPES, type ImageFormat, probeImage, sniffImageFormat } from './probe'; -import { hasAlpha, type Raster } from './raster'; -import { type ResizeSpec, resizeRaster } from './resize'; +// Single responsibility: THE image pipeline. Decode -> resize -> encode, one entry point, one +// capability list. `storage`, `seo` and `pwa` all call this file and none of them owns a second +// copy: responsive variants, blur placeholders and PWA icons are the same three steps with +// different numbers, and a framework that generated them twice would drift twice. + +import { composeOnto, layOut, type ResizeSpec } from './canvas'; +import { imageFromBunError, imageUnsupported } from './errors'; +import { unshared } from './png-bytes'; +import { decodeImage, encodeImage } from './png-pixels'; +import { IMAGE_MIME_TYPES, type ImageFormat } from './probe'; +import { MAX_IMAGE_PIXELS } from './raster'; /** - * What the built-in, zero-dependency pipeline can actually produce. Bun ships no image API and - * the contract forbids `sharp`, so WebP and AVIF are *probed and served*, never synthesised - * here — a caller that needs them routes transforms through a driver (see `@ultimat3/seo`'s - * `ImageTransformDriver`). Publishing the real list is what stops `` - * from promising a variant nothing can encode. + * What the pipeline can produce, on every platform, byte for byte. `Bun.Image` also reaches + * HEIC and AVIF **through an OS codec** — Apple's ImageIO, Windows' WIC — which is a variant + * that exists on the developer's laptop and not on the Linux node that serves it, under a key + * that says nothing about which machine minted it. `backend = 'bun'` below refuses that trade, + * so those two formats are refused HERE rather than silently on one deploy out of two: a caller + * that needs them routes transforms through a driver (`@ultimat3/seo`'s `ImageTransformDriver`). */ -export const DECODABLE_FORMATS = ['png', 'jpeg'] as const; -export const ENCODABLE_FORMATS = ['png', 'jpeg'] as const; +export const ENCODABLE_FORMATS = ['png', 'jpeg', 'webp'] as const; +/** What the static codecs read. `svg` is markup, and `probeImage` measures it without decoding. */ +export const DECODABLE_FORMATS = ['png', 'jpeg', 'webp', 'gif'] as const; export type DecodableFormat = (typeof DECODABLE_FORMATS)[number]; export type EncodableFormat = (typeof ENCODABLE_FORMATS)[number]; -export const canDecode = (format: ImageFormat): format is DecodableFormat => +export const canDecode = (format: string): format is DecodableFormat => (DECODABLE_FORMATS as readonly string[]).includes(format); -export const canEncode = (format: ImageFormat): format is EncodableFormat => +export const canEncode = (format: string): format is EncodableFormat => (ENCODABLE_FORMATS as readonly string[]).includes(format); -/** Small enough to inline in HTML, big enough to blur convincingly. */ -export const BLUR_PLACEHOLDER_WIDTH = 16; - -const decodeFix = - 'convert the source to PNG or JPEG before it reaches the pipeline, or pass a custom ' + - 'ImageTransformDriver that can read it'; +/** 1-100, lossy formats only. Bun's own default, pinned here so output cannot drift with it. */ +export const DEFAULT_IMAGE_QUALITY = 80; const encodeFix = - "request 'png' or 'jpeg', or route the transform through an ImageTransformDriver (a CDN " + - 'or an external encoder) that can produce it'; - -/** Bytes in, RGBA out. The only place a format is turned into pixels. */ -export function decodeImage(bytes: Uint8Array): Raster { - const format = sniffImageFormat(bytes); - if (format === null) { - throw imageUnsupported('the bytes match no image format this pipeline knows', decodeFix); - } - if (format === 'png') return decodePng(bytes); - if (format === 'jpeg') return decodeJpeg(bytes); - throw imageUnsupported(`decoding ${format} is not built in`, decodeFix, { format }); -} - -/** RGBA in, bytes out. `quality` is ignored by lossless formats. */ -export function encodeImage( - raster: Raster, - format: ImageFormat, - quality: number = DEFAULT_JPEG_QUALITY, -): Uint8Array { - if (format === 'png') return encodePng(raster); - if (format === 'jpeg') return encodeJpeg(raster, quality); - throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format }); -} + "request 'png', 'jpeg' or 'webp', or route the transform through an ImageTransformDriver (a " + + 'CDN or an external encoder) that can produce it'; export interface ImageTransformSpec extends ResizeSpec { - /** Defaults to whichever encodable format preserves the source: PNG if it has alpha. */ + /** Defaults to the source's format when the pipeline can write it, PNG otherwise. */ readonly format?: ImageFormat | undefined; - /** 1-100, JPEG only. */ + /** 1-100, lossy formats only. */ readonly quality?: number | undefined; } /** - * PNG keeps transparency, JPEG does not; picking by the pixels means a logo never silently - * grows a black background because nobody passed `format`. + * `backend = 'bun'` is set on every call, not once at import. It forces the statically-linked + * codecs and the Highway geometry kernels on every OS, which is what makes the same source and + * the same spec the same BYTES on a laptop and on the node — and `variantKey` is content- + * addressed, so a variant that re-encoded differently per platform would be a cache that never + * hits and a hash that never agrees. Per call rather than at import because the property is + * process-global and writable: an app that flips it back would otherwise silently win. */ -export const defaultFormatFor = (raster: Raster): EncodableFormat => - hasAlpha(raster) ? 'png' : 'jpeg'; +function bunImage(bytes: Uint8Array): Bun.Image { + Bun.Image.backend = 'bun'; + // The decompression-bomb ceiling, enforced by the decoder from the header before it allocates + // — the same number `probeImage` refuses at, so the two answers cannot disagree. + return new Bun.Image(unshared(bytes), { maxPixels: MAX_IMAGE_PIXELS }); +} -/** The whole pipeline in one call: decode, resize, encode. */ -export function transformImageBytes(bytes: Uint8Array, spec: ImageTransformSpec = {}): Uint8Array { +function withFormat(image: Bun.Image, format: EncodableFormat, quality: number): Bun.Image { + if (format === 'png') return image.png(); + if (format === 'jpeg') return image.jpeg({ quality }); + return image.webp({ quality }); +} + +/** Every rejection from `Bun.Image` becomes one of the three `X_IMAGE_*` codes, never a bare one. */ +async function run(doing: string, work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + throw imageFromBunError(error, doing); + } +} + +/** The whole pipeline in one call: decode, resize, compose, encode. */ +export async function transformImageBytes( + bytes: Uint8Array, + spec: ImageTransformSpec = {}, +): Promise { const { format } = spec; // The spec alone answers "can this be written?", so answer it here — decoding and resampling // 64 megapixels first, only to refuse at the encoder, is work nobody can use. if (format !== undefined && !canEncode(format)) { throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format }); } - const source = decodeImage(bytes); - const resized = resizeRaster(source, spec); - return encodeImage(resized, format ?? defaultFormatFor(resized), spec.quality); + const quality = spec.quality ?? DEFAULT_IMAGE_QUALITY; + const source = await run('reading the image header', () => bunImage(bytes).metadata()); + const output: EncodableFormat = format ?? (canEncode(source.format) ? source.format : 'png'); + const layout = layOut(source, spec); + const { box, drawn } = layout; + + if (!layout.needsCanvas) { + return run('transforming the image', () => { + const image = bunImage(bytes); + if (box.width !== source.width || box.height !== source.height) { + image.resize(box.width, box.height, { fit: 'fill' }); + } + return withFormat(image, output, quality).bytes(); + }); + } + + // The letterbox / padding / crop path. `Bun.Image` resamples but has no compositor, so the + // artwork comes back as PNG, is placed on the canvas here, and goes back through Bun to be + // written. Back through Bun even when the output IS png: `png-pixels.ts` writes filter 0, which + // is 1.2-1.8x libspng's bytes on a real icon (measured), and one writer for everything this + // function returns is also what makes "same input, same bytes" rest on the static codecs alone. + const art = await run('resampling the image', () => + bunImage(bytes).resize(drawn.width, drawn.height, { fit: 'fill' }).png().bytes(), + ); + const composed = encodeImage(composeOnto(decodeImage(art), layout)); + return run('encoding the image', () => withFormat(bunImage(composed), output, quality).bytes()); } /** Chunked because spreading a whole image into `String.fromCharCode` overflows the stack. */ @@ -104,14 +127,12 @@ export const dataUrl = (bytes: Uint8Array, format: ImageFormat): string => `data:${IMAGE_MIME_TYPES[format]};base64,${base64Of(bytes)}`; /** - * The LQIP: the source at 16px wide, as a `data:` URI. Always PNG — at this size a JPEG's - * own headers cost more than the pixels, and alpha survives. + * The LQIP: a ThumbHash of the source as a `data:image/png;base64,` URI — at most 32px on its + * long edge, with the source's average colour, aspect ratio and rough structure. PNG, so alpha + * survives and no client-side decoder is needed to show it. */ -export function blurDataUrl(bytes: Uint8Array, width: number = BLUR_PLACEHOLDER_WIDTH): string { - const tiny = resizeRaster(decodeImage(bytes), { width }); - return dataUrl(encodePng(tiny), 'png'); +export async function blurDataUrl(bytes: Uint8Array): Promise { + return run('building the blur placeholder', () => bunImage(bytes).placeholder()); } export type { ImageFormat }; -/** Intrinsic dimensions without decoding — this is what keeps CLS at 0 for every format. */ -export { IMAGE_MIME_TYPES, probeImage, sniffImageFormat }; diff --git a/packages/core/src/image/png-pixels.test.ts b/packages/core/src/image/png-pixels.test.ts new file mode 100644 index 00000000..283c41c6 --- /dev/null +++ b/packages/core/src/image/png-pixels.test.ts @@ -0,0 +1,112 @@ +// Single responsibility: proves the raw-pixel seam is lossless in BOTH directions and against +// BOTH writers — ours (filter 0) and libspng's (adaptive filters 1 and 4). The composite path +// runs every PWA icon through here, so an unfilter bug is a logo that arrives as diagonal smear. + +import { describe, expect, test } from 'bun:test'; +import { ImageDecodeFailedError, ImageUnsupportedError } from './errors'; +import { fixtureBytes, PNG_INTERLACED_8X8, PNG_PALETTE_4X1, PNG_RGBA_4X4 } from './fixtures'; +import { decodeImage, encodeImage } from './png-pixels'; +import { probeImage } from './probe'; +import { createRaster, type Raster } from './raster'; + +const thrown = (run: () => unknown): { code: string; cause: string; fix: string } => { + try { + run(); + return { code: 'no-throw', cause: '', fix: '' }; + } catch (error) { + if (error instanceof ImageUnsupportedError || error instanceof ImageDecodeFailedError) { + return { code: error.code, cause: error.cause, fix: error.fix }; + } + return { code: `unexpected: ${String(error)}`, cause: '', fix: '' }; + } +}; + +/** A gradient with partial alpha: every channel varies per pixel, so no filter can be a no-op. */ +const gradient = (width: number, height: number): Raster => { + const raster = createRaster(width, height, 'test'); + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const i = (y * width + x) * 4; + raster.pixels[i] = (x * 7) % 256; + raster.pixels[i + 1] = (y * 11) % 256; + raster.pixels[i + 2] = (x * y) % 256; + raster.pixels[i + 3] = (x + y) % 4 === 0 ? 0 : 255 - ((x + y) % 128); + } + } + return raster; +}; + +describe('encodeImage', () => { + test('writes a PNG the probe and Bun both read at the declared size', async () => { + const bytes = encodeImage(gradient(13, 9)); + expect(probeImage(bytes)).toMatchObject({ format: 'png', width: 13, height: 9 }); + expect(await new Bun.Image(bytes).metadata()).toMatchObject({ + format: 'png', + width: 13, + height: 9, + }); + }); + + test('is deterministic — the same raster is the same bytes, which is what the cache keys on', () => { + expect([...encodeImage(gradient(9, 7))]).toEqual([...encodeImage(gradient(9, 7))]); + }); + + test('refuses any format but PNG, naming the pipeline that writes the others', () => { + // The raw seam has one writer on purpose; `transformImageBytes` is the one with three. + const failure = thrown(() => encodeImage(gradient(2, 2), 'jpeg' as 'png')); + expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); + expect(failure.fix).toContain('transformImageBytes'); + }); +}); + +describe('decodeImage', () => { + test('round trips our own writer exactly — PNG is the lossless half of the pipeline', () => { + const source = gradient(17, 11); + expect([...decodeImage(encodeImage(source)).pixels]).toEqual([...source.pixels]); + }); + + test("round trips libspng's writer, which picks a DIFFERENT filter per row", async () => { + // The one assertion that exercises Sub/Up/Average/Paeth: our encoder only ever writes 0, so + // a broken unfilter round-trips against itself and is invisible without Bun's bytes. + const source = gradient(31, 23); + const reEncoded = await new Bun.Image(encodeImage(source)).png().bytes(); + expect([...decodeImage(reEncoded).pixels]).toEqual([...source.pixels]); + }); + + test('reads a PNG written by an independent encoder, pixel for pixel', () => { + const raster = decodeImage(fixtureBytes(PNG_RGBA_4X4)); + expect([raster.width, raster.height]).toEqual([4, 4]); + expect([...raster.pixels]).toEqual([...(PNG_RGBA_4X4.pixels ?? [])]); + }); + + test.each([ + ['a palette PNG', PNG_PALETTE_4X1], + ['an interlaced PNG', PNG_INTERLACED_8X8], + ])('refuses %s and names the pipeline that reads it', (_label, fixture) => { + const failure = thrown(() => decodeImage(fixtureBytes(fixture))); + expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); + expect(failure.fix).toContain('transformImageBytes'); + }); + + test('refuses bytes that are not a PNG at all', () => { + const failure = thrown(() => decodeImage(new Uint8Array(64).fill(7))); + expect(failure.code).toBe('X_IMAGE_UNSUPPORTED'); + expect(failure.fix).toContain('transformImageBytes'); + }); + + test('a truncated PNG is a decode failure, not a black image', () => { + expect(thrown(() => decodeImage(encodeImage(gradient(16, 16)).subarray(0, 60))).code).toBe( + 'X_IMAGE_DECODE_FAILED', + ); + }); + + test('a PNG whose IDAT inflates to the wrong length is refused, never padded with zeros', () => { + const bytes = encodeImage(gradient(8, 8)); + // Same pixels, a header claiming one row more: the inflated length no longer matches. + const lying = Uint8Array.from(bytes); + lying[23] = 9; + const failure = thrown(() => decodeImage(lying)); + expect(failure.code).toBe('X_IMAGE_DECODE_FAILED'); + expect(failure.cause).toContain('inflates to'); + }); +}); diff --git a/packages/core/src/image/png-pixels.ts b/packages/core/src/image/png-pixels.ts new file mode 100644 index 00000000..f63e7c0b --- /dev/null +++ b/packages/core/src/image/png-pixels.ts @@ -0,0 +1,183 @@ +// Single responsibility: the raw-pixel seam — 8-bit RGBA in and out of a PNG container. `Bun.Image` +// owns every real codec now, but it has no compositor and no raw-pixel terminal, and the maskable +// safe zone `@ultimat3/pwa` promises is a composite. So this file exists for exactly that one hop: +// Bun re-encodes to PNG, this reads the pixels back, `canvas.ts` blits, this writes them again. + +import { imageDecodeFailed, imageUnsupported } from './errors'; +import { + adler32, + chunk, + joinBytes, + PNG_SIGNATURE, + paeth, + readU32, + unshared, + writeU32, +} from './png-bytes'; +import { type Raster, rasterFrom } from './raster'; + +/** Truecolour with alpha, 8 bits per channel — the ONE shape `Raster` is. */ +const RGBA_COLOR_TYPE = 8 << 4; +const BYTES_PER_PIXEL = 4; + +const RAW_FIX = + 'run the bytes through `transformImageBytes()` instead — it is backed by Bun.Image, which ' + + 'reads every real format; the raw-pixel seam is 8-bit RGBA PNG only'; + +// --------------------------------------------------------------------------------- encode + +/** + * Always filter 0. An adaptive filter buys a few percent on a placeholder or an icon and costs a + * second thing to be wrong in; every consumer of these bytes re-encodes through Bun anyway. + */ +function filterRows(raster: Raster): Uint8Array { + const stride = raster.width * BYTES_PER_PIXEL; + const out = new Uint8Array((stride + 1) * raster.height); + for (let y = 0; y < raster.height; y += 1) { + out[y * (stride + 1)] = 0; + out.set(raster.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); + } + return out; +} + +/** RGBA pixels to PNG bytes. Deterministic: the same raster is the same bytes, every run. */ +export function encodeImage(raster: Raster, format: 'png' = 'png'): Uint8Array { + if (format !== 'png') { + throw imageUnsupported(`the raw-pixel seam writes PNG, not ${String(format)}`, RAW_FIX, { + format, + }); + } + const header = new Uint8Array(13); + writeU32(header, 0, raster.width); + writeU32(header, 4, raster.height); + header[8] = 8; + header[9] = 6; + const filtered = filterRows(raster); + // `windowBits: -15` asks for RAW deflate: PNG supplies the zlib envelope itself, and Bun's + // documented default (15, zlib-wrapped) would nest a second one inside it. + const deflated = Bun.deflateSync(filtered, { windowBits: -15 }); + const idat = new Uint8Array(deflated.length + 6); + idat[0] = 0x78; + idat[1] = 0x01; + idat.set(deflated, 2); + writeU32(idat, deflated.length + 2, adler32(filtered)); + return joinBytes([ + PNG_SIGNATURE, + chunk('IHDR', header), + chunk('IDAT', idat), + chunk('IEND', new Uint8Array(0)), + ]); +} + +// --------------------------------------------------------------------------------- decode + +interface PngHeader { + readonly width: number; + readonly height: number; +} + +function readHeader(bytes: Uint8Array): PngHeader { + if (bytes.length < 33) { + throw imageDecodeFailed(`a PNG is at least 33 bytes; these are ${bytes.length}`, { + length: bytes.length, + }); + } + for (let i = 0; i < PNG_SIGNATURE.length; i += 1) { + if (bytes[i] !== PNG_SIGNATURE[i]) { + throw imageUnsupported('the raw-pixel seam reads PNG, and these bytes are not one', RAW_FIX, { + length: bytes.length, + }); + } + } + const depth = bytes[24]; + const colorType = bytes[25]; + const interlace = bytes[28]; + // Bun's encoder emits 8-bit RGBA, non-interlaced, for every source — verified, and the only + // shape this seam ever has to read. Anything else came from outside and says so. + if (((depth ?? 0) << 4) + (colorType ?? 0) !== RGBA_COLOR_TYPE + 6 || interlace !== 0) { + throw imageUnsupported( + `the PNG is ${String(depth)}-bit colour type ${String(colorType)}` + + `${interlace === 0 ? '' : ', interlaced'}, not 8-bit RGBA`, + RAW_FIX, + { depth, colorType, interlace }, + ); + } + return { width: readU32(bytes, 16), height: readU32(bytes, 20) }; +} + +/** Every IDAT concatenated: a PNG may split its stream across any number of them. */ +function idatStream(bytes: Uint8Array): Uint8Array { + const parts: Uint8Array[] = []; + let at = 8; + while (at + 12 <= bytes.length) { + const length = readU32(bytes, at); + const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8)); + if (type === 'IDAT') parts.push(bytes.subarray(at + 8, at + 8 + length)); + if (type === 'IEND') break; + at += 12 + length; + } + if (parts.length === 0) { + throw imageDecodeFailed('the PNG carries no IDAT chunk, so it declares no pixels', {}); + } + const stream = joinBytes(parts); + try { + // The 2-byte zlib header and the 4-byte Adler-32 trailer are PNG's envelope, stripped here + // so the payload inflates as RAW deflate — see the encoder above for the mirror image. + return Bun.inflateSync(unshared(stream.subarray(2, stream.length - 4)), { windowBits: -15 }); + } catch { + throw imageDecodeFailed(`the PNG IDAT stream (${stream.length} bytes) could not be inflated`, { + length: stream.length, + }); + } +} + +/** + * The five PNG predictors, undone row by row. `raw` is `[filter, ...pixels]` per row. + * + * Reconstructed into a `Uint8Array`, never straight into the `Uint8ClampedArray` a `Raster` holds: + * every filter is arithmetic MOD 256 and a clamped array saturates instead, so `255 + 1` lands on + * 255 rather than 0. That is invisible on a filter-0 stream (ours) and wrong on every adaptive one + * (libspng's) — the alpha channel of a transparent pixel first, which is exactly the case a PWA + * icon is made of. + */ +function unfilter(raw: Uint8Array, width: number, height: number): Uint8ClampedArray { + const stride = width * BYTES_PER_PIXEL; + const out = new Uint8Array(stride * height); + for (let y = 0; y < height; y += 1) { + const type = raw[y * (stride + 1)] ?? 0; + if (type > 4) { + throw imageDecodeFailed(`PNG row ${y} declares filter ${type}, and there are only 0-4`, { + row: y, + filter: type, + }); + } + const from = y * (stride + 1) + 1; + const to = y * stride; + for (let i = 0; i < stride; i += 1) { + const x = raw[from + i] ?? 0; + const a = i >= BYTES_PER_PIXEL ? (out[to + i - BYTES_PER_PIXEL] ?? 0) : 0; + const b = y > 0 ? (out[to - stride + i] ?? 0) : 0; + const c = y > 0 && i >= BYTES_PER_PIXEL ? (out[to - stride + i - BYTES_PER_PIXEL] ?? 0) : 0; + if (type === 0) out[to + i] = x; + else if (type === 1) out[to + i] = x + a; + else if (type === 2) out[to + i] = x + b; + else if (type === 3) out[to + i] = x + ((a + b) >> 1); + else out[to + i] = x + paeth(a, b, c); + } + } + return new Uint8ClampedArray(out.buffer, out.byteOffset, out.length); +} + +/** PNG bytes to RGBA pixels. Refuses anything but 8-bit RGBA, naming the pipeline that reads it. */ +export function decodeImage(bytes: Uint8Array): Raster { + const { width, height } = readHeader(bytes); + const raw = idatStream(bytes); + const expected = (width * BYTES_PER_PIXEL + 1) * height; + if (raw.length !== expected) { + throw imageDecodeFailed( + `the PNG inflates to ${raw.length} bytes but ${width}x${height} RGBA needs ${expected}`, + { inflated: raw.length, expected, width, height }, + ); + } + return rasterFrom(width, height, unfilter(raw, width, height)); +} diff --git a/packages/core/src/image/png.test.ts b/packages/core/src/image/png.test.ts deleted file mode 100644 index 5f758294..00000000 --- a/packages/core/src/image/png.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -// Single responsibility: proof for the PNG codec. Every fixture must decode to the exact bytes -// an independent encoder wrote, malformed files must be refused with a stable code, and encode -// must round trip losslessly. The CRC-32, Adler-32 and chunk walking here are written a second -// time on purpose — a checksum verified with the same code that produced it proves nothing. - -import { describe, expect, test } from 'bun:test'; -import { - fixtureBytes, - gradientPixel, - type ImageFixture, - PNG_GRADIENT_32X24, - PNG_GRAY_2X2, - PNG_GRAY_ALPHA_2X2, - PNG_GRAY16_2X2, - PNG_INTERLACED_8X8, - PNG_PALETTE_4X1, - PNG_RGB_3X2, - PNG_RGBA_4X4, -} from './fixtures'; -import { decodePng, encodePng } from './png'; -import { MAX_IMAGE_PIXELS, type Raster, rasterFrom } from './raster'; - -const SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a); - -/** Bit-by-bit rather than table-driven, so it shares no line of code with the codec's. */ -function crc32(bytes: Uint8Array): number { - let c = 0xffffffff; - for (const byte of bytes) { - c ^= byte; - for (let k = 0; k < 8; k += 1) c = (c & 1) === 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - } - return (c ^ 0xffffffff) >>> 0; -} - -/** The naive RFC 1950 form — no block deferral of the modulo. */ -function adler32(bytes: Uint8Array): number { - let a = 1; - let b = 0; - for (const byte of bytes) { - a = (a + byte) % 65521; - b = (b + a) % 65521; - } - return ((b << 16) | a) >>> 0; -} - -const readU32 = (bytes: Uint8Array, at: number): number => - (bytes[at] ?? 0) * 0x1000000 + - (((bytes[at + 1] ?? 0) << 16) | ((bytes[at + 2] ?? 0) << 8) | (bytes[at + 3] ?? 0)); - -function writeU32(out: Uint8Array, at: number, value: number): void { - out[at] = (value >>> 24) & 0xff; - out[at + 1] = (value >>> 16) & 0xff; - out[at + 2] = (value >>> 8) & 0xff; - out[at + 3] = value & 0xff; -} - -interface Chunk { - readonly type: string; - readonly data: Uint8Array; - /** Offset of the first data byte within the file, so a test can corrupt a known chunk. */ - readonly dataAt: number; - readonly crcOk: boolean; -} - -function chunksOf(bytes: Uint8Array): Chunk[] { - const chunks: Chunk[] = []; - let at = 8; - while (at + 12 <= bytes.length) { - const length = readU32(bytes, at); - const signed = bytes.subarray(at + 4, at + 8 + length); - const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8)); - chunks.push({ - type, - data: bytes.subarray(at + 8, at + 8 + length), - dataAt: at + 8, - crcOk: readU32(bytes, at + 8 + length) === crc32(signed), - }); - at += length + 12; - } - return chunks; -} - -function chunkOf(type: string, data: Uint8Array): Uint8Array { - const out = new Uint8Array(data.length + 12); - writeU32(out, 0, data.length); - for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i); - out.set(data, 8); - writeU32(out, data.length + 8, crc32(out.subarray(4, data.length + 8))); - return out; -} - -function concat(parts: readonly Uint8Array[]): Uint8Array { - let total = 0; - for (const part of parts) total += part.length; - const out = new Uint8Array(total); - let at = 0; - for (const part of parts) { - out.set(part, at); - at += part.length; - } - return out; -} - -/** - * Strips the zlib envelope and inflates the payload as RAW deflate, `windowBits: -15` named the - * same way the codec names it. Copies because Bun's zlib rejects a shared-backed view. - */ -const unwrapZlib = (stream: Uint8Array): Uint8Array => - Bun.inflateSync(new Uint8Array(stream.subarray(2, stream.length - 4)), { windowBits: -15 }); - -/** The envelope written by hand, so a test's IDAT is a real zlib stream, not today's default. */ -function zlibWrap(raw: Uint8Array): Uint8Array { - const deflated = Bun.deflateSync(new Uint8Array(raw), { windowBits: -15 }); - const out = new Uint8Array(deflated.length + 6); - out[0] = 0x78; - out[1] = 0x01; - out.set(deflated, 2); - writeU32(out, out.length - 4, adler32(raw)); - return out; -} - -/** Rebuilds a fixture with its unfiltered scanline stream replaced, CRCs and envelope redone. */ -function rebuilt(bytes: Uint8Array, mutate: (raw: Uint8Array) => Uint8Array): Uint8Array { - const parts: Uint8Array[] = [SIGNATURE]; - for (const piece of chunksOf(bytes)) { - if (piece.type !== 'IDAT') { - parts.push(chunkOf(piece.type, piece.data)); - continue; - } - parts.push(chunkOf('IDAT', zlibWrap(mutate(unwrapZlib(piece.data))))); - } - return concat(parts); -} - -/** Builds a whole PNG, so a test can reach a colour type and depth no fixture happens to carry. */ -function syntheticPng( - size: readonly [number, number], - bitDepth: number, - colourType: number, - rows: Uint8Array, - extra: readonly (readonly [string, Uint8Array])[] = [], -): Uint8Array { - const ihdr = new Uint8Array(13); - writeU32(ihdr, 0, size[0]); - writeU32(ihdr, 4, size[1]); - ihdr[8] = bitDepth; - ihdr[9] = colourType; - const parts = [SIGNATURE, chunkOf('IHDR', ihdr)]; - for (const [type, data] of extra) parts.push(chunkOf(type, data)); - parts.push(chunkOf('IDAT', zlibWrap(rows)), chunkOf('IEND', new Uint8Array(0))); - return concat(parts); -} - -/** - * The interlace flag is IHDR's 13th byte, so re-signing a fixture with it set produces the file - * the decoder must refuse — a refusal decided from the header alone, before any IDAT is read. - */ -function asInterlaced(fixture: ImageFixture): Uint8Array { - const bytes = fixtureBytes(fixture); - const ihdr = Uint8Array.from(bytes.subarray(16, 29)); - ihdr[12] = 1; - return concat([SIGNATURE, chunkOf('IHDR', ihdr), bytes.subarray(33)]); -} - -function codeOf(run: () => unknown): string { - try { - run(); - } catch (error) { - return (error as { code?: string }).code ?? 'NOT_AN_ULTIMATE_ERROR'; - } - return 'NOTHING_THROWN'; -} - -function causeOf(run: () => unknown): string { - try { - run(); - } catch (error) { - return String((error as { cause?: unknown }).cause ?? ''); - } - return ''; -} - -const PIXEL_FIXTURES: readonly (readonly [string, ImageFixture])[] = [ - ['truecolour with alpha', PNG_RGBA_4X4], - ['truecolour', PNG_RGB_3X2], - ['greyscale', PNG_GRAY_2X2], - ['indexed colour with a tRNS table', PNG_PALETTE_4X1], - ['greyscale with alpha', PNG_GRAY_ALPHA_2X2], - ['16 bits per sample', PNG_GRAY16_2X2], -]; - -describe('decodePng', () => { - for (const [label, fixture] of PIXEL_FIXTURES) { - test(`decodes ${label} to its exact reference pixels`, () => { - const expected = fixture.pixels ?? []; - expect(expected.length).toBe(fixture.width * fixture.height * 4); - const raster = decodePng(fixtureBytes(fixture)); - expect(raster.width).toBe(fixture.width); - expect(raster.height).toBe(fixture.height); - expect(Array.from(raster.pixels)).toEqual(Array.from(expected)); - }); - } - - test('decodes an adaptively filtered image, which exercises all five row filters', () => { - const raster = decodePng(fixtureBytes(PNG_GRADIENT_32X24)); - expect(raster.width).toBe(32); - expect(raster.height).toBe(24); - const wrong: string[] = []; - for (let y = 0; y < raster.height; y += 1) { - for (let x = 0; x < raster.width; x += 1) { - const at = (y * raster.width + x) * 4; - const [r, g, b] = gradientPixel(x, y); - const got = [raster.pixels[at], raster.pixels[at + 1], raster.pixels[at + 2]]; - const alpha = raster.pixels[at + 3]; - if (got[0] !== r || got[1] !== g || got[2] !== b || alpha !== 255) { - wrong.push(`${x},${y}: ${got.join()},${alpha} != ${r},${g},${b},255`); - } - } - } - expect(wrong).toEqual([]); - }); - - test('scales sub-byte greyscale samples across the full 0-255 range', () => { - // Four 2-bit samples — 0, 1, 2, 3 — packed into one byte, behind a filter-0 marker. - const png = syntheticPng([4, 1], 2, 0, Uint8Array.of(0, 0b00_01_10_11)); - expect(Array.from(decodePng(png).pixels)).toEqual([ - 0, 0, 0, 255, 85, 85, 85, 255, 170, 170, 170, 255, 255, 255, 255, 255, - ]); - }); - - test('reads sub-byte palette samples as raw indices, never scaled', () => { - const plte = Uint8Array.of(255, 0, 0, 0, 0, 255); - const png = syntheticPng([2, 1], 4, 3, Uint8Array.of(0, 0x10), [['PLTE', plte]]); - expect(Array.from(decodePng(png).pixels)).toEqual([0, 0, 255, 255, 255, 0, 0, 255]); - }); - - test('honours a tRNS key colour on a greyscale image', () => { - const png = syntheticPng([2, 1], 8, 0, Uint8Array.of(0, 0, 255), [ - ['tRNS', Uint8Array.of(0, 0)], - ]); - expect(Array.from(decodePng(png).pixels)).toEqual([0, 0, 0, 0, 255, 255, 255, 255]); - }); - - test('reads an IDAT whose zlib envelope was written by hand, header and Adler-32 included', () => { - // The codec writes that envelope itself and inflates the payload as raw deflate. A file built - // any other way would keep passing while the two halves quietly disagreed about the mode. - const rows = Uint8Array.of(0, 9, 9, 9, 255, 0, 200, 200, 200, 255); - const png = syntheticPng([1, 2], 8, 6, rows); - const idat = chunksOf(png).find((piece) => piece.type === 'IDAT')?.data ?? new Uint8Array(0); - expect(idat[0]).toBe(0x78); - expect((((idat[0] ?? 0) << 8) | (idat[1] ?? 0)) % 31).toBe(0); - expect(readU32(idat, idat.length - 4)).toBe(adler32(rows)); - expect(Array.from(decodePng(png).pixels)).toEqual([9, 9, 9, 255, 200, 200, 200, 255]); - }); - - test('refuses a palette index that runs past the end of PLTE', () => { - const png = syntheticPng([2, 1], 8, 3, Uint8Array.of(0, 0, 5), [ - ['PLTE', Uint8Array.of(1, 2, 3)], - ]); - expect(codeOf(() => decodePng(png))).toBe('X_IMAGE_DECODE_FAILED'); - }); - - test('refuses an Adam7 interlaced PNG instead of garbling it', () => { - // Both a real interlaced file and a re-flagged one: the refusal must key on the IHDR byte, - // not on the sub-image layout, or a hostile flag would still reach the unfilter loop. - const real = fixtureBytes(PNG_INTERLACED_8X8); - expect(codeOf(() => decodePng(real))).toBe('X_IMAGE_UNSUPPORTED'); - expect(causeOf(() => decodePng(real))).toContain('Adam7'); - expect(codeOf(() => decodePng(asInterlaced(PNG_RGBA_4X4)))).toBe('X_IMAGE_UNSUPPORTED'); - expect(causeOf(() => decodePng(asInterlaced(PNG_RGBA_4X4)))).toContain('Adam7'); - }); - - test('refuses a chunk whose CRC-32 does not match its bytes, naming the chunk', () => { - const bytes = fixtureBytes(PNG_RGBA_4X4); - const idat = chunksOf(bytes).find((piece) => piece.type === 'IDAT'); - expect(idat?.crcOk).toBe(true); - const corrupt = Uint8Array.from(bytes); - const at = (idat?.dataAt ?? 0) + 3; - corrupt[at] = (corrupt[at] ?? 0) ^ 0xff; - expect(codeOf(() => decodePng(corrupt))).toBe('X_IMAGE_DECODE_FAILED'); - expect(causeOf(() => decodePng(corrupt))).toContain('IDAT'); - }); - - test('refuses a truncated file rather than decoding a partial image', () => { - const bytes = fixtureBytes(PNG_RGBA_4X4); - expect(codeOf(() => decodePng(bytes.subarray(0, bytes.length - 20)))).toBe( - 'X_IMAGE_DECODE_FAILED', - ); - }); - - test('refuses bytes that are not a PNG at all', () => { - const jpeg = Uint8Array.of(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46); - expect(codeOf(() => decodePng(jpeg))).toBe('X_IMAGE_DECODE_FAILED'); - expect(codeOf(() => decodePng(new Uint8Array(4)))).toBe('X_IMAGE_DECODE_FAILED'); - }); - - test('refuses a colour type and bit depth combination the format does not define', () => { - const ihdr = new Uint8Array(13); - writeU32(ihdr, 0, 4); - writeU32(ihdr, 4, 4); - ihdr[8] = 16; - ihdr[9] = 3; - const file = concat([SIGNATURE, chunkOf('IHDR', ihdr), chunkOf('IEND', new Uint8Array(0))]); - expect(codeOf(() => decodePng(file))).toBe('X_IMAGE_DECODE_FAILED'); - }); - - test('refuses an unknown scanline filter type', () => { - const bad = rebuilt(fixtureBytes(PNG_GRAY_2X2), (raw) => { - raw[0] = 5; - return raw; - }); - expect(codeOf(() => decodePng(bad))).toBe('X_IMAGE_DECODE_FAILED'); - expect(causeOf(() => decodePng(bad))).toContain('filter type 5'); - }); - - test('refuses a stream that inflates to the wrong number of scanline bytes', () => { - const short = rebuilt(fixtureBytes(PNG_GRAY_2X2), (raw) => raw.subarray(0, raw.length - 1)); - expect(codeOf(() => decodePng(short))).toBe('X_IMAGE_DECODE_FAILED'); - }); - - test('refuses a declared size over the pixel budget before allocating for it', () => { - const side = 30_000; - expect(side * side).toBeGreaterThan(MAX_IMAGE_PIXELS); - const ihdr = new Uint8Array(13); - writeU32(ihdr, 0, side); - writeU32(ihdr, 4, side); - ihdr[8] = 8; - ihdr[9] = 6; - const file = concat([SIGNATURE, chunkOf('IHDR', ihdr), chunkOf('IEND', new Uint8Array(0))]); - expect(codeOf(() => decodePng(file))).toBe('X_IMAGE_TOO_LARGE'); - }); -}); - -describe('encodePng', () => { - const gradient = (): Raster => decodePng(fixtureBytes(PNG_GRADIENT_32X24)); - - test('writes the PNG signature, the three required chunks, and correct CRCs', () => { - const bytes = encodePng(gradient()); - expect(Array.from(bytes.subarray(0, 8))).toEqual(Array.from(SIGNATURE)); - const chunks = chunksOf(bytes); - expect(chunks.map((piece) => piece.type)).toEqual(['IHDR', 'IDAT', 'IEND']); - expect(chunks.every((piece) => piece.crcOk)).toBe(true); - const ihdr = chunks[0]?.data ?? new Uint8Array(0); - expect(readU32(ihdr, 0)).toBe(32); - expect(readU32(ihdr, 4)).toBe(24); - expect(Array.from(ihdr.subarray(8))).toEqual([8, 6, 0, 0, 0]); - }); - - test('wraps the deflate stream in a zlib envelope with a correct Adler-32', () => { - const bytes = encodePng(gradient()); - const idat = chunksOf(bytes).find((piece) => piece.type === 'IDAT')?.data ?? new Uint8Array(0); - expect(idat[0]).toBe(0x78); - expect((((idat[0] ?? 0) << 8) | (idat[1] ?? 0)) % 31).toBe(0); - const raw = unwrapZlib(idat); - expect(readU32(idat, idat.length - 4)).toBe(adler32(raw)); - expect(raw.length).toBe(24 * (32 * 4 + 1)); - }); - - test('chooses a filter per scanline instead of writing filter 0 everywhere', () => { - const raster = gradient(); - const idat = - chunksOf(encodePng(raster)).find((piece) => piece.type === 'IDAT')?.data ?? new Uint8Array(0); - const raw = unwrapZlib(idat); - const filters = new Set(); - for (let y = 0; y < raster.height; y += 1) { - filters.add(raw[y * (raster.width * 4 + 1)] ?? 0); - } - expect(filters.size).toBeGreaterThan(1); - }); - - test('round trips a raster carrying alpha, unchanged', () => { - const source = decodePng(fixtureBytes(PNG_RGBA_4X4)); - const back = decodePng(encodePng(source)); - expect(back.width).toBe(source.width); - expect(back.height).toBe(source.height); - expect(Array.from(back.pixels)).toEqual(Array.from(source.pixels)); - }); - - test('round trips a single pixel', () => { - const source = rasterFrom(1, 1, Uint8ClampedArray.of(12, 34, 56, 78)); - const back = decodePng(encodePng(source)); - expect(back.width).toBe(1); - expect(back.height).toBe(1); - expect(Array.from(back.pixels)).toEqual([12, 34, 56, 78]); - }); - - test('round trips a full gradient, every byte identical', () => { - const source = gradient(); - const back = decodePng(encodePng(source)); - expect(Array.from(back.pixels)).toEqual(Array.from(source.pixels)); - }); - - test('is deterministic — the same raster always encodes to the same bytes', () => { - const source = gradient(); - expect(Array.from(encodePng(source))).toEqual(Array.from(encodePng(source))); - }); -}); diff --git a/packages/core/src/image/png.ts b/packages/core/src/image/png.ts deleted file mode 100644 index 71655ad3..00000000 --- a/packages/core/src/image/png.ts +++ /dev/null @@ -1,433 +0,0 @@ -// Single responsibility: the PNG codec. Every colour type, bit depth and row filter the format -// allows decodes into the one 8-bit RGBA raster; encoding has exactly one output shape (colour -// type 6, adaptive filters). Chunk CRCs are verified on the way in because a decoder that -// tolerates a corrupt chunk hands the app wrong pixels instead of a coded error. - -import { imageDecodeFailed, imageUnsupported } from './errors'; -import { - adler32, - chunk, - crc32, - EMPTY_CHUNK_DATA, - joinBytes, - PNG_SIGNATURE, - paeth, - readU32, - unshared, - writeU32, -} from './png-bytes'; -import { assertPixelBudget, type Raster, rasterFrom } from './raster'; - -/** Bytes per pixel of the encoder's one output shape, and its filter offset. */ -const RGBA_BPP = 4; - -/** Samples per pixel, by colour type. A palette row carries one index, not one colour. */ -const CHANNELS = Uint8Array.of(1, 0, 3, 1, 2, 0, 4); - -/** - * PNG spec table 11.1, keyed by colour type. Every legal depth is a power of two, so the set of - * them masks directly — which is also why a depth like 3 has to be rejected as not one at all. - */ -const LEGAL_DEPTHS: Readonly> = { - 0: 1 | 2 | 4 | 8 | 16, - 2: 8 | 16, - 3: 1 | 2 | 4 | 8, - 4: 8 | 16, - 6: 8 | 16, -}; - -const channelsOf = (colourType: number): number => CHANNELS[colourType] ?? 1; - -const isLegalShape = (colourType: number, bitDepth: number): boolean => - bitDepth !== 0 && - (bitDepth & (bitDepth - 1)) === 0 && - ((LEGAL_DEPTHS[colourType] ?? 0) & bitDepth) === bitDepth; - -/** Stretches a sub-byte sample across the full range, so depth 1 reads 0/255 rather than 0/1. */ -const UPSCALE: Readonly> = { 1: 255, 2: 85, 4: 17 }; - -interface PngHeader { - readonly width: number; - readonly height: number; - readonly bitDepth: number; - readonly colourType: number; -} - -function assertSignature(bytes: Uint8Array): void { - for (let i = 0; i < PNG_SIGNATURE.length; i += 1) { - if (bytes[i] === PNG_SIGNATURE[i]) continue; - const found = Array.from(bytes.subarray(0, 8)); - throw imageDecodeFailed(`the bytes are not a PNG: signature reads ${found}`, { - signature: found, - }); - } -} - -function readHeader(bytes: Uint8Array, at: number, length: number): PngHeader { - if (length !== 13) { - throw imageDecodeFailed(`the PNG IHDR chunk carries ${length} bytes, not the required 13`, { - length, - }); - } - const width = readU32(bytes, at); - const height = readU32(bytes, at + 4); - const bitDepth = bytes[at + 8] ?? 0; - const colourType = bytes[at + 9] ?? 0; - const compression = bytes[at + 10] ?? 0; - const filter = bytes[at + 11] ?? 0; - const interlace = bytes[at + 12] ?? 0; - if (!isLegalShape(colourType, bitDepth)) { - throw imageDecodeFailed( - `the PNG declares colour type ${colourType} at ${bitDepth} bits, a pair the format omits`, - { colourType, bitDepth }, - ); - } - if (compression !== 0 || filter !== 0 || interlace > 1) { - throw imageDecodeFailed( - `the PNG declares compression ${compression}, filter method ${filter}, interlace ` + - `${interlace}; only 0, 0 and 0-or-1 have ever been defined`, - { compression, filter, interlace }, - ); - } - // Before a single byte is allocated: the declared size is the only bomb guard that is cheap. - assertPixelBudget(width, height, 'png'); - if (interlace === 1) { - throw imageUnsupported( - 'the file is an Adam7 interlaced PNG, which the built-in decoder does not implement', - 'convert the file to a non-interlaced PNG: `convert in.png -interlace none out.png`', - { width, height }, - ); - } - return { width, height, bitDepth, colourType }; -} - -/** - * PNG wraps its deflate stream in zlib, so the 2-byte header and 4-byte Adler-32 trailer are - * validated and stripped here and the payload is inflated as RAW deflate. `windowBits: -15` states - * that: Bun's documented default is 15, meaning zlib-wrapped, and a version that starts honouring - * it would otherwise reject every PNG we read. - */ -function inflateIdat(stream: Uint8Array): Uint8Array { - const cmf = stream[0] ?? 0; - const flg = stream[1] ?? 0; - const check = ((cmf << 8) | flg) >>> 0; - if (stream.length < 6 || (cmf & 0x0f) !== 8 || check % 31 !== 0) { - throw imageDecodeFailed( - `the PNG IDAT stream is ${stream.length} bytes opening 0x${check.toString(16)}, not zlib`, - { header: check, compressed: stream.length }, - ); - } - if ((flg & 0x20) !== 0) { - throw imageUnsupported( - 'the PNG IDAT stream sets a zlib preset dictionary, which the PNG format forbids', - 're-export the file with a conformant encoder: `convert in.png out.png`', - ); - } - try { - return Bun.inflateSync(unshared(stream.subarray(2, stream.length - 4)), { windowBits: -15 }); - } catch (error) { - const why = error instanceof Error ? error.message : String(error); - throw imageDecodeFailed(`the PNG IDAT stream could not be inflated: ${why}`, { - compressed: stream.length, - }); - } -} - -/** Reverses the per-scanline filter in place, leaving each row's raw bytes where they lay. */ -function unfilter(raw: Uint8Array, height: number, rowBytes: number, bpp: number): void { - const stride = rowBytes + 1; - for (let y = 0; y < height; y += 1) { - const at = y * stride; - const filter = raw[at] ?? 0; - const row = at + 1; - const prev = row - stride; - const hasPrev = y > 0; - switch (filter) { - case 0: - break; - case 1: { - for (let i = bpp; i < rowBytes; i += 1) { - raw[row + i] = ((raw[row + i] ?? 0) + (raw[row + i - bpp] ?? 0)) & 0xff; - } - break; - } - case 2: { - if (!hasPrev) break; - for (let i = 0; i < rowBytes; i += 1) { - raw[row + i] = ((raw[row + i] ?? 0) + (raw[prev + i] ?? 0)) & 0xff; - } - break; - } - // Average and Paeth read the same three neighbours; only the predictor differs. - case 3: - case 4: { - for (let i = 0; i < rowBytes; i += 1) { - const left = i >= bpp ? (raw[row + i - bpp] ?? 0) : 0; - const up = hasPrev ? (raw[prev + i] ?? 0) : 0; - const upLeft = hasPrev && i >= bpp ? (raw[prev + i - bpp] ?? 0) : 0; - const guess = filter === 3 ? (left + up) >> 1 : paeth(left, up, upLeft); - raw[row + i] = ((raw[row + i] ?? 0) + guess) & 0xff; - } - break; - } - default: - throw imageDecodeFailed( - `PNG scanline ${y} declares filter type ${filter}, which is not one of 0-4`, - { row: y, filter }, - ); - } - } -} - -/** One scanline of unfiltered bytes into whole samples, at whatever precision the file uses. */ -function readSamples(raw: Uint8Array, at: number, out: Uint16Array, bitDepth: number): void { - const count = out.length; - if (bitDepth === 8) { - for (let i = 0; i < count; i += 1) out[i] = raw[at + i] ?? 0; - return; - } - if (bitDepth === 16) { - for (let i = 0; i < count; i += 1) { - out[i] = ((raw[at + i * 2] ?? 0) << 8) | (raw[at + i * 2 + 1] ?? 0); - } - return; - } - const perByte = 8 / bitDepth; - const mask = (1 << bitDepth) - 1; - for (let i = 0; i < count; i += 1) { - const byte = raw[at + ((i / perByte) | 0)] ?? 0; - out[i] = (byte >> (8 - bitDepth * ((i % perByte) + 1))) & mask; - } -} - -/** - * `tRNS` on a greyscale or truecolour image is not a table: it names ONE sample value that is - * fully transparent, at the image's own bit depth. Ignoring it drops a logo's cut-out. - */ -function transparentKey(colourType: number, trns: Uint8Array | undefined): Uint16Array | undefined { - if (trns === undefined || (colourType !== 0 && colourType !== 2)) return undefined; - const samples = colourType === 2 ? 3 : 1; - if (trns.length < samples * 2) return undefined; - const key = new Uint16Array(samples); - for (let i = 0; i < samples; i += 1) { - key[i] = ((trns[i * 2] ?? 0) << 8) | (trns[i * 2 + 1] ?? 0); - } - return key; -} - -/** - * Which sample of a pixel feeds R, G, B and A, by colour type; `-1` is "no alpha sample". Stating - * the mapping once stops four colour types from becoming four loops that can each be wrong. - */ -const RGBA_SOURCE: Readonly> = { - 0: [0, 0, 0, -1], - 2: [0, 1, 2, -1], - 4: [0, 0, 0, 1], - 6: [0, 1, 2, 3], -}; - -/** Opaque unless a `tRNS` key colour matches this pixel's samples exactly. */ -function opacityFor(samples: Uint16Array, at: number, key: Uint16Array | undefined): number { - if (key === undefined) return 255; - for (let i = 0; i < key.length; i += 1) { - if (samples[at + i] !== key[i]) return 255; - } - return 0; -} - -function expand( - raw: Uint8Array, - header: PngHeader, - palette: Uint8Array | undefined, - trns: Uint8Array | undefined, -): Uint8ClampedArray { - const { width, height, bitDepth, colourType } = header; - if (colourType === 3 && palette === undefined) { - throw imageDecodeFailed('the PNG is indexed colour but carries no PLTE chunk'); - } - const plte = palette ?? EMPTY_CHUNK_DATA; - const entries = (plte.length / 3) | 0; - const channels = channelsOf(colourType); - const stride = Math.ceil((width * channels * bitDepth) / 8) + 1; - const upscale = UPSCALE[bitDepth] ?? 1; - const key = transparentKey(colourType, trns); - const samples = new Uint16Array(width * channels); - const pixels = new Uint8ClampedArray(width * height * 4); - const [sr, sg, sb, sa] = RGBA_SOURCE[colourType] ?? [0, 0, 0, -1]; - const byteOf = (sample: number): number => (bitDepth === 16 ? sample >>> 8 : sample * upscale); - - for (let y = 0; y < height; y += 1) { - readSamples(raw, y * stride + 1, samples, bitDepth); - let p = y * width * 4; - for (let x = 0; x < width; x += 1) { - const s = x * channels; - if (colourType === 3) { - const index = samples[s] ?? 0; - if (index >= entries) { - throw imageDecodeFailed( - `PNG pixel ${x},${y} uses palette index ${index} but PLTE holds ${entries} entries`, - { x, y, index, entries }, - ); - } - pixels[p] = plte[index * 3] ?? 0; - pixels[p + 1] = plte[index * 3 + 1] ?? 0; - pixels[p + 2] = plte[index * 3 + 2] ?? 0; - pixels[p + 3] = trns !== undefined && index < trns.length ? (trns[index] ?? 255) : 255; - } else { - pixels[p] = byteOf(samples[s + sr] ?? 0); - pixels[p + 1] = byteOf(samples[s + sg] ?? 0); - pixels[p + 2] = byteOf(samples[s + sb] ?? 0); - pixels[p + 3] = sa >= 0 ? byteOf(samples[s + sa] ?? 0) : opacityFor(samples, s, key); - } - p += 4; - } - } - return pixels; -} - -/** PNG bytes in, RGBA out. Every chunk is CRC-checked before a single pixel is believed. */ -export function decodePng(bytes: Uint8Array): Raster { - assertSignature(bytes); - let header: PngHeader | undefined; - let palette: Uint8Array | undefined; - let trns: Uint8Array | undefined; - const idat: Uint8Array[] = []; - let ended = false; - let offset = 8; - - while (offset + 8 <= bytes.length) { - const length = readU32(bytes, offset); - const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8)); - const dataAt = offset + 8; - const crcAt = dataAt + length; - if (crcAt + 4 > bytes.length) { - throw imageDecodeFailed( - `the PNG ends after ${bytes.length} bytes, inside chunk ${type} at offset ${offset}`, - { chunk: type, offset, declared: length }, - ); - } - const declared = readU32(bytes, crcAt); - const actual = crc32(bytes, offset + 4, crcAt); - if (declared !== actual) { - throw imageDecodeFailed( - `PNG chunk ${type} fails its CRC-32: the file says ${declared}, the bytes hash to ${actual}`, - { chunk: type, declared, actual }, - ); - } - if (header === undefined && type !== 'IHDR') { - throw imageDecodeFailed(`the first PNG chunk is ${type}, not IHDR`, { chunk: type }); - } - if (type === 'IHDR') { - if (header !== undefined) throw imageDecodeFailed('the PNG carries more than one IHDR chunk'); - header = readHeader(bytes, dataAt, length); - } else if (type === 'PLTE') { - palette = bytes.subarray(dataAt, crcAt); - } else if (type === 'tRNS') { - trns = bytes.subarray(dataAt, crcAt); - } else if (type === 'IDAT') { - idat.push(bytes.subarray(dataAt, crcAt)); - } else if (type === 'IEND') { - ended = true; - break; - } - offset = crcAt + 4; - } - - if (!ended) throw imageDecodeFailed('the PNG never reaches IEND, so the file is truncated'); - if (header === undefined) throw imageDecodeFailed('the PNG carries no IHDR chunk'); - if (idat.length === 0) - throw imageDecodeFailed('the PNG carries no IDAT chunk, so it has no rows'); - - const { width, height, bitDepth, colourType } = header; - const channels = channelsOf(colourType); - const rowBytes = Math.ceil((width * channels * bitDepth) / 8); - const expected = height * (rowBytes + 1); - const raw = inflateIdat(joinBytes(idat)); - if (raw.length !== expected) { - throw imageDecodeFailed( - `the PNG inflates to ${raw.length} bytes but ${width}x${height} at ${bitDepth} bits over ` + - `${channels} channels needs exactly ${expected}`, - { inflated: raw.length, expected }, - ); - } - unfilter(raw, height, rowBytes, Math.max(1, Math.ceil((bitDepth * channels) / 8))); - return rasterFrom(width, height, expand(raw, header, palette, trns)); -} - -/** - * Filters one scanline into `out`, scored by the libpng heuristic: the sum of its bytes read as - * signed. The lowest sum is the row deflate compresses best, which is why an encoder that always - * wrote filter 0 would ship files roughly twice this size. A negative `prev` is "no row above". - */ -function filterScanline( - pixels: Uint8ClampedArray, - row: number, - prev: number, - stride: number, - filter: number, - out: Uint8Array, -): number { - let score = 0; - const hasPrev = prev >= 0; - for (let i = 0; i < stride; i += 1) { - const raw = pixels[row + i] ?? 0; - const left = i >= RGBA_BPP ? (pixels[row + i - RGBA_BPP] ?? 0) : 0; - const up = hasPrev ? (pixels[prev + i] ?? 0) : 0; - let value = raw; - if (filter === 1) value = raw - left; - else if (filter === 2) value = raw - up; - else if (filter === 3) value = raw - ((left + up) >> 1); - else if (filter === 4) { - const upLeft = hasPrev && i >= RGBA_BPP ? (pixels[prev + i - RGBA_BPP] ?? 0) : 0; - value = raw - paeth(left, up, upLeft); - } - value &= 0xff; - out[i] = value; - score += value < 128 ? value : 256 - value; - } - return score; -} - -/** - * RGBA in, PNG bytes out — always 8-bit colour type 6, non-interlaced. Branching on opacity would - * give the framework two encoders to keep correct, and alpha on an opaque image is nearly free - * after deflate, so there is one path. - */ -export function encodePng(raster: Raster): Uint8Array { - const { width, height, pixels } = raster; - const stride = width * 4; - const filtered = new Uint8Array(height * (stride + 1)); - const scratch = new Uint8Array(stride); - for (let y = 0; y < height; y += 1) { - const row = y * stride; - const at = y * (stride + 1); - let best = Number.POSITIVE_INFINITY; - for (let filter = 0; filter <= 4; filter += 1) { - const score = filterScanline(pixels, row, y > 0 ? row - stride : -1, stride, filter, scratch); - if (score >= best) continue; - best = score; - filtered[at] = filter; - filtered.set(scratch, at + 1); - } - } - - // `windowBits: -15` asks for RAW deflate explicitly, because the zlib envelope PNG requires is - // written by hand below — leaving it to the default would double-wrap the stream the day Bun's - // documented default (15, zlib-wrapped) is the one that actually applies. - const deflated = Bun.deflateSync(filtered, { windowBits: -15 }); - const idat = new Uint8Array(deflated.length + 6); - idat.set([0x78, 0x01]); - idat.set(deflated, 2); - writeU32(idat, deflated.length + 2, adler32(filtered)); - - const ihdr = new Uint8Array(13); - writeU32(ihdr, 0, width); - writeU32(ihdr, 4, height); - ihdr.set([8, 6], 8); - return joinBytes([ - PNG_SIGNATURE, - chunk('IHDR', ihdr), - chunk('IDAT', idat), - chunk('IEND', EMPTY_CHUNK_DATA), - ]); -} diff --git a/packages/core/src/image/resize.test.ts b/packages/core/src/image/resize.test.ts deleted file mode 100644 index ea8afc49..00000000 --- a/packages/core/src/image/resize.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -// Single responsibility: proves the one scaler's contract — box maths, colour parsing, and the -// resampling guarantees a PWA icon and an srcset variant both depend on (no drift, no halo). - -import { describe, expect, test } from 'bun:test'; -import { parseColor } from './color'; -import { ImageUnsupportedError } from './errors'; -import { createRaster, type Raster, rasterFrom } from './raster'; -import { fitBox, resizeRaster, scaledToFit } from './resize'; - -type Rgba = readonly [number, number, number, number]; - -const codeOf = (run: () => unknown): string => { - try { - run(); - return 'no-throw'; - } catch (error) { - return error instanceof ImageUnsupportedError ? error.code : `unexpected: ${String(error)}`; - } -}; - -const solid = (width: number, height: number, color: Rgba): Raster => { - const raster = createRaster(width, height, 'test'); - for (let i = 0; i < raster.pixels.length; i += 4) { - raster.pixels[i] = color[0]; - raster.pixels[i + 1] = color[1]; - raster.pixels[i + 2] = color[2]; - raster.pixels[i + 3] = color[3]; - } - return raster; -}; - -/** Builds a raster from `width * height` pixels in row-major order. */ -const gridOf = (width: number, height: number, cells: readonly Rgba[]): Raster => { - const pixels = new Uint8ClampedArray(width * height * 4); - cells.forEach((cell, index) => { - pixels[index * 4] = cell[0]; - pixels[index * 4 + 1] = cell[1]; - pixels[index * 4 + 2] = cell[2]; - pixels[index * 4 + 3] = cell[3]; - }); - return rasterFrom(width, height, pixels); -}; - -const at = (raster: Raster, x: number, y: number): Rgba => { - const i = (y * raster.width + x) * 4; - const p = raster.pixels; - return [p[i] ?? 0, p[i + 1] ?? 0, p[i + 2] ?? 0, p[i + 3] ?? 0]; -}; - -const RED: Rgba = [255, 0, 0, 255]; -const BLUE: Rgba = [0, 0, 255, 255]; -const BLACK: Rgba = [0, 0, 0, 255]; -const WHITE: Rgba = [255, 255, 255, 255]; -const CLEAR: Rgba = [0, 0, 0, 0]; - -describe('fitBox', () => { - const source = { width: 400, height: 200 }; - - test('keeps the source when neither axis is requested', () => { - expect(fitBox(source, {})).toEqual({ width: 400, height: 200 }); - }); - - test('derives the height from a width request', () => { - expect(fitBox(source, { width: 100 })).toEqual({ width: 100, height: 50 }); - }); - - test('derives the width from a height request', () => { - expect(fitBox(source, { height: 50 })).toEqual({ width: 100, height: 50 }); - }); - - test('never upscales on a single-axis request', () => { - expect(fitBox(source, { width: 4000 })).toEqual({ width: 400, height: 200 }); - expect(fitBox(source, { height: 2000 })).toEqual({ width: 400, height: 200 }); - }); - - test('returns exactly the box when both axes are given, upscale or not', () => { - expect(fitBox(source, { width: 4000, height: 7 })).toEqual({ width: 4000, height: 7 }); - }); - - test('never rounds a derived edge below one pixel', () => { - expect(fitBox({ width: 1000, height: 3 }, { width: 10 })).toEqual({ width: 10, height: 1 }); - }); - - test('rejects a non-integer, zero or negative dimension, naming the field', () => { - expect(codeOf(() => fitBox(source, { width: 10.5 }))).toBe('X_IMAGE_UNSUPPORTED'); - expect(codeOf(() => fitBox(source, { width: 0 }))).toBe('X_IMAGE_UNSUPPORTED'); - expect(codeOf(() => fitBox(source, { height: -4 }))).toBe('X_IMAGE_UNSUPPORTED'); - expect(() => fitBox(source, { height: -4 })).toThrow(/height/); - }); -}); - -describe('scaledToFit', () => { - const source = { width: 400, height: 200 }; - - test('cover fills the box, overflowing the short axis', () => { - expect(scaledToFit(source, { width: 100, height: 100 }, 'cover')).toEqual({ - width: 200, - height: 100, - }); - }); - - test('contain fits inside the box, leaving the long axis short', () => { - expect(scaledToFit(source, { width: 100, height: 100 }, 'contain')).toEqual({ - width: 100, - height: 50, - }); - }); - - test('upscales when the box is bigger — a 128px source asked for a 512px icon', () => { - expect( - scaledToFit({ width: 128, height: 128 }, { width: 512, height: 512 }, 'contain'), - ).toEqual({ width: 512, height: 512 }); - }); - - test('never returns a zero edge', () => { - expect(scaledToFit({ width: 1000, height: 2 }, { width: 4, height: 4 }, 'contain')).toEqual({ - width: 4, - height: 1, - }); - }); -}); - -describe('resampling quality', () => { - test('a solid image survives a downscale exactly — the area average must not drift', () => { - const out = resizeRaster(solid(4, 4, RED), { width: 2, height: 2 }); - expect(out.width).toBe(2); - expect(out.height).toBe(2); - for (let y = 0; y < 2; y += 1) { - for (let x = 0; x < 2; x += 1) expect(at(out, x, y)).toEqual(RED); - } - }); - - test('a black/white checkerboard averages to mid grey, not to one of its cells', () => { - const out = resizeRaster(gridOf(2, 2, [BLACK, WHITE, WHITE, BLACK]), { width: 1, height: 1 }); - const [r, g, b, a] = at(out, 0, 0); - expect(r).toBeGreaterThanOrEqual(127); - expect(r).toBeLessThanOrEqual(128); - expect(g).toBe(r); - expect(b).toBe(r); - expect(a).toBe(255); - }); - - test('upscaling is bilinear, not nearest — the interior takes intermediate values', () => { - const out = resizeRaster(gridOf(2, 2, [BLACK, WHITE, BLACK, WHITE]), { width: 4, height: 4 }); - const row = [0, 1, 2, 3].map((x) => at(out, x, 0)[0]); - expect(row[0]).toBe(0); - expect(row[3]).toBe(255); - // Nearest neighbour would give [0, 0, 255, 255]; bilinear must land strictly between. - expect(row[1] ?? 0).toBeGreaterThan(0); - expect(row[1] ?? 0).toBeLessThan(row[2] ?? 0); - expect(row[2] ?? 0).toBeLessThan(255); - }); - - test('resamples in premultiplied alpha, so a transparent neighbour cannot darken the edge', () => { - const transparentBlack: Rgba = [0, 0, 0, 0]; - const source = gridOf(2, 2, [RED, transparentBlack, transparentBlack, RED]); - const [r, g, b, a] = at(resizeRaster(source, { width: 1, height: 1 }), 0, 0); - // Averaging without premultiplying halves the red toward black: r would be ~128, not ~255. - expect(r).toBeGreaterThan(240); - expect(g).toBeLessThan(8); - expect(b).toBeLessThan(8); - expect(r).toBeGreaterThan(g + 200); - expect(r).toBeGreaterThan(b + 200); - expect(a).toBeGreaterThanOrEqual(127); - expect(a).toBeLessThanOrEqual(128); - }); -}); - -describe('the separable passes', () => { - /** Each spec reaches a different first pass, and the first pass is the one reading 8-bit bytes. */ - test.each([ - ['width only', { width: 100, height: 2 }, { width: 90, height: 2 }], - ['height only', { width: 2, height: 100 }, { width: 2, height: 90 }], - ['both axes', { width: 8, height: 4 }, { width: 4, height: 2 }], - ])('scales %s and keeps a solid colour exact', (_label, source, box) => { - const out = resizeRaster(solid(source.width, source.height, RED), box); - expect([out.width, out.height]).toEqual([box.width, box.height]); - for (let y = 0; y < out.height; y += 1) { - for (let x = 0; x < out.width; x += 1) expect(at(out, x, y)).toEqual(RED); - } - }); - - test('a large but legal resize completes without a float copy of the whole source', () => { - // 16 megapixels is a quarter of the ceiling, and premultiplying it into a standalone buffer - // first cost 268MB of Float32 before the scaler read a single tap. The first pass reads the - // 8-bit source itself now, so only pass OUTPUT is allocated — and a solid image still survives. - const out = resizeRaster(solid(4096, 4096, RED), { width: 512, height: 512 }); - expect([out.width, out.height]).toEqual([512, 512]); - expect(at(out, 0, 0)).toEqual(RED); - expect(at(out, 256, 256)).toEqual(RED); - expect(at(out, 511, 511)).toEqual(RED); - }); -}); - -describe('fit', () => { - /** Four distinct columns, so a crop is visible in the output. */ - const stripes = gridOf(4, 2, [ - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - [10, 0, 0, 255], - [20, 0, 0, 255], - [30, 0, 0, 255], - [40, 0, 0, 255], - ]); - - test('cover centre-crops: the result comes from the middle columns', () => { - const out = resizeRaster(stripes, { width: 2, height: 2, fit: 'cover' }); - expect(out.width).toBe(2); - expect(out.height).toBe(2); - expect(at(out, 0, 0)[0]).toBe(20); - expect(at(out, 1, 0)[0]).toBe(30); - expect(at(out, 0, 1)[0]).toBe(20); - expect(at(out, 1, 1)[0]).toBe(30); - }); - - test('contain letterboxes with exactly the background that was asked for', () => { - const out = resizeRaster(solid(4, 2, RED), { - width: 2, - height: 4, - fit: 'contain', - background: '#0000ff', - }); - expect(parseColor('#0000ff')).toEqual(BLUE); - expect(at(out, 0, 0)).toEqual(BLUE); - expect(at(out, 1, 1)).toEqual(BLUE); - expect(at(out, 0, 2)).toEqual(RED); - expect(at(out, 1, 2)).toEqual(RED); - expect(at(out, 0, 3)).toEqual(BLUE); - }); - - test('contain is the default fit', () => { - const boxed = resizeRaster(stripes, { width: 2, height: 4, background: '#0000ff' }); - const explicit = resizeRaster(stripes, { - width: 2, - height: 4, - fit: 'contain', - background: '#0000ff', - }); - expect(Array.from(boxed.pixels)).toEqual(Array.from(explicit.pixels)); - }); -}); - -describe('padding', () => { - test('leaves an empty border and centres the artwork in what is left', () => { - const out = resizeRaster(solid(100, 100, RED), { width: 100, height: 100, padding: 0.1 }); - expect(out.width).toBe(100); - expect(out.height).toBe(100); - for (let i = 0; i < 10; i += 1) { - expect(at(out, i, i)).toEqual(CLEAR); - expect(at(out, 99 - i, 99 - i)).toEqual(CLEAR); - expect(at(out, i, 50)).toEqual(CLEAR); - expect(at(out, 50, i)).toEqual(CLEAR); - } - expect(at(out, 10, 10)).toEqual(RED); - expect(at(out, 89, 89)).toEqual(RED); - expect(at(out, 50, 50)).toEqual(RED); - }); - - test('rejects a padding outside [0, 0.5)', () => { - const source = solid(4, 4, RED); - expect(codeOf(() => resizeRaster(source, { width: 4, padding: 0.6 }))).toBe( - 'X_IMAGE_UNSUPPORTED', - ); - expect(codeOf(() => resizeRaster(source, { width: 4, padding: -0.1 }))).toBe( - 'X_IMAGE_UNSUPPORTED', - ); - expect(codeOf(() => resizeRaster(source, { width: 4, padding: 0.5 }))).toBe( - 'X_IMAGE_UNSUPPORTED', - ); - }); - - test('rejects a padding that leaves no room at the requested size', () => { - expect( - codeOf(() => resizeRaster(solid(4, 4, RED), { width: 2, height: 2, padding: 0.4 })), - ).toBe('X_IMAGE_UNSUPPORTED'); - }); -}); - -describe('compositing', () => { - const halfRed: Rgba = [255, 0, 0, 128]; - - test('an opaque background makes every output pixel opaque', () => { - const out = resizeRaster(solid(4, 4, halfRed), { - width: 8, - height: 4, - background: '#ffffff', - }); - for (let i = 3; i < out.pixels.length; i += 4) expect(out.pixels[i]).toBe(255); - // Half-transparent red over white lightens toward pink, it does not stay pure red. - const [r, g, b] = at(out, 4, 2); - expect(r).toBe(255); - expect(g).toBeGreaterThan(100); - expect(b).toBeGreaterThan(100); - }); - - test('a transparent background preserves the source alpha', () => { - const out = resizeRaster(solid(4, 4, halfRed), { - width: 2, - height: 2, - background: 'transparent', - }); - for (let y = 0; y < 2; y += 1) { - for (let x = 0; x < 2; x += 1) expect(at(out, x, y)).toEqual(halfRed); - } - }); - - test('a fully transparent background leaves the letterbox at zero, colour included', () => { - const out = resizeRaster(solid(4, 2, RED), { width: 2, height: 4, background: '#ff000000' }); - expect(at(out, 0, 0)).toEqual(CLEAR); - expect(at(out, 0, 2)).toEqual(RED); - }); -}); - -describe('the fast path', () => { - test('returns the identical object when nothing is asked for', () => { - const source = solid(4, 4, RED); - expect(resizeRaster(source, {})).toBe(source); - expect(resizeRaster(source, { fit: 'cover' })).toBe(source); - expect(resizeRaster(source, { padding: 0 })).toBe(source); - expect(resizeRaster(source, { width: 4, height: 4 })).toBe(source); - }); - - test('does not take the fast path once a background is requested', () => { - const source = solid(4, 4, RED); - expect(resizeRaster(source, { background: 'transparent' })).not.toBe(source); - }); -}); diff --git a/packages/core/src/image/resize.ts b/packages/core/src/image/resize.ts deleted file mode 100644 index e4a80282..00000000 --- a/packages/core/src/image/resize.ts +++ /dev/null @@ -1,320 +0,0 @@ -// Single responsibility: the geometry and the pixels of a resize — output box, drawn size, -// resampling and the source-over composite. Every format shares this one scaler on purpose: a -// second one is a second place for a PWA icon to grow a grey halo. - -import { parseColor } from './color'; -import { imageUnsupported } from './errors'; -import { assertPixelBudget, createRaster, type ImageSize, type Raster, rasterFrom } from './raster'; - -export type ImageFit = 'cover' | 'contain'; - -export interface ResizeSpec { - readonly width?: number | undefined; - readonly height?: number | undefined; - /** Default 'contain'. */ - readonly fit?: ImageFit | undefined; - /** Fraction of the shorter OUTPUT edge left empty on every side. `0 <= padding < 0.5`. */ - readonly padding?: number | undefined; - /** '#rgb' | '#rgba' | '#rrggbb' | '#rrggbbaa' | 'transparent'. Default transparent. */ - readonly background?: string | undefined; -} - -function assertDimension(value: number, field: string): void { - if (!Number.isInteger(value) || value < 1) { - throw imageUnsupported( - `resize ${field} is ${value}, which is not a whole number of pixels above zero`, - `pass an integer ${field} of 1 or more, or omit it to derive it from the source`, - { field, value }, - ); - } -} - -/** - * The output CANVAS size. A single-axis request clamps to the source: asking for `width: 2000` - * of a 400px original must not invent 1600 pixels of blur, it must hand back the 400. - */ -export function fitBox(source: ImageSize, spec: ResizeSpec): ImageSize { - const { width, height } = spec; - if (width !== undefined) assertDimension(width, 'width'); - if (height !== undefined) assertDimension(height, 'height'); - if (width !== undefined && height !== undefined) return { width, height }; - if (width !== undefined) { - const w = Math.min(width, source.width); - return { width: w, height: Math.max(1, Math.round((w * source.height) / source.width)) }; - } - if (height !== undefined) { - const h = Math.min(height, source.height); - return { width: Math.max(1, Math.round((h * source.width) / source.height)), height: h }; - } - return { width: source.width, height: source.height }; -} - -/** The size the source is DRAWN at inside `box` — no letterbox, no crop maths. May upscale. */ -export function scaledToFit(source: ImageSize, box: ImageSize, fit: ImageFit): ImageSize { - const x = box.width / source.width; - const y = box.height / source.height; - const scale = fit === 'cover' ? Math.max(x, y) : Math.min(x, y); - return { - width: Math.max(1, Math.round(source.width * scale)), - height: Math.max(1, Math.round(source.height * scale)), - }; -} - -interface InnerBox { - readonly pad: number; - readonly inner: ImageSize; -} - -function innerBox(box: ImageSize, padding: number): InnerBox { - if (!Number.isFinite(padding) || padding < 0 || padding >= 0.5) { - throw imageUnsupported( - `resize padding is ${padding}, outside the 0 <= padding < 0.5 range`, - 'pass a fraction of the shorter output edge, e.g. 0.1 for a 10% border on every side', - { padding }, - ); - } - const pad = Math.round(Math.min(box.width, box.height) * padding); - const inner = { width: box.width - 2 * pad, height: box.height - 2 * pad }; - if (inner.width < 1 || inner.height < 1) { - throw imageUnsupported( - `padding ${padding} leaves no room inside a ${box.width}x${box.height} output`, - 'lower the padding or raise the requested width and height', - { padding, pad, width: box.width, height: box.height }, - ); - } - return { pad, inner }; -} - -interface AxisPlan { - /** First contributing source index per target index. */ - readonly starts: Int32Array; - /** `taps` normalised weights per target index, at `i * taps`. Unused taps are 0. */ - readonly weights: Float32Array; - readonly taps: number; -} - -/** - * Area average when shrinking, bilinear when growing — chosen per axis. Nearest neighbour is - * what makes a downscaled `srcset` variant look cheap, and this file feeds every one of them. - */ -function planAxis(source: number, target: number): AxisPlan { - const ratio = source / target; - if (target > source) { - const starts = new Int32Array(target); - const weights = new Float32Array(target * 2); - for (let i = 0; i < target; i += 1) { - const center = (i + 0.5) * ratio - 0.5; - const left = Math.floor(center); - const first = Math.min(Math.max(left, 0), source - 1); - const second = Math.min(Math.max(left + 1, 0), source - 1); - starts[i] = first; - // Clamping at an edge collapses the pair; the surviving tap carries the whole weight. - weights[i * 2] = second === first ? 1 : 1 - (center - left); - weights[i * 2 + 1] = second === first ? 0 : center - left; - } - return { starts, weights, taps: 2 }; - } - const taps = Math.ceil(ratio) + 1; - const starts = new Int32Array(target); - const weights = new Float32Array(target * taps); - for (let i = 0; i < target; i += 1) { - const from = i * ratio; - const to = (i + 1) * ratio; - const first = Math.min(Math.floor(from), source - 1); - starts[i] = first; - let total = 0; - for (let k = 0; k < taps; k += 1) { - const s = first + k; - if (s >= source) break; - const overlap = Math.min(to, s + 1) - Math.max(from, s); - if (overlap <= 0) break; - weights[i * taps + k] = overlap; - total += overlap; - } - // Normalising is what keeps a partially covered edge column its own colour instead of - // fading it toward zero, and what makes a solid image survive a downscale unchanged. - if (total > 0) { - for (let k = 0; k < taps; k += 1) - weights[i * taps + k] = (weights[i * taps + k] ?? 0) / total; - } - } - return { starts, weights, taps }; -} - -function unpremultiply(src: Float32Array, width: number, height: number): Raster { - const pixels = new Uint8ClampedArray(width * height * 4); - for (let i = 0; i < pixels.length; i += 4) { - const a = src[i + 3] ?? 0; - if (a <= 0) continue; - const f = 255 / a; - pixels[i] = (src[i] ?? 0) * f; - pixels[i + 1] = (src[i + 1] ?? 0) * f; - pixels[i + 2] = (src[i + 2] ?? 0) * f; - pixels[i + 3] = a; - } - return rasterFrom(width, height, pixels); -} - -/** Either plane a pass can read: the 8-bit source itself, or a previous pass's float output. */ -type Plane = Float32Array | Uint8ClampedArray; - -/** - * The one weighted 4-channel sum every pass shares. `base` + `step` are the only thing that differs - * between horizontal and vertical, so there is a single accumulation to get right. - * - * Averaging non-premultiplied RGBA bleeds a transparent pixel's colour into the visible edge, so - * every tap is premultiplied — and when the plane is the 8-bit source (`eightBit`) that happens - * HERE, on read, instead of in a float copy of the whole image. `Math.fround` is what a - * `Float32Array` store did in that copy, so the fused read produces identical bytes; on a float - * plane the value is already float32 and it is a no-op. - */ -function tapSum( - src: Plane, - out: Float32Array, - q: number, - base: number, - step: number, - plan: AxisPlan, - i: number, - eightBit: boolean, -): void { - const { starts, weights, taps } = plan; - const from = base + (starts[i] ?? 0) * step; - let r = 0; - let g = 0; - let b = 0; - let a = 0; - for (let k = 0; k < taps; k += 1) { - const w = weights[i * taps + k] ?? 0; - // A zero weight is a tap the plan clamped away; skipping it is also what keeps the - // read inside the buffer at the trailing edge. - if (w === 0) continue; - const p = from + k * step; - const alpha = src[p + 3] ?? 0; - const f = eightBit ? alpha / 255 : 1; - r += Math.fround((src[p] ?? 0) * f) * w; - g += Math.fround((src[p + 1] ?? 0) * f) * w; - b += Math.fround((src[p + 2] ?? 0) * f) * w; - a += alpha * w; - } - out[q] = r; - out[q + 1] = g; - out[q + 2] = b; - out[q + 3] = a; -} - -function scaleX(src: Plane, sw: number, rows: number, dw: number, plan: AxisPlan, first: boolean) { - const out = new Float32Array(dw * rows * 4); - for (let y = 0; y < rows; y += 1) { - for (let x = 0; x < dw; x += 1) { - tapSum(src, out, (y * dw + x) * 4, y * sw * 4, 4, plan, x, first); - } - } - return out; -} - -function scaleY(src: Plane, cols: number, dh: number, plan: AxisPlan, first: boolean) { - const out = new Float32Array(cols * dh * 4); - for (let y = 0; y < dh; y += 1) { - for (let x = 0; x < cols; x += 1) { - tapSum(src, out, (y * cols + x) * 4, x * 4, cols * 4, plan, y, first); - } - } - return out; -} - -/** - * Separable: one axis into scratch, then the other. O(w·h·taps), never O(w·h·taps²). - * - * The FIRST pass reads `raster.pixels` directly, whichever axis it scales, so the only float buffer - * ever allocated is a pass OUTPUT. A standalone premultiplied copy of the source would be - * `Float32Array(w * h * 4)` — a gigabyte for a legal 64MP upload, before the scaler even starts, - * on a path `storage`, `seo` and `pwa` all feed user bytes into. - */ -function resample(raster: Raster, size: ImageSize): Raster { - if (raster.width === size.width && raster.height === size.height) return raster; - assertPixelBudget(size.width, size.height, 'resize'); - const { pixels, width, height } = raster; - // The early return above means at least one axis scales, so `first` always reads the 8-bit source. - const scalesX = size.width !== width; - const first = scalesX - ? scaleX(pixels, width, height, size.width, planAxis(width, size.width), true) - : scaleY(pixels, width, size.height, planAxis(height, size.height), true); - const both = scalesX && size.height !== height; - const scaled = both - ? scaleY(first, size.width, size.height, planAxis(height, size.height), false) - : first; - return unpremultiply(scaled, size.width, size.height); -} - -function fill(canvas: Raster, color: readonly [number, number, number, number]): void { - const [r, g, b, a] = color; - // A zero-alpha background is canonicalised to all-zero, matching the composite's own - // `outA === 0 -> outC = 0`: '#ff000000' and 'transparent' must not produce different bytes. - if (a === 0) return; - const { pixels } = canvas; - for (let i = 0; i < pixels.length; i += 4) { - pixels[i] = r; - pixels[i + 1] = g; - pixels[i + 2] = b; - pixels[i + 3] = a; - } -} - -/** Source-over. `outA === 0` means every contributor was transparent — the colour is nothing. */ -function blend(dst: Uint8ClampedArray, d: number, s: Uint8ClampedArray, p: number): void { - const sa = s[p + 3] ?? 0; - if (sa === 0) return; - const da = dst[d + 3] ?? 0; - if (sa === 255 || da === 0) { - dst[d] = s[p] ?? 0; - dst[d + 1] = s[p + 1] ?? 0; - dst[d + 2] = s[p + 2] ?? 0; - dst[d + 3] = sa; - return; - } - const sf = sa / 255; - const df = (da / 255) * (1 - sf); - const outA = sf + df; - dst[d] = ((s[p] ?? 0) * sf + (dst[d] ?? 0) * df) / outA; - dst[d + 1] = ((s[p + 1] ?? 0) * sf + (dst[d + 1] ?? 0) * df) / outA; - dst[d + 2] = ((s[p + 2] ?? 0) * sf + (dst[d + 2] ?? 0) * df) / outA; - dst[d + 3] = outA * 255; -} - -/** Centres `art` in the inner area and clips to it — that clip is exactly the `cover` crop. */ -function composite(canvas: Raster, art: Raster, pad: number, inner: ImageSize): void { - const ox = pad + Math.round((inner.width - art.width) / 2); - const oy = pad + Math.round((inner.height - art.height) / 2); - const x1 = Math.min(pad + inner.width, ox + art.width); - const y1 = Math.min(pad + inner.height, oy + art.height); - for (let y = Math.max(pad, oy); y < y1; y += 1) { - for (let x = Math.max(pad, ox); x < x1; x += 1) { - blend( - canvas.pixels, - (y * canvas.width + x) * 4, - art.pixels, - ((y - oy) * art.width + (x - ox)) * 4, - ); - } - } -} - -/** Box, background, resample, centre, composite — the whole resize, in that order. */ -export function resizeRaster(raster: Raster, spec: ResizeSpec): Raster { - const box = fitBox(raster, spec); - const { pad, inner } = innerBox(box, spec.padding ?? 0); - const unchanged = - box.width === raster.width && - box.height === raster.height && - pad === 0 && - spec.background === undefined; - if (unchanged) return raster; - - assertPixelBudget(box.width, box.height, 'resize'); - const drawn = scaledToFit(raster, inner, spec.fit ?? 'contain'); - const canvas = createRaster(box.width, box.height, 'resize'); - fill(canvas, parseColor(spec.background ?? 'transparent')); - composite(canvas, resample(raster, drawn), pad, inner); - return canvas; -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc535d4b..af890566 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -384,37 +384,33 @@ export { uuid, uuidTimestamp, } from './ids'; +export { fitBox, type ImageFit, type ResizeSpec, scaledToFit } from './image/canvas'; export { parseColor } from './image/color'; export { ImageDecodeFailedError, ImageTooLargeError, ImageUnsupportedError, imageDecodeFailed, + imageFromBunError, imageTooLarge, imageUnsupported, } from './image/errors'; -export { decodeJpeg } from './image/jpeg-decode'; -export { encodeJpeg } from './image/jpeg-encode'; -export { DEFAULT_JPEG_QUALITY } from './image/jpeg-tables'; export type { DecodableFormat, EncodableFormat, ImageTransformSpec, } from './image/pipeline'; export { - BLUR_PLACEHOLDER_WIDTH, blurDataUrl, canDecode, canEncode, DECODABLE_FORMATS, + DEFAULT_IMAGE_QUALITY, dataUrl, - decodeImage, - defaultFormatFor, ENCODABLE_FORMATS, - encodeImage, transformImageBytes, } from './image/pipeline'; -export { decodePng, encodePng } from './image/png'; +export { decodeImage, encodeImage } from './image/png-pixels'; export type { ImageFormat, ImageInfo } from './image/probe'; export { IMAGE_FORMATS, IMAGE_MIME_TYPES, probeImage, sniffImageFormat } from './image/probe'; export type { ImageSize, Raster } from './image/raster'; @@ -425,7 +421,6 @@ export { MAX_IMAGE_PIXELS, rasterFrom, } from './image/raster'; -export { fitBox, type ImageFit, type ResizeSpec, resizeRaster, scaledToFit } from './image/resize'; export { impersonate, impersonationReason, isImpersonating } from './impersonate'; export { cachedFormatter, canonicalLocale, MAX_CACHED_FORMATTERS } from './intl-cache'; export type { diff --git a/packages/pwa/src/icons.ts b/packages/pwa/src/icons.ts index 8f0df11f..b6d8283f 100644 --- a/packages/pwa/src/icons.ts +++ b/packages/pwa/src/icons.ts @@ -87,12 +87,13 @@ export interface ImagePipeline { * Always square PNG: `toManifestIcon` declares `type: 'image/png'`, so any other format * would make the manifest lie about bytes the browser then refuses. * - * `async` on purpose. A `Promise`-typed method that throws synchronously skips every - * `.catch()` a caller wrote; an undecodable source or a named colour must reject instead. + * `await` inside, not a returned promise: core's own refusals (a named colour, a padding out of + * range) are thrown before the first `await` in it, and re-raising them here is what keeps every + * failure of this method a rejection rather than a synchronous throw past a caller's `.catch()`. */ export class BuiltinImagePipeline implements ImagePipeline { async resize(source: Uint8Array, transform: ImageTransform): Promise { - return transformImageBytes(source, { + return await transformImageBytes(source, { width: transform.size, height: transform.size, fit: 'contain', diff --git a/packages/seo/src/image-driver.test.ts b/packages/seo/src/image-driver.test.ts index 53f3c773..89008219 100644 --- a/packages/seo/src/image-driver.test.ts +++ b/packages/seo/src/image-driver.test.ts @@ -57,11 +57,19 @@ describe('builtinImageDriver', () => { expect(probeImage(result.bytes).width).toBe(64); }); - test('an alpha source stays PNG and an opaque one becomes JPEG when nobody asks', async () => { + test('with no format asked for, the source format is KEPT — alpha can never be lost', async () => { + // The old rule guessed from the pixels and turned an opaque PNG into a JPEG; this one cannot + // flatten a logo, because the only way out of PNG is asking for it. const alpha = builtinImageDriver({ read: reader(TRANSLUCENT).read }); const opaque = builtinImageDriver({ read: reader(OPAQUE).read }); expect((await alpha.transform({ src: '/logo.png', width: 32 })).contentType).toBe('image/png'); expect((await opaque.transform({ src: '/photo.png', width: 32 })).contentType).toBe( + 'image/png', + ); + + const jpeg = await opaque.transform({ src: '/photo.png', width: 32, format: 'jpeg' }); + const fromJpeg = builtinImageDriver({ read: reader(jpeg.bytes).read }); + expect((await fromJpeg.transform({ src: '/photo.jpg', width: 16 })).contentType).toBe( 'image/jpeg', ); }); @@ -78,9 +86,13 @@ describe('builtinImageDriver', () => { await expect( driver.transform({ src: '/img/hero.png', width: 32, format: 'avif' }), ).rejects.toMatchObject({ code: 'X_IMAGE_UNSUPPORTED' }); - await expect( - driver.transform({ src: '/img/hero.png', width: 32, format: 'webp' }), - ).rejects.toMatchObject({ code: 'X_IMAGE_UNSUPPORTED' }); + }); + + test('webp is served by the builtin driver now, not only by a CDN one', async () => { + const driver = builtinImageDriver({ read: reader(OPAQUE).read }); + const result = await driver.transform({ src: '/img/hero.png', width: 32, format: 'webp' }); + expect(result.contentType).toBe('image/webp'); + expect(probeImage(result.bytes)).toMatchObject({ format: 'webp', width: 32, height: 24 }); }); test('a string that names no format is the same failure, and the fix lists the real ones', async () => { @@ -91,13 +103,14 @@ describe('builtinImageDriver', () => { ).rejects.toMatchObject({ code: 'X_IMAGE_UNSUPPORTED', fix: expect.stringContaining('jpeg') }); }); - test('blurPlaceholder is a 16px PNG data URI', async () => { + test('blurPlaceholder is a ThumbHash PNG data URI, at most 32px on its long edge', async () => { const driver = builtinImageDriver({ read: reader(OPAQUE).read }); const uri = await driver.blurPlaceholder('/img/hero.png'); expect(uri.startsWith('data:image/png;base64,')).toBe(true); - expect( - probeImage(Uint8Array.from(atob(uri.split(',')[1] ?? ''), (c) => c.charCodeAt(0))), - ).toMatchObject({ format: 'png', width: 16 }); + const info = probeImage(Uint8Array.from(atob(uri.split(',')[1] ?? ''), (c) => c.charCodeAt(0))); + expect(info.format).toBe('png'); + expect(Math.max(info.width, info.height)).toBeLessThanOrEqual(32); + expect(uri.length).toBeLessThan(2048); }); /** diff --git a/packages/seo/src/image-driver.ts b/packages/seo/src/image-driver.ts index 25a7c5d2..d6233bbf 100644 --- a/packages/seo/src/image-driver.ts +++ b/packages/seo/src/image-driver.ts @@ -60,17 +60,17 @@ function requestedFormat(format: string | undefined): ImageFormat | undefined { } /** - * The zero-dependency pipeline in `@ultimat3/core`: PNG and JPEG in, PNG and JPEG out. - * `` still offers AVIF and WebP, but nothing here synthesises them — asking for one - * raises core's `X_IMAGE_UNSUPPORTED`, and those variants belong on a CDN driver instead. + * The zero-dependency pipeline in `@ultimat3/core`: PNG, JPEG, WebP and GIF in, PNG, JPEG and + * WebP out. `` still offers AVIF, and nothing here synthesises it — asking for one + * raises core's `X_IMAGE_UNSUPPORTED`, and that variant belongs on a CDN driver instead. */ export function builtinImageDriver(options: BuiltinImageDriverOptions): ImageTransformDriver { return { name: 'builtin', async transform(request: TransformRequest): Promise { - const bytes = transformImageBytes(await options.read(request.src), { + const bytes = await transformImageBytes(await options.read(request.src), { width: request.width, - // Omitted means core picks by the pixels: PNG when the raster has alpha. + // Omitted means core keeps the source's format when it can write it, PNG otherwise. format: requestedFormat(request.format), quality: request.quality ?? options.quality, }); diff --git a/packages/storage/README.md b/packages/storage/README.md index ddb514cd..587f22f5 100644 --- a/packages/storage/README.md +++ b/packages/storage/README.md @@ -235,7 +235,7 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan | `X_STORAGE_QUARANTINED` | `promoteAttachment` on a key nothing has released from `pending/quarantine/` | | `X_NOT_IMPLEMENTED` | S3 user metadata / cache-control; `serverSideEncryption` on either driver | | `X_ENV_MISSING` | core's: S3 credential env vars, or a `localDriver` built outside development where neither `signingSecret` nor `STORAGE_SIGNING_SECRET` holds a secret other than the published `DEV_SIGNING_SECRET` | -| `X_IMAGE_UNSUPPORTED` | core's: an `avif`/`webp` encode, or a source no built-in decoder reads | +| `X_IMAGE_UNSUPPORTED` | core's: an `avif` encode, or a source no built-in decoder reads | | `X_IMAGE_DECODE_FAILED` | core's: truncated or corrupt image bytes | ## Images @@ -243,11 +243,14 @@ Inside `pending/` deliberately: an upload nobody ever scanned is still an orphan `variantKey()`, `srcsetDescriptors()`, `fitDimensions()` are pure — `@ultimat3/seo` builds `srcset` from them without decoding a byte. -`transformImage()` and `blurPlaceholder()` are real, over `@ultimat3/core`'s zero-dependency -pipeline. **It encodes `png` and `jpeg`, nothing else** — `avif`/`webp` remain key and `srcset` -math, and asking for their bytes rejects with core's `X_IMAGE_UNSUPPORTED` naming the two that -work; produce them through a CDN or a custom `ImageTransformDriver`. `png` is the only output -that keeps alpha. The encoded size is always exactly `fitDimensions()`, so the `width`/`height` -`@ultimat3/seo` already wrote into the tag match the bytes — `contain` fits inside the box, it -does not letterbox to it. `blurPlaceholder()` returns a real 16px-wide PNG `data:` URI. +`transformImage()` and `blurPlaceholder()` are real, over `@ultimat3/core`'s pipeline, which is +`Bun.Image` — no `sharp`, no dependency. **It encodes `png`, `jpeg` and `webp`** — so the default +format and the default `.webp` key extension finally agree, and a `srcset` entry can be served +rather than only named. `avif` remains key and `srcset` math: it needs an OS codec the portable +backend never uses, so asking for its bytes rejects with core's `X_IMAGE_UNSUPPORTED` naming the +three that work; produce it through a CDN or a custom `ImageTransformDriver`. `png` and `webp` are +the outputs that keep alpha. The encoded size is always exactly `fitDimensions()`, so the +`width`/`height` `@ultimat3/seo` already wrote into the tag match the bytes — `contain` fits inside +the box, it does not letterbox to it. `blurPlaceholder()` returns a ThumbHash PNG `data:` URI, at +most 32px on its long edge. `bun test` from `packages/storage`. diff --git a/packages/storage/src/image.test.ts b/packages/storage/src/image.test.ts index 1959abc9..36eb6b4c 100644 --- a/packages/storage/src/image.test.ts +++ b/packages/storage/src/image.test.ts @@ -12,7 +12,6 @@ import { probeImage, } from '@ultimat3/core'; import { - BLUR_PLACEHOLDER_WIDTH, blurPlaceholder, DEFAULT_QUALITY, DEFAULT_SRCSET_WIDTHS, @@ -56,6 +55,7 @@ function alphaPng(): Uint8Array { const dataUrlBytes = (uri: string): Uint8Array => Uint8Array.from(atob(uri.slice(uri.indexOf(',') + 1)), (char) => char.charCodeAt(0)); +/** PNG bytes only — `decodeImage` is core's raw-pixel seam, not a second decoder. */ const alphaAt = (bytes: Uint8Array, x: number, y: number): number => { const raster = decodeImage(bytes); return raster.pixels[(y * raster.width + x) * 4 + 3] ?? -1; @@ -166,8 +166,10 @@ describe('transformImage', () => { }); test('jpeg drops the alpha png keeps — the reason png is encodable at all', async () => { - const bytes = await transformImage(alphaPng(), { width: 4, format: 'jpeg' }); - expect(hasAlpha(decodeImage(bytes))).toBe(false); + // Read back through PNG, because the raw-pixel seam decodes PNG and the JPEG is the subject. + const jpeg = await transformImage(alphaPng(), { width: 4, format: 'jpeg' }); + const asPng = await transformImage(jpeg, { width: 4, format: 'png' }); + expect(hasAlpha(decodeImage(asPng))).toBe(false); }); test('never upscales past the source', async () => { @@ -184,10 +186,22 @@ describe('transformImage', () => { } }); - test('rejects webp — the built-in encoder produces png and jpeg only', async () => { + test('webp is a real output now — one encoder, three formats', async () => { + const bytes = await transformImage(opaquePng(), { width: 10, format: 'webp' }); + expect(probeImage(bytes)).toMatchObject({ format: 'webp', width: 10, height: 5 }); + }); + + test('the default format is webp, which is also the default key extension', async () => { + // These two must agree: `variantKey` writes `.webp` with no format asked for, so bytes in any + // other format would be served under a key that names one the browser will not read. + expect(variantKey('a/b.png', { width: 10 }).endsWith('.webp')).toBe(true); + expect(probeImage(await transformImage(opaquePng(), { width: 10 })).format).toBe('webp'); + }); + + test('rejects avif — it needs an OS codec the portable backend never uses', async () => { // A rejection, not a synchronous throw: this line would blow up before `expect` if the // function still threw out of a Promise-typed body. - const pending = transformImage(opaquePng(), { width: 10, format: 'webp' }); + const pending = transformImage(opaquePng(), { width: 10, format: 'avif' }); expect(pending).toBeInstanceOf(Promise); await expect(pending).rejects.toMatchObject({ code: 'X_IMAGE_UNSUPPORTED' }); // `Promise.catch` widens the value to `Uint8Array | `, so the rejection has to @@ -196,13 +210,7 @@ describe('transformImage', () => { const rejection: unknown = await pending.catch((reason: unknown) => reason); const fix = isUltimateError(rejection) ? rejection.fix : ''; expect(fix).toContain('png'); - expect(fix).toContain('jpeg'); - }); - - test('rejects the default format too — the default key extension is .webp', async () => { - await expect(transformImage(opaquePng(), { width: 10 })).rejects.toMatchObject({ - code: 'X_IMAGE_UNSUPPORTED', - }); + expect(fix).toContain('webp'); }); test('rejects bytes that are no image at all', async () => { @@ -221,15 +229,16 @@ describe('transformImage', () => { }); describe('blurPlaceholder', () => { - test('is a 16px-wide png data URI', async () => { + test('is a png data URI at most 32px on its long edge, at the source aspect ratio', async () => { const uri = await blurPlaceholder(opaquePng()); expect(uri.startsWith('data:image/png;base64,')).toBe(true); - expect(probeImage(dataUrlBytes(uri))).toMatchObject({ - format: 'png', - width: BLUR_PLACEHOLDER_WIDTH, - height: 8, - }); - expect(BLUR_PLACEHOLDER_WIDTH).toBe(16); + const info = probeImage(dataUrlBytes(uri)); + expect(info.format).toBe('png'); + expect(info.width).toBeLessThanOrEqual(32); + // 40x20 source: landscape stays landscape. ThumbHash quantises the ratio (32x18 here, not + // 32x16), which costs nothing because `@ultimat3/seo` paints this as `background-size:cover` + // inside a box already sized from the real width/height — the LQIP never sets the box. + expect(info.width).toBeGreaterThan(info.height); }); test('stays small enough to inline in the document head', async () => { diff --git a/packages/storage/src/image.ts b/packages/storage/src/image.ts index ee94a1e9..444456ab 100644 --- a/packages/storage/src/image.ts +++ b/packages/storage/src/image.ts @@ -3,12 +3,7 @@ // `` from `srcsetDescriptors()` without decoding a byte, and when it does need the // bytes, `transformImage()` returns exactly the size `fitDimensions()` already promised. -import { - blurDataUrl, - BLUR_PLACEHOLDER_WIDTH as CORE_BLUR_PLACEHOLDER_WIDTH, - probeImage, - transformImageBytes, -} from '@ultimat3/core'; +import { blurDataUrl, probeImage, transformImageBytes } from '@ultimat3/core'; import { assertSafeKey, keyExtname } from './path'; export const IMAGE_FORMATS = ['avif', 'webp', 'jpeg', 'png'] as const; @@ -33,8 +28,6 @@ export interface ImageSize { export const DEFAULT_QUALITY = 80; export const DEFAULT_SRCSET_WIDTHS = [320, 640, 960, 1280, 1920] as const; -/** Small enough to inline in HTML; big enough to blur convincingly. Core owns the number. */ -export const BLUR_PLACEHOLDER_WIDTH = CORE_BLUR_PLACEHOLDER_WIDTH; const FORMAT_EXTENSIONS: Readonly> = { avif: 'avif', @@ -116,10 +109,10 @@ export function fitDimensions(source: ImageSize, transform: ImageTransform): Ima } /** - * Decode, resize, encode — core's pipeline, which encodes **png and jpeg only**. `avif` and - * `webp` stay key/`srcset` math: asking for their bytes rejects with core's - * `X_IMAGE_UNSUPPORTED` (not re-wrapped — one failure, one code), and producing them means a - * CDN or a custom `ImageTransformDriver`. PNG is also the only output that keeps alpha. + * Decode, resize, encode — core's pipeline, which encodes **png, jpeg and webp**. `avif` stays + * key/`srcset` math: asking for its bytes rejects with core's `X_IMAGE_UNSUPPORTED` (not + * re-wrapped — one failure, one code) because it needs an OS codec the portable backend never + * uses, and producing it means a CDN or a custom `ImageTransformDriver`. * * The output box is `fitDimensions()`, always: that is the size `@ultimat3/seo` has already * written into the `` tag, and bytes that disagreed with it would be the layout shift @@ -144,5 +137,5 @@ export async function transformImage( /** A `data:` URI small enough to inline as the LQIP behind a real image. Always PNG. */ export async function blurPlaceholder(bytes: Uint8Array): Promise { - return blurDataUrl(bytes, BLUR_PLACEHOLDER_WIDTH); + return blurDataUrl(bytes); } diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index a830bb82..2d82fbc9 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -104,7 +104,6 @@ export type { SrcsetOptions, } from './image'; export { - BLUR_PLACEHOLDER_WIDTH, blurPlaceholder, DEFAULT_QUALITY, DEFAULT_SRCSET_WIDTHS, From ce6d99a7ba61b65b7db5bc451f1446e23d499c52 Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 20 Aug 2026 17:02:35 -0500 Subject: [PATCH 2/8] =?UTF-8?q?fix(time)!:=20a=20timezone=20is=20Area/Loca?= =?UTF-8?q?tion,=20or=20UTC=20=E2=80=94=20never=20an=20abbreviation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING. Bun 1.4 ships ICU 78, where `new Intl.DateTimeFormat('en', {timeZone:'CET'})` no longer throws. `resolve()` delegated the IANA-ness judgement to `Intl`, so a runtime upgrade alone reopened the framework's "no date without an explicit IANA timeZone" rule — silently, and in the direction that fails dangerous: isValidTimeZone('CET') -> true (was false) also EST, EST5EDT, GMT, MST `Intl` answers "can I format this", never "is this IANA", and at ICU 78 the two stopped agreeing. The judgement is now structural: `UTC`, or a name containing `/`. It cannot move with the runtime again. 43 names change answer, enumerated with their replacements in CHANGELOG.md. The 24 geographic `backward` links and the three UTC aliases swap textually — each verified against seven probe instants including both 2026 DST transitions. The GMT family maps to `Etc/GMT`, not `UTC`, because `UTC` renders a different zone label. The eleven abbreviations have no mechanical replacement, and that is the defect: an abbreviation names no jurisdiction and carries no DST rule. No denylist. A list of refused abbreviations grows with every tzdata release, and there is no structural rule that keeps `CET` out and lets `Japan` in. `X_TIMEZONE_INVALID`'s `cause:` was itself false — `Japan` IS an IANA name, a `backward` link in shipped tzdata — so it now reads "not an IANA Area/Location zone name", which is true for all four refused classes. The `fix:` instructs both classes: the mechanical swap for a legacy link, and "name the city" for an abbreviation, because only the author can choose one. Three `format.test.ts` expectations move to anchored patterns rather than pinned ICU-78 literals, so the suite is green on ICU 75 and 78 both and this does not have to merge with the Bun upgrade. Refs #251 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KgsU1WBJMAjnLaazvfuJmD --- packages/time/CLAUDE.md | 11 ++++ packages/time/README.md | 9 +++- packages/time/src/errors.ts | 9 +++- packages/time/src/format.test.ts | 20 ++++++-- packages/time/src/zone-canonical.ts | 29 +++++++++-- packages/time/src/zones.test.ts | 79 +++++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 11 deletions(-) diff --git a/packages/time/CLAUDE.md b/packages/time/CLAUDE.md index 8e9afcc1..378a0ba6 100644 --- a/packages/time/CLAUDE.md +++ b/packages/time/CLAUDE.md @@ -32,6 +32,17 @@ so `currentTimeZone()` answered `UTC` for every request and every `@ultimat3/ui` server render formatted in UTC regardless of the zone the caller sent. Two ambient defaults is the worst possible version of the rule above. Never reintroduce either half. +- **`Intl` answers "can I format this", never "is this an IANA zone", and the two stopped + agreeing.** ICU 78 (Bun 1.4) RESOLVES `CET`, `EST`, `EST5EDT`, `GMT`, `MST` and their families + where ICU 75 threw, so a runtime upgrade alone reopened the golden rule above — silently, and in + the direction that fails dangerous: an abbreviation names no DST rule. So the judgement is never + delegated to `Intl`. `canonicalTimeZone` asserts the structural property itself: a zone is + `Area/Location`, and `UTC` is the one legal exception. Never a denylist of the names ICU newly + accepts — that list grows with every tzdata and ICU release, and no rule in it keeps `CET` out + while letting `Japan` in, both being one label. The single-label `backward` links go with them + (`Japan` → `Asia/Tokyo`, `GB` → `Europe/London`) and that is the point: the slashed spelling is + the one that survives being a formatter-cache key. **Breaking at 6.0.0.** `zones.test.ts` pins + one named case per refused name, so an ICU bump that reopens one names it. - **Never cache an `Intl` formatter on a raw caller string.** A zone and a locale both arrive from a request header, so the key must be canonical (`canonicalTimeZone` for a zone, `canonicalLocale` for a locale) and the cache must be bounded (`cachedFormatter`). **`cachedFormatter`, diff --git a/packages/time/README.md b/packages/time/README.md index 05c439c7..d811b53c 100644 --- a/packages/time/README.md +++ b/packages/time/README.md @@ -21,6 +21,13 @@ answers the canonical spelling (or `undefined`), and `assertTimeZone` / `resolve return it. Anything reading a zone off a request header should canonicalize before caching on it: 4,096 casings of `Europe/Berlin` used to mint 4,096 permanent `Intl.DateTimeFormat`s, 31 MB. +**A zone is `Area/Location`, or `UTC`.** Nothing else. `CET`, `EST5EDT` and `+02:00` name no +jurisdiction and carry no DST rule; the single-label `backward` links (`Japan`, `GB`, `Eire`) are +refused with them, because no rule keeps the first group out and lets the second in. Write the +slashed spelling — `Europe/Paris`, `Asia/Tokyo`, `Europe/London`. `Intl` is not the judge: ICU 78 +resolves what ICU 75 threw on, so the check is structural and does not move with the runtime. +**Breaking at 6.0.0**, `Japan` → `Asia/Tokyo`. + One **locale** is one key for the same reason — `Accept-Language` spells one locale `EN-us`, `en-US` and `en-latn-us`, and `formatDateTime` and `describeCron` collapse the three before they reach a formatter cache. The cap stays either way: an unknown `-u-` extension value survives @@ -141,7 +148,7 @@ negated; an empty one is `0` in either direction, never `-0`. | Code | When | |---|---| -| `X_TIMEZONE_INVALID` | not an IANA name (abbreviations and numeric offsets are rejected) | +| `X_TIMEZONE_INVALID` | not `Area/Location` or `UTC`: an abbreviation (`CET`), a numeric offset (`+02:00`), or a single-label legacy name (`Japan`) | | `X_CRON_INVALID` | unparseable expression, or one that can never match | | `X_DURATION_INVALID` | `'3'` with no unit, trailing junk, unknown unit | | `X_DST_AMBIGUOUS` | overlap hit with `overlap: 'throw'` | diff --git a/packages/time/src/errors.ts b/packages/time/src/errors.ts index c27c59a0..3a202231 100644 --- a/packages/time/src/errors.ts +++ b/packages/time/src/errors.ts @@ -63,11 +63,16 @@ export function scheduleInvalid(field: string, value: unknown, range: string): T }); } +/** + * Two refused classes, and they need different instructions — one `fix:` that only described the + * abbreviations left an operator holding `"Japan"` reading advice about `CET`. A legacy single-label + * link has a mechanical replacement; an abbreviation has none, and saying so IS the instruction. + */ export function timezoneInvalid(zone: string): TimeError { return new TimeError({ code: 'X_TIMEZONE_INVALID', - cause: `"${zone}" is not an IANA timezone name`, - fix: 'use an IANA identifier such as Europe/Berlin, America/New_York or UTC — never an abbreviation like CET or a numeric offset', + cause: `"${zone}" is not an IANA Area/Location zone name`, + fix: "use Area/Location, or UTC. A single-label legacy name swaps mechanically — Japan → Asia/Tokyo, GB → Europe/London, Universal → UTC. An abbreviation or a numeric offset does not: CET and EST5EDT name no jurisdiction and carry no DST rule, so name the city whose clock you mean (Europe/Paris, America/New_York). Every accepted name: Intl.supportedValuesOf('timeZone')", }); } diff --git a/packages/time/src/format.test.ts b/packages/time/src/format.test.ts index faf3f899..dbc4a2a3 100644 --- a/packages/time/src/format.test.ts +++ b/packages/time/src/format.test.ts @@ -132,9 +132,15 @@ describe('formatDateTime', () => { // `style` sets BOTH halves, and the two wide styles deliberately do not set a wide TIME style: // `timeStyle: 'full'` appends the zone name, which `formatWithOffset` exists to render instead. + // + // Matched, not compared: CLDR moves the SEPARATORS between ICU releases and this repo's runtime + // and its CI runner are on different ones. ICU 75 renders `Saturday 14 March…`, ICU 78 (Bun 1.4) + // `Saturday, 14 March…`. The optional comma is the only tolerance — the pattern is anchored, so + // a `timeStyle` that widened to `'full'` still fails on the appended zone name, which is the + // whole claim of this test. test("style: 'full' widens the date and holds the time at medium", () => { - expect(formatDateTime(at, { locale: 'en-GB', zone: 'Europe/Berlin', style: 'full' })).toBe( - 'Saturday 14 March 2026 at 09:00:00', + expect(formatDateTime(at, { locale: 'en-GB', zone: 'Europe/Berlin', style: 'full' })).toMatch( + /^Saturday,? 14 March 2026 at 09:00:00$/u, ); expect(formatDateTime(at, { locale: 'en-GB', zone: 'Europe/Berlin', style: 'short' })).toBe( '14/03/2026, 09:00', @@ -194,8 +200,14 @@ describe('formatTime', () => { describe('formatRange', () => { const to = fromIso('2026-03-16T08:00:00Z'); + // Anchored, with the spacing around the en dash optional, for the reason `style: 'full'` above + // gives: ICU 75 collapses to `14–16 Mar 2026` and ICU 78 to `14 – 16 Mar 2026`. What the test + // asserts is that `Mar 2026` appears ONCE — an implementation that formatted both endpoints + // separately fails the anchors regardless of which ICU renders it. + const COLLAPSED = /^14 ?– ?16 Mar 2026$/u; + test('one call, so the locale collapses the shared parts', () => { - expect(formatRange(at, to, { locale: 'en-GB', zone: 'Europe/Berlin' })).toBe('14–16 Mar 2026'); + expect(formatRange(at, to, { locale: 'en-GB', zone: 'Europe/Berlin' })).toMatch(COLLAPSED); // A range whose endpoints land on one local day collapses to that single day. expect(formatRange(at, at, { locale: 'en-GB', zone: 'Europe/Berlin' })).toBe('14 Mar 2026'); }); @@ -225,6 +237,6 @@ describe('formatRange', () => { } finally { Object.defineProperty(Intl.DateTimeFormat.prototype, 'formatRange', descriptor); } - expect(formatRange(at, to, { locale: 'en-GB', zone: 'Europe/Berlin' })).toBe('14–16 Mar 2026'); + expect(formatRange(at, to, { locale: 'en-GB', zone: 'Europe/Berlin' })).toMatch(COLLAPSED); }); }); diff --git a/packages/time/src/zone-canonical.ts b/packages/time/src/zone-canonical.ts index d9cc211a..3877af4a 100644 --- a/packages/time/src/zone-canonical.ts +++ b/packages/time/src/zone-canonical.ts @@ -25,9 +25,10 @@ function listedZones(): Map { } /** - * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) and the runtime's extras (`EST`, `GMT`) are - * not in the listed set, so they take the `Intl` probe once — bounded for the same reason every - * other cache here is. + * Deprecated aliases (`US/Eastern`, `Asia/Calcutta`) are not in the listed set — `supportedValuesOf` + * holds canonical zones only, and ICU does not fold a `backward` link into its target — so they + * take the `resolve` probe once, as do the runtime's extras (`EST`, `GMT`), the aliases to be + * accepted and the extras refused. Both cached: either can arrive from a header on every request. */ const probed = new Map(); @@ -45,9 +46,29 @@ export function canonicalTimeZone(zone: string): string | undefined { return resolved === '' ? undefined : resolved; } +/** + * `Intl` answers "can I format this", never "is this an IANA zone", and the two stopped agreeing: + * ICU 78 (Bun 1.4) resolves `CET`, `EST`, `EST5EDT`, `GMT` and `MST` where ICU 75 threw, so a + * runtime upgrade alone reopened the guard — silently, and in the direction that fails dangerous, + * because an abbreviation names no DST rule. The IANA-ness judgement is therefore never delegated + * to `Intl`: an identifier is `Area/Location`, and `UTC` is the one legal exception. + * + * That refuses the single-label `backward` links (`Japan`, `GB`, `Eire`) along with the + * abbreviations, and it is meant to. No structural rule keeps `CET` out and lets `Japan` in — both + * are one label — and the alternative is a denylist that grows with every tzdata and ICU release. + * `Asia/Tokyo` is the spelling that survives being a formatter-cache key, which is what this file + * is for. `Etc/GMT+2` passes: the `+` is inside a real zone name, and only a LEADING sign is a + * bare offset. + * + * `UTC` is compared on the RESOLVED name rather than assumed unreachable. It is unreachable today + * — `UTC` is in `supportedValuesOf` and never gets this far — but a runtime that folds an alias + * into its target would resolve `Etc/UTC` to `UTC`, and refusing `Etc/UTC` would be the bug. + */ function resolve(zone: string): string | '' { try { - return new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions().timeZone; + const resolved = new Intl.DateTimeFormat('en-US', { timeZone: zone }).resolvedOptions() + .timeZone; + return resolved === 'UTC' || resolved.includes('/') ? resolved : ''; } catch { return ''; } diff --git a/packages/time/src/zones.test.ts b/packages/time/src/zones.test.ts index e08d34c3..4588a530 100644 --- a/packages/time/src/zones.test.ts +++ b/packages/time/src/zones.test.ts @@ -65,6 +65,85 @@ describe('isValidTimeZone', () => { expect(isValidTimeZone('+01:00')).toBe(false); expect(isValidTimeZone('')).toBe(false); }); + + // ICU 78 (Bun 1.4) resolves every one of these where ICU 75 threw, so the guard cannot ask + // `Intl` whether a string is an IANA zone and asserts `Area/Location` itself. One case per name, + // named, so a later ICU bump that reopens one fails with the name in the report rather than + // silently widening the guard. + const ABBREVIATIONS = [ + 'CET', + 'EET', + 'MET', + 'WET', + 'EST', + 'MST', + 'HST', + 'GMT', + 'GMT0', + 'UCT', + 'Zulu', + 'EST5EDT', + 'CST6CDT', + 'MST7MDT', + 'PST8PDT', + ]; + + test.each(ABBREVIATIONS)('rejects %s — an abbreviation carries no DST rule', (zone) => { + expect(isValidTimeZone(zone)).toBe(false); + // Every casing, because `Intl` accepts every casing and the string arrives from a header. + expect(isValidTimeZone(zone.toLowerCase())).toBe(false); + expect(canonicalTimeZone(zone)).toBe(undefined); + }); + + // Single-label `backward` links name real zones, and refusing them is deliberate rather than ICU + // drift: no structural rule keeps `CET` out and lets `Japan` in, both being one label, and the + // alternative is a denylist that grows with every tzdata release. `Asia/Tokyo` is the spelling + // that survives being a formatter-cache key. BREAKING at 6.0.0 — `Japan` → `Asia/Tokyo`. + test.each(['Japan', 'GB', 'Eire', 'W-SU', 'PRC', 'ROK', 'Singapore', 'Israel', 'Universal'])( + 'rejects the single-label legacy link %s', + (zone) => { + expect(isValidTimeZone(zone)).toBe(false); + expect(canonicalTimeZone(zone)).toBe(undefined); + }, + ); + + test.each(['Europe/Berlin', 'UTC', 'utc', 'US/Eastern', 'Asia/Calcutta', 'Etc/GMT+2'])( + 'still accepts %s', + (zone) => { + expect(isValidTimeZone(zone)).toBe(true); + }, + ); +}); + +// Axiom 4, applied to a refusal that grew a second class. `Japan` and `CET` are both refused and +// the remedies are not the same — one swaps mechanically, the other has no replacement at all — +// so a `fix:` describing only abbreviations left an operator holding `"Japan"` reading about `CET`. +describe('X_TIMEZONE_INVALID instructs both refused classes', () => { + function refusal(zone: string): UltimateError { + try { + assertTimeZone(zone); + } catch (error) { + if (isUltimateError(error)) return error; + } + return expect.unreachable(`${zone} must be refused with an UltimateError`); + } + + test('the cause names the input and the shape it is missing', () => { + expect(refusal('Japan').cause).toBe('"Japan" is not an IANA Area/Location zone name'); + expect(refusal('CET').cause).toBe('"CET" is not an IANA Area/Location zone name'); + }); + + test('the fix carries the mechanical swap, the class that has none, and how to look one up', () => { + const fix = refusal('Japan').fix; + // The legacy-link half: a replacement the operator can paste, not a description of the rule. + expect(fix).toContain('Japan → Asia/Tokyo'); + // The abbreviation half, and WHY it gets no replacement rather than a wrong one. + expect(fix).toContain('carry no DST rule'); + expect(fix).toContain("Intl.supportedValuesOf('timeZone')"); + // One code, one instruction: an abbreviation and an offset read the same remedy. + expect(refusal('CET').fix).toBe(fix); + expect(refusal('+01:00').fix).toBe(fix); + }); }); // `Intl` accepts every casing of an IANA name, and every formatter cache was keyed on the raw From 0d715dd7b5a31c8000db7a04218c81283b3f7479 Mon Sep 17 00:00:00 2001 From: sebi Date: Thu, 20 Aug 2026 17:02:55 -0500 Subject: [PATCH 3/8] fix(render,admin)!: delete render:'spa' and createRouter, which never worked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING. Every `spa` route ever declared served an empty document — 200, correct headers, blank page:
`dev-render.ts:205` passed `chunks: []`, hardcoded; `prerender.ts` refused the mode outright; the container mounted the same table. No document in the framework's history ever carried a