From c69e961fcd860abb9f0623cc445b7a01c3fdfc35 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:19:52 +0800 Subject: [PATCH 1/3] 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 ec4c39ec8a018ca79f4b705d39bd6c9b18fec247 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:24:43 +0800 Subject: [PATCH 2/3] fix: match encoded depth to decoded pixels and expand PSD alpha --- src/lib/util/magick-convert.ts | 23 +++++++++- tests/depth-alpha.test.mjs | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/depth-alpha.test.mjs diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts index fd91e5c2..e6628b0d 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 { + ColorType, + Quantum, + MagickFormat, + type IMagickImage, +} from "@imagemagick/magick-wasm"; export const magickConvert = async ( img: IMagickImage, @@ -30,6 +35,22 @@ export const magickConvert = async ( if (compression) img.quality = compression; if (!keepMetadata) img.strip(); + // Source depth can describe palette indices or exceed the WASM quantum depth. + // Channel values (including alpha) need at least 8 bits after decoding. + img.depth = Math.min(Quantum.depth, Math.max(8, img.depth)); + if ( + fmt === "PSD" && + img.hasAlpha && + [ + ColorType.Palette, + ColorType.PaletteAlpha, + ColorType.PaletteBilevelAlpha, + ].some((type) => type === img.colorType) + ) { + // The PSD writer cannot encode indexed images with an alpha channel. + img.colorType = ColorType.TrueColorAlpha; + } + img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { resolve(structuredClone(o)); }); diff --git a/tests/depth-alpha.test.mjs b/tests/depth-alpha.test.mjs new file mode 100644 index 00000000..1fee7378 --- /dev/null +++ b/tests/depth-alpha.test.mjs @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + MagickImage, + MagickReadSettings, + MagickFormat, +} from "@imagemagick/magick-wasm"; +import { fixture, write, rgba, convert } from "./helpers-magick.mjs"; +const source = fixture(); +const png16 = write(source, MagickFormat.Png48); +source.dispose(); + +for (const to of [ + ".png", + ".tiff", + ".tif", + ".jxl", + ".jp2", + ".psd", + ".ppm", + ".webp", +]) + test(`16-bit PNG → ${to} uses actual Q8 precision and preserves decoded pixels`, async () => { + const input = MagickImage.create(png16); + const output = MagickImage.create(await convert(png16, to, true, 100)); + try { + assert.equal(input.depth, 16); + assert.equal(output.depth, 8); + assert.deepEqual(rgba(output), rgba(input)); + } finally { + input.dispose(); + output.dispose(); + } + }); + +for (const to of [".tiff", ".tif", ".psd", ".png", ".jxl"]) + test(`palette PNG → ${to} preserves semi-transparent alpha instead of using palette index depth`, async () => { + const src = fixture(true); + const png = write(src, MagickFormat.Png); + src.dispose(); + const input = MagickImage.create(png); + const output = MagickImage.create(await convert(png, to, false, 100)); + try { + assert.ok( + input.depth < 8, + "Fixture must have a low-bit palette index", + ); + assert.deepEqual(rgba(output), rgba(input)); + } finally { + input.dispose(); + output.dispose(); + } + }); + +for (const to of [".tiff", ".psd", ".ppm", ".jp2"]) + test(`palette PNG → ${to} preserves 8-bit RGB values that are not palette indices`, async () => { + const colors = [ + [28, 99, 157, 255], + [121, 188, 33, 255], + [94, 77, 14, 255], + ]; + const pixels = Uint8Array.from( + Array.from({ length: 48 * 32 }, (_, i) => colors[i % 3]).flat(), + ); + const src = MagickImage.create( + pixels, + new MagickReadSettings({ + format: MagickFormat.Rgba, + width: 48, + height: 32, + }), + ); + const png = write(src, MagickFormat.Png); + src.dispose(); + const input = MagickImage.create(png); + const output = MagickImage.create(await convert(png, to, true, 100)); + try { + assert.ok(input.depth < 8); + assert.deepEqual(rgba(output), pixels); + } finally { + input.dispose(); + output.dispose(); + } + }); From c5347a0c01aed9253d4bd7818adeceb1a0b83813 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:47:59 +0800 Subject: [PATCH 3/3] fix: retain TIFF depth when decoded channels fit packed samples --- src/lib/util/magick-convert.ts | 12 ++++++-- tests/tiff-packed-depth.test.mjs | 49 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/tiff-packed-depth.test.mjs diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts index e6628b0d..635826a9 100644 --- a/src/lib/util/magick-convert.ts +++ b/src/lib/util/magick-convert.ts @@ -36,8 +36,16 @@ export const magickConvert = async ( if (!keepMetadata) img.strip(); // Source depth can describe palette indices or exceed the WASM quantum depth. - // Channel values (including alpha) need at least 8 bits after decoding. - img.depth = Math.min(Quantum.depth, Math.max(8, img.depth)); + // Keep valid packed TIFF samples; palette indices alone cannot tell us + // whether the decoded RGB/alpha channels fit in that source depth. + const preserveTiffDepth = + (fmt === "TIFF" || fmt === "TIF") && + img.depth < 8 && + img.determineBitDepth() <= img.depth; + img.depth = Math.min( + Quantum.depth, + Math.max(preserveTiffDepth ? img.depth : 8, img.depth), + ); if ( fmt === "PSD" && img.hasAlpha && diff --git a/tests/tiff-packed-depth.test.mjs b/tests/tiff-packed-depth.test.mjs new file mode 100644 index 00000000..e91e8ff6 --- /dev/null +++ b/tests/tiff-packed-depth.test.mjs @@ -0,0 +1,49 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + MagickFormat, + MagickImage, + MagickReadSettings, +} from "@imagemagick/magick-wasm"; +import { write, rgba, convert } from "./helpers-magick.mjs"; + +for (const depth of [1, 2, 4]) { + for (const to of [".tiff", ".tif"]) { + test(`${depth}-bit grayscale keeps packed TIFF samples for ${to}`, async () => { + const width = 256, + height = 128, + levels = 2 ** depth; + const pixels = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i++) { + const value = ((i % levels) * 255) / (levels - 1); + pixels.set([value, value, value, 255], i * 4); + } + const source = MagickImage.create( + pixels, + new MagickReadSettings({ + format: MagickFormat.Rgba, + width, + height, + }), + ); + let input, output; + try { + source.depth = depth; + const png = write(source, MagickFormat.Png); + input = MagickImage.create(png); + assert.equal(input.depth, depth); + output = MagickImage.create(await convert(png, to, false, 100)); + assert.equal( + output.depth, + depth, + "Do not expand valid packed samples to 8 bits", + ); + assert.deepEqual(rgba(output), rgba(input)); + } finally { + output?.dispose(); + input?.dispose(); + source.dispose(); + } + }); + } +}