From c69e961fcd860abb9f0623cc445b7a01c3fdfc35 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:19:52 +0800 Subject: [PATCH 1/4] test: exercise image conversion with real WASM codecs --- package.json | 3 +- src/lib/util/magick-convert.ts | 42 +++++++++++++++++++ src/lib/workers/magick.ts | 43 +------------------- tests/README.md | 7 ++++ tests/helpers-load-ts.mjs | 19 +++++++++ tests/helpers-magick.mjs | 60 ++++++++++++++++++++++++++++ tests/magick-convert.test.mjs | 73 ++++++++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 43 deletions(-) create mode 100644 src/lib/util/magick-convert.ts create mode 100644 tests/README.md create mode 100644 tests/helpers-load-ts.mjs create mode 100644 tests/helpers-magick.mjs create mode 100644 tests/magick-convert.test.mjs diff --git a/package.json b/package.json index 0987dd26..da3b11d6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", - "lint": "prettier --check . && eslint ." + "lint": "prettier --check . && eslint .", + "test": "node --test --test-concurrency=1 tests/*.test.mjs" }, "devDependencies": { "@inlang/paraglide-js": "^2.5.0", diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts new file mode 100644 index 00000000..fd91e5c2 --- /dev/null +++ b/src/lib/util/magick-convert.ts @@ -0,0 +1,42 @@ +import { MagickFormat, type IMagickImage } from "@imagemagick/magick-wasm"; + +export const magickConvert = async ( + img: IMagickImage, + to: string, + keepMetadata: boolean, + compression?: number, +) => { + let fmt = to.slice(1).toUpperCase(); + if (fmt === "JFIF") fmt = "JPEG"; + + // ICO size clamp to avoid WidthOrHeightExceedsLimit + if (fmt === "ICO") { + const max = 256; + const w = img.width; + const h = img.height; + + if (w > max || h > max) { + const scale = max / Math.max(w, h); + const newW = Math.max(1, Math.round(w * scale)); + const newH = Math.max(1, Math.round(h * scale)); + + img.resize(newW, newH); + } + } + + const result = await new Promise((resolve, reject) => { + try { + // magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) + if (compression) img.quality = compression; + if (!keepMetadata) img.strip(); + + img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { + resolve(structuredClone(o)); + }); + } catch (error) { + reject(error); + } + }); + + return result; +}; diff --git a/src/lib/workers/magick.ts b/src/lib/workers/magick.ts index aa3ab5d2..b9d62c29 100644 --- a/src/lib/workers/magick.ts +++ b/src/lib/workers/magick.ts @@ -4,8 +4,8 @@ import { MagickImage, MagickImageCollection, MagickReadSettings, - type IMagickImage, } from "@imagemagick/magick-wasm"; +import { magickConvert } from "$lib/util/magick-convert"; import { makeZip } from "client-zip"; import { parseAni } from "$lib/util/parse/ani"; import { parseIcns } from "vert-wasm"; @@ -284,47 +284,6 @@ const readToEnd = async (reader: ReadableStreamDefaultReader) => { return new Uint8Array(arrayBuffer); }; -const magickConvert = async ( - img: IMagickImage, - to: string, - keepMetadata: boolean, - compression?: number, -) => { - let fmt = to.slice(1).toUpperCase(); - if (fmt === "JFIF") fmt = "JPEG"; - - // ICO size clamp to avoid WidthOrHeightExceedsLimit - if (fmt === "ICO") { - const max = 256; - const w = img.width; - const h = img.height; - - if (w > max || h > max) { - const scale = max / Math.max(w, h); - const newW = Math.max(1, Math.round(w * scale)); - const newH = Math.max(1, Math.round(h * scale)); - - img.resize(newW, newH); - } - } - - const result = await new Promise((resolve, reject) => { - try { - // magick-wasm automatically clamps (https://github.com/dlemstra/magick-wasm/blob/76fc6f2b0c0497d2ddc251bbf6174b4dc92ac3ea/src/magick-image.ts#L2480) - if (compression) img.quality = compression; - if (!keepMetadata) img.strip(); - - img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { - resolve(structuredClone(o)); - }); - } catch (error) { - reject(error); - } - }); - - return result; -}; - onmessage = async (e) => { const message = e.data; try { diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..d1b720e6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,7 @@ +# Image conversion regression tests + +Run `bun install --frozen-lockfile`, then `bun run test` (Node.js 20+). + +These tests initialize the installed ImageMagick WASM module and invoke the same `magickConvert` function used by the worker. Fixtures are generated in memory. Encoded outputs are decoded again to inspect dimensions, pixels and metadata; no network service or user images are required. + +`helpers-load-ts.mjs` transpiles the small TypeScript utility and its relative imports using the existing TypeScript dependency. The test command runs files sequentially to limit WASM memory usage. Application type checking remains a separate `bun run check` command. diff --git a/tests/helpers-load-ts.mjs b/tests/helpers-load-ts.mjs new file mode 100644 index 00000000..094e5833 --- /dev/null +++ b/tests/helpers-load-ts.mjs @@ -0,0 +1,19 @@ +import { readFile } from "node:fs/promises"; +import ts from "typescript"; +export async function moduleUrl(url) { + const { outputText } = ts.transpileModule(await readFile(url, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + }); + let source = outputText; + for (const match of outputText.matchAll(/from "([^"]+)"/g)) { + const name = match[1]; + const target = name.startsWith(".") + ? await moduleUrl(new URL(name + ".ts", url)) + : import.meta.resolve(name); + source = source.replace(JSON.stringify(name), JSON.stringify(target)); + } + return `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`; +} diff --git a/tests/helpers-magick.mjs b/tests/helpers-magick.mjs new file mode 100644 index 00000000..ae23f627 --- /dev/null +++ b/tests/helpers-magick.mjs @@ -0,0 +1,60 @@ +import { readFile } from "node:fs/promises"; +import { + initializeImageMagick, + MagickImage, + MagickReadSettings, + MagickFormat, +} from "@imagemagick/magick-wasm"; +import { moduleUrl } from "./helpers-load-ts.mjs"; + +const { magickConvert } = await import( + await moduleUrl( + new URL("../src/lib/util/magick-convert.ts", import.meta.url), + ) +); +await initializeImageMagick( + await readFile( + new URL(import.meta.resolve("@imagemagick/magick-wasm/magick.wasm")), + ), +); + +export const write = (image, format) => + image.write(format, (bytes) => new Uint8Array(bytes)); +export const rgba = (image) => + image.getPixels( + (pixels) => + new Uint8Array( + pixels.toByteArray(0, 0, image.width, image.height, "RGBA"), + ), + ); + +// Synthetic RGB gradient, or three transparent/semitransparent/opaque bands. +export function fixture(alpha = false) { + const width = 48, + height = 32; + const pixels = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + pixels.set( + alpha + ? [255, 0, 0, x < 16 ? 0 : x < 32 ? 128 : 255] + : [x * 5, y * 7, (x * 13 + y * 3) % 256, 255], + (y * width + x) * 4, + ); + } + } + return MagickImage.create( + pixels, + new MagickReadSettings({ format: MagickFormat.Rgba, width, height }), + ); +} + +// Exercise the same function the conversion worker calls, with real WASM codecs. +export async function convert(bytes, to, keepMetadata = false, quality = 100) { + const input = MagickImage.create(bytes); + try { + return await magickConvert(input, to, keepMetadata, quality); + } finally { + input.dispose(); + } +} diff --git a/tests/magick-convert.test.mjs b/tests/magick-convert.test.mjs new file mode 100644 index 00000000..fcdf2586 --- /dev/null +++ b/tests/magick-convert.test.mjs @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + MagickImage, + MagickFormat, + MagickReadSettings, +} from "@imagemagick/magick-wasm"; +import { fixture, write, rgba, convert } from "./helpers-magick.mjs"; + +test("PNG conversion preserves decoded dimensions and pixels", async () => { + const source = fixture(); + let output; + try { + output = MagickImage.create( + await convert(write(source, MagickFormat.Png), ".png"), + ); + assert.deepEqual( + [output.width, output.height], + [source.width, source.height], + ); + assert.deepEqual(rgba(output), rgba(source)); + } finally { + output?.dispose(); + source.dispose(); + } +}); + +for (const keep of [false, true]) { + test(`PNG comment follows keepMetadata=${keep}`, async () => { + const source = fixture(); + let output; + try { + source.setAttribute("comment", "synthetic-test"); + output = MagickImage.create( + await convert(write(source, MagickFormat.Png), ".png", keep), + ); + assert.equal( + output.getAttribute("comment"), + keep ? "synthetic-test" : null, + ); + } finally { + output?.dispose(); + source.dispose(); + } + }); +} + +test("ICO conversion keeps its 256-pixel size limit and aspect ratio", async () => { + const source = fixture(); + let output; + try { + source.resize(600, 400); + output = MagickImage.create( + await convert(write(source, MagickFormat.Png), ".ico"), + new MagickReadSettings({ format: MagickFormat.Ico }), + ); + assert.deepEqual([output.width, output.height], [256, 171]); + } finally { + output?.dispose(); + source.dispose(); + } +}); + +test("encoder failures reject the conversion promise", async () => { + const source = fixture(); + try { + await assert.rejects( + convert(write(source, MagickFormat.Png), ".invalid-format"), + ); + } finally { + source.dispose(); + } +}); From 11d3270eb4e442edaa417c6a942caed0d550e35a Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:26:36 +0800 Subject: [PATCH 2/4] fix: composite transparent JPEG input onto white --- src/lib/util/magick-convert.ts | 13 ++++++++++++- tests/jpeg-transparency.test.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tests/jpeg-transparency.test.mjs diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts index fd91e5c2..a1655f24 100644 --- a/src/lib/util/magick-convert.ts +++ b/src/lib/util/magick-convert.ts @@ -1,4 +1,9 @@ -import { MagickFormat, type IMagickImage } from "@imagemagick/magick-wasm"; +import { + AlphaAction, + MagickColors, + MagickFormat, + type IMagickImage, +} from "@imagemagick/magick-wasm"; export const magickConvert = async ( img: IMagickImage, @@ -30,6 +35,12 @@ export const magickConvert = async ( if (compression) img.quality = compression; if (!keepMetadata) img.strip(); + if (["JPEG", "JPG", "JPE"].includes(fmt) && img.hasAlpha) { + // JPEG has no alpha channel; composite edges instead of exposing hidden RGB. + img.backgroundColor = MagickColors.White; + img.alpha(AlphaAction.Remove); + } + img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { resolve(structuredClone(o)); }); diff --git a/tests/jpeg-transparency.test.mjs b/tests/jpeg-transparency.test.mjs new file mode 100644 index 00000000..44108e76 --- /dev/null +++ b/tests/jpeg-transparency.test.mjs @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { MagickImage, MagickFormat } from "@imagemagick/magick-wasm"; +import { fixture, write, convert } from "./helpers-magick.mjs"; +for (const to of [".jpeg", ".jpg", ".jpe", ".jfif"]) + test(`transparent PNG → ${to} composites on white rather than exposing hidden RGB`, async () => { + const src = fixture(true); + const png = write(src, MagickFormat.Png); + src.dispose(); + const output = MagickImage.create(await convert(png, to, false, 100)); + try { + for (const [x, expected] of [ + [8, [255, 255, 255]], + [24, [255, 127, 127]], + [40, [255, 0, 0]], + ]) { + const actual = output.getPixels((p) => + p.toByteArray(x, 16, 1, 1, "RGB"), + ); + assert.ok( + actual.every((v, i) => Math.abs(v - expected[i]) <= 2), + `${actual} ≠ ${expected}`, + ); + } + } finally { + output.dispose(); + } + }); From 73d89025fc3c3d12ac9b98c53670ce8f7f91e09d Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:45:47 +0800 Subject: [PATCH 3/4] fix: use CMYK white when flattening CMYK transparency --- src/lib/util/magick-convert.ts | 8 ++++- tests/jpeg-color-spaces.test.mjs | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/jpeg-color-spaces.test.mjs diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts index a1655f24..80747dbf 100644 --- a/src/lib/util/magick-convert.ts +++ b/src/lib/util/magick-convert.ts @@ -1,5 +1,7 @@ import { AlphaAction, + ColorSpace, + MagickColor, MagickColors, MagickFormat, type IMagickImage, @@ -37,7 +39,11 @@ export const magickConvert = async ( if (["JPEG", "JPG", "JPE"].includes(fmt) && img.hasAlpha) { // JPEG has no alpha channel; composite edges instead of exposing hidden RGB. - img.backgroundColor = MagickColors.White; + // Alpha removal reads channel values in the image's color space. + img.backgroundColor = + img.colorSpace === ColorSpace.CMYK + ? new MagickColor("cmyk(0,0,0,0)") + : MagickColors.White; img.alpha(AlphaAction.Remove); } diff --git a/tests/jpeg-color-spaces.test.mjs b/tests/jpeg-color-spaces.test.mjs new file mode 100644 index 00000000..4271e189 --- /dev/null +++ b/tests/jpeg-color-spaces.test.mjs @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + AlphaAction, + ColorSpace, + MagickFormat, + MagickImage, +} from "@imagemagick/magick-wasm"; +import { fixture, write, convert } from "./helpers-magick.mjs"; + +for (const keep of [true, false]) { + for (const to of [".jpeg", ".jpg", ".jpe", ".jfif"]) { + test(`CMYK transparency composites onto white for ${to} (metadata ${keep})`, async () => { + const source = fixture(true); + let input, output; + try { + source.colorSpace = ColorSpace.CMYK; + const tiff = write(source, MagickFormat.Tiff); + input = MagickImage.create(tiff); + assert.equal(input.colorSpace, ColorSpace.CMYK); + assert.equal(input.hasAlpha, true); + output = MagickImage.create(await convert(tiff, to, keep, 100)); + // Inspect displayed RGB values, not raw CMYK channel values. + output.colorSpace = ColorSpace.sRGB; + for (const [x, expected] of [ + [8, [255, 255, 255]], + [24, [255, 127, 127]], + [40, [255, 0, 0]], + ]) { + const actual = output.getPixels((p) => + p.toByteArray(x, 16, 1, 1, "RGB"), + ); + assert.ok( + actual.every((v, i) => Math.abs(v - expected[i]) <= 2), + `${actual} != ${expected}`, + ); + } + } finally { + output?.dispose(); + input?.dispose(); + source.dispose(); + } + }); + } +} + +test("opaque CMYK keeps its color space and does not enter the alpha path", async () => { + const source = fixture(); + let output; + try { + source.colorSpace = ColorSpace.CMYK; + source.alpha(AlphaAction.Off); + assert.equal(source.hasAlpha, false); + output = MagickImage.create( + await convert(write(source, MagickFormat.Tiff), ".jpeg", true, 100), + ); + assert.equal(output.colorSpace, ColorSpace.CMYK); + } finally { + output?.dispose(); + source.dispose(); + } +}); From 06f847478f81a26bb58b0c13854ba6814c685488 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:54:26 +0800 Subject: [PATCH 4/4] fix: derive JPEG matte values in the source color space --- src/lib/util/magick-convert.ts | 19 ++++++++++------ tests/jpeg-color-spaces.test.mjs | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts index 80747dbf..92569d67 100644 --- a/src/lib/util/magick-convert.ts +++ b/src/lib/util/magick-convert.ts @@ -1,7 +1,6 @@ import { AlphaAction, - ColorSpace, - MagickColor, + MagickImage, MagickColors, MagickFormat, type IMagickImage, @@ -39,11 +38,17 @@ export const magickConvert = async ( if (["JPEG", "JPG", "JPE"].includes(fmt) && img.hasAlpha) { // JPEG has no alpha channel; composite edges instead of exposing hidden RGB. - // Alpha removal reads channel values in the image's color space. - img.backgroundColor = - img.colorSpace === ColorSpace.CMYK - ? new MagickColor("cmyk(0,0,0,0)") - : MagickColors.White; + // Alpha removal reads channel values in the source color space. + // Transform a single white pixel so CMYK, Lab and RGB agree on white. + const background = MagickImage.create(MagickColors.White, 1, 1); + try { + background.colorSpace = img.colorSpace; + background.getPixels((pixels) => { + img.backgroundColor = pixels.getColor(0, 0)!; + }); + } finally { + background.dispose(); + } img.alpha(AlphaAction.Remove); } diff --git a/tests/jpeg-color-spaces.test.mjs b/tests/jpeg-color-spaces.test.mjs index 4271e189..f0a0b246 100644 --- a/tests/jpeg-color-spaces.test.mjs +++ b/tests/jpeg-color-spaces.test.mjs @@ -60,3 +60,41 @@ test("opaque CMYK keeps its color space and does not enter the alpha path", asyn source.dispose(); } }); + +for (const keep of [true, false]) { + test(`Lab transparency composites onto white (metadata ${keep})`, async () => { + const source = fixture(true); + let input, output; + try { + source.colorSpace = ColorSpace.Lab; + const tiff = write(source, MagickFormat.Tiff); + input = MagickImage.create(tiff); + assert.equal(input.colorSpace, ColorSpace.Lab); + assert.equal(input.hasAlpha, true); + input.colorSpace = ColorSpace.sRGB; + const opaque = input.getPixels( + (p) => new Uint8Array(p.toByteArray(40, 16, 1, 1, "RGB")), + ); + output = MagickImage.create( + await convert(tiff, ".jpeg", keep, 100), + ); + output.colorSpace = ColorSpace.sRGB; + for (const [x, expected] of [ + [8, [255, 255, 255]], + [40, opaque], + ]) { + const actual = output.getPixels((p) => + p.toByteArray(x, 16, 1, 1, "RGB"), + ); + assert.ok( + actual.every((v, i) => Math.abs(v - expected[i]) <= 2), + `${actual} != ${expected}`, + ); + } + } finally { + output?.dispose(); + input?.dispose(); + source.dispose(); + } + }); +}