Skip to content
Open
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
71 changes: 71 additions & 0 deletions src/lib/util/magick-convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
ColorType,
Quantum,
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();

// Source depth can describe palette indices or exceed the WASM quantum 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 &&
[
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));
});
} 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.
84 changes: 84 additions & 0 deletions tests/depth-alpha.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
});
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();
}
});
Loading