1 Commits
Author SHA1 Message Date
zemion 8f34d05b75 Release Scan Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 10:57:17 +02:00
29 changed files with 1000 additions and 136 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 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
+9 -4
View File
@@ -4,23 +4,27 @@ Correct photographed document pages, assemble them, optionally recognize
English text, and export images or PDFs without sending content away. Scan
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
View File
@@ -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`.
+4 -4
View File
@@ -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
View File
@@ -5,13 +5,17 @@ serializable page settings. No page content enters local storage. Each source is
decoded with `createImageBitmap`; its dimensions and byte size are checked before
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.
+22 -23
View File
@@ -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
View File
@@ -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
View File
@@ -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"] },
},
],
});
+9
View File
@@ -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
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- 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
View File
@@ -4,23 +4,27 @@ Correct photographed document pages, assemble them, optionally recognize
English text, and export images or PDFs without sending content away. Scan
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
View File
@@ -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`.
+4 -4
View File
@@ -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
View File
@@ -5,13 +5,17 @@ serializable page settings. No page content enters local storage. Each source is
decoded with `createImageBitmap`; its dimensions and byte size are checked before
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
View File
@@ -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
View File
@@ -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,
+270 -44
View File
@@ -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">
<canvas
ref={canvasRef}
aria-label={`Processed preview of ${page.name}`}
/>
<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) {
setError(
reason instanceof Error ? reason.message : "Deskew estimation failed.",
);
if (
!controller.signal.aborted &&
!(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
reason instanceof Error
? reason.message
: "Deskew estimation failed.",
);
} finally {
setBusy(false);
finishProcessing(controller);
}
};
const exportCurrentImage = async (type: "image/png" | "image/jpeg") => {
if (!selected) return;
setBusy(true);
const controller = beginProcessing("Preparing image export");
setError(undefined);
setNotice(`Rendering ${type === "image/png" ? "PNG" : "JPEG"} locally…`);
try {
await exportImage(selected, type);
await exportImage(selected, type, {
signal: controller.signal,
onProgress: reportProcessing,
});
setNotice(
`Rendered ${selected.name} within the 10-megapixel export budget.`,
);
} catch (reason) {
setError(
`${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`,
);
if (
!controller.signal.aborted &&
!(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
`${reason instanceof Error ? reason.message : "Image export failed."} The workspace was retained.`,
);
} finally {
setBusy(false);
finishProcessing(controller);
}
};
const runOcr = async () => {
if (!selected || !ocrConsent) return;
setBusy(true);
const controller = beginProcessing("Preparing OCR image");
setError(undefined);
setOcrProgress({ status: "Preparing local OCR", progress: 0 });
try {
const rendered = await renderPage(selected, 4_000_000);
const rendered = await renderPage(selected, 4_000_000, {
signal: controller.signal,
onProgress: reportProcessing,
});
const image = await canvasBlob(rendered.canvas, "image/png");
const result = await recognizeEnglish(image, (status, progress) =>
setOcrProgress({ status, progress }),
);
const ocrWords = result.words.map((word) => ({
text: word.text,
confidence: word.confidence,
bbox: {
x0: Math.max(0, Math.min(1, word.bbox.x0 / rendered.canvas.width)),
y0: Math.max(0, Math.min(1, word.bbox.y0 / rendered.canvas.height)),
x1: Math.max(0, Math.min(1, word.bbox.x1 / rendered.canvas.width)),
y1: Math.max(0, Math.min(1, word.bbox.y1 / rendered.canvas.height)),
},
}));
setPages((current) =>
current.map((page) =>
page.id === selected.id
@@ -410,6 +568,9 @@ export function Workbench() {
...page,
ocrText: result.text,
ocrConfidence: result.confidence,
ocrWords,
ocrHocr: result.hocr,
ocrTsv: result.tsv,
}
: page,
),
@@ -418,17 +579,21 @@ export function Workbench() {
`English OCR finished with ${result.confidence.toFixed(1)}% engine confidence.`,
);
} catch (reason) {
setError(
`${reason instanceof Error ? reason.message : "OCR failed."} The page and previous text were retained.`,
);
if (
!controller.signal.aborted &&
!(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
`${reason instanceof Error ? reason.message : "OCR failed."} The page and previous text were retained.`,
);
} finally {
setBusy(false);
finishProcessing(controller);
setOcrProgress(undefined);
}
};
const exportPdf = async () => {
setBusy(true);
const controller = beginProcessing("Preparing PDF export");
setError(undefined);
try {
const pdf = await buildPdf(
@@ -437,15 +602,23 @@ export function Workbench() {
pdfMargin,
(completed, total) =>
setNotice(`Rendering PDF page ${completed} of ${total}`),
{
signal: controller.signal,
onProgress: reportProcessing,
},
);
triggerBlobDownload(pdf, "local-scan.pdf");
setNotice(`Created a ${formatBytes(pdf.size)} image-only PDF locally.`);
} catch (reason) {
setError(
`${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`,
);
if (
!controller.signal.aborted &&
!(reason instanceof DOMException && reason.name === "AbortError")
)
setError(
`${reason instanceof Error ? reason.message : "PDF export failed."} The workspace was retained.`,
);
} finally {
setBusy(false);
finishProcessing(controller);
}
};
@@ -478,6 +651,17 @@ export function Workbench() {
<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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+185
View File
@@ -0,0 +1,185 @@
/// <reference lib="webworker" />
import { correctedDimensions } from "./geometry";
import { estimateDeskew } from "./pixels";
import { MAX_WORKING_SOURCE_PIXELS, perspectiveCorrectPixels } from "./render";
import { MAX_SOURCE_PIXELS, type PageAdjustments } from "./types";
type Request =
| {
id: number;
task: "render";
source: File;
adjustments: PageAdjustments;
maximumPixels: number;
}
| { id: number; task: "deskew"; source: File };
declare const self: DedicatedWorkerGlobalScope;
self.onmessage = (event: MessageEvent<Request>) => {
void run(event.data);
};
async function run(request: Request): Promise<void> {
try {
progress(request.id, 0.05, "Decoding page");
const bitmap = await createImageBitmap(request.source);
try {
if (bitmap.width * bitmap.height > MAX_SOURCE_PIXELS)
throw new RangeError(
`Decoded image exceeds ${MAX_SOURCE_PIXELS.toLocaleString()} pixels.`,
);
if (request.task === "deskew") {
const angle = renderDeskewSample(request.id, bitmap);
self.postMessage({ id: request.id, kind: "deskew", angle });
} else {
const result = await render(
request.id,
bitmap,
request.adjustments,
request.maximumPixels,
);
self.postMessage({ id: request.id, kind: "render", ...result }, [
result.bitmap,
]);
}
} finally {
bitmap.close();
}
} catch (reason) {
self.postMessage({
id: request.id,
kind: "error",
error:
reason instanceof Error ? reason.message : "Page processing failed.",
});
}
}
async function render(
id: number,
bitmap: ImageBitmap,
adjustments: PageAdjustments,
maximumPixels: number,
) {
progress(id, 0.15, "Preparing source pixels");
const sourceScale = Math.min(
1,
Math.sqrt(MAX_WORKING_SOURCE_PIXELS / (bitmap.width * bitmap.height)),
);
const source = makeCanvas(
Math.max(1, Math.round(bitmap.width * sourceScale)),
Math.max(1, Math.round(bitmap.height * sourceScale)),
);
const sourceContext = context(source);
sourceContext.drawImage(bitmap, 0, 0, source.width, source.height);
const dimensions = correctedDimensions(
adjustments.quad,
source.width,
source.height,
maximumPixels,
);
progress(id, 0.3, "Correcting perspective and pixels");
const corrected = makeCanvas(dimensions.width, dimensions.height);
context(corrected).putImageData(
perspectiveCorrectPixels(
sourceContext.getImageData(0, 0, source.width, source.height),
dimensions.width,
dimensions.height,
adjustments,
),
0,
0,
);
progress(id, 0.8, "Rotating corrected page");
const rotated = rotate(
corrected,
adjustments.quarterTurns * 90 + adjustments.deskew,
);
const bounded = boundPixels(rotated, maximumPixels);
const result = await createImageBitmap(bounded);
progress(id, 1, "Page ready");
return {
bitmap: result,
width: bounded.width,
height: bounded.height,
sourceScale,
outputScale: dimensions.scale,
};
}
function renderDeskewSample(id: number, bitmap: ImageBitmap): number {
progress(id, 0.25, "Preparing deskew sample");
const scale = Math.min(1, 420 / Math.max(bitmap.width, bitmap.height));
const work = makeCanvas(
Math.max(16, Math.round(bitmap.width * scale)),
Math.max(16, Math.round(bitmap.height * scale)),
);
const workContext = context(work);
workContext.drawImage(bitmap, 0, 0, work.width, work.height);
progress(id, 0.5, "Scoring text-line angles");
const angle = estimateDeskew(
workContext.getImageData(0, 0, work.width, work.height).data,
work.width,
work.height,
);
progress(id, 1, "Deskew estimate ready");
return angle;
}
function makeCanvas(width: number, height: number): OffscreenCanvas {
return new OffscreenCanvas(width, height);
}
function context(canvas: OffscreenCanvas): OffscreenCanvasRenderingContext2D {
const value = canvas.getContext("2d", { willReadFrequently: true });
if (!value)
throw new Error("This browser could not create an offscreen 2D canvas.");
return value;
}
function rotate(source: OffscreenCanvas, degrees: number): OffscreenCanvas {
const normalized = ((degrees % 360) + 360) % 360;
if (Math.abs(normalized) < 0.001) return source;
const radians = (normalized * Math.PI) / 180;
const width = Math.ceil(
Math.abs(source.width * Math.cos(radians)) +
Math.abs(source.height * Math.sin(radians)),
);
const height = Math.ceil(
Math.abs(source.width * Math.sin(radians)) +
Math.abs(source.height * Math.cos(radians)),
);
const output = makeCanvas(width, height);
const target = context(output);
target.fillStyle = "white";
target.fillRect(0, 0, width, height);
target.translate(width / 2, height / 2);
target.rotate(radians);
target.drawImage(source, -source.width / 2, -source.height / 2);
return output;
}
function boundPixels(
source: OffscreenCanvas,
maximumPixels: number,
): OffscreenCanvas {
if (source.width * source.height <= maximumPixels) return source;
const scale = Math.sqrt(maximumPixels / (source.width * source.height));
const output = makeCanvas(
Math.max(16, Math.floor(source.width * scale)),
Math.max(16, Math.floor(source.height * scale)),
);
const target = context(output);
target.imageSmoothingEnabled = true;
target.imageSmoothingQuality = "high";
target.drawImage(source, 0, 0, output.width, output.height);
return output;
}
function progress(id: number, value: number, stage: string): void {
self.postMessage({ id, kind: "progress", progress: value, stage });
}
export {};
+10
View File
@@ -33,6 +33,16 @@ export interface ScanPage {
adjustments: PageAdjustments;
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
View File
@@ -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) {
+22 -3
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+18 -2
View File
@@ -82,6 +82,11 @@ test("adds and corrects a real local image while retaining the previous preview"
"aria-valuetext",
/^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);
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/scan-tools/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+28
View File
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { perspectiveCorrectPixels } from "../../src/scan/render";
import { defaultAdjustments } from "../../src/scan/types";
class TestImageData {
data: Uint8ClampedArray;
width: number;
height: number;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
this.data = new Uint8ClampedArray(width * height * 4);
}
}
describe("scan render core", () => {
afterEach(() => vi.unstubAllGlobals());
it("maps an identity quad without changing corner colours", () => {
vi.stubGlobal("ImageData", TestImageData);
const source = new TestImageData(2, 2) as unknown as ImageData;
source.data.set([
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
]);
const result = perspectiveCorrectPixels(source, 2, 2, defaultAdjustments());
expect([...result.data]).toEqual([...source.data]);
});
});