Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f34d05b75 |
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Generated
+22
-23
@@ -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
@@ -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
@@ -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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
+255
-29
@@ -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">
|
||||||
|
<div className="processed-image">
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
aria-label={`Processed preview of ${page.name}`}
|
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) {
|
||||||
|
if (
|
||||||
|
!controller.signal.aborted &&
|
||||||
|
!(reason instanceof DOMException && reason.name === "AbortError")
|
||||||
|
)
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error ? reason.message : "Deskew estimation failed.",
|
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) {
|
||||||
|
if (
|
||||||
|
!controller.signal.aborted &&
|
||||||
|
!(reason instanceof DOMException && reason.name === "AbortError")
|
||||||
|
)
|
||||||
setError(
|
setError(
|
||||||
`${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`,
|
`${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) {
|
||||||
|
if (
|
||||||
|
!controller.signal.aborted &&
|
||||||
|
!(reason instanceof DOMException && reason.name === "AbortError")
|
||||||
|
)
|
||||||
setError(
|
setError(
|
||||||
`${reason instanceof Error ? reason.message : "OCR failed."} The page and previous text were retained.`,
|
`${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) {
|
||||||
|
if (
|
||||||
|
!controller.signal.aborted &&
|
||||||
|
!(reason instanceof DOMException && reason.name === "AbortError")
|
||||||
|
)
|
||||||
setError(
|
setError(
|
||||||
`${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`,
|
`${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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 {};
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user