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
64 changes: 64 additions & 0 deletions src/lib/util/magick-convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import {
AlphaAction,
MagickImage,
MagickColors,
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 (["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 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);
}

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();
}
}
100 changes: 100 additions & 0 deletions tests/jpeg-color-spaces.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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();
}
});

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();
}
});
}
28 changes: 28 additions & 0 deletions tests/jpeg-transparency.test.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading