Release Image Tools 0.1.0

This commit is contained in:
2026-09-01 02:47:11 +02:00
commit 83926457b2
76 changed files with 11815 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
import { expect, test, type Page } from "@playwright/test";
import { Buffer } from "node:buffer";
import { VALID_PNG_BASE64, pngFixture } from "../fixtures/images";
const ORIGIN = "http://127.0.0.1:4173";
async function localOnly(page: Page) {
const external: string[] = [];
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.origin !== ORIGIN) {
external.push(url.href);
await route.abort();
} else await route.continue();
});
return external;
}
test("runs from a nested path without external requests", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
const external = await localOnly(page);
await page.goto("/deep/nested/image/");
await expect(
page.getByRole("heading", { name: "Image Tools" }),
).toBeVisible();
expect(external).toEqual([]);
expect(errors).toEqual([]);
});
test("serves the release identity and hardened headers", async ({
request,
}) => {
const index = await request.get("/deep/nested/image/");
expect(index.ok()).toBe(true);
expect(index.headers()["content-security-policy"]).toContain(
"default-src 'self'",
);
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
const manifest = await request.get("/deep/nested/image/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.image-tools",
version: "0.1.0",
entry: "./",
});
});
test("inspects, previews, resizes and exports a static PNG locally", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const external = await localOnly(page);
await page.goto("/deep/nested/image/");
await page.locator('input[type="file"]').setInputFiles({
name: "tiny.png",
mimeType: "image/png",
buffer: Buffer.from(VALID_PNG_BASE64, "base64"),
});
await expect(page.getByRole("heading", { name: "tiny.png" })).toBeVisible();
await expect(
page.getByRole("img", { name: "tiny.png, edited preview" }),
).toBeVisible();
await page.getByLabel("Mode").selectOption("exact");
await page.getByLabel("Width px").fill("4");
await page.getByLabel("Height px").fill("3");
await expect(page.getByText("Export 4 × 3")).toBeVisible();
await expect(
page.getByRole("img", { name: "tiny.png, edited preview" }),
).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export current" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("tiny-edited.png");
await expect(page.getByText("Last export report")).toBeVisible();
expect(external).toEqual([]);
expect(errors).toEqual([]);
});
test("rejects an animated PNG without decoding a frame", async ({ page }) => {
await page.goto("/deep/nested/image/");
await page.locator('input[type="file"]').setInputFiles({
name: "animated.png",
mimeType: "image/png",
buffer: Buffer.from(pngFixture(8, 8, { animated: true })),
});
await expect(
page.getByText(/Animated and multi-picture images are not processed/u),
).toBeVisible();
await expect(page.getByRole("img")).toHaveCount(0);
});
test("uses exact native JPEG and WebP codec capabilities", async ({ page }) => {
const external = await localOnly(page);
await page.goto("/deep/nested/image/");
const jpeg = await browserRaster(page, "image/jpeg");
const webp = await browserRaster(page, "image/webp");
expect(jpeg.mimeType).toBe("image/jpeg");
expect(webp.mimeType).toBe("image/webp");
await page.locator('input[type="file"]').setInputFiles([
{
name: "sample.jpg",
mimeType: jpeg.mimeType,
buffer: Buffer.from(jpeg.bytes),
},
{
name: "sample.webp",
mimeType: webp.mimeType,
buffer: Buffer.from(webp.bytes),
},
]);
await expect(page.getByRole("heading", { name: "sample.jpg" })).toBeVisible();
await page.getByLabel("Format").selectOption("jpeg");
let downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export current" }).click();
expect((await downloadPromise).suggestedFilename()).toBe("sample-edited.jpg");
await page
.locator(".queue-select")
.filter({ hasText: "sample.webp" })
.click();
await expect(
page.getByRole("heading", { name: "sample.webp" }),
).toBeVisible();
await page.getByLabel("Format").selectOption("webp");
downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "Export current" }).click();
expect((await downloadPromise).suggestedFilename()).toBe(
"sample-edited.webp",
);
expect(external).toEqual([]);
});
async function browserRaster(page: Page, type: "image/jpeg" | "image/webp") {
return page.evaluate(async (mimeType) => {
const canvas = document.createElement("canvas");
canvas.width = 4;
canvas.height = 3;
const context = canvas.getContext("2d");
if (!context) throw new Error("No canvas context");
context.fillStyle = "#7c4dff";
context.fillRect(0, 0, 2, 3);
context.fillStyle = "#19a974";
context.fillRect(2, 0, 2, 3);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) => (value ? resolve(value) : reject(new Error("No encoder"))),
mimeType,
0.9,
),
);
return {
mimeType: blob.type,
bytes: Array.from(new Uint8Array(await blob.arrayBuffer())),
};
}, type);
}
+17
View File
@@ -0,0 +1,17 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { App } from "../../src/App";
describe("Image Tools", () => {
it("renders the local workbench and standard shell", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("Not found", { status: 404 })),
);
render(<App />);
expect(
await screen.findByRole("heading", { name: "Image Tools" }),
).toBeVisible();
expect(await screen.findByText("Browser-local")).toBeVisible();
});
});
+185
View File
@@ -0,0 +1,185 @@
export const VALID_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
export function pngFixture(
width: number,
height: number,
options: {
readonly animated?: boolean;
readonly exif?: boolean;
readonly text?: boolean;
readonly paddingBytes?: number;
} = {},
): Uint8Array {
const chunks: number[][] = [
chunk("IHDR", [...u32be(width), ...u32be(height), 8, 6, 0, 0, 0]),
];
if (options.paddingBytes) {
chunks.push(chunk("ruSt", new Array<number>(options.paddingBytes).fill(0)));
}
if (options.animated) chunks.push(chunk("acTL", [...u32be(2), ...u32be(0)]));
if (options.exif) chunks.push(chunk("eXIf", tiffOrientation(6)));
if (options.text) chunks.push(chunk("tEXt", asciiBytes("Comment\0fixture")));
chunks.push(chunk("IDAT", []));
return Uint8Array.from([
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
...chunks.flat(),
]);
}
export function jpegFixture(
width: number,
height: number,
options: {
readonly orientation?: number;
readonly multiPicture?: boolean;
} = {},
): Uint8Array {
const segments: number[][] = [];
if (options.orientation) {
segments.push(
jpegSegment(0xe1, [
...asciiBytes("Exif\0\0"),
...tiffOrientation(options.orientation),
]),
);
}
if (options.multiPicture)
segments.push(jpegSegment(0xe2, asciiBytes("MPF\0fixture")));
segments.push(
jpegSegment(0xc0, [
8,
...u16be(height),
...u16be(width),
3,
1,
0x11,
0,
2,
0x11,
0,
3,
0x11,
0,
]),
);
return Uint8Array.from([0xff, 0xd8, ...segments.flat(), 0xff, 0xd9]);
}
export function webpFixture(
width: number,
height: number,
options: {
readonly animated?: boolean;
readonly alpha?: boolean;
readonly metadata?: boolean;
readonly animationChunk?: boolean;
readonly paddingBytes?: number;
} = {},
): Uint8Array {
const flags =
(options.animated ? 0x02 : 0) |
(options.alpha ? 0x10 : 0) |
(options.metadata ? 0x2c : 0);
const payload = [flags, 0, 0, 0, ...u24le(width - 1), ...u24le(height - 1)];
const vp8x = riffChunk("VP8X", payload);
const padding = options.paddingBytes
? riffChunk("JUNK", new Array<number>(options.paddingBytes).fill(0))
: [];
const animation = options.animationChunk
? riffChunk("ANIM", new Array<number>(6).fill(0))
: [];
const chunks = [...vp8x, ...padding, ...animation];
const size = 4 + chunks.length;
return Uint8Array.from([
...asciiBytes("RIFF"),
...u32le(size),
...asciiBytes("WEBP"),
...chunks,
]);
}
function jpegSegment(marker: number, data: readonly number[]): number[] {
return [0xff, marker, ...u16be(data.length + 2), ...data];
}
function chunk(type: string, data: readonly number[]): number[] {
return [...u32be(data.length), ...asciiBytes(type), ...data, 0, 0, 0, 0];
}
function riffChunk(type: string, data: readonly number[]): number[] {
return [
...asciiBytes(type),
...u32le(data.length),
...data,
...(data.length % 2 ? [0] : []),
];
}
function tiffOrientation(orientation: number): number[] {
return [
0x49,
0x49,
0x2a,
0x00,
0x08,
0x00,
0x00,
0x00,
0x01,
0x00,
0x12,
0x01,
0x03,
0x00,
0x01,
0x00,
0x00,
0x00,
orientation,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
];
}
function asciiBytes(value: string): number[] {
return Array.from(value, (character) => character.charCodeAt(0));
}
function u16be(value: number): number[] {
return [(value >>> 8) & 0xff, value & 0xff];
}
function u32be(value: number): number[] {
return [
(value >>> 24) & 0xff,
(value >>> 16) & 0xff,
(value >>> 8) & 0xff,
value & 0xff,
];
}
function u32le(value: number): number[] {
return [
value & 0xff,
(value >>> 8) & 0xff,
(value >>> 16) & 0xff,
(value >>> 24) & 0xff,
];
}
function u24le(value: number): number[] {
return [value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff];
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import {
createTransformPlan,
normalizeCrop,
orientedDimensions,
} from "../../src/image/geometry";
import { DEFAULT_RECIPE } from "../../src/image/types";
describe("image transform geometry", () => {
it("swaps dimensions for transpose orientations", () => {
expect(orientedDimensions(400, 300, 6)).toEqual({
width: 300,
height: 400,
});
expect(orientedDimensions(400, 300, 3)).toEqual({
width: 400,
height: 300,
});
});
it("plans orientation-relative crop and quarter turn", () => {
const plan = createTransformPlan(
{ orientedWidth: 1000, orientedHeight: 800 },
{
...DEFAULT_RECIPE,
crop: { x: 0.1, y: 0.25, width: 0.5, height: 0.5 },
rotation: 90,
},
);
expect(plan.crop).toEqual({ x: 100, y: 200, width: 500, height: 400 });
expect([plan.transformedWidth, plan.transformedHeight]).toEqual([400, 500]);
});
it("fits without changing aspect ratio", () => {
const plan = createTransformPlan(
{ orientedWidth: 4000, orientedHeight: 3000 },
{ ...DEFAULT_RECIPE, resize: { mode: "fit", width: 1000, height: 1000 } },
);
expect([plan.outputWidth, plan.outputHeight]).toEqual([1000, 750]);
});
it("center-crops fill while exact stretches", () => {
const fill = createTransformPlan(
{ orientedWidth: 4000, orientedHeight: 3000 },
{
...DEFAULT_RECIPE,
resize: { mode: "fill", width: 1000, height: 1000 },
},
);
expect(fill.resizeSource).toEqual({
x: 500,
y: 0,
width: 3000,
height: 3000,
});
expect([fill.outputWidth, fill.outputHeight]).toEqual([1000, 1000]);
const exact = createTransformPlan(
{ orientedWidth: 4000, orientedHeight: 3000 },
{
...DEFAULT_RECIPE,
resize: { mode: "exact", width: 1000, height: 1000 },
},
);
expect(exact.resizeSource).toEqual({
x: 0,
y: 0,
width: 4000,
height: 3000,
});
});
it("clamps crop rectangles and rejects unsafe output", () => {
expect(normalizeCrop({ x: -1, y: 0.9, width: 5, height: 0.5 })).toEqual({
x: 0,
y: 0.9,
width: 1,
height: 0.09999999999999998,
});
expect(() =>
createTransformPlan(
{ orientedWidth: 10, orientedHeight: 10 },
{
...DEFAULT_RECIPE,
resize: { mode: "exact", width: 100_000, height: 100_000 },
},
),
).toThrow(/may not exceed/u);
});
});
+133
View File
@@ -0,0 +1,133 @@
import { describe, expect, it } from "vitest";
import { inspectImageFile, inspectImageHeader } from "../../src/image/headers";
import { IMAGE_LIMITS } from "../../src/image/limits";
import { jpegFixture, pngFixture, webpFixture } from "../fixtures/images";
describe("bounded image header inspection", () => {
it("reads PNG dimensions, alpha, metadata and EXIF orientation", () => {
const inspected = inspectImageHeader(
pngFixture(640, 480, { exif: true, text: true }),
);
expect(inspected).toMatchObject({
format: "png",
width: 640,
height: 480,
orientedWidth: 480,
orientedHeight: 640,
orientation: 6,
hasAlpha: true,
animated: false,
metadata: { exif: true, text: true },
});
});
it("detects APNG before decode and refuses to flatten it", async () => {
const bytes = pngFixture(32, 24, { animated: true });
expect(inspectImageHeader(bytes).animated).toBe(true);
const file = new File([arrayBuffer(bytes)], "animated.png", {
type: "image/png",
});
await expect(inspectImageFile(file)).rejects.toMatchObject({
code: "STATIC_ONLY",
});
});
it("finds APNG control chunks beyond the bounded metadata window", async () => {
const bytes = pngFixture(32, 24, {
animated: true,
paddingBytes: IMAGE_LIMITS.maxHeaderBytes + 32,
});
expect(
inspectImageHeader(bytes.subarray(0, IMAGE_LIMITS.maxHeaderBytes))
.animated,
).toBe(false);
await expect(
inspectImageFile(new File([arrayBuffer(bytes)], "late-animation.png")),
).rejects.toMatchObject({ code: "STATIC_ONLY" });
});
it("reads JPEG frame dimensions and orientation", () => {
const inspected = inspectImageHeader(
jpegFixture(4032, 3024, { orientation: 8 }),
);
expect(inspected).toMatchObject({
format: "jpeg",
width: 4032,
height: 3024,
orientedWidth: 3024,
orientedHeight: 4032,
orientation: 8,
hasAlpha: false,
});
});
it("flags recognized JPEG multi-picture containers", async () => {
const bytes = jpegFixture(64, 48, { multiPicture: true });
expect(inspectImageHeader(bytes).metadata.multiPicture).toBe(true);
await expect(
inspectImageFile(new File([arrayBuffer(bytes)], "multi.jpg")),
).rejects.toMatchObject({
code: "STATIC_ONLY",
});
});
it("reads WebP extended dimensions and feature flags", () => {
const inspected = inspectImageHeader(
webpFixture(1920, 1080, { alpha: true, metadata: true }),
);
expect(inspected).toMatchObject({
format: "webp",
width: 1920,
height: 1080,
animated: false,
hasAlpha: true,
metadata: { exif: true, xmp: true, icc: true },
});
});
it("finds WebP animation chunks beyond the metadata window", async () => {
const bytes = webpFixture(32, 24, {
animationChunk: true,
paddingBytes: IMAGE_LIMITS.maxHeaderBytes + 32,
});
expect(
inspectImageHeader(
bytes.subarray(0, IMAGE_LIMITS.maxHeaderBytes),
bytes.byteLength,
).animated,
).toBe(false);
await expect(
inspectImageFile(new File([arrayBuffer(bytes)], "late-animation.webp")),
).rejects.toMatchObject({ code: "STATIC_ONLY" });
});
it("rejects unsafe declared dimensions before decode", async () => {
const file = new File(
[arrayBuffer(pngFixture(32_769, 1))],
"too-wide.png",
{
type: "image/png",
},
);
await expect(inspectImageFile(file)).rejects.toMatchObject({
code: "PIXEL_LIMIT",
});
});
it("fails closed for unsupported and truncated input", () => {
expect(() => inspectImageHeader(Uint8Array.of(0, 1, 2, 3))).toThrow(
/Only static JPEG/u,
);
expect(() =>
inspectImageHeader(
Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
),
).toThrow(/PNG header is truncated/u);
});
});
function arrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { createOutputName } from "../../src/image/filenames";
import {
createProcessingReport,
serializeReport,
} from "../../src/image/reports";
import {
DEFAULT_OUTPUT,
DEFAULT_RECIPE,
type ImageInspection,
} from "../../src/image/types";
const inspection: ImageInspection = {
format: "jpeg",
mimeType: "image/jpeg",
extension: "jpg",
width: 10,
height: 20,
orientedWidth: 20,
orientedHeight: 10,
orientation: 6,
animated: false,
hasAlpha: false,
bitsPerChannel: 8,
colorDescription: "Three-component JPEG",
metadata: {
exif: true,
xmp: false,
icc: true,
comments: false,
text: false,
physicalDimensions: false,
multiPicture: false,
},
headerBytesInspected: 100,
warnings: [],
};
describe("image output identity and reports", () => {
it("normalizes unsafe output names", () => {
expect(createOutputName("../bad:name?.jpeg", "webp")).toBe(
"_bad_name_-edited.webp",
);
expect(createOutputName(".jpg", "png")).toBe("image-edited.png");
});
it("states metadata and color limitations explicitly", () => {
const report = createProcessingReport({
file: new File([Uint8Array.of(1, 2)], "source.jpg", {
type: "image/jpeg",
}),
inspection,
recipe: DEFAULT_RECIPE,
output: DEFAULT_OUTPUT,
outputName: "source-edited.png",
outputBytes: 123,
outputMimeType: "image/png",
outputWidth: 20,
outputHeight: 10,
});
expect(report.metadataPolicy.copied).toBe(false);
expect(report.colorPolicy.profilePreserved).toBe(false);
expect(report.limitations.join(" ")).toMatch(/does not promise privacy/u);
expect(serializeReport(report)).toMatch(/"schemaVersion": 1/u);
});
});