diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml
new file mode 100644
index 0000000..b84a7cb
--- /dev/null
+++ b/.gitea/workflows/verify.yml
@@ -0,0 +1,39 @@
+name: Verify
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: verify-${{ gitea.repository }}-${{ gitea.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ CI: "true"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ - name: Select declared npm version
+ run: npm install --global npm@11.17.0
+ - name: Install dependencies
+ run: npm ci
+ - name: Audit runtime dependencies
+ run: npm audit --omit=dev --audit-level=moderate
+ - name: Check, test, and build
+ run: npm run check
+ - name: Install browser engines
+ run: npx playwright install --with-deps chromium firefox webkit
+ - name: Browser tests
+ run: npm run test:browser
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33a3464..135fd2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.2.0 - 2026-09-02
+
+- Moved pixel and perspective processing into cancellable worker jobs with
+ progress, stale-result suppression and a bounded main-thread fallback.
+- Added precise manual corner fields, OCR word geometry overlays, hOCR/TSV
+ export and combined multipage text export.
+- Declared image/PDF/OCR input, export and optional worker/camera capability
+ profiles for Toolbox handoff.
+
## 0.1.0 - 2026-09-01
- Add bounded multi-image and camera-picker ingestion with real decoded-pixel checks
diff --git a/README.md b/README.md
index 95c5c8c..28d6c94 100644
--- a/README.md
+++ b/README.md
@@ -4,23 +4,27 @@ Correct photographed document pages, assemble them, optionally recognize
English text, and export images or PDFs without sending content away. Scan
Tools is a local-first module in the add·ideas Toolbox.
-## v0.1.0 capabilities
+## v0.2.0 capabilities
- Add up to 24 browser-decodable PNG, JPEG or WebP images through a file or
camera-enabled picker
- Drag four document corners to apply an actual projective correction; handles
- also support keyboard movement
+ support keyboard movement and precise percentage-coordinate entry
- Rotate in quarter turns, adjust deskew manually, or estimate a text-line
correction within ±5 degrees
- Tune brightness and contrast, retain colour, convert to grayscale, or create
black-and-white pages using Otsu or a manual threshold
- Preserve the previous preview until its debounced replacement is ready
+- Run perspective correction, pixel adjustment, rotation and deskew analysis in
+ cancellable OffscreenCanvas workers with progress; fall back to bounded main-
+ thread canvases only where those browser APIs are unavailable
- Reorder pages with drag and drop, remove them without touching source files,
and export the selected page as PNG or JPEG
- Assemble every corrected page into A4, US Letter, or source-sized image-only
PDFs with a configurable margin
- Run optional English OCR using a bundled integer model and same-origin
- Tesseract.js WebAssembly runtime; edit and download recognized text
+ Tesseract.js WebAssembly runtime; inspect word geometry and export editable
+ text, hOCR, TSV, or combined multipage text
- Work at nested paths, install as an offline PWA, use the shared system/light/
dark themes, and perform no remote content request
@@ -28,7 +32,8 @@ Tools is a local-first module in the add·ideas Toolbox.
This first release does not automatically detect page corners, remove fingers
or shadows, process input PDFs, recognize handwriting reliably, or create a
-searchable invisible PDF text layer. OCR confidence is engine evidence, not an
+searchable invisible PDF text layer. Geometry is exported separately so an
+unverified alignment is never embedded into the PDF. OCR confidence is engine evidence, not an
accuracy guarantee. Perspective correction is high-quality bilinear resampling;
each new render is lossy unless exported as PNG.
diff --git a/SOURCE.md b/SOURCE.md
index 1950e56..9d538a2 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -1,7 +1,7 @@
# Corresponding source
-The corresponding source for Scan Tools 0.1.0 is available at:
+The corresponding source for Scan Tools 0.2.0 is available at:
-https://git.add-ideas.de/lotobo/scan-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/scan-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 9fec761..e52270e 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,12 +1,12 @@
# Third-party notices
-Scan Tools 0.1.0 directly depends on these runtime packages:
+Scan Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
-| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
-| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
-| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
+| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
+| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
+| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@tesseract.js-data/eng` | 1.0.0 | MIT |
| `jspdf` | 4.2.1 | MIT |
| `react` | 19.2.8 | MIT |
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index deef3c5..8a3d318 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -5,13 +5,17 @@ serializable page settings. No page content enters local storage. Each source is
decoded with `createImageBitmap`; its dimensions and byte size are checked before
it joins the workspace.
-The renderer downscales exceptionally large source rasters to a 24-megapixel
-working ceiling. A projective map sends every destination pixel through the four
-selected source corners and samples it bilinearly. A bounded pixel pass applies
-brightness, contrast, perceptual grayscale and optional Otsu/manual threshold.
-Canvas rotation handles quarter turns and the final ±5 degree deskew. Preview,
-OCR and export call the same renderer with different disclosed pixel budgets, so
-the preview semantics match the downloaded result.
+The renderer uses a disposable module worker and `OffscreenCanvas` when the
+browser supports them. It downscales exceptionally large source rasters to a
+24-megapixel working ceiling, then a projective map sends every destination
+pixel through the four selected source corners and samples it bilinearly. A
+bounded pixel pass applies brightness, contrast, perceptual grayscale and
+optional Otsu/manual threshold. Canvas rotation handles quarter turns and the
+final ±5 degree deskew. Worker jobs report stages, are terminated by abort and
+cannot replace a newer preview. A bounded HTML-canvas fallback preserves the
+same result where Worker/OffscreenCanvas are unavailable. Preview, OCR and
+export call this renderer with different disclosed pixel budgets, so the preview
+semantics match the downloaded result.
Deskew estimation downsamples to at most 420 pixels on the long edge, tests
half-degree candidates from −5 to +5, and scores the horizontal projection of
@@ -24,3 +28,10 @@ runtime variants and English integer trained data are copied from pinned npm
packages to same-origin release assets. Tesseract's persistent model cache is
disabled, one in-memory worker is reused and can be terminated, and no fallback
CDN path is used.
+
+OCR requests bounded block geometry, hOCR and TSV alongside plain text. Word
+boxes are normalized to the corrected OCR raster for an inert SVG overlay and
+are capped at 100,000 entries. hOCR is never injected into the application DOM;
+it and TSV are download-only evidence. Any subsequent page adjustment clears
+positioned OCR artifacts while retaining the user's editable plain text. PDFs
+remain image-only rather than silently embedding a potentially misaligned layer.
diff --git a/package-lock.json b/package-lock.json
index 6529012..8e33144 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,17 +1,17 @@
{
"name": "scan-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scan-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3",
- "@add-ideas/toolbox-helpers": "0.1.0",
- "@add-ideas/toolbox-shell-react": "0.2.3",
+ "@add-ideas/toolbox-contract": "0.3.0",
+ "@add-ideas/toolbox-helpers": "0.2.0",
+ "@add-ideas/toolbox-shell-react": "0.3.0",
"@tesseract.js-data/eng": "1.0.0",
"jspdf": "4.2.1",
"react": "19.2.8",
@@ -19,7 +19,7 @@
"tesseract.js": "7.0.0"
},
"devDependencies": {
- "@add-ideas/toolbox-testkit": "0.2.3",
+ "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -45,24 +45,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
- "version": "0.2.3",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=20"
- }
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
+ "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
+ "license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
- "version": "0.1.0",
- "license": "GPL-3.0-or-later",
- "engines": {
- "node": ">=22"
- }
+ "version": "0.2.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
+ "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
+ "license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
- "version": "0.2.3",
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
+ "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3"
+ "@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -70,17 +70,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
- "version": "0.2.3",
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
+ "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3"
+ "@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
- },
- "engines": {
- "node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
diff --git a/package.json b/package.json
index e33fd6b..43c7637 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "scan-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Correct, assemble, recognize and export scanned pages locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -40,9 +40,9 @@
"ocr:prepare": "node scripts/prepare-ocr-assets.mjs"
},
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3",
- "@add-ideas/toolbox-helpers": "0.1.0",
- "@add-ideas/toolbox-shell-react": "0.2.3",
+ "@add-ideas/toolbox-contract": "0.3.0",
+ "@add-ideas/toolbox-helpers": "0.2.0",
+ "@add-ideas/toolbox-shell-react": "0.3.0",
"@tesseract.js-data/eng": "1.0.0",
"jspdf": "4.2.1",
"react": "19.2.8",
@@ -50,7 +50,7 @@
"tesseract.js": "7.0.0"
},
"devDependencies": {
- "@add-ideas/toolbox-testkit": "0.2.3",
+ "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index d80e1b2..ff7067f 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
- { name: "chromium", use: { ...devices["Desktop Chrome"] } },
- { name: "firefox", use: { ...devices["Desktop Firefox"] } },
+ {
+ name: "chromium",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Chrome"] },
+ },
+ {
+ name: "firefox",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Firefox"] },
+ },
+ {
+ name: "webkit",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Safari"] },
+ },
+ {
+ name: "mobile-chromium",
+ testMatch: /responsive\.spec\.ts/,
+ use: { ...devices["Pixel 5"] },
+ },
],
});
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index 33a3464..135fd2c 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.2.0 - 2026-09-02
+
+- Moved pixel and perspective processing into cancellable worker jobs with
+ progress, stale-result suppression and a bounded main-thread fallback.
+- Added precise manual corner fields, OCR word geometry overlays, hOCR/TSV
+ export and combined multipage text export.
+- Declared image/PDF/OCR input, export and optional worker/camera capability
+ profiles for Toolbox handoff.
+
## 0.1.0 - 2026-09-01
- Add bounded multi-image and camera-picker ingestion with real decoded-pixel checks
diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt
index 3366c22..3810f0a 100644
--- a/public/LICENSES/npm-runtime-licenses.txt
+++ b/public/LICENSES/npm-runtime-licenses.txt
@@ -1,5 +1,5 @@
==============================================================================
-@add-ideas/toolbox-contract@0.2.3
+@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
-@add-ideas/toolbox-helpers@0.1.0
+@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
-@add-ideas/toolbox-shell-react@0.2.3
+@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
diff --git a/public/README.md b/public/README.md
index 95c5c8c..28d6c94 100644
--- a/public/README.md
+++ b/public/README.md
@@ -4,23 +4,27 @@ Correct photographed document pages, assemble them, optionally recognize
English text, and export images or PDFs without sending content away. Scan
Tools is a local-first module in the add·ideas Toolbox.
-## v0.1.0 capabilities
+## v0.2.0 capabilities
- Add up to 24 browser-decodable PNG, JPEG or WebP images through a file or
camera-enabled picker
- Drag four document corners to apply an actual projective correction; handles
- also support keyboard movement
+ support keyboard movement and precise percentage-coordinate entry
- Rotate in quarter turns, adjust deskew manually, or estimate a text-line
correction within ±5 degrees
- Tune brightness and contrast, retain colour, convert to grayscale, or create
black-and-white pages using Otsu or a manual threshold
- Preserve the previous preview until its debounced replacement is ready
+- Run perspective correction, pixel adjustment, rotation and deskew analysis in
+ cancellable OffscreenCanvas workers with progress; fall back to bounded main-
+ thread canvases only where those browser APIs are unavailable
- Reorder pages with drag and drop, remove them without touching source files,
and export the selected page as PNG or JPEG
- Assemble every corrected page into A4, US Letter, or source-sized image-only
PDFs with a configurable margin
- Run optional English OCR using a bundled integer model and same-origin
- Tesseract.js WebAssembly runtime; edit and download recognized text
+ Tesseract.js WebAssembly runtime; inspect word geometry and export editable
+ text, hOCR, TSV, or combined multipage text
- Work at nested paths, install as an offline PWA, use the shared system/light/
dark themes, and perform no remote content request
@@ -28,7 +32,8 @@ Tools is a local-first module in the add·ideas Toolbox.
This first release does not automatically detect page corners, remove fingers
or shadows, process input PDFs, recognize handwriting reliably, or create a
-searchable invisible PDF text layer. OCR confidence is engine evidence, not an
+searchable invisible PDF text layer. Geometry is exported separately so an
+unverified alignment is never embedded into the PDF. OCR confidence is engine evidence, not an
accuracy guarantee. Perspective correction is high-quality bilinear resampling;
each new render is lossy unless exported as PNG.
diff --git a/public/SOURCE.md b/public/SOURCE.md
index 1950e56..9d538a2 100644
--- a/public/SOURCE.md
+++ b/public/SOURCE.md
@@ -1,7 +1,7 @@
# Corresponding source
-The corresponding source for Scan Tools 0.1.0 is available at:
+The corresponding source for Scan Tools 0.2.0 is available at:
-https://git.add-ideas.de/lotobo/scan-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/scan-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md
index 9fec761..e52270e 100644
--- a/public/THIRD_PARTY_NOTICES.md
+++ b/public/THIRD_PARTY_NOTICES.md
@@ -1,12 +1,12 @@
# Third-party notices
-Scan Tools 0.1.0 directly depends on these runtime packages:
+Scan Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
-| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
-| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
-| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
+| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
+| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
+| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@tesseract.js-data/eng` | 1.0.0 | MIT |
| `jspdf` | 4.2.1 | MIT |
| `react` | 19.2.8 | MIT |
diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md
index deef3c5..8a3d318 100644
--- a/public/docs/ARCHITECTURE.md
+++ b/public/docs/ARCHITECTURE.md
@@ -5,13 +5,17 @@ serializable page settings. No page content enters local storage. Each source is
decoded with `createImageBitmap`; its dimensions and byte size are checked before
it joins the workspace.
-The renderer downscales exceptionally large source rasters to a 24-megapixel
-working ceiling. A projective map sends every destination pixel through the four
-selected source corners and samples it bilinearly. A bounded pixel pass applies
-brightness, contrast, perceptual grayscale and optional Otsu/manual threshold.
-Canvas rotation handles quarter turns and the final ±5 degree deskew. Preview,
-OCR and export call the same renderer with different disclosed pixel budgets, so
-the preview semantics match the downloaded result.
+The renderer uses a disposable module worker and `OffscreenCanvas` when the
+browser supports them. It downscales exceptionally large source rasters to a
+24-megapixel working ceiling, then a projective map sends every destination
+pixel through the four selected source corners and samples it bilinearly. A
+bounded pixel pass applies brightness, contrast, perceptual grayscale and
+optional Otsu/manual threshold. Canvas rotation handles quarter turns and the
+final ±5 degree deskew. Worker jobs report stages, are terminated by abort and
+cannot replace a newer preview. A bounded HTML-canvas fallback preserves the
+same result where Worker/OffscreenCanvas are unavailable. Preview, OCR and
+export call this renderer with different disclosed pixel budgets, so the preview
+semantics match the downloaded result.
Deskew estimation downsamples to at most 420 pixels on the long edge, tests
half-degree candidates from −5 to +5, and scores the horizontal projection of
@@ -24,3 +28,10 @@ runtime variants and English integer trained data are copied from pinned npm
packages to same-origin release assets. Tesseract's persistent model cache is
disabled, one in-memory worker is reused and can be terminated, and no fallback
CDN path is used.
+
+OCR requests bounded block geometry, hOCR and TSV alongside plain text. Word
+boxes are normalized to the corrected OCR raster for an inert SVG overlay and
+are capped at 100,000 entries. hOCR is never injected into the application DOM;
+it and TSV are download-only evidence. Any subsequent page adjustment clears
+positioned OCR artifacts while retaining the user's editable plain text. PDFs
+remain image-only rather than silently embedding a potentially misaligned layer.
diff --git a/public/sw.js b/public/sw.js
index 3f4dddb..ce038cc 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "scan-tools-shell-";
-const CACHE_NAME = CACHE_PREFIX + "0.1.0";
+const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
index c55be0d..c283189 100644
--- a/public/toolbox-app.json
+++ b/public/toolbox-app.json
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.scan-tools",
"name": "Scan Tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Correct, assemble, recognize and export scanned pages locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["document", "image", "productivity"],
- "tags": ["scan", "camera", "deskew", "perspective", "ocr", "pdf"],
+ "tags": ["scan", "camera", "deskew", "perspective", "ocr", "hocr", "pdf"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -16,11 +16,30 @@
},
"requirements": {
"secureContext": false,
- "workers": true,
+ "workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
+ "io": {
+ "accepts": [
+ { "mediaType": "image/png", "extensions": [".png"] },
+ { "mediaType": "image/jpeg", "extensions": [".jpg", ".jpeg"] },
+ { "mediaType": "image/webp", "extensions": [".webp"] }
+ ],
+ "produces": [
+ { "mediaType": "image/png", "extensions": [".png"] },
+ { "mediaType": "image/jpeg", "extensions": [".jpg"] },
+ { "mediaType": "application/pdf", "extensions": [".pdf"] },
+ { "mediaType": "text/plain", "extensions": [".txt"] },
+ { "mediaType": "text/html", "extensions": [".hocr.html"] },
+ { "mediaType": "text/tab-separated-values", "extensions": [".tsv"] }
+ ]
+ },
+ "capabilities": {
+ "required": [],
+ "optional": ["workers", "offscreen-canvas", "webassembly", "camera-capture"]
+ },
"privacy": {
"processing": "local",
"fileUploads": true,
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index 70d3321..3c9784e 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -149,6 +149,55 @@ function SourceCorners({
))}
+
+ {page.adjustments.quad.map((point, index) => (
+
+ Corner {index + 1}
+
+ X (%)
+ {
+ if (!Number.isFinite(event.target.valueAsNumber)) return;
+ update({
+ ...page.adjustments,
+ quad: moveQuadPoint(page.adjustments.quad, index, {
+ x: event.target.valueAsNumber / 100,
+ y: point.y,
+ }),
+ });
+ }}
+ />
+
+
+ Y (%)
+ {
+ if (!Number.isFinite(event.target.valueAsNumber)) return;
+ update({
+ ...page.adjustments,
+ quad: moveQuadPoint(page.adjustments.quad, index, {
+ x: point.x,
+ y: event.target.valueAsNumber / 100,
+ }),
+ });
+ }}
+ />
+
+
+ ))}
+
);
}
@@ -158,8 +207,14 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
const [state, setState] = useState("Rendering preview…");
useEffect(() => {
let current = true;
+ const controller = new AbortController();
const timeout = window.setTimeout(() => {
- void renderPage(page, MAX_PREVIEW_PIXELS)
+ void renderPage(page, MAX_PREVIEW_PIXELS, {
+ signal: controller.signal,
+ onProgress: (progress, stage) => {
+ if (current) setState(`${stage} · ${Math.round(progress * 100)}%`);
+ },
+ })
.then((rendered) => {
if (!current || !canvasRef.current) return;
const target = canvasRef.current;
@@ -171,7 +226,10 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
);
})
.catch((reason: unknown) => {
- if (current)
+ if (
+ current &&
+ !(reason instanceof DOMException && reason.name === "AbortError")
+ )
setState(
reason instanceof Error ? reason.message : "Preview failed.",
);
@@ -179,6 +237,7 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
}, 120);
return () => {
current = false;
+ controller.abort();
window.clearTimeout(timeout);
};
}, [page]);
@@ -189,10 +248,34 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
{state}
-
+
+
+ {page.ocrWords?.length ? (
+
+ {page.ocrWords.slice(0, 2_000).map((word, index) => (
+
+
+ {word.text} · {word.confidence.toFixed(1)}%
+
+
+ ))}
+
+ ) : null}
+
);
@@ -263,6 +346,10 @@ export function Workbench() {
"Add photographs or image files to begin.",
);
const [busy, setBusy] = useState(false);
+ const [pixelProgress, setPixelProgress] = useState<{
+ value: number;
+ stage: string;
+ }>();
const [ocrConsent, setOcrConsent] = useState(false);
const [ocrProgress, setOcrProgress] = useState<{
status: string;
@@ -270,10 +357,12 @@ export function Workbench() {
}>();
const [pdfSize, setPdfSize] = useState("a4");
const [pdfMargin, setPdfMargin] = useState(18);
+ const processing = useRef(undefined);
const selected = pages.find((page) => page.id === selectedId) ?? pages[0];
useEffect(
() => () => {
+ processing.current?.abort();
void closeOcr().catch(() => undefined);
},
[],
@@ -283,6 +372,38 @@ export function Workbench() {
[pages],
);
+ const beginProcessing = (stage: string) => {
+ processing.current?.abort();
+ const controller = new AbortController();
+ processing.current = controller;
+ setBusy(true);
+ setPixelProgress({ value: 0, stage });
+ return controller;
+ };
+
+ const reportProcessing = (value: number, stage: string) => {
+ setPixelProgress({ value, stage });
+ };
+
+ const finishProcessing = (controller: AbortController) => {
+ if (processing.current !== controller) return;
+ processing.current = undefined;
+ setBusy(false);
+ setPixelProgress(undefined);
+ };
+
+ const cancelProcessing = () => {
+ processing.current?.abort();
+ void cancelOcr().catch(() => undefined);
+ processing.current = undefined;
+ setBusy(false);
+ setPixelProgress(undefined);
+ setOcrProgress(undefined);
+ setNotice(
+ "Page processing cancelled; pages and prior output were retained.",
+ );
+ };
+
const addFiles = async (event: ChangeEvent) => {
const files = [...(event.target.files ?? [])];
event.target.value = "";
@@ -343,7 +464,15 @@ export function Workbench() {
if (!selected) return;
setPages((current) =>
current.map((page) =>
- page.id === selected.id ? { ...page, adjustments } : page,
+ page.id === selected.id
+ ? {
+ ...page,
+ adjustments,
+ ocrWords: undefined,
+ ocrHocr: undefined,
+ ocrTsv: undefined,
+ }
+ : page,
),
);
};
@@ -353,11 +482,14 @@ export function Workbench() {
const runDeskew = async () => {
if (!selected) return;
- setBusy(true);
+ const controller = beginProcessing("Preparing deskew detection");
setError(undefined);
setNotice("Estimating text-line angle locally…");
try {
- const angle = await detectDeskew(selected);
+ const angle = await detectDeskew(selected, {
+ signal: controller.signal,
+ onProgress: reportProcessing,
+ });
patchSelected({ deskew: angle });
setNotice(
angle === 0
@@ -365,44 +497,70 @@ export function Workbench() {
: `Applied an estimated ${angle.toFixed(1)}° correction.`,
);
} catch (reason) {
- setError(
- reason instanceof Error ? reason.message : "Deskew estimation failed.",
- );
+ if (
+ !controller.signal.aborted &&
+ !(reason instanceof DOMException && reason.name === "AbortError")
+ )
+ setError(
+ reason instanceof Error
+ ? reason.message
+ : "Deskew estimation failed.",
+ );
} finally {
- setBusy(false);
+ finishProcessing(controller);
}
};
const exportCurrentImage = async (type: "image/png" | "image/jpeg") => {
if (!selected) return;
- setBusy(true);
+ const controller = beginProcessing("Preparing image export");
setError(undefined);
setNotice(`Rendering ${type === "image/png" ? "PNG" : "JPEG"} locally…`);
try {
- await exportImage(selected, type);
+ await exportImage(selected, type, {
+ signal: controller.signal,
+ onProgress: reportProcessing,
+ });
setNotice(
`Rendered ${selected.name} within the 10-megapixel export budget.`,
);
} catch (reason) {
- setError(
- `${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`,
- );
+ if (
+ !controller.signal.aborted &&
+ !(reason instanceof DOMException && reason.name === "AbortError")
+ )
+ setError(
+ `${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`,
+ );
} finally {
- setBusy(false);
+ finishProcessing(controller);
}
};
const runOcr = async () => {
if (!selected || !ocrConsent) return;
- setBusy(true);
+ const controller = beginProcessing("Preparing OCR image");
setError(undefined);
setOcrProgress({ status: "Preparing local OCR", progress: 0 });
try {
- const rendered = await renderPage(selected, 4_000_000);
+ const rendered = await renderPage(selected, 4_000_000, {
+ signal: controller.signal,
+ onProgress: reportProcessing,
+ });
const image = await canvasBlob(rendered.canvas, "image/png");
const result = await recognizeEnglish(image, (status, progress) =>
setOcrProgress({ status, progress }),
);
+ const ocrWords = result.words.map((word) => ({
+ text: word.text,
+ confidence: word.confidence,
+ bbox: {
+ x0: Math.max(0, Math.min(1, word.bbox.x0 / rendered.canvas.width)),
+ y0: Math.max(0, Math.min(1, word.bbox.y0 / rendered.canvas.height)),
+ x1: Math.max(0, Math.min(1, word.bbox.x1 / rendered.canvas.width)),
+ y1: Math.max(0, Math.min(1, word.bbox.y1 / rendered.canvas.height)),
+ },
+ }));
setPages((current) =>
current.map((page) =>
page.id === selected.id
@@ -410,6 +568,9 @@ export function Workbench() {
...page,
ocrText: result.text,
ocrConfidence: result.confidence,
+ ocrWords,
+ ocrHocr: result.hocr,
+ ocrTsv: result.tsv,
}
: page,
),
@@ -418,17 +579,21 @@ export function Workbench() {
`English OCR finished with ${result.confidence.toFixed(1)}% engine confidence.`,
);
} catch (reason) {
- setError(
- `${reason instanceof Error ? reason.message : "OCR failed."} The page and previous text were retained.`,
- );
+ if (
+ !controller.signal.aborted &&
+ !(reason instanceof DOMException && reason.name === "AbortError")
+ )
+ setError(
+ `${reason instanceof Error ? reason.message : "OCR failed."} The page and previous text were retained.`,
+ );
} finally {
- setBusy(false);
+ finishProcessing(controller);
setOcrProgress(undefined);
}
};
const exportPdf = async () => {
- setBusy(true);
+ const controller = beginProcessing("Preparing PDF export");
setError(undefined);
try {
const pdf = await buildPdf(
@@ -437,15 +602,23 @@ export function Workbench() {
pdfMargin,
(completed, total) =>
setNotice(`Rendering PDF page ${completed} of ${total}…`),
+ {
+ signal: controller.signal,
+ onProgress: reportProcessing,
+ },
);
triggerBlobDownload(pdf, "local-scan.pdf");
setNotice(`Created a ${formatBytes(pdf.size)} image-only PDF locally.`);
} catch (reason) {
- setError(
- `${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`,
- );
+ if (
+ !controller.signal.aborted &&
+ !(reason instanceof DOMException && reason.name === "AbortError")
+ )
+ setError(
+ `${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`,
+ );
} finally {
- setBusy(false);
+ finishProcessing(controller);
}
};
@@ -478,6 +651,17 @@ export function Workbench() {
{notice}
+ {pixelProgress ? (
+
+
+ {pixelProgress.stage}{" "}
+
+
+
+ Cancel processing
+
+
+ ) : null}
{error ? (
{error}
@@ -732,18 +916,7 @@ export function Workbench() {
Recognize selected page
{busy && ocrProgress ? (
-
- void cancelOcr().catch((reason: unknown) =>
- setError(
- reason instanceof Error
- ? reason.message
- : "OCR cancellation failed.",
- ),
- )
- }
- >
+
Cancel OCR
) : null}
@@ -791,6 +964,36 @@ export function Workbench() {
>
Download text
+ {selected.ocrHocr ? (
+
+ triggerBlobDownload(
+ new Blob([selected.ocrHocr ?? ""], {
+ type: "text/html;charset=utf-8",
+ }),
+ `${selected.name.replace(/\.[^.]+$/u, "")}.hocr.html`,
+ )
+ }
+ >
+ Download hOCR geometry
+
+ ) : null}
+ {selected.ocrTsv ? (
+
+ triggerBlobDownload(
+ new Blob([selected.ocrTsv ?? ""], {
+ type: "text/tab-separated-values;charset=utf-8",
+ }),
+ `${selected.name.replace(/\.[^.]+$/u, "")}.tsv`,
+ )
+ }
+ >
+ Download OCR TSV
+
+ ) : null}
) : null}
@@ -829,9 +1032,32 @@ export function Workbench() {
>
Download {pages.length}-page PDF
+ page.ocrText)}
+ onClick={() =>
+ triggerBlobDownload(
+ new Blob(
+ [
+ pages
+ .map(
+ (page, index) =>
+ `--- Page ${index + 1}: ${page.name} ---\n${page.ocrText ?? ""}`,
+ )
+ .join("\n\n"),
+ ],
+ { type: "text/plain;charset=utf-8" },
+ ),
+ "local-scan-ocr.txt",
+ )
+ }
+ >
+ Download all OCR text
+
- The PDF is image-only in v0.1. OCR text downloads separately; no
- invisible text layer is guessed without reliable word geometry.
+ The PDF stays image-only so text is never silently misaligned.
+ Positioned OCR can be inspected and exported separately as hOCR or
+ TSV.
diff --git a/src/scan/export.ts b/src/scan/export.ts
index d20d980..8b8e89a 100644
--- a/src/scan/export.ts
+++ b/src/scan/export.ts
@@ -1,5 +1,5 @@
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
-import { canvasBlob, renderPage } from "./render";
+import { canvasBlob, renderPage, type RenderTaskOptions } from "./render";
import { MAX_EXPORT_PIXELS, type ScanPage } from "./types";
export type PdfPageSize = "source" | "a4" | "letter";
@@ -7,8 +7,9 @@ export type PdfPageSize = "source" | "a4" | "letter";
export async function exportImage(
page: ScanPage,
type: "image/png" | "image/jpeg",
+ options: RenderTaskOptions = {},
) {
- const rendered = await renderPage(page, MAX_EXPORT_PIXELS);
+ const rendered = await renderPage(page, MAX_EXPORT_PIXELS, options);
const blob = await canvasBlob(rendered.canvas, type);
const extension = type === "image/png" ? "png" : "jpg";
triggerBlobDownload(
@@ -32,12 +33,13 @@ export async function buildPdf(
pageSize: PdfPageSize,
marginPoints: number,
onProgress: (completed: number, total: number) => void,
+ options: RenderTaskOptions = {},
): Promise {
if (pages.length === 0)
throw new RangeError("Add at least one page before exporting a PDF.");
const [{ jsPDF }, first] = await Promise.all([
import("jspdf"),
- renderPage(pages[0]!, MAX_EXPORT_PIXELS),
+ renderPage(pages[0]!, MAX_EXPORT_PIXELS, options),
]);
const firstSize = pagePoints(
pageSize,
@@ -53,7 +55,9 @@ export async function buildPdf(
});
for (let index = 0; index < pages.length; index += 1) {
const rendered =
- index === 0 ? first : await renderPage(pages[index]!, MAX_EXPORT_PIXELS);
+ index === 0
+ ? first
+ : await renderPage(pages[index]!, MAX_EXPORT_PIXELS, options);
const dimensions = pagePoints(
pageSize,
rendered.canvas.width,
@@ -85,6 +89,8 @@ export async function buildPdf(
"FAST",
);
onProgress(index + 1, pages.length);
+ if (options.signal?.aborted)
+ throw new DOMException("PDF export cancelled.", "AbortError");
await new Promise((resolve) =>
requestAnimationFrame(() => resolve()),
);
diff --git a/src/scan/ocr.ts b/src/scan/ocr.ts
index 69ce96e..4999a84 100644
--- a/src/scan/ocr.ts
+++ b/src/scan/ocr.ts
@@ -1,4 +1,4 @@
-import type { Worker } from "tesseract.js";
+import type { Bbox, Worker } from "tesseract.js";
let activeWorker: Worker | undefined;
let creation: Promise | undefined;
@@ -38,10 +38,39 @@ async function worker(onProgress: (status: string, progress: number) => void) {
export async function recognizeEnglish(
image: Blob,
onProgress: (status: string, progress: number) => void,
-): Promise<{ text: string; confidence: number }> {
+): Promise<{
+ text: string;
+ confidence: number;
+ words: Array<{ text: string; confidence: number; bbox: Bbox }>;
+ hocr: string;
+ tsv: string;
+}> {
const instance = await worker(onProgress);
- const result = await instance.recognize(image);
- return { text: result.data.text, confidence: result.data.confidence };
+ const result = await instance.recognize(
+ image,
+ {},
+ { text: true, blocks: true, hocr: true, tsv: true },
+ );
+ const words: Array<{ text: string; confidence: number; bbox: Bbox }> = [];
+ for (const block of result.data.blocks ?? [])
+ for (const paragraph of block.paragraphs)
+ for (const line of paragraph.lines)
+ for (const word of line.words)
+ if (word.text.trim())
+ words.push({
+ text: word.text.slice(0, 500),
+ confidence: word.confidence,
+ bbox: word.bbox,
+ });
+ if (words.length > 100_000)
+ throw new RangeError("OCR produced more than 100,000 positioned words.");
+ return {
+ text: result.data.text,
+ confidence: result.data.confidence,
+ words,
+ hocr: result.data.hocr ?? "",
+ tsv: result.data.tsv ?? "",
+ };
}
export async function cancelOcr(): Promise {
diff --git a/src/scan/render.ts b/src/scan/render.ts
index 2211d45..309fa79 100644
--- a/src/scan/render.ts
+++ b/src/scan/render.ts
@@ -6,7 +6,12 @@ import {
type ScanPage,
} from "./types";
-const MAX_WORKING_SOURCE_PIXELS = 24_000_000;
+export const MAX_WORKING_SOURCE_PIXELS = 24_000_000;
+
+export interface RenderTaskOptions {
+ signal?: AbortSignal;
+ onProgress?: (progress: number, stage: string) => void;
+}
function canvas(width: number, height: number): HTMLCanvasElement {
const element = document.createElement("canvas");
@@ -61,7 +66,7 @@ function sampleBilinear(
return top * (1 - fy) + bottom * fy;
}
-function perspectiveCorrect(
+export function perspectiveCorrectPixels(
source: ImageData,
outputWidth: number,
outputHeight: number,
@@ -156,9 +161,16 @@ export interface RenderedPage {
export async function renderPage(
page: ScanPage,
maximumPixels: number,
+ options: RenderTaskOptions = {},
): Promise {
+ if (canRenderInWorker())
+ return renderPageInWorker(page, maximumPixels, options);
+ throwIfAborted(options.signal);
+ options.onProgress?.(0.05, "Decoding page");
const bitmap = await createImageBitmap(page.source);
try {
+ throwIfAborted(options.signal);
+ options.onProgress?.(0.2, "Preparing source pixels");
const sourceScale = Math.min(
1,
Math.sqrt(MAX_WORKING_SOURCE_PIXELS / (bitmap.width * bitmap.height)),
@@ -183,7 +195,7 @@ export async function renderPage(
);
const corrected = canvas(dimensions.width, dimensions.height);
context(corrected).putImageData(
- perspectiveCorrect(
+ perspectiveCorrectPixels(
sourceContext.getImageData(
0,
0,
@@ -197,23 +209,34 @@ export async function renderPage(
0,
0,
);
+ throwIfAborted(options.signal);
+ options.onProgress?.(0.8, "Rotating corrected page");
const rotated = rotate(
corrected,
page.adjustments.quarterTurns * 90 + page.adjustments.deskew,
);
- return {
+ const result = {
canvas: boundPixels(rotated, maximumPixels),
sourceScale,
outputScale: dimensions.scale,
};
+ options.onProgress?.(1, "Page ready");
+ return result;
} finally {
bitmap.close();
}
}
-export async function detectDeskew(page: ScanPage): Promise {
+export async function detectDeskew(
+ page: ScanPage,
+ options: RenderTaskOptions = {},
+): Promise {
+ if (canRenderInWorker()) return detectDeskewInWorker(page, options);
+ throwIfAborted(options.signal);
+ options.onProgress?.(0.1, "Decoding deskew sample");
const bitmap = await createImageBitmap(page.source);
try {
+ throwIfAborted(options.signal);
const scale = Math.min(1, 420 / Math.max(bitmap.width, bitmap.height));
const work = canvas(
Math.max(16, Math.round(bitmap.width * scale)),
@@ -221,16 +244,142 @@ export async function detectDeskew(page: ScanPage): Promise {
);
const workContext = context(work);
workContext.drawImage(bitmap, 0, 0, work.width, work.height);
- return estimateDeskew(
+ options.onProgress?.(0.5, "Scoring text-line angles");
+ const angle = estimateDeskew(
workContext.getImageData(0, 0, work.width, work.height).data,
work.width,
work.height,
);
+ throwIfAborted(options.signal);
+ options.onProgress?.(1, "Deskew estimate ready");
+ return angle;
} finally {
bitmap.close();
}
}
+interface RenderWorkerResponse {
+ id: number;
+ kind: "progress" | "render" | "deskew" | "error";
+ progress?: number;
+ stage?: string;
+ bitmap?: ImageBitmap;
+ width?: number;
+ height?: number;
+ sourceScale?: number;
+ outputScale?: number;
+ angle?: number;
+ error?: string;
+}
+
+let nextWorkerId = 0;
+
+function canRenderInWorker(): boolean {
+ return (
+ typeof Worker !== "undefined" && typeof OffscreenCanvas !== "undefined"
+ );
+}
+
+function renderPageInWorker(
+ page: ScanPage,
+ maximumPixels: number,
+ options: RenderTaskOptions,
+): Promise {
+ return runRenderWorker(
+ {
+ task: "render",
+ source: page.source,
+ adjustments: page.adjustments,
+ maximumPixels,
+ },
+ options,
+ ).then((message) => {
+ if (!message.bitmap || !message.width || !message.height)
+ throw new Error("Scan worker returned no rendered page.");
+ const output = canvas(message.width, message.height);
+ context(output).drawImage(message.bitmap, 0, 0);
+ message.bitmap.close();
+ return {
+ canvas: output,
+ sourceScale: message.sourceScale ?? 1,
+ outputScale: message.outputScale ?? 1,
+ };
+ });
+}
+
+function detectDeskewInWorker(
+ page: ScanPage,
+ options: RenderTaskOptions,
+): Promise {
+ return runRenderWorker({ task: "deskew", source: page.source }, options).then(
+ (message) => {
+ if (typeof message.angle !== "number")
+ throw new Error("Scan worker returned no deskew angle.");
+ return message.angle;
+ },
+ );
+}
+
+function runRenderWorker(
+ request:
+ | {
+ task: "render";
+ source: File;
+ adjustments: PageAdjustments;
+ maximumPixels: number;
+ }
+ | { task: "deskew"; source: File },
+ options: RenderTaskOptions,
+): Promise {
+ throwIfAborted(options.signal);
+ const id = ++nextWorkerId;
+ const worker = new Worker(new URL("./render.worker.ts", import.meta.url), {
+ type: "module",
+ });
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = () => {
+ settled = true;
+ worker.terminate();
+ options.signal?.removeEventListener("abort", abort);
+ };
+ const abort = () => {
+ if (settled) return;
+ finish();
+ reject(abortError());
+ };
+ options.signal?.addEventListener("abort", abort, { once: true });
+ worker.onmessage = (event: MessageEvent) => {
+ if (event.data.id !== id || settled) return;
+ if (event.data.kind === "progress") {
+ options.onProgress?.(
+ event.data.progress ?? 0,
+ event.data.stage ?? "Processing page",
+ );
+ return;
+ }
+ finish();
+ if (event.data.kind === "error")
+ reject(new Error(event.data.error ?? "Scan worker failed."));
+ else resolve(event.data);
+ };
+ worker.onerror = () => {
+ if (settled) return;
+ finish();
+ reject(new Error("Scan worker failed to start."));
+ };
+ worker.postMessage({ id, ...request });
+ });
+}
+
+function throwIfAborted(signal?: AbortSignal): void {
+ if (signal?.aborted) throw abortError();
+}
+
+function abortError(): DOMException {
+ return new DOMException("Page processing cancelled.", "AbortError");
+}
+
export function canvasBlob(
source: HTMLCanvasElement,
type: "image/png" | "image/jpeg",
diff --git a/src/scan/render.worker.ts b/src/scan/render.worker.ts
new file mode 100644
index 0000000..1bc0186
--- /dev/null
+++ b/src/scan/render.worker.ts
@@ -0,0 +1,185 @@
+///
+
+import { correctedDimensions } from "./geometry";
+import { estimateDeskew } from "./pixels";
+import { MAX_WORKING_SOURCE_PIXELS, perspectiveCorrectPixels } from "./render";
+import { MAX_SOURCE_PIXELS, type PageAdjustments } from "./types";
+
+type Request =
+ | {
+ id: number;
+ task: "render";
+ source: File;
+ adjustments: PageAdjustments;
+ maximumPixels: number;
+ }
+ | { id: number; task: "deskew"; source: File };
+
+declare const self: DedicatedWorkerGlobalScope;
+
+self.onmessage = (event: MessageEvent) => {
+ void run(event.data);
+};
+
+async function run(request: Request): Promise {
+ try {
+ progress(request.id, 0.05, "Decoding page");
+ const bitmap = await createImageBitmap(request.source);
+ try {
+ if (bitmap.width * bitmap.height > MAX_SOURCE_PIXELS)
+ throw new RangeError(
+ `Decoded image exceeds ${MAX_SOURCE_PIXELS.toLocaleString()} pixels.`,
+ );
+ if (request.task === "deskew") {
+ const angle = renderDeskewSample(request.id, bitmap);
+ self.postMessage({ id: request.id, kind: "deskew", angle });
+ } else {
+ const result = await render(
+ request.id,
+ bitmap,
+ request.adjustments,
+ request.maximumPixels,
+ );
+ self.postMessage({ id: request.id, kind: "render", ...result }, [
+ result.bitmap,
+ ]);
+ }
+ } finally {
+ bitmap.close();
+ }
+ } catch (reason) {
+ self.postMessage({
+ id: request.id,
+ kind: "error",
+ error:
+ reason instanceof Error ? reason.message : "Page processing failed.",
+ });
+ }
+}
+
+async function render(
+ id: number,
+ bitmap: ImageBitmap,
+ adjustments: PageAdjustments,
+ maximumPixels: number,
+) {
+ progress(id, 0.15, "Preparing source pixels");
+ const sourceScale = Math.min(
+ 1,
+ Math.sqrt(MAX_WORKING_SOURCE_PIXELS / (bitmap.width * bitmap.height)),
+ );
+ const source = makeCanvas(
+ Math.max(1, Math.round(bitmap.width * sourceScale)),
+ Math.max(1, Math.round(bitmap.height * sourceScale)),
+ );
+ const sourceContext = context(source);
+ sourceContext.drawImage(bitmap, 0, 0, source.width, source.height);
+ const dimensions = correctedDimensions(
+ adjustments.quad,
+ source.width,
+ source.height,
+ maximumPixels,
+ );
+ progress(id, 0.3, "Correcting perspective and pixels");
+ const corrected = makeCanvas(dimensions.width, dimensions.height);
+ context(corrected).putImageData(
+ perspectiveCorrectPixels(
+ sourceContext.getImageData(0, 0, source.width, source.height),
+ dimensions.width,
+ dimensions.height,
+ adjustments,
+ ),
+ 0,
+ 0,
+ );
+ progress(id, 0.8, "Rotating corrected page");
+ const rotated = rotate(
+ corrected,
+ adjustments.quarterTurns * 90 + adjustments.deskew,
+ );
+ const bounded = boundPixels(rotated, maximumPixels);
+ const result = await createImageBitmap(bounded);
+ progress(id, 1, "Page ready");
+ return {
+ bitmap: result,
+ width: bounded.width,
+ height: bounded.height,
+ sourceScale,
+ outputScale: dimensions.scale,
+ };
+}
+
+function renderDeskewSample(id: number, bitmap: ImageBitmap): number {
+ progress(id, 0.25, "Preparing deskew sample");
+ const scale = Math.min(1, 420 / Math.max(bitmap.width, bitmap.height));
+ const work = makeCanvas(
+ Math.max(16, Math.round(bitmap.width * scale)),
+ Math.max(16, Math.round(bitmap.height * scale)),
+ );
+ const workContext = context(work);
+ workContext.drawImage(bitmap, 0, 0, work.width, work.height);
+ progress(id, 0.5, "Scoring text-line angles");
+ const angle = estimateDeskew(
+ workContext.getImageData(0, 0, work.width, work.height).data,
+ work.width,
+ work.height,
+ );
+ progress(id, 1, "Deskew estimate ready");
+ return angle;
+}
+
+function makeCanvas(width: number, height: number): OffscreenCanvas {
+ return new OffscreenCanvas(width, height);
+}
+
+function context(canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D {
+ const value = canvas.getContext("2d", { willReadFrequently: true });
+ if (!value)
+ throw new Error("This browser could not create an offscreen 2D canvas.");
+ return value;
+}
+
+function rotate(source: OffscreenCanvas, degrees: number): OffscreenCanvas {
+ const normalized = ((degrees % 360) + 360) % 360;
+ if (Math.abs(normalized) < 0.001) return source;
+ const radians = (normalized * Math.PI) / 180;
+ const width = Math.ceil(
+ Math.abs(source.width * Math.cos(radians)) +
+ Math.abs(source.height * Math.sin(radians)),
+ );
+ const height = Math.ceil(
+ Math.abs(source.width * Math.sin(radians)) +
+ Math.abs(source.height * Math.cos(radians)),
+ );
+ const output = makeCanvas(width, height);
+ const target = context(output);
+ target.fillStyle = "white";
+ target.fillRect(0, 0, width, height);
+ target.translate(width / 2, height / 2);
+ target.rotate(radians);
+ target.drawImage(source, -source.width / 2, -source.height / 2);
+ return output;
+}
+
+function boundPixels(
+ source: OffscreenCanvas,
+ maximumPixels: number,
+): OffscreenCanvas {
+ if (source.width * source.height <= maximumPixels) return source;
+ const scale = Math.sqrt(maximumPixels / (source.width * source.height));
+ const output = makeCanvas(
+ Math.max(16, Math.floor(source.width * scale)),
+ Math.max(16, Math.floor(source.height * scale)),
+ );
+ const target = context(output);
+ target.imageSmoothingEnabled = true;
+ target.imageSmoothingQuality = "high";
+ target.drawImage(source, 0, 0, output.width, output.height);
+ return output;
+}
+
+function progress(id: number, value: number, stage: string): void {
+ self.postMessage({ id, kind: "progress", progress: value, stage });
+}
+
+export {};
diff --git a/src/scan/types.ts b/src/scan/types.ts
index 33e1dbf..6bda644 100644
--- a/src/scan/types.ts
+++ b/src/scan/types.ts
@@ -33,6 +33,16 @@ export interface ScanPage {
adjustments: PageAdjustments;
ocrText?: string;
ocrConfidence?: number;
+ ocrWords?: OcrWord[];
+ ocrHocr?: string;
+ ocrTsv?: string;
+}
+
+export interface OcrWord {
+ text: string;
+ confidence: number;
+ /** Normalized bounds in the corrected OCR image. */
+ bbox: { x0: number; y0: number; x1: number; y1: number };
}
export const DEFAULT_QUAD: Quad = [
diff --git a/src/styles.css b/src/styles.css
index ed269c7..5423133 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -309,9 +309,46 @@ button:disabled {
text-anchor: middle;
dominant-baseline: central;
}
+.corner-fields {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 0.4rem;
+ margin-top: 0.55rem;
+}
+.corner-fields fieldset {
+ min-width: 0;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.35rem;
+ margin: 0;
+ padding: 0.45rem;
+ border: 1px solid var(--scan-line);
+ border-radius: 0.55rem;
+}
+.corner-fields legend {
+ padding: 0 0.2rem;
+ color: var(--scan-muted);
+ font-size: 0.68rem;
+ font-weight: 750;
+}
+.corner-fields input {
+ width: 100%;
+ min-width: 0;
+}
.processed-stage {
min-height: 12rem;
}
+.processed-image {
+ position: relative;
+ display: grid;
+ place-items: center;
+ max-width: 100%;
+ max-height: 28rem;
+}
+.processed-image canvas,
+.processed-image .ocr-overlay {
+ grid-area: 1 / 1;
+}
.processed-stage canvas {
display: block;
max-width: 100%;
@@ -319,6 +356,18 @@ button:disabled {
width: auto;
height: auto;
}
+.ocr-overlay {
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+.ocr-overlay rect {
+ fill: color-mix(in srgb, var(--scan-accent) 8%, transparent);
+ stroke: #00e3b4;
+ stroke-width: 0.2;
+ vector-effect: non-scaling-stroke;
+}
.controls-grid {
display: grid;
@@ -429,6 +478,9 @@ button:disabled {
}
@media (max-width: 72rem) {
+ .corner-fields {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
.workspace-grid {
grid-template-columns: 14rem minmax(0, 1fr);
}
@@ -474,7 +526,8 @@ button:disabled {
flex-direction: column;
}
.controls-grid,
- .export-card {
+ .export-card,
+ .corner-fields {
grid-template-columns: 1fr;
}
.export-card > :is(.eyebrow, h2, .microcopy) {
diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json
index c55be0d..c283189 100644
--- a/src/toolbox/manifest.source.json
+++ b/src/toolbox/manifest.source.json
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.scan-tools",
"name": "Scan Tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Correct, assemble, recognize and export scanned pages locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["document", "image", "productivity"],
- "tags": ["scan", "camera", "deskew", "perspective", "ocr", "pdf"],
+ "tags": ["scan", "camera", "deskew", "perspective", "ocr", "hocr", "pdf"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -16,11 +16,30 @@
},
"requirements": {
"secureContext": false,
- "workers": true,
+ "workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
+ "io": {
+ "accepts": [
+ { "mediaType": "image/png", "extensions": [".png"] },
+ { "mediaType": "image/jpeg", "extensions": [".jpg", ".jpeg"] },
+ { "mediaType": "image/webp", "extensions": [".webp"] }
+ ],
+ "produces": [
+ { "mediaType": "image/png", "extensions": [".png"] },
+ { "mediaType": "image/jpeg", "extensions": [".jpg"] },
+ { "mediaType": "application/pdf", "extensions": [".pdf"] },
+ { "mediaType": "text/plain", "extensions": [".txt"] },
+ { "mediaType": "text/html", "extensions": [".hocr.html"] },
+ { "mediaType": "text/tab-separated-values", "extensions": [".tsv"] }
+ ]
+ },
+ "capabilities": {
+ "required": [],
+ "optional": ["workers", "offscreen-canvas", "webassembly", "camera-capture"]
+ },
"privacy": {
"processing": "local",
"fileUploads": true,
diff --git a/src/version.ts b/src/version.ts
index 76162f8..0ba6e37 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = "0.1.0";
+export const APP_VERSION = "0.2.0";
diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts
index 4d7ce66..076de8b 100644
--- a/tests/browser/app.spec.ts
+++ b/tests/browser/app.spec.ts
@@ -82,6 +82,11 @@ test("adds and corrects a real local image while retaining the previous preview"
"aria-valuetext",
/^1% from left/u,
);
+ await page.getByLabel("Corner 2 X (%)").fill("98.5");
+ await expect(page.getByRole("slider", { name: "Corner 2" })).toHaveAttribute(
+ "aria-valuetext",
+ /^99% from left/u,
+ );
const download = page.waitForEvent("download");
await page.getByRole("button", { name: "Download PNG" }).click();
expect((await download).suggestedFilename()).toBe("page-scan.png");
@@ -156,6 +161,13 @@ test("runs the bundled OCR engine without an external request", async ({
timeout: 90_000,
});
await expect(page.getByLabel("Recognized text")).toContainText(/LOCAL|SCAN/u);
+ await expect(page.getByLabel(/positioned OCR words/u)).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Download hOCR geometry" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Download OCR TSV" }),
+ ).toBeVisible();
expect(external).toEqual([]);
});
@@ -191,8 +203,12 @@ test("integrates help, themes, identity, headers and offline reload", async ({
);
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.scan-tools",
- version: "0.1.0",
- requirements: { workers: true },
+ version: "0.2.0",
+ requirements: { workers: false },
+ capabilities: {
+ required: [],
+ optional: expect.arrayContaining(["workers"]),
+ },
privacy: { processing: "local", telemetry: false },
});
await context.setOffline(true);
diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts
new file mode 100644
index 0000000..dabad44
--- /dev/null
+++ b/tests/browser/responsive.spec.ts
@@ -0,0 +1,18 @@
+import { expect, test } from "@playwright/test";
+
+test("keeps the primary workspace inside a narrow viewport", async ({
+ page,
+}) => {
+ await page.goto("/deep/nested/scan-tools/");
+ await expect(page.locator("main").first()).toBeVisible();
+ await expect(
+ page.locator("main .loading, main .workbench-loading"),
+ ).toHaveCount(0);
+
+ const widths = await page.evaluate(() => ({
+ content: document.documentElement.scrollWidth,
+ viewport: document.documentElement.clientWidth,
+ }));
+ expect(widths.viewport).toBeLessThanOrEqual(430);
+ expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
+});
diff --git a/tests/scan/render.test.ts b/tests/scan/render.test.ts
new file mode 100644
index 0000000..e571afb
--- /dev/null
+++ b/tests/scan/render.test.ts
@@ -0,0 +1,28 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { perspectiveCorrectPixels } from "../../src/scan/render";
+import { defaultAdjustments } from "../../src/scan/types";
+
+class TestImageData {
+ data: Uint8ClampedArray;
+ width: number;
+ height: number;
+ constructor(width: number, height: number) {
+ this.width = width;
+ this.height = height;
+ this.data = new Uint8ClampedArray(width * height * 4);
+ }
+}
+
+describe("scan render core", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("maps an identity quad without changing corner colours", () => {
+ vi.stubGlobal("ImageData", TestImageData);
+ const source = new TestImageData(2, 2) as unknown as ImageData;
+ source.data.set([
+ 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
+ ]);
+ const result = perspectiveCorrectPixels(source, 2, 2, defaultAdjustments());
+ expect([...result.data]).toEqual([...source.data]);
+ });
+});