From c69e961fcd860abb9f0623cc445b7a01c3fdfc35 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:19:52 +0800 Subject: [PATCH 1/2] 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 05128c52bbfdf01b81f1519ca1f12bfa3d09f496 Mon Sep 17 00:00:00 2001 From: yeagoo Date: Mon, 7 Sep 2026 12:26:57 +0800 Subject: [PATCH 2/2] fix: preserve transparent RGB pixels in quality 100 WebP --- src/lib/util/magick-convert.ts | 5 ++++ tests/webp-lossless-pixels.test.mjs | 42 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/webp-lossless-pixels.test.mjs diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts index fd91e5c2..f916e6ab 100644 --- a/src/lib/util/magick-convert.ts +++ b/src/lib/util/magick-convert.ts @@ -30,6 +30,11 @@ export const magickConvert = async ( if (compression) img.quality = compression; if (!keepMetadata) img.strip(); + if (fmt === "WEBP" && img.quality === 100) { + // libwebp otherwise discards RGB underneath fully transparent pixels. + img.settings.setDefine("webp:exact", "true"); + } + img.write(fmt as unknown as MagickFormat, (o: Uint8Array) => { resolve(structuredClone(o)); }); diff --git a/tests/webp-lossless-pixels.test.mjs b/tests/webp-lossless-pixels.test.mjs new file mode 100644 index 00000000..f8ef1494 --- /dev/null +++ b/tests/webp-lossless-pixels.test.mjs @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { MagickImage, MagickFormat } from "@imagemagick/magick-wasm"; +import { fixture, write, rgba, convert } from "./helpers-magick.mjs"; +import { moduleUrl } from "./helpers-load-ts.mjs"; + +const { magickConvert } = await import( + await moduleUrl( + new URL("../src/lib/util/magick-convert.ts", import.meta.url), + ) +); + +for (const keep of [false, true]) { + test(`quality 100 WebP preserves RGB under full and partial transparency (metadata ${keep})`, async () => { + const image = fixture(true); + let input, output; + try { + const bytes = write(image, MagickFormat.Png32); + input = MagickImage.create(bytes); + assert.deepEqual([...rgba(input).slice(0, 4)], [255, 0, 0, 0]); + output = MagickImage.create( + await convert(bytes, ".webp", keep, 100), + ); + assert.deepEqual(rgba(output), rgba(input)); + } finally { + output?.dispose(); + input?.dispose(); + image.dispose(); + } + }); +} + +test("lossy WebP retains the encoder default for transparent RGB", async () => { + const image = fixture(true); + try { + assert.ok((await magickConvert(image, ".webp", false, 80)).length > 0); + assert.equal(image.quality, 80); + assert.equal(image.settings.getDefine("webp:exact"), null); + } finally { + image.dispose(); + } +});