Release Image Tools 0.1.0
This commit is contained in:
@@ -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