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/assets/profiles/LICENSE.txt b/src/lib/assets/profiles/LICENSE.txt new file mode 100644 index 00000000..550e190e --- /dev/null +++ b/src/lib/assets/profiles/LICENSE.txt @@ -0,0 +1,116 @@ +CC0 1.0 Universal + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator and +subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the +purpose of contributing to a commons of creative, cultural and scientific +works ("Commons") that the public can reliably and without fear of later +claims of infringement build upon, modify, incorporate in other works, reuse +and redistribute as freely as possible in any form whatsoever and for any +purposes, including without limitation commercial purposes. These owners may +contribute to the Commons to promote the ideal of a free culture and the +further production of creative, cultural and scientific works, or to gain +reputation or greater distribution for their Work in part through the use and +efforts of others. + +For these and/or other purposes and motivations, and without any expectation +of additional consideration or compensation, the person associating CC0 with a +Work (the "Affirmer"), to the extent that he or she is an owner of Copyright +and Related Rights in the Work, voluntarily elects to apply CC0 to the Work +and publicly distribute the Work under its terms, with knowledge of his or her +Copyright and Related Rights in the Work and the meaning and intended legal +effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not limited +to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, communicate, + and translate a Work; + + ii. moral rights retained by the original author(s) and/or performer(s); + + iii. publicity and privacy rights pertaining to a person's image or likeness + depicted in a Work; + + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + + v. rights protecting the extraction, dissemination, use and reuse of data in + a Work; + + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation thereof, + including any amended or successor version of such directive); and + + vii. other similar, equivalent or corresponding rights throughout the world + based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, +applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and +unconditionally waives, abandons, and surrenders all of Affirmer's Copyright +and Related Rights and associated claims and causes of action, whether now +known or unknown (including existing as well as future claims and causes of +action), in the Work (i) in all territories worldwide, (ii) for the maximum +duration provided by applicable law or treaty (including future time +extensions), (iii) in any current or future medium and for any number of +copies, and (iv) for any purpose whatsoever, including without limitation +commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes +the Waiver for the benefit of each member of the public at large and to the +detriment of Affirmer's heirs and successors, fully intending that such Waiver +shall not be subject to revocation, rescission, cancellation, termination, or +any other legal or equitable action to disrupt the quiet enjoyment of the Work +by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be +judged legally invalid or ineffective under applicable law, then the Waiver +shall be preserved to the maximum extent permitted taking into account +Affirmer's express Statement of Purpose. In addition, to the extent the Waiver +is so judged Affirmer hereby grants to each affected person a royalty-free, +non transferable, non sublicensable, non exclusive, irrevocable and +unconditional license to exercise Affirmer's Copyright and Related Rights in +the Work (i) in all territories worldwide, (ii) for the maximum duration +provided by applicable law or treaty (including future time extensions), (iii) +in any current or future medium and for any number of copies, and (iv) for any +purpose whatsoever, including without limitation commercial, advertising or +promotional purposes (the "License"). The License shall be deemed effective as +of the date CC0 was applied by Affirmer to the Work. Should any part of the +License for any reason be judged legally invalid or ineffective under +applicable law, such partial invalidity or ineffectiveness shall not +invalidate the remainder of the License, and in such case Affirmer hereby +affirms that he or she will not (i) exercise any of his or her remaining +Copyright and Related Rights in the Work or (ii) assert any associated claims +and causes of action with respect to the Work, in either case contrary to +Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + + b. Affirmer offers the Work as-is and makes no representations or warranties + of any kind concerning the Work, express, implied, statutory or otherwise, + including without limitation warranties of title, merchantability, fitness + for a particular purpose, non infringement, or the absence of latent or + other defects, accuracy, or the present or absence of errors, whether or not + discoverable, all to the greatest extent permissible under applicable law. + + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without limitation + any person's Copyright and Related Rights in the Work. Further, Affirmer + disclaims responsibility for obtaining any necessary consents, permissions + or other rights required for any use of the Work. + + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to this + CC0 or use of the Work. + +For more information, please see +http://creativecommons.org/publicdomain/zero/1.0/ \ No newline at end of file diff --git a/src/lib/assets/profiles/README.md b/src/lib/assets/profiles/README.md new file mode 100644 index 00000000..a6c695e8 --- /dev/null +++ b/src/lib/assets/profiles/README.md @@ -0,0 +1,11 @@ +# Bundled color profile + +`srgb.ts` embeds the unmodified 480-byte `sRGB-v4.icc` profile as Base64. It is used by Little CMS inside ImageMagick to convert profiled pixels to standard sRGB before removing metadata. No network request is needed. + +Source: [Compact-ICC-Profiles](https://github.com/saucecontrol/Compact-ICC-Profiles/tree/bdd84663061bc4ae95ca70decff54f581e27f702), commit `bdd84663061bc4ae95ca70decff54f581e27f702`, `profiles/sRGB-v4.icc`. + +License: [CC0-1.0](LICENSE.txt). + +SHA-256: `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. + +The test fixture `tests/fixtures/display-p3.icc` is the unmodified 480-byte `profiles/DisplayP3-v4.icc` from the same source and license. SHA-256: `cb51de38e482ee974c0c76b9689e16aad04bad16e226fed2f30c842d15ff3a3d`. diff --git a/src/lib/assets/profiles/srgb.ts b/src/lib/assets/profiles/srgb.ts new file mode 100644 index 00000000..58dc5caf --- /dev/null +++ b/src/lib/assets/profiles/srgb.ts @@ -0,0 +1,8 @@ +// sRGB-v4.icc, Compact-ICC-Profiles (CC0). Source and checksum: README.md. +// Embed the 480-byte standard profile so workers need no additional fetch. +export const srgbProfileBytes = Uint8Array.from( + atob( + "AAAB4GxjbXMEIAAAbW50clJHQiBYWVogB+IAAwAUAAkADgAdYWNzcE1TRlQAAAAAc2F3c2N0cmwAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1oYW5keem/Vlo+AbaDI4VVRvdPqgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKZGVzYwAAAPwAAAAkY3BydAAAASAAAAAid3RwdAAAAUQAAAAUY2hhZAAAAVgAAAAsclhZWgAAAYQAAAAUZ1hZWgAAAZgAAAAUYlhZWgAAAawAAAAUclRSQwAAAcAAAAAgZ1RSQwAAAcAAAAAgYlRSQwAAAcAAAAAgbWx1YwAAAAAAAAABAAAADGVuVVMAAAAIAAAAHABzAFIARwBCbWx1YwAAAAAAAAABAAAADGVuVVMAAAAGAAAAHABDAEMAMAAAWFlaIAAAAAAAAPbWAAEAAAAA0y1zZjMyAAAAAAABDD8AAAXd///zJgAAB5AAAP2S///7of///aIAAAPcAADAcVhZWiAAAAAAAABvoAAAOPIAAAOPWFlaIAAAAAAAAGKWAAC3iQAAGNpYWVogAAAAAAAAJKAAAA+FAAC2xHBhcmEAAAAAAAMAAAACZmkAAPKnAAANWQAAE9AAAApb", + ), + (char) => char.charCodeAt(0), +); diff --git a/src/lib/util/magick-convert.ts b/src/lib/util/magick-convert.ts new file mode 100644 index 00000000..bc1baeaa --- /dev/null +++ b/src/lib/util/magick-convert.ts @@ -0,0 +1,59 @@ +import { + ColorProfile, + ColorSpace, + MagickFormat, + type IMagickImage, +} from "@imagemagick/magick-wasm"; +import { srgbProfileBytes } from "../assets/profiles/srgb"; + +const srgbProfile = new ColorProfile(srgbProfileBytes); + +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) { + // Removing an ICC profile without converting its pixels changes their appearance. + if (img.getColorProfile()) img.transformColorSpace(srgbProfile); + else if ( + img.colorSpace !== ColorSpace.sRGB && + img.colorSpace !== ColorSpace.Gray + ) + img.colorSpace = ColorSpace.sRGB; + 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/fixtures/display-p3.icc b/tests/fixtures/display-p3.icc new file mode 100644 index 00000000..4ee40fb5 Binary files /dev/null and b/tests/fixtures/display-p3.icc differ 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/image-color-profiles.test.mjs b/tests/image-color-profiles.test.mjs new file mode 100644 index 00000000..19860dd1 --- /dev/null +++ b/tests/image-color-profiles.test.mjs @@ -0,0 +1,59 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + MagickImage, + MagickFormat, + ColorProfile, +} from "@imagemagick/magick-wasm"; +import { fixture, write, rgba, convert } from "./helpers-magick.mjs"; +import { readFile } from "node:fs/promises"; +import { moduleUrl } from "./helpers-load-ts.mjs"; +const { srgbProfileBytes } = await import( + await moduleUrl( + new URL("../src/lib/assets/profiles/srgb.ts", import.meta.url), + ) +); +const p3 = new ColorProfile( + new Uint8Array( + await readFile(new URL("./fixtures/display-p3.icc", import.meta.url)), + ), +); +for (const to of [".png", ".webp", ".jxl", ".tiff"]) + test(`Display P3 → ${to}: retaining metadata keeps the source ICC and pixels`, async () => { + const src = fixture(); + src.setProfile(p3); + const png = write(src, MagickFormat.Png); + const output = MagickImage.create(await convert(png, to, true, 100)); + try { + assert.deepEqual(rgba(output), rgba(src)); + assert.deepEqual(output.getColorProfile()?.data, p3.data); + } finally { + src.dispose(); + output.dispose(); + } + }); +for (const to of [".png", ".webp", ".jxl", ".tiff"]) + test(`Display P3 → ${to}: metadata removal converts colors before discarding ICC`, async () => { + const src = fixture(); + src.setProfile(p3); + src.setAttribute("comment", "private-comment"); + const png = write(src, MagickFormat.Png); + const raw = rgba(src); + src.transformColorSpace(new ColorProfile(srgbProfileBytes)); + const expected = rgba(src); + src.dispose(); + assert.notDeepEqual( + raw, + expected, + "Fixture must require a real color transform", + ); + const output = MagickImage.create(await convert(png, to, false, 100)); + try { + assert.deepEqual(rgba(output), expected); + assert.equal(output.getAttribute("comment"), null); + // JXL may synthesize a standard output profile. Never retain the source profile. + assert.notDeepEqual(output.getColorProfile()?.data, p3.data); + } finally { + output.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(); + } +});