1 Commits
Author SHA1 Message Date
zemion 8f34d05b75 Release Scan Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 10:57:17 +02:00
29 changed files with 1000 additions and 136 deletions
+39
View File
@@ -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
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Add bounded multi-image and camera-picker ingestion with real decoded-pixel checks - Add bounded multi-image and camera-picker ingestion with real decoded-pixel checks
+9 -4
View File
@@ -4,23 +4,27 @@ Correct photographed document pages, assemble them, optionally recognize
English text, and export images or PDFs without sending content away. Scan English text, and export images or PDFs without sending content away. Scan
Tools is a local-first module in the add·ideas Toolbox. 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 - Add up to 24 browser-decodable PNG, JPEG or WebP images through a file or
camera-enabled picker camera-enabled picker
- Drag four document corners to apply an actual projective correction; handles - 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 - Rotate in quarter turns, adjust deskew manually, or estimate a text-line
correction within ±5 degrees correction within ±5 degrees
- Tune brightness and contrast, retain colour, convert to grayscale, or create - Tune brightness and contrast, retain colour, convert to grayscale, or create
black-and-white pages using Otsu or a manual threshold black-and-white pages using Otsu or a manual threshold
- Preserve the previous preview until its debounced replacement is ready - 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, - Reorder pages with drag and drop, remove them without touching source files,
and export the selected page as PNG or JPEG and export the selected page as PNG or JPEG
- Assemble every corrected page into A4, US Letter, or source-sized image-only - Assemble every corrected page into A4, US Letter, or source-sized image-only
PDFs with a configurable margin PDFs with a configurable margin
- Run optional English OCR using a bundled integer model and same-origin - 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/ - Work at nested paths, install as an offline PWA, use the shared system/light/
dark themes, and perform no remote content request 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 This first release does not automatically detect page corners, remove fingers
or shadows, process input PDFs, recognize handwriting reliably, or create a 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; accuracy guarantee. Perspective correction is high-quality bilinear resampling;
each new render is lossy unless exported as PNG. each new render is lossy unless exported as PNG.
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # 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`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # 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 | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@tesseract.js-data/eng` | 1.0.0 | MIT | | `@tesseract.js-data/eng` | 1.0.0 | MIT |
| `jspdf` | 4.2.1 | MIT | | `jspdf` | 4.2.1 | MIT |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
+18 -7
View File
@@ -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 decoded with `createImageBitmap`; its dimensions and byte size are checked before
it joins the workspace. it joins the workspace.
The renderer downscales exceptionally large source rasters to a 24-megapixel The renderer uses a disposable module worker and `OffscreenCanvas` when the
working ceiling. A projective map sends every destination pixel through the four browser supports them. It downscales exceptionally large source rasters to a
selected source corners and samples it bilinearly. A bounded pixel pass applies 24-megapixel working ceiling, then a projective map sends every destination
brightness, contrast, perceptual grayscale and optional Otsu/manual threshold. pixel through the four selected source corners and samples it bilinearly. A
Canvas rotation handles quarter turns and the final ±5 degree deskew. Preview, bounded pixel pass applies brightness, contrast, perceptual grayscale and
OCR and export call the same renderer with different disclosed pixel budgets, so optional Otsu/manual threshold. Canvas rotation handles quarter turns and the
the preview semantics match the downloaded result. 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 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 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 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 disabled, one in-memory worker is reused and can be terminated, and no fallback
CDN path is used. 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.
+22 -23
View File
@@ -1,17 +1,17 @@
{ {
"name": "scan-tools", "name": "scan-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scan-tools", "name": "scan-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@tesseract.js-data/eng": "1.0.0", "@tesseract.js-data/eng": "1.0.0",
"jspdf": "4.2.1", "jspdf": "4.2.1",
"react": "19.2.8", "react": "19.2.8",
@@ -19,7 +19,7 @@
"tesseract.js": "7.0.0" "tesseract.js": "7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -45,24 +45,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"engines": { "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"node": ">=22" "license": "GPL-3.0-or-later"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "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", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -70,17 +70,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "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, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "scan-tools", "name": "scan-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Correct, assemble, recognize and export scanned pages locally in the browser.", "description": "Correct, assemble, recognize and export scanned pages locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -40,9 +40,9 @@
"ocr:prepare": "node scripts/prepare-ocr-assets.mjs" "ocr:prepare": "node scripts/prepare-ocr-assets.mjs"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@tesseract.js-data/eng": "1.0.0", "@tesseract.js-data/eng": "1.0.0",
"jspdf": "4.2.1", "jspdf": "4.2.1",
"react": "19.2.8", "react": "19.2.8",
@@ -50,7 +50,7 @@
"tesseract.js": "7.0.0" "tesseract.js": "7.0.0"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Add bounded multi-image and camera-picker ingestion with real decoded-pixel checks - Add bounded multi-image and camera-picker ingestion with real decoded-pixel checks
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+9 -4
View File
@@ -4,23 +4,27 @@ Correct photographed document pages, assemble them, optionally recognize
English text, and export images or PDFs without sending content away. Scan English text, and export images or PDFs without sending content away. Scan
Tools is a local-first module in the add·ideas Toolbox. 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 - Add up to 24 browser-decodable PNG, JPEG or WebP images through a file or
camera-enabled picker camera-enabled picker
- Drag four document corners to apply an actual projective correction; handles - 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 - Rotate in quarter turns, adjust deskew manually, or estimate a text-line
correction within ±5 degrees correction within ±5 degrees
- Tune brightness and contrast, retain colour, convert to grayscale, or create - Tune brightness and contrast, retain colour, convert to grayscale, or create
black-and-white pages using Otsu or a manual threshold black-and-white pages using Otsu or a manual threshold
- Preserve the previous preview until its debounced replacement is ready - 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, - Reorder pages with drag and drop, remove them without touching source files,
and export the selected page as PNG or JPEG and export the selected page as PNG or JPEG
- Assemble every corrected page into A4, US Letter, or source-sized image-only - Assemble every corrected page into A4, US Letter, or source-sized image-only
PDFs with a configurable margin PDFs with a configurable margin
- Run optional English OCR using a bundled integer model and same-origin - 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/ - Work at nested paths, install as an offline PWA, use the shared system/light/
dark themes, and perform no remote content request 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 This first release does not automatically detect page corners, remove fingers
or shadows, process input PDFs, recognize handwriting reliably, or create a 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; accuracy guarantee. Perspective correction is high-quality bilinear resampling;
each new render is lossy unless exported as PNG. each new render is lossy unless exported as PNG.
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # 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`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # 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 | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@tesseract.js-data/eng` | 1.0.0 | MIT | | `@tesseract.js-data/eng` | 1.0.0 | MIT |
| `jspdf` | 4.2.1 | MIT | | `jspdf` | 4.2.1 | MIT |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
+18 -7
View File
@@ -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 decoded with `createImageBitmap`; its dimensions and byte size are checked before
it joins the workspace. it joins the workspace.
The renderer downscales exceptionally large source rasters to a 24-megapixel The renderer uses a disposable module worker and `OffscreenCanvas` when the
working ceiling. A projective map sends every destination pixel through the four browser supports them. It downscales exceptionally large source rasters to a
selected source corners and samples it bilinearly. A bounded pixel pass applies 24-megapixel working ceiling, then a projective map sends every destination
brightness, contrast, perceptual grayscale and optional Otsu/manual threshold. pixel through the four selected source corners and samples it bilinearly. A
Canvas rotation handles quarter turns and the final ±5 degree deskew. Preview, bounded pixel pass applies brightness, contrast, perceptual grayscale and
OCR and export call the same renderer with different disclosed pixel budgets, so optional Otsu/manual threshold. Canvas rotation handles quarter turns and the
the preview semantics match the downloaded result. 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 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 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 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 disabled, one in-memory worker is reused and can be terminated, and no fallback
CDN path is used. 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.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "scan-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+22 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.scan-tools", "id": "de.add-ideas.scan-tools",
"name": "Scan Tools", "name": "Scan Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Correct, assemble, recognize and export scanned pages locally.", "description": "Correct, assemble, recognize and export scanned pages locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["document", "image", "productivity"], "categories": ["document", "image", "productivity"],
"tags": ["scan", "camera", "deskew", "perspective", "ocr", "pdf"], "tags": ["scan", "camera", "deskew", "perspective", "ocr", "hocr", "pdf"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -16,11 +16,30 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+270 -44
View File
@@ -149,6 +149,55 @@ function SourceCorners({
))} ))}
</svg> </svg>
</div> </div>
<div className="corner-fields" aria-label="Precise corner coordinates">
{page.adjustments.quad.map((point, index) => (
<fieldset key={index}>
<legend>Corner {index + 1}</legend>
<label>
X (%)
<input
type="number"
min="0"
max="100"
step="0.1"
aria-label={`Corner ${index + 1} X (%)`}
value={Number((point.x * 100).toFixed(2))}
onChange={(event) => {
if (!Number.isFinite(event.target.valueAsNumber)) return;
update({
...page.adjustments,
quad: moveQuadPoint(page.adjustments.quad, index, {
x: event.target.valueAsNumber / 100,
y: point.y,
}),
});
}}
/>
</label>
<label>
Y (%)
<input
type="number"
min="0"
max="100"
step="0.1"
aria-label={`Corner ${index + 1} Y (%)`}
value={Number((point.y * 100).toFixed(2))}
onChange={(event) => {
if (!Number.isFinite(event.target.valueAsNumber)) return;
update({
...page.adjustments,
quad: moveQuadPoint(page.adjustments.quad, index, {
x: point.x,
y: event.target.valueAsNumber / 100,
}),
});
}}
/>
</label>
</fieldset>
))}
</div>
</section> </section>
); );
} }
@@ -158,8 +207,14 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
const [state, setState] = useState("Rendering preview…"); const [state, setState] = useState("Rendering preview…");
useEffect(() => { useEffect(() => {
let current = true; let current = true;
const controller = new AbortController();
const timeout = window.setTimeout(() => { 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) => { .then((rendered) => {
if (!current || !canvasRef.current) return; if (!current || !canvasRef.current) return;
const target = canvasRef.current; const target = canvasRef.current;
@@ -171,7 +226,10 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
); );
}) })
.catch((reason: unknown) => { .catch((reason: unknown) => {
if (current) if (
current &&
!(reason instanceof DOMException && reason.name === "AbortError")
)
setState( setState(
reason instanceof Error ? reason.message : "Preview failed.", reason instanceof Error ? reason.message : "Preview failed.",
); );
@@ -179,6 +237,7 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
}, 120); }, 120);
return () => { return () => {
current = false; current = false;
controller.abort();
window.clearTimeout(timeout); window.clearTimeout(timeout);
}; };
}, [page]); }, [page]);
@@ -189,10 +248,34 @@ function ProcessedPreview({ page }: { page: ScanPage }) {
{state} {state}
</p> </p>
<div className="processed-stage"> <div className="processed-stage">
<canvas <div className="processed-image">
ref={canvasRef} <canvas
aria-label={`Processed preview of ${page.name}`} ref={canvasRef}
/> aria-label={`Processed preview of ${page.name}`}
/>
{page.ocrWords?.length ? (
<svg
className="ocr-overlay"
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-label={`${page.ocrWords.length} positioned OCR words`}
>
{page.ocrWords.slice(0, 2_000).map((word, index) => (
<rect
key={`${index}-${word.text}`}
x={word.bbox.x0 * 100}
y={word.bbox.y0 * 100}
width={(word.bbox.x1 - word.bbox.x0) * 100}
height={(word.bbox.y1 - word.bbox.y0) * 100}
>
<title>
{word.text} · {word.confidence.toFixed(1)}%
</title>
</rect>
))}
</svg>
) : null}
</div>
</div> </div>
</section> </section>
); );
@@ -263,6 +346,10 @@ export function Workbench() {
"Add photographs or image files to begin.", "Add photographs or image files to begin.",
); );
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [pixelProgress, setPixelProgress] = useState<{
value: number;
stage: string;
}>();
const [ocrConsent, setOcrConsent] = useState(false); const [ocrConsent, setOcrConsent] = useState(false);
const [ocrProgress, setOcrProgress] = useState<{ const [ocrProgress, setOcrProgress] = useState<{
status: string; status: string;
@@ -270,10 +357,12 @@ export function Workbench() {
}>(); }>();
const [pdfSize, setPdfSize] = useState<PdfPageSize>("a4"); const [pdfSize, setPdfSize] = useState<PdfPageSize>("a4");
const [pdfMargin, setPdfMargin] = useState(18); const [pdfMargin, setPdfMargin] = useState(18);
const processing = useRef<AbortController | undefined>(undefined);
const selected = pages.find((page) => page.id === selectedId) ?? pages[0]; const selected = pages.find((page) => page.id === selectedId) ?? pages[0];
useEffect( useEffect(
() => () => { () => () => {
processing.current?.abort();
void closeOcr().catch(() => undefined); void closeOcr().catch(() => undefined);
}, },
[], [],
@@ -283,6 +372,38 @@ export function Workbench() {
[pages], [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<HTMLInputElement>) => { const addFiles = async (event: ChangeEvent<HTMLInputElement>) => {
const files = [...(event.target.files ?? [])]; const files = [...(event.target.files ?? [])];
event.target.value = ""; event.target.value = "";
@@ -343,7 +464,15 @@ export function Workbench() {
if (!selected) return; if (!selected) return;
setPages((current) => setPages((current) =>
current.map((page) => 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 () => { const runDeskew = async () => {
if (!selected) return; if (!selected) return;
setBusy(true); const controller = beginProcessing("Preparing deskew detection");
setError(undefined); setError(undefined);
setNotice("Estimating text-line angle locally…"); setNotice("Estimating text-line angle locally…");
try { try {
const angle = await detectDeskew(selected); const angle = await detectDeskew(selected, {
signal: controller.signal,
onProgress: reportProcessing,
});
patchSelected({ deskew: angle }); patchSelected({ deskew: angle });
setNotice( setNotice(
angle === 0 angle === 0
@@ -365,44 +497,70 @@ export function Workbench() {
: `Applied an estimated ${angle.toFixed(1)}° correction.`, : `Applied an estimated ${angle.toFixed(1)}° correction.`,
); );
} catch (reason) { } catch (reason) {
setError( if (
reason instanceof Error ? reason.message : "Deskew estimation failed.", !controller.signal.aborted &&
); !(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
reason instanceof Error
? reason.message
: "Deskew estimation failed.",
);
} finally { } finally {
setBusy(false); finishProcessing(controller);
} }
}; };
const exportCurrentImage = async (type: "image/png" | "image/jpeg") => { const exportCurrentImage = async (type: "image/png" | "image/jpeg") => {
if (!selected) return; if (!selected) return;
setBusy(true); const controller = beginProcessing("Preparing image export");
setError(undefined); setError(undefined);
setNotice(`Rendering ${type === "image/png" ? "PNG" : "JPEG"} locally…`); setNotice(`Rendering ${type === "image/png" ? "PNG" : "JPEG"} locally…`);
try { try {
await exportImage(selected, type); await exportImage(selected, type, {
signal: controller.signal,
onProgress: reportProcessing,
});
setNotice( setNotice(
`Rendered ${selected.name} within the 10-megapixel export budget.`, `Rendered ${selected.name} within the 10-megapixel export budget.`,
); );
} catch (reason) { } catch (reason) {
setError( if (
`${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`, !controller.signal.aborted &&
); !(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
`${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`,
);
} finally { } finally {
setBusy(false); finishProcessing(controller);
} }
}; };
const runOcr = async () => { const runOcr = async () => {
if (!selected || !ocrConsent) return; if (!selected || !ocrConsent) return;
setBusy(true); const controller = beginProcessing("Preparing OCR image");
setError(undefined); setError(undefined);
setOcrProgress({ status: "Preparing local OCR", progress: 0 }); setOcrProgress({ status: "Preparing local OCR", progress: 0 });
try { 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 image = await canvasBlob(rendered.canvas, "image/png");
const result = await recognizeEnglish(image, (status, progress) => const result = await recognizeEnglish(image, (status, progress) =>
setOcrProgress({ 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) => setPages((current) =>
current.map((page) => current.map((page) =>
page.id === selected.id page.id === selected.id
@@ -410,6 +568,9 @@ export function Workbench() {
...page, ...page,
ocrText: result.text, ocrText: result.text,
ocrConfidence: result.confidence, ocrConfidence: result.confidence,
ocrWords,
ocrHocr: result.hocr,
ocrTsv: result.tsv,
} }
: page, : page,
), ),
@@ -418,17 +579,21 @@ export function Workbench() {
`English OCR finished with ${result.confidence.toFixed(1)}% engine confidence.`, `English OCR finished with ${result.confidence.toFixed(1)}% engine confidence.`,
); );
} catch (reason) { } catch (reason) {
setError( if (
`${reason instanceof Error ? reason.message : "OCR failed."} The page and previous text were retained.`, !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 { } finally {
setBusy(false); finishProcessing(controller);
setOcrProgress(undefined); setOcrProgress(undefined);
} }
}; };
const exportPdf = async () => { const exportPdf = async () => {
setBusy(true); const controller = beginProcessing("Preparing PDF export");
setError(undefined); setError(undefined);
try { try {
const pdf = await buildPdf( const pdf = await buildPdf(
@@ -437,15 +602,23 @@ export function Workbench() {
pdfMargin, pdfMargin,
(completed, total) => (completed, total) =>
setNotice(`Rendering PDF page ${completed} of ${total}`), setNotice(`Rendering PDF page ${completed} of ${total}`),
{
signal: controller.signal,
onProgress: reportProcessing,
},
); );
triggerBlobDownload(pdf, "local-scan.pdf"); triggerBlobDownload(pdf, "local-scan.pdf");
setNotice(`Created a ${formatBytes(pdf.size)} image-only PDF locally.`); setNotice(`Created a ${formatBytes(pdf.size)} image-only PDF locally.`);
} catch (reason) { } catch (reason) {
setError( if (
`${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`, !controller.signal.aborted &&
); !(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
`${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`,
);
} finally { } finally {
setBusy(false); finishProcessing(controller);
} }
}; };
@@ -478,6 +651,17 @@ export function Workbench() {
<p className="status-line" role="status"> <p className="status-line" role="status">
{notice} {notice}
</p> </p>
{pixelProgress ? (
<div className="button-row" aria-live="polite">
<label>
{pixelProgress.stage}{" "}
<progress value={pixelProgress.value} max="1" />
</label>
<button type="button" onClick={cancelProcessing}>
Cancel processing
</button>
</div>
) : null}
{error ? ( {error ? (
<p className="error" role="alert"> <p className="error" role="alert">
{error} {error}
@@ -732,18 +916,7 @@ export function Workbench() {
Recognize selected page Recognize selected page
</button> </button>
{busy && ocrProgress ? ( {busy && ocrProgress ? (
<button <button type="button" onClick={cancelProcessing}>
type="button"
onClick={() =>
void cancelOcr().catch((reason: unknown) =>
setError(
reason instanceof Error
? reason.message
: "OCR cancellation failed.",
),
)
}
>
Cancel OCR Cancel OCR
</button> </button>
) : null} ) : null}
@@ -791,6 +964,36 @@ export function Workbench() {
> >
Download text Download text
</button> </button>
{selected.ocrHocr ? (
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([selected.ocrHocr ?? ""], {
type: "text/html;charset=utf-8",
}),
`${selected.name.replace(/\.[^.]+$/u, "")}.hocr.html`,
)
}
>
Download hOCR geometry
</button>
) : null}
{selected.ocrTsv ? (
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([selected.ocrTsv ?? ""], {
type: "text/tab-separated-values;charset=utf-8",
}),
`${selected.name.replace(/\.[^.]+$/u, "")}.tsv`,
)
}
>
Download OCR TSV
</button>
) : null}
</section> </section>
) : null} ) : null}
</section> </section>
@@ -829,9 +1032,32 @@ export function Workbench() {
> >
Download {pages.length}-page PDF Download {pages.length}-page PDF
</button> </button>
<button
type="button"
disabled={!pages.some((page) => 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
</button>
<p className="microcopy"> <p className="microcopy">
The PDF is image-only in v0.1. OCR text downloads separately; no The PDF stays image-only so text is never silently misaligned.
invisible text layer is guessed without reliable word geometry. Positioned OCR can be inspected and exported separately as hOCR or
TSV.
</p> </p>
</aside> </aside>
</div> </div>
+10 -4
View File
@@ -1,5 +1,5 @@
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers"; 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"; import { MAX_EXPORT_PIXELS, type ScanPage } from "./types";
export type PdfPageSize = "source" | "a4" | "letter"; export type PdfPageSize = "source" | "a4" | "letter";
@@ -7,8 +7,9 @@ export type PdfPageSize = "source" | "a4" | "letter";
export async function exportImage( export async function exportImage(
page: ScanPage, page: ScanPage,
type: "image/png" | "image/jpeg", 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 blob = await canvasBlob(rendered.canvas, type);
const extension = type === "image/png" ? "png" : "jpg"; const extension = type === "image/png" ? "png" : "jpg";
triggerBlobDownload( triggerBlobDownload(
@@ -32,12 +33,13 @@ export async function buildPdf(
pageSize: PdfPageSize, pageSize: PdfPageSize,
marginPoints: number, marginPoints: number,
onProgress: (completed: number, total: number) => void, onProgress: (completed: number, total: number) => void,
options: RenderTaskOptions = {},
): Promise<Blob> { ): Promise<Blob> {
if (pages.length === 0) if (pages.length === 0)
throw new RangeError("Add at least one page before exporting a PDF."); throw new RangeError("Add at least one page before exporting a PDF.");
const [{ jsPDF }, first] = await Promise.all([ const [{ jsPDF }, first] = await Promise.all([
import("jspdf"), import("jspdf"),
renderPage(pages[0]!, MAX_EXPORT_PIXELS), renderPage(pages[0]!, MAX_EXPORT_PIXELS, options),
]); ]);
const firstSize = pagePoints( const firstSize = pagePoints(
pageSize, pageSize,
@@ -53,7 +55,9 @@ export async function buildPdf(
}); });
for (let index = 0; index < pages.length; index += 1) { for (let index = 0; index < pages.length; index += 1) {
const rendered = 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( const dimensions = pagePoints(
pageSize, pageSize,
rendered.canvas.width, rendered.canvas.width,
@@ -85,6 +89,8 @@ export async function buildPdf(
"FAST", "FAST",
); );
onProgress(index + 1, pages.length); onProgress(index + 1, pages.length);
if (options.signal?.aborted)
throw new DOMException("PDF export cancelled.", "AbortError");
await new Promise<void>((resolve) => await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()), requestAnimationFrame(() => resolve()),
); );
+33 -4
View File
@@ -1,4 +1,4 @@
import type { Worker } from "tesseract.js"; import type { Bbox, Worker } from "tesseract.js";
let activeWorker: Worker | undefined; let activeWorker: Worker | undefined;
let creation: Promise<Worker> | undefined; let creation: Promise<Worker> | undefined;
@@ -38,10 +38,39 @@ async function worker(onProgress: (status: string, progress: number) => void) {
export async function recognizeEnglish( export async function recognizeEnglish(
image: Blob, image: Blob,
onProgress: (status: string, progress: number) => void, 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 instance = await worker(onProgress);
const result = await instance.recognize(image); const result = await instance.recognize(
return { text: result.data.text, confidence: result.data.confidence }; 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<void> { export async function cancelOcr(): Promise<void> {
+155 -6
View File
@@ -6,7 +6,12 @@ import {
type ScanPage, type ScanPage,
} from "./types"; } 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 { function canvas(width: number, height: number): HTMLCanvasElement {
const element = document.createElement("canvas"); const element = document.createElement("canvas");
@@ -61,7 +66,7 @@ function sampleBilinear(
return top * (1 - fy) + bottom * fy; return top * (1 - fy) + bottom * fy;
} }
function perspectiveCorrect( export function perspectiveCorrectPixels(
source: ImageData, source: ImageData,
outputWidth: number, outputWidth: number,
outputHeight: number, outputHeight: number,
@@ -156,9 +161,16 @@ export interface RenderedPage {
export async function renderPage( export async function renderPage(
page: ScanPage, page: ScanPage,
maximumPixels: number, maximumPixels: number,
options: RenderTaskOptions = {},
): Promise<RenderedPage> { ): Promise<RenderedPage> {
if (canRenderInWorker())
return renderPageInWorker(page, maximumPixels, options);
throwIfAborted(options.signal);
options.onProgress?.(0.05, "Decoding page");
const bitmap = await createImageBitmap(page.source); const bitmap = await createImageBitmap(page.source);
try { try {
throwIfAborted(options.signal);
options.onProgress?.(0.2, "Preparing source pixels");
const sourceScale = Math.min( const sourceScale = Math.min(
1, 1,
Math.sqrt(MAX_WORKING_SOURCE_PIXELS / (bitmap.width * bitmap.height)), 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); const corrected = canvas(dimensions.width, dimensions.height);
context(corrected).putImageData( context(corrected).putImageData(
perspectiveCorrect( perspectiveCorrectPixels(
sourceContext.getImageData( sourceContext.getImageData(
0, 0,
0, 0,
@@ -197,23 +209,34 @@ export async function renderPage(
0, 0,
0, 0,
); );
throwIfAborted(options.signal);
options.onProgress?.(0.8, "Rotating corrected page");
const rotated = rotate( const rotated = rotate(
corrected, corrected,
page.adjustments.quarterTurns * 90 + page.adjustments.deskew, page.adjustments.quarterTurns * 90 + page.adjustments.deskew,
); );
return { const result = {
canvas: boundPixels(rotated, maximumPixels), canvas: boundPixels(rotated, maximumPixels),
sourceScale, sourceScale,
outputScale: dimensions.scale, outputScale: dimensions.scale,
}; };
options.onProgress?.(1, "Page ready");
return result;
} finally { } finally {
bitmap.close(); bitmap.close();
} }
} }
export async function detectDeskew(page: ScanPage): Promise<number> { export async function detectDeskew(
page: ScanPage,
options: RenderTaskOptions = {},
): Promise<number> {
if (canRenderInWorker()) return detectDeskewInWorker(page, options);
throwIfAborted(options.signal);
options.onProgress?.(0.1, "Decoding deskew sample");
const bitmap = await createImageBitmap(page.source); const bitmap = await createImageBitmap(page.source);
try { try {
throwIfAborted(options.signal);
const scale = Math.min(1, 420 / Math.max(bitmap.width, bitmap.height)); const scale = Math.min(1, 420 / Math.max(bitmap.width, bitmap.height));
const work = canvas( const work = canvas(
Math.max(16, Math.round(bitmap.width * scale)), Math.max(16, Math.round(bitmap.width * scale)),
@@ -221,16 +244,142 @@ export async function detectDeskew(page: ScanPage): Promise<number> {
); );
const workContext = context(work); const workContext = context(work);
workContext.drawImage(bitmap, 0, 0, work.width, work.height); 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, workContext.getImageData(0, 0, work.width, work.height).data,
work.width, work.width,
work.height, work.height,
); );
throwIfAborted(options.signal);
options.onProgress?.(1, "Deskew estimate ready");
return angle;
} finally { } finally {
bitmap.close(); 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<RenderedPage> {
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<number> {
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<RenderWorkerResponse> {
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<RenderWorkerResponse>) => {
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( export function canvasBlob(
source: HTMLCanvasElement, source: HTMLCanvasElement,
type: "image/png" | "image/jpeg", type: "image/png" | "image/jpeg",
+185
View File
@@ -0,0 +1,185 @@
/// <reference lib="webworker" />
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<Request>) => {
void run(event.data);
};
async function run(request: Request): Promise<void> {
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 {};
+10
View File
@@ -33,6 +33,16 @@ export interface ScanPage {
adjustments: PageAdjustments; adjustments: PageAdjustments;
ocrText?: string; ocrText?: string;
ocrConfidence?: number; 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 = [ export const DEFAULT_QUAD: Quad = [
+54 -1
View File
@@ -309,9 +309,46 @@ button:disabled {
text-anchor: middle; text-anchor: middle;
dominant-baseline: central; 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 { .processed-stage {
min-height: 12rem; 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 { .processed-stage canvas {
display: block; display: block;
max-width: 100%; max-width: 100%;
@@ -319,6 +356,18 @@ button:disabled {
width: auto; width: auto;
height: 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 { .controls-grid {
display: grid; display: grid;
@@ -429,6 +478,9 @@ button:disabled {
} }
@media (max-width: 72rem) { @media (max-width: 72rem) {
.corner-fields {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-grid { .workspace-grid {
grid-template-columns: 14rem minmax(0, 1fr); grid-template-columns: 14rem minmax(0, 1fr);
} }
@@ -474,7 +526,8 @@ button:disabled {
flex-direction: column; flex-direction: column;
} }
.controls-grid, .controls-grid,
.export-card { .export-card,
.corner-fields {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.export-card > :is(.eyebrow, h2, .microcopy) { .export-card > :is(.eyebrow, h2, .microcopy) {
+22 -3
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.scan-tools", "id": "de.add-ideas.scan-tools",
"name": "Scan Tools", "name": "Scan Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Correct, assemble, recognize and export scanned pages locally.", "description": "Correct, assemble, recognize and export scanned pages locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
"categories": ["document", "image", "productivity"], "categories": ["document", "image", "productivity"],
"tags": ["scan", "camera", "deskew", "perspective", "ocr", "pdf"], "tags": ["scan", "camera", "deskew", "perspective", "ocr", "hocr", "pdf"],
"integration": { "integration": {
"contextVersion": 1, "contextVersion": 1,
"launchModes": ["navigate", "new-tab"], "launchModes": ["navigate", "new-tab"],
@@ -16,11 +16,30 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+18 -2
View File
@@ -82,6 +82,11 @@ test("adds and corrects a real local image while retaining the previous preview"
"aria-valuetext", "aria-valuetext",
/^1% from left/u, /^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"); const download = page.waitForEvent("download");
await page.getByRole("button", { name: "Download PNG" }).click(); await page.getByRole("button", { name: "Download PNG" }).click();
expect((await download).suggestedFilename()).toBe("page-scan.png"); 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, timeout: 90_000,
}); });
await expect(page.getByLabel("Recognized text")).toContainText(/LOCAL|SCAN/u); 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([]); expect(external).toEqual([]);
}); });
@@ -191,8 +203,12 @@ test("integrates help, themes, identity, headers and offline reload", async ({
); );
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.scan-tools", id: "de.add-ideas.scan-tools",
version: "0.1.0", version: "0.2.0",
requirements: { workers: true }, requirements: { workers: false },
capabilities: {
required: [],
optional: expect.arrayContaining(["workers"]),
},
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
await context.setOffline(true); await context.setOffline(true);
+18
View File
@@ -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);
});
+28
View File
@@ -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]);
});
});