Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions src/lib/util/magick-convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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<Uint8Array>((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();

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));
});
} catch (error) {
reject(error);
}
});

return result;
};
43 changes: 1 addition & 42 deletions src/lib/workers/magick.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -284,47 +284,6 @@ const readToEnd = async (reader: ReadableStreamDefaultReader<Uint8Array>) => {
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<Uint8Array>((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 {
Expand Down
7 changes: 7 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions tests/helpers-load-ts.mjs
Original file line number Diff line number Diff line change
@@ -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")}`;
}
60 changes: 60 additions & 0 deletions tests/helpers-magick.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
}
73 changes: 73 additions & 0 deletions tests/magick-convert.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
});
42 changes: 42 additions & 0 deletions tests/webp-lossless-pixels.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
});