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
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+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
|
||||
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.
|
||||
|
||||
Generated
+22
-23
@@ -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": {
|
||||
|
||||
+5
-5
@@ -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",
|
||||
|
||||
+20
-2
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
+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
|
||||
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.
|
||||
|
||||
|
||||
+2
-2
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+22
-3
@@ -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,
|
||||
|
||||
+255
-29
@@ -149,6 +149,55 @@ function SourceCorners({
|
||||
))}
|
||||
</svg>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</p>
|
||||
<div className="processed-stage">
|
||||
<div className="processed-image">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-label={`Processed preview of ${page.name}`}
|
||||
/>
|
||||
{page.ocrWords?.length ? (
|
||||
<svg
|
||||
className="ocr-overlay"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
aria-label={`${page.ocrWords.length} positioned OCR words`}
|
||||
>
|
||||
{page.ocrWords.slice(0, 2_000).map((word, index) => (
|
||||
<rect
|
||||
key={`${index}-${word.text}`}
|
||||
x={word.bbox.x0 * 100}
|
||||
y={word.bbox.y0 * 100}
|
||||
width={(word.bbox.x1 - word.bbox.x0) * 100}
|
||||
height={(word.bbox.y1 - word.bbox.y0) * 100}
|
||||
>
|
||||
<title>
|
||||
{word.text} · {word.confidence.toFixed(1)}%
|
||||
</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -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<PdfPageSize>("a4");
|
||||
const [pdfMargin, setPdfMargin] = useState(18);
|
||||
const processing = useRef<AbortController | undefined>(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<HTMLInputElement>) => {
|
||||
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) {
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
!(reason instanceof DOMException && reason.name === "AbortError")
|
||||
)
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Deskew estimation failed.",
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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() {
|
||||
<p className="status-line" role="status">
|
||||
{notice}
|
||||
</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 ? (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
@@ -732,18 +916,7 @@ export function Workbench() {
|
||||
Recognize selected page
|
||||
</button>
|
||||
{busy && ocrProgress ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void cancelOcr().catch((reason: unknown) =>
|
||||
setError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "OCR cancellation failed.",
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
<button type="button" onClick={cancelProcessing}>
|
||||
Cancel OCR
|
||||
</button>
|
||||
) : null}
|
||||
@@ -791,6 +964,36 @@ export function Workbench() {
|
||||
>
|
||||
Download text
|
||||
</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>
|
||||
) : null}
|
||||
</section>
|
||||
@@ -829,9 +1032,32 @@ export function Workbench() {
|
||||
>
|
||||
Download {pages.length}-page PDF
|
||||
</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">
|
||||
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.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -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<Blob> {
|
||||
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<void>((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 creation: Promise<Worker> | 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<void> {
|
||||
|
||||
+155
-6
@@ -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<RenderedPage> {
|
||||
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<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);
|
||||
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<number> {
|
||||
);
|
||||
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<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(
|
||||
source: HTMLCanvasElement,
|
||||
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;
|
||||
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 = [
|
||||
|
||||
+54
-1
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+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",
|
||||
/^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);
|
||||
|
||||
@@ -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