From 8f34d05b758ae2719cf313c343f6808b92905ade Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 2 Sep 2026 10:57:17 +0200 Subject: [PATCH] Release Scan Tools 0.2.0 --- .gitea/workflows/verify.yml | 39 +++ CHANGELOG.md | 9 + README.md | 13 +- SOURCE.md | 4 +- THIRD_PARTY_NOTICES.md | 8 +- docs/ARCHITECTURE.md | 25 +- package-lock.json | 45 ++-- package.json | 10 +- playwright.config.ts | 22 +- public/CHANGELOG.md | 9 + public/LICENSES/npm-runtime-licenses.txt | 6 +- public/README.md | 13 +- public/SOURCE.md | 4 +- public/THIRD_PARTY_NOTICES.md | 8 +- public/docs/ARCHITECTURE.md | 25 +- public/sw.js | 2 +- public/toolbox-app.json | 25 +- src/components/Workbench.tsx | 314 +++++++++++++++++++---- src/scan/export.ts | 14 +- src/scan/ocr.ts | 37 ++- src/scan/render.ts | 161 +++++++++++- src/scan/render.worker.ts | 185 +++++++++++++ src/scan/types.ts | 10 + src/styles.css | 55 +++- src/toolbox/manifest.source.json | 25 +- src/version.ts | 2 +- tests/browser/app.spec.ts | 20 +- tests/browser/responsive.spec.ts | 18 ++ tests/scan/render.test.ts | 28 ++ 29 files changed, 1000 insertions(+), 136 deletions(-) create mode 100644 .gitea/workflows/verify.yml create mode 100644 src/scan/render.worker.ts create mode 100644 tests/browser/responsive.spec.ts create mode 100644 tests/scan/render.test.ts 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} + + +
+ ))} +
); } @@ -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 ? ( +
+ + +
+ ) : null} {error ? (

{error} @@ -732,18 +916,7 @@ export function Workbench() { Recognize selected page {busy && ocrProgress ? ( - ) : null} @@ -791,6 +964,36 @@ export function Workbench() { > Download text + {selected.ocrHocr ? ( + + ) : null} + {selected.ocrTsv ? ( + + ) : null} ) : null} @@ -829,9 +1032,32 @@ export function Workbench() { > Download {pages.length}-page PDF +

- 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]); + }); +});