Release Image Tools 0.1.0
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+185
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user