Release Scan Tools v0.1.0

This commit is contained in:
2026-09-01 14:34:51 +02:00
commit 75cd6a6195
64 changed files with 11626 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import {
correctedDimensions,
isValidQuad,
moveQuadPoint,
polygonArea,
squareToQuad,
} from "../../src/scan/geometry";
import { DEFAULT_QUAD, type Quad } from "../../src/scan/types";
describe("document geometry", () => {
it("validates ordered convex quadrilaterals", () => {
expect(isValidQuad(DEFAULT_QUAD)).toBe(true);
expect(polygonArea(DEFAULT_QUAD)).toBe(1);
expect(
isValidQuad([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
]),
).toBe(false);
});
it("rejects a dragged corner that would invert the page", () => {
expect(moveQuadPoint(DEFAULT_QUAD, 0, { x: 0.9, y: 0.9 })).toEqual(
DEFAULT_QUAD,
);
expect(moveQuadPoint(DEFAULT_QUAD, 0, { x: 0.1, y: 0.1 })[0]).toEqual({
x: 0.1,
y: 0.1,
});
});
it("maps every destination corner onto the selected source corner", () => {
const quad: Quad = [
{ x: 0.12, y: 0.08 },
{ x: 0.9, y: 0.16 },
{ x: 0.82, y: 0.94 },
{ x: 0.05, y: 0.8 },
];
const map = squareToQuad(quad);
expect(map(0, 0)).toEqual(quad[0]);
expect(map(1, 0).x).toBeCloseTo(quad[1].x);
expect(map(1, 1).y).toBeCloseTo(quad[2].y);
expect(map(0, 1).x).toBeCloseTo(quad[3].x);
});
it("preserves aspect and obeys the pixel budget", () => {
expect(correctedDimensions(DEFAULT_QUAD, 4000, 3000, 1_000_000)).toEqual({
width: 1154,
height: 866,
scale: expect.closeTo(Math.sqrt(1 / 12), 8),
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import {
adjustPixels,
estimateDeskew,
luminance,
otsuThreshold,
} from "../../src/scan/pixels";
function rgba(values: number[]) {
return new Uint8ClampedArray(
values.flatMap((value) => [value, value, value, 255]),
);
}
describe("document pixels", () => {
it("uses perceptual luminance", () => {
expect(luminance(255, 0, 0)).toBe(54);
expect(luminance(0, 255, 0)).toBe(182);
expect(luminance(0, 0, 255)).toBe(18);
});
it("finds a separating Otsu threshold", () => {
const threshold = otsuThreshold(rgba([5, 10, 15, 230, 240, 250]));
expect(threshold).toBeGreaterThanOrEqual(15);
expect(threshold).toBeLessThan(230);
});
it("creates an opaque black-and-white page", () => {
expect([
...adjustPixels(rgba([20, 220]), {
colourMode: "threshold",
threshold: 100,
brightness: 0,
contrast: 0,
}),
]).toEqual([0, 0, 0, 255, 255, 255, 255, 255]);
});
it("does not invent skew in an empty page", () => {
expect(estimateDeskew(rgba(new Array(20 * 20).fill(255)), 20, 20)).toBe(0);
});
it("returns the corrective angle for tilted text lines", () => {
const width = 120;
const height = 90;
const data = rgba(new Array(width * height).fill(255));
const slope = Math.tan((3 * Math.PI) / 180);
for (const baseline of [24, 44, 64]) {
for (let x = 8; x < width - 8; x += 1) {
const y = Math.round(baseline + (x - width / 2) * slope);
for (let thickness = -1; thickness <= 1; thickness += 1) {
const index = ((y + thickness) * width + x) * 4;
data[index] = 0;
data[index + 1] = 0;
data[index + 2] = 0;
}
}
}
expect(estimateDeskew(data, width, height)).toBeCloseTo(-3, 0);
});
});