feat: release Colour Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBoundedSampleRegion,
|
||||
mapClientPointToPixel,
|
||||
validateImageDimensions,
|
||||
validateImageFile,
|
||||
} from "../../src/palette/bounds";
|
||||
import type { ImageLimits } from "../../src/palette/types";
|
||||
|
||||
const limits: ImageLimits = {
|
||||
maxBytes: 1_000,
|
||||
maxPixels: 100,
|
||||
maxDimension: 20,
|
||||
acceptedMimeTypes: ["image/png"],
|
||||
};
|
||||
|
||||
describe("local image bounds", () => {
|
||||
it("rejects empty, oversized, and unsupported files", () => {
|
||||
expect(
|
||||
validateImageFile(
|
||||
{ name: "empty.png", size: 0, type: "image/png" },
|
||||
limits,
|
||||
).valid,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateImageFile(
|
||||
{ name: "large.png", size: 1_001, type: "image/png" },
|
||||
limits,
|
||||
).valid,
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateImageFile(
|
||||
{ name: "vector.svg", size: 10, type: "image/svg+xml" },
|
||||
limits,
|
||||
).valid,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a bounded raster file", () => {
|
||||
expect(
|
||||
validateImageFile(
|
||||
{ name: "pixel.png", size: 999, type: "IMAGE/PNG" },
|
||||
limits,
|
||||
),
|
||||
).toEqual({
|
||||
valid: true,
|
||||
});
|
||||
expect(
|
||||
validateImageFile({ name: "pixel.png", size: 999, type: "" }, limits)
|
||||
.valid,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("checks decoded dimensions and pixel area", () => {
|
||||
expect(validateImageDimensions(10, 10, limits).valid).toBe(true);
|
||||
expect(validateImageDimensions(21, 2, limits).valid).toBe(false);
|
||||
expect(validateImageDimensions(11, 10, limits).valid).toBe(false);
|
||||
expect(validateImageDimensions(Number.NaN, 10, limits).valid).toBe(false);
|
||||
});
|
||||
|
||||
it("maps CSS-scaled canvas coordinates to bounded image pixels", () => {
|
||||
const rect = { left: 100, top: 50, width: 200, height: 100 };
|
||||
expect(mapClientPointToPixel(200, 100, rect, 1_000, 500)).toEqual({
|
||||
x: 500,
|
||||
y: 250,
|
||||
});
|
||||
expect(mapClientPointToPixel(500, -20, rect, 1_000, 500)).toEqual({
|
||||
x: 999,
|
||||
y: 0,
|
||||
});
|
||||
expect(
|
||||
mapClientPointToPixel(200, 100, { ...rect, width: 0 }, 1_000, 500),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("clips sampling regions at image edges", () => {
|
||||
expect(getBoundedSampleRegion(0, 0, 2, 10, 8)).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 3,
|
||||
height: 3,
|
||||
centerX: 0,
|
||||
centerY: 0,
|
||||
});
|
||||
expect(getBoundedSampleRegion(Number.NaN, 0, 2, 10, 8)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { decodeLocalImage } from "../../src/palette/decode";
|
||||
|
||||
const nativeUrl = globalThis.URL;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const installImageElementDecoder = (loadImmediately: boolean) => {
|
||||
const createObjectURL = vi.fn(() => "blob:local-image");
|
||||
const revokeObjectURL = vi.fn();
|
||||
class MockUrl extends nativeUrl {
|
||||
static createObjectURL = createObjectURL;
|
||||
static revokeObjectURL = revokeObjectURL;
|
||||
}
|
||||
class MockImage {
|
||||
decoding = "auto";
|
||||
naturalWidth = 320;
|
||||
naturalHeight = 180;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private source = "";
|
||||
|
||||
set src(value: string) {
|
||||
this.source = value;
|
||||
if (value && loadImmediately) queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
|
||||
get src(): string {
|
||||
return this.source;
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("URL", MockUrl);
|
||||
vi.stubGlobal("Image", MockImage);
|
||||
vi.stubGlobal("createImageBitmap", undefined);
|
||||
return { createObjectURL, revokeObjectURL };
|
||||
};
|
||||
|
||||
describe("local image decoding", () => {
|
||||
it("revokes its object URL as soon as the image has decoded", async () => {
|
||||
const spies = installImageElementDecoder(true);
|
||||
const image = await decodeLocalImage(
|
||||
new File(["image"], "sample.png", { type: "image/png" }),
|
||||
);
|
||||
|
||||
expect(image).toMatchObject({ width: 320, height: 180 });
|
||||
expect(spies.createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(spies.revokeObjectURL).toHaveBeenCalledWith("blob:local-image");
|
||||
image.dispose();
|
||||
});
|
||||
|
||||
it("revokes its object URL when decoding is cancelled", async () => {
|
||||
const spies = installImageElementDecoder(false);
|
||||
const controller = new AbortController();
|
||||
const decoding = decodeLocalImage(
|
||||
new File(["image"], "sample.png", { type: "image/png" }),
|
||||
controller.signal,
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(decoding).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(spies.revokeObjectURL).toHaveBeenCalledWith("blob:local-image");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { extractPalette } from "../../src/palette/extract";
|
||||
import { extractPaletteSafely } from "../../src/palette/worker-client";
|
||||
|
||||
const makePixels = (
|
||||
colours: readonly (readonly [number, number, number, number])[],
|
||||
) => ({
|
||||
width: colours.length,
|
||||
height: 1,
|
||||
data: new Uint8ClampedArray(colours.flat()),
|
||||
});
|
||||
|
||||
describe("deterministic Oklab palette extraction", () => {
|
||||
it("returns dominant colours in coverage order", () => {
|
||||
const input = makePixels([
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
[255, 0, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
]);
|
||||
const palette = extractPalette(input, { count: 2 });
|
||||
|
||||
expect(palette.map((colour) => colour.hex)).toEqual(["#FF0000", "#0000FF"]);
|
||||
expect(palette[0]?.coverage).toBeCloseTo(0.75, 8);
|
||||
expect(palette[1]?.coverage).toBeCloseTo(0.25, 8);
|
||||
});
|
||||
|
||||
it("produces byte-for-byte stable results for repeated runs", () => {
|
||||
const input = makePixels([
|
||||
[248, 245, 238, 255],
|
||||
[20, 80, 180, 255],
|
||||
[245, 140, 20, 255],
|
||||
[248, 245, 238, 255],
|
||||
[25, 85, 175, 220],
|
||||
[240, 145, 25, 255],
|
||||
]);
|
||||
|
||||
const first = extractPalette(input, { count: 3 });
|
||||
const second = extractPalette(input, { count: 3 });
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it("ignores fully transparent pixels and respects the alpha threshold", () => {
|
||||
const input = makePixels([
|
||||
[255, 0, 255, 0],
|
||||
[255, 0, 0, 15],
|
||||
[0, 255, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
]);
|
||||
const palette = extractPalette(input, { count: 3, minimumAlpha: 16 });
|
||||
|
||||
expect(palette.map((colour) => colour.hex)).toEqual(["#0000FF", "#00FF00"]);
|
||||
expect(
|
||||
palette.reduce((sum, colour) => sum + colour.coverage, 0),
|
||||
).toBeCloseTo(1, 8);
|
||||
});
|
||||
|
||||
it("handles a fully transparent image without inventing colours", () => {
|
||||
expect(extractPalette(makePixels([[10, 20, 30, 0]]), { count: 6 })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed pixel buffers", () => {
|
||||
expect(() =>
|
||||
extractPalette(
|
||||
{ width: 2, height: 2, data: new Uint8ClampedArray(4) },
|
||||
{ count: 2 },
|
||||
),
|
||||
).toThrow("Invalid pixel buffer");
|
||||
});
|
||||
|
||||
it("falls back safely when Web Workers are unavailable", async () => {
|
||||
const input = makePixels([
|
||||
[255, 0, 0, 255],
|
||||
[0, 0, 255, 255],
|
||||
]);
|
||||
|
||||
vi.stubGlobal("Worker", undefined);
|
||||
try {
|
||||
await expect(
|
||||
extractPaletteSafely(input, { count: 2 }),
|
||||
).resolves.toHaveLength(2);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { samplePixels } from "../../src/palette/sample";
|
||||
|
||||
const pixels = (
|
||||
width: number,
|
||||
colours: readonly (readonly [number, number, number, number])[],
|
||||
) => ({
|
||||
width,
|
||||
height: colours.length / width,
|
||||
data: new Uint8ClampedArray(colours.flat()),
|
||||
});
|
||||
|
||||
describe("pixel sampling", () => {
|
||||
it("returns the exact pixel for a zero-radius sample", () => {
|
||||
const result = samplePixels(
|
||||
pixels(2, [
|
||||
[12, 34, 56, 255],
|
||||
[200, 100, 50, 128],
|
||||
]),
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
"average",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
r: 200,
|
||||
g: 100,
|
||||
b: 50,
|
||||
a: 128,
|
||||
hex: "#C8643280",
|
||||
pixelCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a circular neighbourhood and alpha-weighted RGB average", () => {
|
||||
const data = pixels(3, [
|
||||
[0, 0, 0, 0],
|
||||
[255, 0, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
[0, 255, 0, 255],
|
||||
[0, 0, 255, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[255, 255, 255, 255],
|
||||
[0, 0, 0, 0],
|
||||
]);
|
||||
const result = samplePixels(data, 1, 1, 1, "average");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
r: 170,
|
||||
g: 170,
|
||||
b: 85,
|
||||
a: 153,
|
||||
pixelCount: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates channel medians from visible samples", () => {
|
||||
const result = samplePixels(
|
||||
pixels(3, [
|
||||
[10, 90, 200, 255],
|
||||
[200, 50, 10, 128],
|
||||
[100, 10, 90, 0],
|
||||
]),
|
||||
1,
|
||||
0,
|
||||
1,
|
||||
"median",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
r: 105,
|
||||
g: 70,
|
||||
b: 105,
|
||||
a: 128,
|
||||
pixelCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid coordinates and truncated buffers", () => {
|
||||
expect(
|
||||
samplePixels(pixels(1, [[1, 2, 3, 4]]), 1, 0, 0, "average"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
samplePixels(
|
||||
{ width: 2, height: 2, data: new Uint8ClampedArray(4) },
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"average",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user