Release Scan Tools v0.1.0
This commit is contained in:
@@ -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();
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user