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
+201
View File
@@ -0,0 +1,201 @@
import { expect, test, type Page } from "@playwright/test";
import { readFile } from "node:fs/promises";
const ORIGIN = "http://127.0.0.1:4203";
async function watchLocalOnly(page: Page) {
const external: string[] = [];
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.protocol.startsWith("http") && url.origin !== ORIGIN) {
external.push(url.href);
await route.abort();
} else await route.continue();
});
return external;
}
async function generatedPage(
page: Page,
name = "page.png",
text = "LOCAL SCAN",
) {
const base64 = await page.evaluate((label) => {
const canvas = document.createElement("canvas");
canvas.width = 600;
canvas.height = 800;
const context = canvas.getContext("2d")!;
context.fillStyle = "white";
context.fillRect(0, 0, 600, 800);
context.strokeStyle = "#111";
context.lineWidth = 4;
context.strokeRect(35, 45, 530, 700);
context.fillStyle = "black";
context.font = "bold 52px sans-serif";
context.fillText(label, 95, 210);
context.font = "32px sans-serif";
context.fillText("Processed only in this browser", 65, 300);
return canvas.toDataURL("image/png").split(",")[1]!;
}, text);
return { name, mimeType: "image/png", buffer: Buffer.from(base64, "base64") };
}
test("loads the nested local-first empty workspace", async ({ page }) => {
await page.setViewportSize({ width: 1800, height: 1000 });
const external = await watchLocalOnly(page);
await page.goto("/deep/nested/scan-tools/");
await expect(page.getByRole("heading", { name: "Scan Tools" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "No pages yet" }),
).toBeVisible();
expect(external).toEqual([]);
expect(
await page
.locator(".toolbox-shell__main")
.evaluate((node) => getComputedStyle(node).width),
).toBe("1440px");
});
test("adds and corrects a real local image while retaining the previous preview", async ({
page,
}) => {
await page.goto("/deep/nested/scan-tools/");
await page
.getByLabel("Add images or camera pages")
.setInputFiles(await generatedPage(page));
await expect(page.getByRole("heading", { name: "page.png" })).toBeVisible();
const preview = page.getByLabel("Processed preview of page.png");
await expect
.poll(() => preview.evaluate((node: HTMLCanvasElement) => node.width))
.toBeGreaterThan(100);
const before = await preview.evaluate((node: HTMLCanvasElement) =>
node.toDataURL(),
);
await page.getByLabel("Brightness adjustment").fill("30");
await expect
.poll(() => preview.evaluate((node: HTMLCanvasElement) => node.toDataURL()))
.not.toBe(before);
await page
.getByRole("slider", { name: "Corner 1" })
.press("Shift+ArrowRight");
await expect(page.getByRole("slider", { name: "Corner 1" })).toHaveAttribute(
"aria-valuetext",
/^1% from left/u,
);
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "Download PNG" }).click();
expect((await download).suggestedFilename()).toBe("page-scan.png");
});
test("assembles, reorders and removes pages", async ({ page }) => {
await page.goto("/deep/nested/scan-tools/");
await page
.getByLabel("Add images or camera pages")
.setInputFiles([
await generatedPage(page, "first.png", "FIRST"),
await generatedPage(page, "second.png", "SECOND"),
]);
const cards = page.locator(".page-strip li");
await expect(cards).toHaveCount(2);
await cards.nth(0).dragTo(cards.nth(1));
await expect(cards.nth(0)).toContainText("second.png");
await page.getByRole("button", { name: "Remove page 1" }).click();
await expect(cards).toHaveCount(1);
});
test("exports a real image-only PDF", async ({ page }) => {
await page.goto("/deep/nested/scan-tools/");
await page
.getByLabel("Add images or camera pages")
.setInputFiles(await generatedPage(page));
const pending = page.waitForEvent("download");
await page.getByRole("button", { name: "Download 1-page PDF" }).click();
const download = await pending;
expect(download.suggestedFilename()).toBe("local-scan.pdf");
const path = await download.path();
expect(path).toBeTruthy();
expect((await readFile(path!)).subarray(0, 5).toString()).toBe("%PDF-");
});
test("serves the complete OCR runtime from the app origin", async ({
page,
request,
}) => {
const external = await watchLocalOnly(page);
await page.goto("/deep/nested/scan-tools/");
for (const path of [
"ocr/worker.min.js",
"ocr/core/tesseract-core-lstm.wasm.js",
"ocr/core/tesseract-core-simd-lstm.wasm.js",
"ocr/core/tesseract-core-relaxedsimd-lstm.wasm.js",
"ocr/lang/eng.traineddata.gz",
]) {
const response = await request.get(`/deep/nested/scan-tools/${path}`);
expect(response.ok(), path).toBe(true);
expect((await response.body()).byteLength, path).toBeGreaterThan(100_000);
}
expect(external).toEqual([]);
});
test("runs the bundled OCR engine without an external request", async ({
page,
browserName,
}) => {
test.skip(
browserName !== "chromium",
"The engine path is exercised once; Firefox is covered by the asset, CSP, and UI matrix.",
);
const external = await watchLocalOnly(page);
await page.goto("/deep/nested/scan-tools/");
await page
.getByLabel("Add images or camera pages")
.setInputFiles(await generatedPage(page, "ocr.png", "LOCAL SCAN"));
await page.getByLabel("I understand the OCR memory cost.").check();
await page.getByRole("button", { name: "Recognize selected page" }).click();
await expect(page.getByLabel("Recognized text")).toBeVisible({
timeout: 90_000,
});
await expect(page.getByLabel("Recognized text")).toContainText(/LOCAL|SCAN/u);
expect(external).toEqual([]);
});
test("integrates help, themes, identity, headers and offline reload", async ({
page,
request,
context,
}) => {
await page.goto("/deep/nested/scan-tools/");
await page.getByRole("button", { name: "Help" }).click();
await expect(
page.getByRole("dialog", { name: "About Scan Tools" }),
).toContainText("image-only");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Personalize" }).click();
await page.getByRole("button", { name: "Dark" }).click();
await expect(page.locator(".toolbox-shell").first()).toHaveAttribute(
"data-toolbox-theme",
"dark",
);
expect(
await page.evaluate(async () =>
Boolean(await navigator.serviceWorker.ready),
),
).toBe(true);
const index = await request.get("/deep/nested/scan-tools/");
expect(index.headers()["content-security-policy"]).toContain(
"worker-src 'self' blob:",
);
expect(await index.text()).not.toMatch(/\b(?:src|href)=["']\//u);
const manifest = await request.get(
"/deep/nested/scan-tools/toolbox-app.json",
);
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.scan-tools",
version: "0.1.0",
requirements: { workers: true },
privacy: { processing: "local", telemetry: false },
});
await context.setOffline(true);
await page.reload();
await expect(page.getByRole("heading", { name: "Scan Tools" })).toBeVisible();
});
+24
View File
@@ -0,0 +1,24 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Workbench } from "../../src/components/Workbench";
describe("Scan Tools workbench", () => {
it("opens with a bounded local workflow and no content", () => {
render(<Workbench />);
expect(
screen.getByRole("heading", {
name: "Correct pages, assemble a document, then export.",
}),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "No pages yet" }),
).toBeInTheDocument();
expect(
screen.getByText(/Four-corner perspective correction/u),
).toBeInTheDocument();
expect(screen.getByLabelText("Add images or camera pages")).toHaveAttribute(
"capture",
"environment",
);
});
});
+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);
});
});