diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b659085..5d121fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: # Everything, not --omit=dev: the libraries bundled into the app (pdf.js, KaTeX, the Markdown and OCR # code) and Electron itself are devDependencies here, so leaving them out audits almost nothing that ships. - run: npm audit --audit-level=high + - run: npm test - run: npm run test:chat-math - run: npm run test:thumbnails - run: node --test scripts/test-word-files.cjs scripts/test-network-security.cjs scripts/test-release-build.mjs scripts/test-claude-path.cjs @@ -54,8 +55,9 @@ jobs: - name: Smoke test if: runner.os != 'Linux' run: npm run smoke + # Keep PR and temporary CI-run screenshots on the runner; only main retains its existing artifact. - uses: actions/upload-artifact@v7 - if: always() + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: name: smoke-${{ matrix.os }} path: release/smoke/ diff --git a/.gitignore b/.gitignore index 570a066..5be813e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ release release-test release-*/ dev-app-update.yml +public/ocr diff --git a/README.md b/README.md index c720372..dc78c2a 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ Ask AI is optional and desktop-only. See [Using Ask AI](#using-ask-ai) for setup ### Pick up where you left off - **Recent library:** keep up to eight recently opened PDFs with thumbnail previews and the last page read; reopen - with one click, remove individual entries, or choose **Clear all**. + with one click, remove individual entries, or choose **Clear all**. Removing entries also removes their saved recognized text; **Clear all** removes all saved recognized text. You can also clear saved recognized text from Settings without removing documents. - **Startup restore:** reopen the last active document automatically when it is still in the recent library. - **Reading state:** remember each document's page, scroll position, zoom, rotation, sidebar visibility, and two-page mode. @@ -249,8 +249,9 @@ On macOS, use `⌘` wherever this table says `Ctrl`. Press `?` with a document o ### Current limits -- Automatic OCR adds a selection layer in the reader. The source PDF stays intact. Document-wide search, - signature-label detection, and whole-document AI questions use the PDF’s embedded text. +- Automatic OCR reads scanned pages on your device using the scan languages selected in Settings. Recognized text becomes available for selection, + document search and Ask AI. Saving a copy adds a searchable text layer when the file can safely be rewritten. + Pages whose text the export font cannot represent stay available for recognition when reopened. - Ask AI uses extracted text, not page images, and includes up to about 400,000 characters of document text. Long documents may be truncated; AI answers can be wrong, so check the cited pages. - Folio opens PDFs up to 300 MiB and 10,000 pages. Webpage conversion uses a separate session, so pages behind a diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f2934e6..c984cfc 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,6 +17,9 @@ they cover, and Electron's and Chromium's licenses are in the app's installation | pdf.js standard fonts: Liberation | Liberation font license (GPLv2 with font exception) | Red Hat, Inc. | | pdf.js CMaps | BSD-3-Clause | Adobe Systems Incorporated | | pdf.js ICC profile | CC0-1.0 | | +| [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) via [tesseract.js](https://github.com/naptha/tesseract.js) and `tesseract.js-core` | Apache-2.0 | Tesseract OCR authors, tesseract.js contributors | +| Tesseract English language data (`@tesseract.js-data/eng`) | MIT (wrapper); model from `tessdata_best`, Apache-2.0 | tesseract.js contributors, Tesseract OCR authors | +| [pdf-lib](https://github.com/Hopding/pdf-lib) and its dependencies (`pako`, `@pdf-lib/standard-fonts`, `@pdf-lib/upng`, `tslib`) | MIT, Zlib, 0BSD | pdf-lib contributors, Nodeca (pako), and others | | [React](https://react.dev) and React DOM | MIT | Meta Platforms, Inc. and affiliates | | [react-markdown](https://github.com/remarkjs/react-markdown), [remark-math / rehype-katex](https://github.com/remarkjs/remark-math) and their unified parsing dependencies | MIT and ISC | unified contributors | | [KaTeX](https://github.com/KaTeX/KaTeX), including its bundled fonts | MIT | Khan Academy and other contributors | diff --git a/electron/ai-prompt.cjs b/electron/ai-prompt.cjs index b2f1802..cc7c3a9 100644 --- a/electron/ai-prompt.cjs +++ b/electron/ai-prompt.cjs @@ -10,6 +10,10 @@ const instructions = [ "Be concise. Use short paragraphs, lists, and light Markdown. Use LaTeX \\( ... \\) for inline math and \\[ ... \\] for display math, outside code blocks.", ].join("\n"); +function instructionsFor(document) { + return document.recognized === true ? instructions + "\nSome or all of the text was read from scanned pages by OCR and may contain recognition errors. Allow for that and flag uncertain names or figures." : instructions; +} + function chatRequest(model, document, messages) { const input = [ { role: "user", content: JSON.stringify({ document }) }, @@ -17,13 +21,13 @@ function chatRequest(model, document, messages) { role, content: context ? JSON.stringify({ question: content, selectedPassage: context }) : content, })), ]; - return { model, instructions, input, store: false, stream: true }; + return { model, instructions: instructionsFor(document), input, store: false, stream: true }; } function claudePrompt(document, messages) { // The CLI reads one user prompt. JSON keeps document strings inside their own fields even // when the PDF contains literal closing tags or text that resembles a conversation. - return { system: instructions, input: JSON.stringify({ document, conversation: messages }) }; + return { system: instructionsFor(document), input: JSON.stringify({ document, conversation: messages }) }; } module.exports = { chatRequest, claudePrompt }; diff --git a/electron/ai.cjs b/electron/ai.cjs index 9758dfc..9a650ec 100644 --- a/electron/ai.cjs +++ b/electron/ai.cjs @@ -547,7 +547,7 @@ const MAX_CHARS = 1_500_000; function validRequest(req) { return req && typeof req.id === "string" && req.id.length < 100 && ["chatgpt", "claude", "openrouter", "custom"].includes(req.provider) && typeof req.model === "string" && req.model.length <= (req.provider === "openrouter" || req.provider === "custom" ? 256 : 99) - && typeof req.document?.name === "string" && typeof req.document.text === "string" && typeof req.document.truncated === "boolean" + && typeof req.document?.name === "string" && typeof req.document.text === "string" && typeof req.document.truncated === "boolean" && typeof req.document.recognized === "boolean" && Array.isArray(req.messages) && req.messages.length > 0 && req.messages.length < 200 && req.messages.every((m) => m && (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && (m.context === undefined || (m.role === "user" && typeof m.context?.text === "string" && Number.isSafeInteger(m.context.page) && m.context.page >= 1))) @@ -609,10 +609,10 @@ function register(handle, send, { confirmLocal } = {}) { active.set(req.id, controller); const emit = (text) => send("folio:ai-event", { id: req.id, type: "delta", text }); const ask = { chatgpt: askChatGPT, claude: askClaude, openrouter: openRouter.ask, custom: customEndpoint.ask }[req.provider]; - const { name, text, truncated } = req.document; + const { name, text, truncated, recognized } = req.document; const messages = req.messages.map(({ role, content, context }) => context ? { role, content, context: { text: context.text, page: context.page } } : { role, content }); - Promise.resolve(ask({ model: req.model, document: { name, text, truncated }, messages }, emit, controller.signal)) + Promise.resolve(ask({ model: req.model, document: { name, text, truncated, recognized }, messages }, emit, controller.signal)) .then(() => send("folio:ai-event", { id: req.id, type: "done" })) .catch((e) => send("folio:ai-event", controller.signal.aborted ? { id: req.id, type: "done" } : { id: req.id, type: "error", message: e.message || "Something went wrong.", code: e.code ?? "", link: e.link ?? "" })) diff --git a/electron/custom-endpoint.cjs b/electron/custom-endpoint.cjs index bdbb550..154c090 100644 --- a/electron/custom-endpoint.cjs +++ b/electron/custom-endpoint.cjs @@ -143,7 +143,7 @@ function createCustomEndpoint({ fetch, resolve, confirmLocal, safeStorage, file, const passage = messages.at(-1)?.context; if (document.truncated && !passage) throw new Error("This document exceeds Folio’s whole-document limit. Select a passage and ask again."); const { instructions, input } = chatRequest(model, passage - ? { name: document.name, text: `[Page ${passage.page}]\n${passage.text}`, truncated: true } : document, messages); + ? { ...document, text: `[Page ${passage.page}]\n${passage.text}`, truncated: true } : document, messages); const body = JSON.stringify({ model, messages: [{ role: "system", content: instructions }, ...input], stream: true }); const name = nameOf(endpoint.baseUrl), timer = AbortSignal.timeout(5 * 60_000); try { diff --git a/electron/openrouter.cjs b/electron/openrouter.cjs index 7e28819..26285a6 100644 --- a/electron/openrouter.cjs +++ b/electron/openrouter.cjs @@ -71,7 +71,7 @@ function createOpenRouter({ net, safeStorage, file, writeAtomic }) { const passage = messages.at(-1)?.context; if (document.truncated && !passage) throw new Error("This document exceeds Folio’s whole-document limit. Select a passage and ask again."); const { instructions, input } = chatRequest(model, passage - ? { name: document.name, text: `[Page ${passage.page}]\n${passage.text}`, truncated: true } : document, messages); + ? { ...document, text: `[Page ${passage.page}]\n${passage.text}`, truncated: true } : document, messages); // Small-context endpoints can otherwise silently drop the middle of the document. const body = JSON.stringify({ model, messages: [{ role: "system", content: instructions }, ...input], stream: true, plugins: [{ id: "context-compression", enabled: false }] }); diff --git a/package-lock.json b/package-lock.json index b61f4da..42cec42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,8 @@ "@vitejs/plugin-react": "^6.1.2", "electron": "^45.2.1", "electron-builder": "^26.18.0", + "fake-indexeddb": "^6.2.7", + "jsdom": "^29.1.1", "idb-keyval": "^6.3.0", "katex": "^0.19.1", "mdast-util-from-markdown": "^2.1.0", @@ -38,7 +40,212 @@ "remark-math": "^6.0.0", "tesseract.js": "^7.0.0", "typescript": "^7.0.2", - "vite": "^8.3.5" + "vite": "^8.3.5", + "vitest": "^4.1.12" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" } }, "node_modules/@electron-internal/extract-zip": { @@ -352,6 +559,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -365,6 +590,13 @@ "node": ">=18.0.0" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1088,6 +1320,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -1135,6 +1374,17 @@ "@types/responselike": "^1.0.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -1145,6 +1395,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/emscripten": { "version": "1.41.6", "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", @@ -1654,6 +1911,119 @@ } } }, + "node_modules/@vitest/expect": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.12", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.12", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", @@ -1910,6 +2280,16 @@ "node": ">=12.0.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1992,6 +2372,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2138,6 +2528,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2331,6 +2731,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2384,6 +2791,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2391,6 +2812,58 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2408,6 +2881,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -2925,6 +3405,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -2997,6 +3484,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -3011,6 +3518,16 @@ "dev": true, "license": "MIT" }, + "node_modules/fake-indexeddb": { + "version": "6.2.7", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.7.tgz", + "integrity": "sha512-g0ghQ+BmU3CSZaHup5FigEAAtCmtNyxkrmID7OAeK2qONHghw1aQ2l2T1RxmfhP1H1tEXVRWArUTGVCk5832ww==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3596,6 +4113,19 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -3765,6 +4295,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-url": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", @@ -3851,6 +4388,121 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz", + "integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -4300,6 +4952,16 @@ "node": ">=10" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/mammoth": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.13.0.tgz", @@ -4598,6 +5260,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", @@ -5399,6 +6068,20 @@ "node": ">= 0.4" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5517,6 +6200,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/pdf-lib": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", @@ -5767,6 +6457,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -6135,6 +6835,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -6209,6 +6922,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -6279,6 +6999,13 @@ "license": "BSD-3-Clause", "optional": true }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -6289,6 +7016,13 @@ "node": ">= 6" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6387,6 +7121,13 @@ "node": ">=8" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -6492,6 +7233,23 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -6509,6 +7267,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -6529,6 +7317,19 @@ "tmp": "^0.2.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -6636,7 +7437,6 @@ "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=20.18.1" } @@ -6930,6 +7730,109 @@ } } }, + "node_modules/vitest": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wasm-feature-detect": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", @@ -6969,6 +7872,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -6996,6 +7909,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7021,6 +7951,16 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -7031,6 +7971,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index d4f7a2a..6c4c977 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dist:mac": "npm run build && electron-builder --mac --publish never", "dist:linux": "npm run build && electron-builder --linux --publish never", "smoke": "node scripts/smoke-test.mjs && node scripts/test-print.mjs && node scripts/smoke-print.mjs && node scripts/smoke-print-resolution.mjs", + "test": "vitest run", "test:reader-security": "node scripts/test-reader-security.mjs && node scripts/test-url-client-certificates.mjs", "test:chat-math": "node scripts/test-chat-math.mjs", "test:thumbnails": "node scripts/test-thumbnails.mjs" @@ -36,6 +37,8 @@ "@vitejs/plugin-react": "^6.1.2", "electron": "^45.2.1", "electron-builder": "^26.18.0", + "fake-indexeddb": "^6.2.7", + "jsdom": "^29.1.1", "idb-keyval": "^6.3.0", "katex": "^0.19.1", "mdast-util-from-markdown": "^2.1.0", @@ -50,7 +53,8 @@ "remark-math": "^6.0.0", "tesseract.js": "^7.0.0", "typescript": "^7.0.2", - "vite": "^8.3.5" + "vite": "^8.3.5", + "vitest": "^4.1.12" }, "productName": "Folio", "description": "A simple, clean PDF reader for Windows, macOS, and Linux", diff --git a/scripts/copy-assets.test.ts b/scripts/copy-assets.test.ts new file mode 100644 index 0000000..46c0958 --- /dev/null +++ b/scripts/copy-assets.test.ts @@ -0,0 +1,48 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const root = resolve(import.meta.dirname, ".."); +const ocr = (...parts: string[]) => resolve(root, "public/ocr", ...parts); + +beforeAll(() => { + execFileSync(process.execPath, [resolve(root, "scripts/copy-ocr-assets.mjs")], { cwd: root }); +}); + +describe("the OCR runtime that ships with the app", () => { + it.each([ + "worker.min.js", + "tesseract-core-relaxedsimd-lstm.wasm.js", + "tesseract-core-simd-lstm.wasm.js", + "tesseract-core-lstm.wasm.js", + "eng.traineddata.gz", + ])("includes %s", (file) => { + expect(existsSync(ocr(file))).toBe(true); + expect(statSync(ocr(file)).size).toBeGreaterThan(100_000); + }); + + it("ships the language model compressed, as Tesseract is told to expect", () => { + const [first, second] = readFileSync(ocr("eng.traineddata.gz")); + expect([first, second]).toEqual([0x1f, 0x8b]); + }); + + it("ships exactly the core files this Tesseract version asks for in LSTM-only mode", () => { + // If a Tesseract upgrade renames its cores, OCR would silently fail to start; this catches it at the source. + const loader = readFileSync(resolve(root, "node_modules/tesseract.js/src/worker-script/browser/getCore.js"), "utf8"); + expect(loader).toContain("tesseract-core-relaxedsimd-lstm.wasm.js"); + expect(loader).toContain("tesseract-core-simd-lstm.wasm.js"); + expect(loader).toContain("tesseract-core-lstm.wasm.js"); + }); + + it("is kept out of version control, like the other copied assets", () => { + // (Windows checks files out with CRLF line endings.) + expect(readFileSync(resolve(root, ".gitignore"), "utf8").split(/\r?\n/)).toContain("public/ocr"); + }); + + it("uses main's shared OCR asset paths", () => { + const source = readFileSync(resolve(root, "src/ocrWorker.ts"), "utf8"); + expect(source).toContain('`${import.meta.env.BASE_URL}ocr`'); + expect(source).toContain('corePath: base, langPath: base'); + }); +}); diff --git a/scripts/copy-pdfjs-assets.mjs b/scripts/copy-pdfjs-assets.mjs index 9bbf4dd..f2cdf1b 100644 --- a/scripts/copy-pdfjs-assets.mjs +++ b/scripts/copy-pdfjs-assets.mjs @@ -1,6 +1,6 @@ // Copies pdf.js runtime assets (CJK cmaps, standard fonts, wasm decoders, ICC profiles) into public/. // quickjs-eval.* runs JavaScript embedded in PDFs, which Folio never does, so it stays out. -import { cpSync, existsSync, rmSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, rmSync } from "node:fs"; import { basename, resolve } from "node:path"; const root = resolve(import.meta.dirname, ".."); diff --git a/scripts/fixtures/scanned-sample.pdf b/scripts/fixtures/scanned-sample.pdf new file mode 100644 index 0000000..57c13c7 Binary files /dev/null and b/scripts/fixtures/scanned-sample.pdf differ diff --git a/scripts/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index 35c70d9..09f1d38 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -49,7 +49,7 @@ async function checkScanSelection(app, page) { const image = deflateSync(Buffer.from(pixels)).toString("hex") + ">"; // The scan is upright when displayed, on a rotated, offset page with a non-default unit. const draw = "q 0 400 -200 0 220 30 cm /Scan Do Q"; - const native = draw + "\nBT /F 12 Tf 30 50 Td (Embedded text) Tj ET"; + const native = draw + "\nBT /F 12 Tf 30 50 Td (Embedded text) Tj " + "0 20 Td (More embedded text) Tj ".repeat(9) + "ET"; const pageDict = "/Type /Page /Parent 2 0 R /MediaBox [20 30 220 430] /Rotate 90 /UserUnit 1.25"; const resources = scan => `/Resources << /XObject << /Scan ${scan} 0 R >> /Font << /F 8 0 R >> >>`; const imageObject = kind => `<< /Type /XObject /Subtype /Image /Width 1000 /Height 500 ${kind} /BitsPerComponent 1 /Filter [/ASCIIHexDecode /FlateDecode] /Length ${image.length} >>\nstream\n${image}\nendstream`; @@ -87,10 +87,12 @@ async function checkScanSelection(app, page) { const status = page.locator(".page-text-status"); const waitForJob = n => page.waitForFunction(n => window.ocrJob === n, n, { timeout: 45_000 }); await waitForJob(1); + await page.getByRole("button", { name: "More" }).click(); + await page.getByRole("menuitem", { name: "Recognize text" }).click(); assert.equal(await pageNumber.inputValue(), "1"); const mixed = page.locator('.page[data-page-number="1"] .textLayer'); - await mixed.getByText("Embedded text", { exact: true }).waitFor(); - assert.equal((await mixed.innerText()).trim(), "Embedded text"); + await mixed.getByText("Embedded text", { exact: true }).first().waitFor(); + assert.match((await mixed.innerText()).trim(), /^Embedded text/); await goTo(2); await status.filter({ hasText: "Recognizing text on page 2…" }).waitFor(); await page.evaluate(() => window.resumeOcr()); @@ -129,7 +131,7 @@ async function checkScanSelection(app, page) { await waitForJob(3); assert.equal(await status.innerText(), "Recognizing text on page 5…"); await page.evaluate(() => window.resumeOcr()); - await page.locator('.page[data-page-number="5"] .ocr-word').first().waitFor(); + await page.locator('.page[data-page-number="5"] .textLayer span:not(.markedContent)').first().waitFor(); assert.equal(await status.innerText(), ""); await goTo(3); await page.locator('.page[data-page-number="3"] .textLayer').getByText("Select", { exact: true }).waitFor(); @@ -137,10 +139,14 @@ async function checkScanSelection(app, page) { await goTo(4); await page.evaluate(() => window.failOcr()); await status.filter({ hasText: "Text unavailable on page 4." }).waitFor(); - await goTo(3); - await goTo(4); - await page.locator('.page[data-page-number="4"] .ocr-word').first().waitFor(); + await page.getByRole("button", { name: "Try again" }).click(); + await page.locator('.page[data-page-number="4"] .textLayer span:not(.markedContent)').first().waitFor(); assert.equal(await status.innerText(), ""); + const interceptsPageClicks = await status.evaluate(el => { + const box = el.getBoundingClientRect(); + return document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2) === el; + }); + assert.equal(interceptsPageClicks, false, "the empty OCR status blocks clicks on the page"); console.log("ok background OCR prepares unvisited scans, prioritizes page jumps, and shows selectable text in dark mode"); // Exercise the real desktop download and checksum path without reaching GitHub from CI. @@ -196,6 +202,9 @@ async function checkScanSelection(app, page) { await page.keyboard.press(settingsShortcut); await picker.click(); await languages.getByRole("checkbox", { name: "German", exact: true }).uncheck(); + // Use an uncached selection so this run must encode a scan before we interrupt it. + await search.fill("English"); + await languages.getByRole("checkbox", { name: "English", exact: true }).check(); // Change languages again while the preceding run's scan image is still being encoded. await page.evaluate(() => { const toBlob = HTMLCanvasElement.prototype.toBlob; @@ -211,6 +220,8 @@ async function checkScanSelection(app, page) { await picker.click(); await search.fill("German"); await languages.getByRole("checkbox", { name: "German", exact: true }).check(); + await search.fill("English"); + await languages.getByRole("checkbox", { name: "English", exact: true }).uncheck(); await apply.click(); await languages.waitFor({ state: "hidden" }); await page.evaluate(() => window.finishScanEncoding()); @@ -218,8 +229,33 @@ async function checkScanSelection(app, page) { await currentScan.getByText("Grüße", { exact: true }).waitFor({ timeout: 45_000 }); assert.deepEqual(await app.evaluate(() => globalThis.finishModelDownloads()), ["spa.traineddata.gz", "spa.traineddata.gz", "deu.traineddata.gz"]); await goTo(1); - assert.equal((await mixed.innerText()).trim(), "Embedded text"); + assert.match((await mixed.innerText()).trim(), /^Embedded text/); console.log("ok scan languages verify downloads, retry a corrupt pack, and reuse saved models for mixed-language scans"); + + // A mixed 12-page document still exposes a document-level action when the only scan is on page 1. + const mixedKids = Array.from({ length: 12 }, (_, i) => `${i + 3} 0 R`).join(" "); + const mixedResources = `/Resources << /XObject << /Scan 15 0 R >> /Font << /F 18 0 R >> >>`; + const stamped = draw + "\nBT /F 12 Tf 30 50 Td (Stamp) Tj ET"; + const mixedTwelve = testPdf([ + "<< /Type /Catalog /Pages 2 0 R >>", `<< /Type /Pages /Count 12 /Kids [${mixedKids}] >>`, + `<< ${pageDict} ${mixedResources} /Contents 19 0 R >>`, + ...Array.from({ length: 11 }, () => `<< ${pageDict} ${mixedResources} /Contents 17 0 R >>`), + imageObject("/ColorSpace /DeviceGray"), + `<< /Length ${draw.length} >>\nstream\n${draw}\nendstream`, + `<< /Length ${native.length} >>\nstream\n${native}\nendstream`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + `<< /Length ${stamped.length} >>\nstream\n${stamped}\nendstream`, + ]); + await app.evaluate(({ BrowserWindow }, bytes) => BrowserWindow.getAllWindows()[0].webContents.send( + "folio:open-file", { name: "Twelve page mixed.pdf", data: Buffer.from(bytes) }), mixedTwelve); + await page.getByRole("button", { name: "More" }).click(); + await page.getByRole("menuitem", { name: "Recognize text" }).click(); + await page.waitForFunction(() => window.ocrJob === 1, null, { timeout: 45_000 }); + await page.evaluate(() => window.resumeOcr()); + const stampedLayer = page.locator('.page[data-page-number="1"] .textLayer'); + await stampedLayer.getByText("Stamp", { exact: true }).waitFor({ timeout: 45_000 }); + await stampedLayer.getByText("Select", { exact: true }).waitFor({ timeout: 45_000 }); + console.log("ok recognition preserves native text on a stamped scan in a mixed 12-page PDF"); } async function captureDownload(app, file) { @@ -250,11 +286,15 @@ async function checkPenDrawing(app, page, folder, shots) { await page.getByRole("menuitem", { name: "Fit width" }).click(); await layer.waitFor(); } + const size = await layer.boundingBox(); + const position = { x: Math.floor(size.width * 0.2), y: Math.floor(size.height * (0.3 + index * 0.15)) }; + // Wait for the ink layer to receive the pointer after a page opens or changes scale. + await layer.hover({ position }); const box = await layer.boundingBox(); - const x = box.x + box.width * 0.2; - const y = box.y + box.height * (0.3 + index * 0.15); - await page.mouse.move(x, y); + const x = box.x + position.x; + const y = box.y + position.y; await page.mouse.down(); + assert.ok(await layer.evaluate(el => el.classList.contains("drawing")), `${name} pen did not start a stroke`); await page.mouse.move(x + 25, y - 10); await page.mouse.move(x + 50, y + 10); await page.mouse.move(x + 100, y); @@ -720,6 +760,10 @@ export async function checkReaderTools(app, page, folder, shots) { el.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, ...options })), options); const atPage = n => page.waitForFunction(n => document.querySelector('[aria-label="Page number"]').value === String(n), n); await settings(); + await page.getByRole("button", { name: "Storage", exact: true }).click(); + await page.getByRole("button", { name: "Clear saved text" }).click(); + await page.getByText("Saved recognized text has been removed.").waitFor(); + await page.getByRole("button", { name: "General", exact: true }).click(); assert.equal(await wheelSwitch.getAttribute("aria-checked"), "false"); await wheelSwitch.click(); await page.keyboard.press("Escape"); diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index ab69d39..9b734d9 100644 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -1,6 +1,7 @@ // End-to-end smoke test, run in CI on Windows, macOS, and Linux (and locally with `npm run smoke`): // launches the built app in Electron, opens the sample document, and checks that pages render, text is -// selectable and highlightable, text boxes survive reopening, and the window chrome matches the platform. +// selectable and highlightable, text boxes survive reopening, a scanned page can be recognized and saved with its text, +// and the window chrome matches the platform. // Run `npm run build` first. import { _electron as electron } from "playwright-core"; import { spawnSync } from "node:child_process"; @@ -451,7 +452,9 @@ try { setTimeout(() => post.call(this, message, ...rest), 2500); }; }); - await page.evaluate((n) => document.querySelector(`.page[data-page-number="${n}"]`).scrollIntoView(), lastPage); + const lastPageField = page.getByLabel("Page number", { exact: true }); + await lastPageField.fill(String(lastPage)); + await lastPageField.press("Enter"); await page.locator(`.page[data-page-number="${lastPage}"] .textLayer span:not(.markedContent)`).first().evaluate((span) => { const range = document.createRange(); range.selectNodeContents(span); @@ -658,6 +661,79 @@ try { await page.locator(".freeTextEditor", { hasText: "Saved text box" }).waitFor({ timeout: 15_000 }); console.log("ok text box restored after reopening"); + // A scanned page (an image, no text) is offered text recognition; once read, its words can be searched and selected. + // (Opened from the library by clicking, not with Ctrl+O: where the keyboard focus is after a text box was restored differs by platform.) + // Simulate a real Tesseract language-load rejection, then verify the reader releases the worker and offers a retry. + await page.window.addInitScript(() => { + const post = Worker.prototype.postMessage; + const terminate = Worker.prototype.terminate; + Worker.prototype.terminate = function () { + if (this.folioFailedStartup) top.sessionStorage.setItem("folio-failed-worker-terminated", "yes"); + return terminate.call(this); + }; + Worker.prototype.postMessage = function (message, ...rest) { + if (message?.action === "loadLanguage" && !top.sessionStorage.getItem("folio-language-failed")) { + top.sessionStorage.setItem("folio-language-failed", "yes"); + this.folioFailedStartup = true; + queueMicrotask(() => this.dispatchEvent(new MessageEvent("message", { + data: { workerId: message.workerId, jobId: message.jobId, action: message.action, status: "reject", data: "Test language failure" }, + }))); + return; + } + return post.call(this, message, ...rest); + }; + }); + await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("menuitem", { name: /New tab/ }).click(); + await docs.getByRole("tab", { name: "Library", selected: true }).waitFor(); + const [scanChooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: /Open a PDF/ }).click(), + ]); + await scanChooser.setFiles(path.join(root, "scripts", "fixtures", "scanned-sample.pdf")); + await page.getByRole("button", { name: "Try again" }).waitFor({ timeout: 60_000 }); + assert.equal(await page.evaluate(() => top.sessionStorage.getItem("folio-failed-worker-terminated")), "yes"); + // Search before the scan is read and leave the search open while recognition resumes. + await page.keyboard.press("ControlOrMeta+f"); + await page.getByRole("textbox", { name: "Find in document" }).fill("searchable"); + await page.getByText("No results", { exact: true }).waitFor(); + await page.getByRole("button", { name: "Try again" }).click(); + await page.getByText(/Results may be incomplete while text is being recognized/).waitFor(); + await page.waitForFunction(() => document.querySelectorAll(".textLayer span:not(.markedContent)").length > 60, null, { timeout: 180_000 }); + assert.match(await page.locator(".textLayer").first().innerText(), /Scanned\s+pages\s+become\s+searchable/); + console.log("ok scanned page recognized"); + + // The open search refreshes after recognition; PDF.js must have a matching text-layer map. + await page.locator('.page[data-page-number="1"] .textLayer .highlight').filter({ hasText: "searchable" }).first().waitFor({ timeout: 15_000 }); + await page.getByText(/1 of \d+/).first().waitFor(); + await page.getByRole("textbox", { name: "Find in document" }).press("Escape"); + console.log("ok an open search warns while recognition runs, then highlights newly recognized text"); + + // Saving a copy puts the recognized text into the file: reopened, it has the text at once and is not offered again. + const scannedCopy = path.join(env.FOLIO_TEST_USER_DATA, "scanned-copy.pdf"); + await app.evaluate(({ session }, file) => { + globalThis.folioScanSaved = new Promise((resolve) => session.defaultSession.once("will-download", (_event, item) => { + item.setSavePath(file); + item.once("done", (_done, state) => resolve(state)); + })); + }, scannedCopy); + await page.keyboard.press("ControlOrMeta+s"); + await page.getByText("Saved a copy with the recognized text").waitFor({ timeout: 60_000 }); + assert.equal(await app.evaluate(() => globalThis.folioScanSaved), "completed"); + assert.equal(readFileSync(scannedCopy).subarray(0, 5).toString("latin1"), "%PDF-"); + await app.evaluate(({ BrowserWindow }, data) => BrowserWindow.getAllWindows()[0].webContents.send( + "folio:open-file", { name: "scanned-copy.pdf", data: Buffer.from(data) }), Array.from(readFileSync(scannedCopy))); + await page.waitForFunction(() => document.title.startsWith("scanned-copy.pdf"), null, { timeout: 30_000 }); + await page.waitForFunction(() => document.querySelectorAll(".textLayer span:not(.markedContent)").length > 60, null, { timeout: 30_000 }); + assert.match(await page.locator(".textLayer").first().innerText(), /Scanned\s+pages\s+become\s+searchable/); + await page.waitForTimeout(2000); // a scan is looked for after the page is shown + assert.equal(await page.getByText("This looks like a scan").count(), 0); + console.log("ok saved copy carries the recognized text"); + await docs.getByRole("tab", { name: "Folio sample.pdf", exact: true }).click(); + await docs.getByRole("tab", { name: "Folio sample.pdf", selected: true }).waitFor(); + await docs.getByRole("button", { name: "Close scanned-copy.pdf", exact: true }).click(); + await docs.getByRole("button", { name: "Close scanned-sample.pdf", exact: true }).click(); + await page.waitForTimeout(500); await page.screenshot({ path: path.join(shots, `smoke-${process.platform}.png`) }); await checkReaderTools(app, page, env.FOLIO_TEST_USER_DATA, shots); @@ -992,6 +1068,52 @@ try { await page.evaluate(() => window.restorePrintFrame()); console.log("ok ordinary PDF printing remains available beyond the advanced sheet limit"); + // Cancel must keep both Recent and saved text, including text whose PDF is no longer recent. + await page.getByRole("button", { name: "More", exact: true }).click(); + await page.getByRole("menuitem", { name: /New tab/ }).click(); + await docs.getByRole("tab", { name: "Library", selected: true }).waitFor(); + await page.getByRole("button", { name: "Clear all", exact: true }).waitFor(); + const database = async (action) => page.evaluate(async (action) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("folio"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + return new Promise((resolve, reject) => { + const tx = db.transaction(["recent", "ocr"], action === "seed" ? "readwrite" : "readonly"); + if (action === "seed") tx.objectStore("ocr").put({ id: "not-in-recent#1", items: [{ str: "saved text" }] }); + const recent = tx.objectStore("recent").getAllKeys(); + const saved = tx.objectStore("ocr").get("not-in-recent#1"); + let linked; + recent.onsuccess = () => { + if (action === "seed") window.folioTestRecentKey = recent.result[0]; + if (!window.folioTestRecentKey) return; + const key = `${window.folioTestRecentKey}#1`; + if (action === "seed") tx.objectStore("ocr").put({ id: key, items: [{ str: "recent saved text" }] }); + linked = tx.objectStore("ocr").get(key); + }; + tx.oncomplete = () => { db.close(); resolve({ recent: recent.result, saved: saved.result, linked: linked?.result }); }; + tx.onabort = () => { db.close(); reject(tx.error); }; + }); + }, action); + const beforeClear = await database("seed"); + assert.ok(beforeClear.recent.length > 0); + assert.ok(beforeClear.saved); + assert.ok(beforeClear.linked); + await page.evaluate(() => { + window.confirm = message => { window.folioClearPrompt = message; return false; }; + }); + await page.getByRole("button", { name: "Clear all", exact: true }).click(); + assert.equal(await page.evaluate(() => window.folioClearPrompt), "Remove all recent files and all saved recognized text from this device?"); + assert.deepEqual(await database("read"), beforeClear); + await page.getByRole("button", { name: "Clear all", exact: true }).waitFor(); + await page.evaluate(() => { window.confirm = () => true; }); + await page.getByRole("button", { name: "Clear all", exact: true }).click(); + await page.getByRole("button", { name: "Clear all", exact: true }).waitFor({ state: "hidden" }); + assert.deepEqual(await database("read"), { recent: [], saved: undefined, linked: undefined }); + console.log("ok cancelling Clear all preserves recent files and all saved recognized text"); + + // Find positions count a ligature ("fi") as the one character it is in the PDF. The quoted text has to count // the same way, or the words it marks drift to the left of the match. await app.evaluate(({ BrowserWindow }, data) => BrowserWindow.getAllWindows()[0].webContents.send( diff --git a/scripts/test-custom-endpoint.cjs b/scripts/test-custom-endpoint.cjs index 7c673c3..2dd8c2f 100644 --- a/scripts/test-custom-endpoint.cjs +++ b/scripts/test-custom-endpoint.cjs @@ -189,10 +189,11 @@ test("answers stream from the configured address, and selection questions omit o assert.equal(sent[0].stream, true); assert.equal(sent[0].plugins, undefined); assert.match(sent[0].messages[0].content, /untrusted data/); - const large = { ...question, document: { ...question.document, truncated: true } }; + const large = { ...question, document: { ...question.document, truncated: true, recognized: true } }; await assert.rejects(endpoint.ask(large, () => {}, signal), /whole-document limit/); await endpoint.ask({ ...large, messages: [{ ...question.messages[0], context: { text: "Excerpt", page: 2 } }] }, () => {}, signal); assert.equal(JSON.parse(sent[1].messages[1].content).document.text, "[Page 2]\nExcerpt"); + assert.match(sent[1].messages[0].content, /recognition errors/); assert.doesNotMatch(JSON.stringify(sent[1]), /A sample/); await assert.rejects(endpoint.ask({ ...question, model: "has space" }, () => {}, signal), /Choose a model/); }); diff --git a/scripts/test-openrouter.cjs b/scripts/test-openrouter.cjs index 1668938..40bed58 100644 --- a/scripts/test-openrouter.cjs +++ b/scripts/test-openrouter.cjs @@ -87,11 +87,12 @@ test("answers stream page-marked content, selection questions omit other pages, assert.equal(JSON.parse(sent[0].messages[1].content).document.text, question.document.text); assert.match(sent[0].messages[0].content, /untrusted data/); assert.deepEqual(sent[0].plugins, [{ id: "context-compression", enabled: false }]); - const large = { ...question, document: { ...question.document, truncated: true } }; + const large = { ...question, document: { ...question.document, truncated: true, recognized: true } }; await assert.rejects(router.ask(large, () => {}, signal), /whole-document limit/); assert.equal(sent.length, 1); await router.ask({ ...large, messages: [{ ...question.messages[0], context: { text: "Excerpt", page: 2 } }] }, () => {}, signal); assert.equal(JSON.parse(sent[1].messages[1].content).document.text, "[Page 2]\nExcerpt"); + assert.match(sent[1].messages[0].content, /recognition errors/); assert.doesNotMatch(JSON.stringify(sent[1]), /A sample/); }); diff --git a/scripts/test-reader-security.mjs b/scripts/test-reader-security.mjs index 022692d..2d27972 100644 --- a/scripts/test-reader-security.mjs +++ b/scripts/test-reader-security.mjs @@ -284,7 +284,7 @@ try { assert.deepEqual(cleared.afterChats, { notes: 1, ai: 0 }); console.log("ok cleared stores reject stale writes and accept new edits independently"); - const doc = { name: '"}] HIDDEN_FILENAME', text: '[Page 3]\nHIDDEN_TEXT', truncated: true }; + const doc = { name: '"}] HIDDEN_FILENAME', text: '[Page 3]\nHIDDEN_TEXT', truncated: true, recognized: true }; const messages = [{ role: "user", content: "What is this?", context: { text: "HIDDEN_SELECTION", page: 3 } }, { role: "assistant", content: "A reference (p. 3)." }, { role: "user", content: "Summarize the document." }]; const chat = prompts.chatRequest("model", doc, messages); @@ -292,6 +292,8 @@ try { assert.equal(chat.instructions, claude.system); assert.doesNotMatch(chat.instructions, /HIDDEN/); assert.match(chat.instructions, /untrusted/); + assert.match(chat.instructions, /recognition errors/); + assert.doesNotMatch(prompts.chatRequest("model", { ...doc, recognized: false }, messages).instructions, /recognition errors/); assert.deepEqual(JSON.parse(chat.input[0].content), { document: doc }); assert.deepEqual(JSON.parse(chat.input[1].content), { question: messages[0].content, selectedPassage: messages[0].context }); assert.ok(chat.input.slice(0, 2).every(m => m.role === "user")); diff --git a/src/AiPanel.test.ts b/src/AiPanel.test.ts new file mode 100644 index 0000000..1e88564 --- /dev/null +++ b/src/AiPanel.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./lib/ocr", () => ({ ocrSession: (pdf: { session?: unknown }) => pdf.session })); + +import { documentText } from "./AiPanel"; + +type FakeItem = { str?: string; hasEOL?: boolean }; +type Session = { version: number; pages: Map; embedded: Set }; + +function makePdf(pages: FakeItem[][], session?: Session) { + const getPage = vi.fn(async (n: number) => ({ getTextContent: async () => ({ items: pages[n - 1] }) })); + return { numPages: pages.length, getPage, session } as unknown as Parameters[0] & { getPage: typeof getPage }; +} +const sessionWith = (pages: [number, unknown[]][] = [], version = 0, embedded: number[] = []): Session => ({ version, pages: new Map(pages), embedded: new Set(embedded) }); + +describe("documentText", () => { + it("lays the document out as page blocks, with words joined and line ends kept", async () => { + const pdf = makePdf([ + [{ str: "Hello", hasEOL: false }, { str: "world", hasEOL: true }, { str: "second line", hasEOL: true }], + [{ str: "page two", hasEOL: false }], + ]); + expect((await documentText(pdf)).text).toBe("[Page 1]\nHello world\nsecond line\n\n[Page 2]\npage two\n\n"); + }); + + it("skips items that aren't text (marked-content markers)", async () => { + const pdf = makePdf([[{ str: "kept", hasEOL: true }, { type: "beginMarkedContent" } as FakeItem]]); + expect((await documentText(pdf)).text).toBe("[Page 1]\nkept\n\n"); + }); + + it("stops at the size limit and says the document was cut short", async () => { + const big = "x".repeat(300_000); + const result = await documentText(makePdf([[{ str: big }], [{ str: big }]])); + expect(result.truncated).toBe(true); + expect(result.text).toContain("[Page 1]"); + expect(result.text).not.toContain("[Page 2]"); + }); + + it("is not truncated when it fits", async () => { + expect((await documentText(makePdf([[{ str: "short" }]]))).truncated).toBe(false); + }); + + it("reads each document once, however often it is asked for", async () => { + const pdf = makePdf([[{ str: "a" }], [{ str: "b" }]], sessionWith()); + const first = documentText(pdf); + const second = documentText(pdf); + expect(second).toBe(first); + await first; + expect(pdf.getPage).toHaveBeenCalledTimes(2); + }); + + it("reads it again once recognition has added text, so answers see the new pages", async () => { + const session = sessionWith(); + const pdf = makePdf([[]], session); + const before = await documentText(pdf); + expect(before.text.replace(/\[Page \d+\]/g, "").trim()).toBe(""); + + session.pages.set(1, [{}]); + session.version++; + const after = await documentText(pdf); + expect(after).not.toBe(before); + expect(after.recognized).toBe(true); + expect(pdf.getPage).toHaveBeenCalledTimes(2); // read again, not served from the old result + }); + + it("warns when OCR finishes while document text is being extracted", async () => { + let resume!: () => void; + const wait = new Promise(resolve => { resume = resolve; }); + const session = sessionWith(); + const pdf = { session, numPages: 1, getPage: async () => { + await wait; + return { getTextContent: async () => ({ items: [{ str: "recognized text" }] }) }; + } } as unknown as Parameters[0]; + const extraction = documentText(pdf); + session.pages.set(1, [{ str: "recognized text" }]); + session.version++; + resume(); + expect(await extraction).toMatchObject({ recognized: true, text: expect.stringContaining("recognized text") }); + }); + + it("tells whether any of the text came from recognition", async () => { + expect((await documentText(makePdf([[{ str: "a" }]]))).recognized).toBe(false); + expect((await documentText(makePdf([[{ str: "a" }]], sessionWith()))).recognized).toBe(false); + expect((await documentText(makePdf([[{ str: "a" }]], sessionWith([[1, []]])))).recognized).toBe(false); + expect((await documentText(makePdf([[{ str: "a" }]], sessionWith([[1, [{}]]])))).recognized).toBe(true); + // A copy saved with recognized text carries it inside the PDF, but it is still read by OCR. + expect((await documentText(makePdf([[{ str: "a" }]], sessionWith([], 0, [1])))).recognized).toBe(true); + }); +}); diff --git a/src/AiPanel.tsx b/src/AiPanel.tsx index 1b197fa..078f82c 100644 --- a/src/AiPanel.tsx +++ b/src/AiPanel.tsx @@ -4,6 +4,7 @@ import { Icon } from "./icons"; import { copyText } from "./clipboard"; import type { PDFDocumentProxy } from "./pdf"; import { getAiHistory, saveAiHistory } from "./recent"; +import { ocrSession } from "./lib/ocr"; import { CLAUDE_CODE_URL, PROVIDERS, savedProvider, saveAiPreference, signInChatGPT, subscribeAiPreferences, USAGE_URL } from "./ai"; import { Modal } from "./Dialogs"; import { readSetting } from "./preferences"; @@ -29,30 +30,35 @@ const CLAUDE_MODELS = [ ]; // ── Document text ─────────────────────────────────────────────────────────────────────── -const textCache = new WeakMap>(); +export type DocText = { text: string; truncated: boolean; recognized: boolean }; +// Keyed by the OCR version too: pages of a scan gain text while it is being recognized. +const textCache = new WeakMap }>(); -function documentText(pdf: PDFDocumentProxy) { - let cached = textCache.get(pdf); - if (!cached) { - cached = (async () => { - let text = ""; - let truncated = false; - for (let n = 1; n <= pdf.numPages; n++) { - const content = await (await pdf.getPage(n)).getTextContent(); - const page = content.items.map((item) => ("str" in item ? item.str + (item.hasEOL ? "\n" : " ") : "")).join("") - .replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); - const block = `[Page ${n}]\n${page}\n\n`; - if (text.length + block.length > MAX_DOC_CHARS) { - truncated = true; - break; - } - text += block; +export function documentText(pdf: PDFDocumentProxy) { + const session = ocrSession(pdf); + const version = session?.version ?? 0; + const hit = textCache.get(pdf); + if (hit?.version === version) return hit.text; + const text = (async () => { + let text = ""; + let truncated = false; + for (let n = 1; n <= pdf.numPages; n++) { + const content = await (await pdf.getPage(n)).getTextContent(); + const page = content.items.map((item) => ("str" in item ? item.str + (item.hasEOL ? "\n" : " ") : "")).join("") + .replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); + const block = `[Page ${n}]\n${page}\n\n`; + if (text.length + block.length > MAX_DOC_CHARS) { + truncated = true; + break; } - return { text, truncated }; - })(); - textCache.set(pdf, cached); - } - return cached; + text += block; + } + // OCR can finish while extraction is awaiting a page; use the final state for the safety caveat. + const recognized = !!session && (session.embedded.size > 0 || [...session.pages.values()].some((items) => items.length)); + return { text, truncated, recognized }; + })(); + textCache.set(pdf, { version, text }); + return text; } // ── Panel ───────────────────────────────────────────────────────────────────────────────── @@ -264,7 +270,10 @@ export function AiPanel({ pdf, docId, persist, docName, context, onClearContext, setInput(""); onClearContext(); try { - const doc = (provider === "openrouter" || provider === "custom") && context ? { text: "", truncated: true } : await documentText(pdf); + const session = ocrSession(pdf); + const doc = (provider === "openrouter" || provider === "custom") && context + ? { text: "", truncated: true, recognized: !!session && (session.embedded.has(context.page) || !!session.pages.get(context.page)?.length) } + : await documentText(pdf); if (preparing.current !== id) return; preparing.current = null; await ai.ask({ id, provider, model, document: { name: docName, ...doc }, diff --git a/src/App.tsx b/src/App.tsx index b4bc78a..6aae8a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -46,6 +46,9 @@ import { PrintDialog } from "./PrintDialog"; import type { Release } from "./releaseNotes"; import { detectSignatureTargets, placeSignature, type SignatureTarget } from "./signatures"; import { keepDrawingPointer } from "./drawing"; +import { useOcrSearchRefresh } from "./hooks/ocr"; +import { OcrSession, copyWithText, savedMessage } from "./lib/ocr"; +import { restoreForOpen } from "./lib/ocr/session/restore"; import { finishTextOnClickAway } from "./textEditing"; import { drawShapes, type Shape } from "./shapes"; import { enableWheelPaging } from "./wheelPaging"; @@ -176,6 +179,9 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs } }), []); const persistRef = useRef(false); + const ocrRef = useRef(null); + const pageOcrRef = useRef<{ retry(): void } | null>(null); + const dirtyRef = useRef(false); const exporting = useRef(null); const savingCopy = useRef(false); @@ -248,6 +254,9 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const [aiOpen, setAiOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [aiContext, setAiContext] = useState(null); + const [ocr, setOcr] = useState(null); + const [ocrVersion, setOcrVersion] = useState(0); + const [ocrPhase, setOcrPhase] = useState<"running" | "done">("done"); useLayoutEffect(() => { if (active && window.folioDesktop && !focus && !dialog && !signing && !pending && !imageView) { @@ -274,10 +283,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs useEffect(() => { window.dispatchEvent(new Event("folio:tab-visibility")); }, [active]); const toastTimer = useRef(0); - const flash = useCallback((msg: string) => { + const flash = useCallback((msg: string, ms = 2200) => { setToast(msg); window.clearTimeout(toastTimer.current); - toastTimer.current = window.setTimeout(() => setToast(null), 2200); + toastTimer.current = window.setTimeout(() => setToast(null), ms); }, []); // ── Viewer bootstrap ──────────────────────────────────────────────── @@ -382,8 +391,24 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs const viewer = viewerRef.current!, bus = busRef.current!; const events = new AbortController(); let stop: (() => void) | undefined; - const start = () => { stop = recognizeScannedPages(bus, viewer, scanLanguages, setTextStatus); }; - const end = () => { stop?.(); stop = undefined; }; + let generation = 0; + const end = () => { generation++; stop?.(); stop = undefined; pageOcrRef.current = null; }; + const start = async () => { + end(); + const current = generation; + const pdf = viewer.pdfDocument; + const session = ocrRef.current; + if (!pdf || !session || session.pdf !== pdf) return; + await session.setLanguages(scanLanguages); + if (events.signal.aborted || current !== generation || viewer.pdfDocument !== pdf) return; + for (const view of viewer.getCachedPageViews()) { + view.reset({ keepAnnotationLayer: true, keepAnnotationEditorLayer: true, keepCanvasWrapper: true }); + } + viewer.update(); + const recognition = recognizeScannedPages(bus, viewer, scanLanguages, setTextStatus, setOcrPhase); + pageOcrRef.current = recognition; + stop = recognition.stop; + }; bus.on("pagesinit", start, { signal: events.signal }); bus.on("pagesdestroy", end, { signal: events.signal }); if (viewer.pagesCount) start(); @@ -439,6 +464,14 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs preferencesRef.current = { sidebar: saved.sidebar, spread: saved.spread }; } + // Recognized text for scans plugs into pdf.js page by page; it must be in place before the viewer asks for text. + // Password-protected documents keep theirs in memory only, like everything else Folio doesn't store for them. + const session = new OcrSession(pdf, password ? null : id, scanLanguages); + await restoreForOpen(session, signal); + ocrRef.current?.destroy(); + ocrRef.current = session; + setOcr(session); + const prev = docRef.current; // Password-protected documents aren't stored by Folio (same rule as Recent). const reading = previous ? markupRef.current!.state : undefined; @@ -612,6 +645,12 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs } }, [beginOpen, openBytes, tabs, tabKey]); + useEffect(() => { + if (!ocr) return; + return ocr.onPage(() => setOcrVersion(ocr.version)); + }, [ocr]); + useOcrSearchRefresh({ session: ocr, searchOpen, phase: ocrPhase, version: ocrVersion, find: findRef, bus: busRef }); + // Remember where the reader left off. useEffect(() => { if (!doc) return; @@ -932,8 +971,10 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs try { const copy = await currentBytes(doc, pageOrder); if (!copy || docRef.current !== doc) return; + const withText = await copyWithText(ocr, copy.bytes, pageOrder); + if (docRef.current !== doc) return; const name = doc.sourceId ? copyName.replace(/\.pdf$/i, "") + " (edited).pdf" : copyName; - const blob = pdfBlob(copy.bytes); + const blob = pdfBlob(withText.bytes); saveBlob(blob, /\.pdf$/i.test(name) ? name : name + ".pdf"); if (editRevision.current === revision) setDirty(false); const changes = changesForCopy(doc, copy.annotated, pageOrder); @@ -942,7 +983,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs try { const original = doc.edits?.original ?? pdfBlob(await doc.pdf.getData()); await saveVersion({ id: doc.sourceId ?? doc.id, name: doc.name, data: original }, - { id: await contentKey(copy.bytes), name, data: blob }, changes); + { id: await contentKey(withText.bytes), name, data: blob }, changes); } catch { historyMessage = "Copy exported. Couldn’t save its version history on this device."; } } else if (changes.length) { historyMessage = "Copy exported. History is available for unencrypted PDFs up to 50 MB."; @@ -950,14 +991,18 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs if (docRef.current !== doc) return; setError(null); if (doc.sourceId) { - await openBytes(name, copy.bytes, undefined, beginOpen(), pageOrder ? pageOrder.indexOf(pageRef.current) + 1 : pageRef.current, undefined, doc, pageOrder); + await openBytes(name, withText.bytes, undefined, beginOpen(), pageOrder ? pageOrder.indexOf(pageRef.current) + 1 : pageRef.current, undefined, doc, pageOrder); } else if (pageOrder) { - setPageDraft(draft => draft?.order === pageOrder ? { order: pageOrder, copy: copy.bytes } : draft); - flash("Downloading reordered copy"); + setPageDraft(draft => draft?.order === pageOrder ? { order: pageOrder, copy: withText.bytes } : draft); + flash(withText.problem ? savedMessage({ markup: copy.annotated, ...withText }) : "Downloading reordered copy", withText.problem ? 6000 : undefined); } else { - flash(copy.annotated ? "Saved a copy with your highlights and notes" : "Download started"); + flash(savedMessage({ markup: copy.annotated, ...withText }), withText.problem ? 6000 : undefined); + } + if (historyMessage) { + flash(historyMessage + (withText.problem ? ` ${savedMessage({ markup: copy.annotated, ...withText })}` : ""), 6000); + } else if (doc.sourceId && withText.problem) { + flash(savedMessage({ markup: copy.annotated, ...withText }), 6000); } - if (historyMessage) flash(historyMessage); } catch { if (docRef.current === doc) { if (dirty) setDirty(true); @@ -1374,6 +1419,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs { kind: "action", icon: "history", label: "Version history", onSelect: () => { setAiOpen(false); setHistoryOpen(true); } }, { kind: "action", icon: "print", label: "Print", shortcut: keys("Ctrl P"), onSelect: print }, { kind: "action", icon: "info", label: "Document info", onSelect: () => setDialog("info") }, + { kind: "action", icon: "search", label: "Recognize text", onSelect: () => pageOcrRef.current?.retry() }, { kind: "divider" }, { kind: "toggle", icon: "moon", label: "PDF dark mode", checked: night, onSelect: () => setNightTo(!night) }, { kind: "action", icon: "expand", label: "Focus mode", shortcut: "F", onSelect: toggleFocus }, @@ -1441,11 +1487,11 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs if (!copy || docRef.current !== doc) throw new Error("The document changed. Try again."); return copy.bytes; }} apply={async (bytes, change) => docRef.current === doc && !!await openBytes(doc.name, bytes, undefined, opening.current!.signal, undefined, { doc, change })} />} - {doc &&
{textStatus}
} + {doc &&
{textStatus}{textStatus?.startsWith("Text unavailable") && }
} {doc && searchOpen && ( <> - + )} @@ -1616,6 +1662,7 @@ export default function App({ tabs, tabKey, upgradeNotes }: { tabs: DocumentTabs {loading && doc &&
} + {dragging && (
diff --git a/src/SearchBar.tsx b/src/SearchBar.tsx index 4553cde..20bd428 100644 --- a/src/SearchBar.tsx +++ b/src/SearchBar.tsx @@ -13,10 +13,12 @@ type FindOpts = { again?: boolean; previous?: boolean; caseSensitive?: boolean; export function SearchBar({ result, + recognizing, onFind, onClose, }: { result: FindResult; + recognizing: boolean; onFind: (q: string, o: FindOpts) => void; onClose: () => void; }) { @@ -57,7 +59,8 @@ export function SearchBar({ }} spellCheck={false} /> - {status} + {status} + {query && recognizing && Results may be incomplete while text is being recognized} diff --git a/src/SearchResults.test.tsx b/src/SearchResults.test.tsx new file mode 100644 index 0000000..394f4d9 --- /dev/null +++ b/src/SearchResults.test.tsx @@ -0,0 +1,300 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SearchResults } from "./SearchResults"; +import type { TextPiece } from "./lib/search"; +import { deferred } from "./testing/deferred"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +class Bus { + #handlers = new Map void>>(); + on(name: string, fn: () => void) { this.#handlers.set(name, (this.#handlers.get(name) ?? new Set()).add(fn)); } + off(name: string, fn: () => void) { this.#handlers.get(name)?.delete(fn); } + emit(name: string) { for (const fn of [...(this.#handlers.get(name) ?? [])]) fn(); } + count(name: string) { return this.#handlers.get(name)?.size ?? 0; } +} + +type Pages = Record | Error>; + +function makePdf(pages: Pages) { + const getTextContent = vi.fn(); + const getPage = vi.fn(async (n: number) => ({ + getTextContent: async (options: object) => { + getTextContent(n, options); + const page = pages[n]; + if (page instanceof Error) throw page; + return { items: await page }; + }, + })); + return { pdf: { getPage } as never, getPage, getTextContent }; +} + +/** Matches as the find controller reports them: per page, the offsets of each match and their lengths. */ +function makeController(matches: Record, pageCount = Math.max(...Object.keys(matches).map(Number))) { + const pageMatches: (number[] | undefined)[] = Array.from({ length: pageCount }, (_, i) => matches[i + 1]?.offsets); + const pageMatchesLength: (number[] | undefined)[] = Array.from({ length: pageCount }, (_, i) => matches[i + 1]?.offsets.map(() => matches[i + 1].length)); + return { pageMatches, pageMatchesLength }; +} + +const manyPages = (count: number): Pages => Object.fromEntries(Array.from({ length: count }, (_, i) => [i + 1, [{ str: `page ${i + 1} has the word needle in it` }]])); +const manyMatches = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, i) => [i + 1, { offsets: [i + 1 >= 10 ? 15 : 14], length: 6 }])); + +let root: Root; +let container: HTMLElement; +let bus: Bus; +let onSelect: ReturnType void>>; + +beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + bus = new Bus(); + onSelect = vi.fn<(page: number) => void>(); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +const mount = async (pdf: never, controller: object) => { + await act(async () => { root.render(); }); +}; +/** Lets the debounce that follows a find event run, and the results that follow it. */ +const settle = async (ms = 100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); }; +const rows = () => [...container.querySelectorAll(".search-result-list button")]; +const heading = () => container.querySelector("h3")!.textContent; + +describe("SearchResults", () => { + it("refreshes cached snippets when a later search finds newly recognized text", async () => { + const pages: Pages = { 1: [{ str: "stamp" }] }; + const { pdf } = makePdf(pages); + const controller = makeController({ 1: { offsets: [0], length: 5 } }); + await mount(pdf, controller); await settle(); + expect(rows()[0].querySelector("mark")!.textContent).toBe("stamp"); + pages[1] = [{ str: "stamp needle" }]; + controller.pageMatches[0] = [6]; controller.pageMatchesLength[0] = [6]; + await act(async () => { bus.emit("find"); bus.emit("updatefindmatchescount"); }); + await settle(); + expect(rows()[0].querySelector("mark")!.textContent).toBe("needle"); + }); + it("asks for a search before there is anything to show", async () => { + const { pdf } = makePdf({}); + await mount(pdf, { pageMatches: [], pageMatchesLength: [] }); + await settle(); + expect(container.textContent).toContain("Search above to see matching pages"); + expect(heading()).toBe("Matching pages 0"); + expect(rows()).toHaveLength(0); + }); + + it("copes with a controller that has no matches yet", async () => { + const { pdf } = makePdf({}); + await mount(pdf, {}); + await settle(); + expect(heading()).toBe("Matching pages 0"); + }); + + it("lists each page with its matches and the words around the first one", async () => { + const { pdf } = makePdf({ 1: [{ str: "see the needle here" }], 3: [{ str: "x needle y needle" }] }); + await mount(pdf, makeController({ 1: { offsets: [8], length: 6 }, 3: { offsets: [2, 11], length: 6 } })); + await settle(); + expect(heading()).toBe("Matching pages 2"); + expect(rows().map((r) => r.getAttribute("aria-label"))).toEqual(["Go to page 1, 1 matches", "Go to page 3, 2 matches"]); + expect(rows()[0].querySelector("strong")!.textContent).toBe("Page 1 1 match"); + expect(rows()[1].querySelector("strong")!.textContent).toBe("Page 3 2 matches"); + expect(rows()[0].querySelector("mark")!.textContent).toBe("needle"); + expect(rows()[0].textContent).toContain("see the needle here"); + }); + + it("puts a space where a line ends instead of running the words together", async () => { + const { pdf } = makePdf({ 1: [{ str: "our identification.", hasEOL: true }, { str: "Invoice number" }] }); + await mount(pdf, makeController({ 1: { offsets: [19], length: 7 } })); + await settle(); + expect(rows()[0].querySelector("span:not(strong span)")!.textContent).toBe("our identification. Invoice number"); + expect(rows()[0].querySelector("mark")!.textContent).toBe("Invoice"); + }); + + it("reads each page's text the way find does, so the offsets line up", async () => { + const { pdf, getTextContent } = makePdf({ 1: [{ str: "needle" }] }); + await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } })); + await settle(); + expect(getTextContent).toHaveBeenCalledWith(1, { disableNormalization: true }); + }); + + it("shows no match text when the match has no recorded length", async () => { + const { pdf } = makePdf({ 1: [{ str: "needle" }] }); + await mount(pdf, { pageMatches: [[0]] }); + await settle(); + expect(rows()[0].querySelector("mark")!.textContent).toBe(""); + }); + + it("skips pages without matches", async () => { + const { pdf, getPage } = makePdf({ 2: [{ str: "needle" }] }); + await mount(pdf, { pageMatches: [undefined, [0], []], pageMatchesLength: [undefined, [6], []] }); + await settle(); + expect(rows()).toHaveLength(1); + expect(getPage).toHaveBeenCalledTimes(1); + expect(getPage).toHaveBeenCalledWith(2); + }); + + it("goes to the page that was clicked", async () => { + const { pdf } = makePdf({ 4: [{ str: "needle" }] }); + await mount(pdf, makeController({ 4: { offsets: [0], length: 6 } })); + await settle(); + await act(async () => { rows()[0].click(); }); + expect(onSelect).toHaveBeenCalledWith(4); + }); + + it("shows a page it can't read with an empty snippet rather than failing the list", async () => { + const { pdf } = makePdf({ 1: new Error("broken page"), 2: [{ str: "needle" }] }); + await mount(pdf, makeController({ 1: { offsets: [0], length: 6 }, 2: { offsets: [0], length: 6 } })); + await settle(); + expect(rows()).toHaveLength(2); + expect(rows()[0].querySelector("mark")!.textContent).toBe(""); + expect(rows()[1].querySelector("mark")!.textContent).toBe("needle"); + }); + + it("marks a long text as cut on either side", async () => { + const { pdf } = makePdf({ 1: [{ str: "a".repeat(80) + "needle" + "b".repeat(150) }] }); + await mount(pdf, makeController({ 1: { offsets: [80], length: 6 } })); + await settle(); + const text = rows()[0].querySelector("span:not(strong span)")!.textContent!; + expect(text.startsWith("..." + "a".repeat(55) + "needle")).toBe(true); + expect(text.endsWith("b".repeat(100) + "...")).toBe(true); + }); + + it("refreshes when find reports new matches or new highlights, and reuses the text it already read", async () => { + const { pdf, getPage } = makePdf({ 1: [{ str: "needle" }] }); + const controller = makeController({ 1: { offsets: [0], length: 6 } }); + await mount(pdf, controller); + await settle(); + expect(getPage).toHaveBeenCalledTimes(1); + bus.emit("updatetextlayermatches"); + await settle(); + bus.emit("updatefindmatchescount"); + await settle(); + expect(getPage).toHaveBeenCalledTimes(1); + expect(rows()).toHaveLength(1); + }); + + it("waits for find to go quiet before reading anything", async () => { + const { pdf, getPage } = makePdf({ 1: [{ str: "needle" }] }); + await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } })); + getPage.mockClear(); + bus.emit("updatefindmatchescount"); + await settle(60); + bus.emit("updatefindmatchescount"); + await settle(60); + expect(getPage).not.toHaveBeenCalled(); + await settle(40); + expect(getPage).toHaveBeenCalledTimes(1); + }); + + it("clears the list as soon as a new search starts", async () => { + const { pdf } = makePdf({ 1: [{ str: "needle" }] }); + await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } })); + await settle(); + expect(rows()).toHaveLength(1); + await act(async () => { bus.emit("find"); }); + expect(rows()).toHaveLength(0); + expect(heading()).toBe("Matching pages 0"); + expect(container.textContent).toContain("Search above"); + }); + + it("shows the first 100 matching pages and says so", async () => { + const { pdf, getPage } = makePdf(manyPages(120)); + await mount(pdf, makeController(manyMatches(120))); + await settle(); + await settle(); // the list is built in slices of ten, with a pause between + for (let i = 0; i < 12; i++) await settle(1); + expect(heading()).toBe("Matching pages 120"); + expect(rows()).toHaveLength(100); + expect(getPage).toHaveBeenCalledTimes(100); + expect(container.textContent).toContain("Showing the first 100 matching pages"); + }); + + it("does not say the list is cut when everything is shown", async () => { + const { pdf } = makePdf(manyPages(12)); + await mount(pdf, makeController(manyMatches(12))); + await settle(); + for (let i = 0; i < 3; i++) await settle(1); + expect(rows()).toHaveLength(12); + expect(container.textContent).not.toContain("Showing the first 100"); + }); + + it("shows the first slice of results while it reads on", async () => { + const gate = deferred(); + const pages = manyPages(12); + pages[11] = gate.promise; + const { pdf } = makePdf(pages); + await mount(pdf, makeController(manyMatches(12))); + await settle(); + expect(rows()).toHaveLength(10); + gate.resolve([{ str: "page 11 needle" }]); + await settle(); + expect(rows()).toHaveLength(12); + }); + + it("abandons a read that a newer search has replaced", async () => { + const gate = deferred(); + const { pdf, getPage } = makePdf({ 1: [{ str: "needle" }], 2: gate.promise, 3: [{ str: "needle" }] }); + await mount(pdf, makeController({ 1: { offsets: [0], length: 6 }, 2: { offsets: [0], length: 6 }, 3: { offsets: [0], length: 6 } })); + await settle(); + expect(getPage).toHaveBeenCalledTimes(2); // page 3 hasn't been asked for: page 2 is still being read + await act(async () => { bus.emit("find"); }); + gate.resolve([{ str: "needle" }]); + await settle(1); + expect(getPage).toHaveBeenCalledTimes(2); + expect(rows()).toHaveLength(0); + }); + + it("does not publish a slice that a newer search has replaced", async () => { + const gate = deferred(); + const pages = manyPages(12); + pages[10] = gate.promise; + const { pdf } = makePdf(pages); + await mount(pdf, makeController(manyMatches(12))); + await settle(); + await act(async () => { bus.emit("find"); }); + gate.resolve([{ str: "page 10 needle" }]); + await settle(1); + expect(rows()).toHaveLength(0); + }); + + it("does not publish a finished list that a newer search has replaced", async () => { + const gate = deferred(); + const { pdf } = makePdf({ 1: gate.promise }); + await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } })); + await settle(); + await act(async () => { bus.emit("find"); }); + gate.resolve([{ str: "needle" }]); + await settle(1); + expect(rows()).toHaveLength(0); + }); + + it("starts afresh for another document", async () => { + const first = makePdf({ 1: [{ str: "first needle" }] }); + await mount(first.pdf, makeController({ 1: { offsets: [6], length: 6 } })); + await settle(); + expect(rows()[0].textContent).toContain("first needle"); + const second = makePdf({ 1: [{ str: "second needle" }] }); + await mount(second.pdf, makeController({ 1: { offsets: [7], length: 6 } })); + await settle(); + expect(rows()[0].textContent).toContain("second needle"); + expect(bus.count("find")).toBe(1); + }); + + it("stops listening when it goes away", async () => { + const { pdf } = makePdf({}); + await mount(pdf, { pageMatches: [] }); + expect(bus.count("find")).toBe(1); + expect(bus.count("updatefindmatchescount")).toBe(1); + expect(bus.count("updatetextlayermatches")).toBe(1); + act(() => root.unmount()); + expect(bus.count("find") + bus.count("updatefindmatchescount") + bus.count("updatetextlayermatches")).toBe(0); + root = createRoot(container); // so afterEach has something to unmount + }); +}); diff --git a/src/SearchResults.tsx b/src/SearchResults.tsx index d91514a..fc0aa0f 100644 --- a/src/SearchResults.tsx +++ b/src/SearchResults.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import type { PDFDocumentProxy } from "./pdf"; import type { EventBus, PDFFindController } from "./viewer"; +import { pageText, snippetAt, type PageText, type TextPiece } from "./lib/search"; import { keys } from "./platform"; interface PageResult { @@ -11,6 +12,8 @@ interface PageResult { after: string; } +const UNREADABLE: PageText = { text: "", breaks: [] }; + export function SearchResults({ pdf, controller, bus, onSelect }: { pdf: PDFDocumentProxy; controller: PDFFindController; @@ -19,7 +22,7 @@ export function SearchResults({ pdf, controller, bus, onSelect }: { }) { const [results, setResults] = useState([]); const [pageCount, setPageCount] = useState(0); - const textCache = useRef(new Map>()); + const textCache = useRef(new Map>()); useEffect(() => { let generation = 0; @@ -35,18 +38,13 @@ export function SearchResults({ pdf, controller, bus, onSelect }: { if (request !== generation) return; let text = textCache.current.get(entry.page); if (!text) { - // Match offsets count characters in the text as pdf.js's find controller reads it: not normalized. - // Normalized text can be longer (a ligature becomes two letters), which moves the marked words. + // Read the page the way find does: its offsets are into the text as stored, not as normalized for display. text = pdf.getPage(entry.page).then(page => page.getTextContent({ disableNormalization: true })).then(content => - content.items.map(item => "str" in item ? item.str : "").join("")); + pageText(content.items as TextPiece[])); textCache.current.set(entry.page, text); } - const content = await text.catch(() => ""); - const offset = entry.offset; - next.push({ page: entry.page, count: entry.count, - before: (offset > 55 ? "..." : "") + content.slice(Math.max(0, offset - 55), offset), - match: content.slice(offset, offset + entry.length), - after: content.slice(offset + entry.length, offset + entry.length + 100) + (content.length > offset + entry.length + 100 ? "..." : "") }); + const content = await text.catch(() => UNREADABLE); + next.push({ page: entry.page, count: entry.count, ...snippetAt(content, entry.offset, entry.length) }); if (next.length % 10 === 0 && request === generation) { setResults([...next]); await new Promise(resolve => window.setTimeout(resolve, 0)); @@ -62,6 +60,8 @@ export function SearchResults({ pdf, controller, bus, onSelect }: { const clear = () => { generation++; window.clearTimeout(timer); + // OCR can add text between searches without replacing the PDF object. + textCache.current.clear(); setResults([]); setPageCount(0); }; diff --git a/src/SettingsDialog.tsx b/src/SettingsDialog.tsx index d1bef83..d2a65b8 100644 --- a/src/SettingsDialog.tsx +++ b/src/SettingsDialog.tsx @@ -1,3 +1,4 @@ +import { clearRecognizedText } from "./recent"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { Modal } from "./Dialogs"; import { CLAUDE_CODE_URL, PROVIDERS, savedProvider, saveAiPreference, signInChatGPT, USAGE_URL, type AiProvider } from "./ai"; @@ -152,6 +153,7 @@ export function SettingsDialog({ initialSection = "General", theme, onTheme, nig const desktop = window.folioDesktop; const ai = desktop?.ai; const [error, setError] = useState(null); + const [ocrCleared, setOcrCleared] = useState(false); const [section, setSection] = useState<"General" | "Ask AI" | "Storage">(initialSection); // Updates @@ -262,7 +264,16 @@ export function SettingsDialog({ initialSection = "General", theme, onTheme, nig )} - {section === "Storage" && <>
+ {section === "Storage" && <>
+ + + +
+ +
diff --git a/src/Welcome.tsx b/src/Welcome.tsx index ab11a10..d15a604 100644 --- a/src/Welcome.tsx +++ b/src/Welcome.tsx @@ -66,6 +66,7 @@ interface Props { export function Welcome({ active, onPick, onOpenUrl, onOpenRecent, onSample, loading }: Props) { const [recent, setRecent] = useState([]); + const [recentError, setRecentError] = useState(null); const [version, setVersion] = useState(null); const [update, setUpdate] = useState(null); @@ -83,8 +84,8 @@ export function Welcome({ active, onPick, onOpenUrl, onOpenRecent, onSample, loa }, [active]); const forget = async (id: string) => { - await removeRecent(id); - setRecent((r) => r.filter((f) => f.id !== id)); + try { await removeRecent(id); setRecent((r) => r.filter((f) => f.id !== id)); setRecentError(null); } + catch { setRecentError("Couldn’t remove the recent file and its recognized text. Please try again."); } }; return ( @@ -123,14 +124,15 @@ export function Welcome({ active, onPick, onOpenUrl, onOpenRecent, onSample, loa
+ {recentError &&

{recentError}

}
{recent.map((f) => (
onOpenRecent(f)} role="button" tabIndex={0} onKeyDown={(e) => e.key === "Enter" && onOpenRecent(f)}> diff --git a/src/desktop.d.ts b/src/desktop.d.ts index 80ebcd3..497ffaf 100644 --- a/src/desktop.d.ts +++ b/src/desktop.d.ts @@ -73,7 +73,7 @@ interface FolioAi { openRouterModels(): Promise<{ ok: boolean; models?: OpenRouterModel[]; error?: string }>; customEndpoint(action: "save" | "check" | "remove", input?: { baseUrl: string; key: string }): Promise<{ ok: boolean; status?: CustomEndpointStatus; error?: string }>; customModels(): Promise<{ ok: boolean; models?: OpenRouterModel[]; error?: string }>; - ask(request: { id: string; provider: "chatgpt" | "claude" | "openrouter" | "custom"; model: string; document: { name: string; text: string; truncated: boolean }; + ask(request: { id: string; provider: "chatgpt" | "claude" | "openrouter" | "custom"; model: string; document: { name: string; text: string; truncated: boolean; recognized: boolean }; messages: AiMessage[] }): Promise; cancel(id: string): Promise; onEvent(callback: (event: AiEvent) => void): () => void; diff --git a/src/hooks/ocr/index.ts b/src/hooks/ocr/index.ts new file mode 100644 index 0000000..5912c79 --- /dev/null +++ b/src/hooks/ocr/index.ts @@ -0,0 +1 @@ +export { useOcrSearchRefresh } from "./useOcrSearchRefresh"; diff --git a/src/hooks/ocr/useOcrSearchRefresh.test.tsx b/src/hooks/ocr/useOcrSearchRefresh.test.tsx new file mode 100644 index 0000000..5c53bce --- /dev/null +++ b/src/hooks/ocr/useOcrSearchRefresh.test.tsx @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OcrSession } from "../../lib/ocr/session/OcrSession"; +import type { OcrPhase } from "../../lib/ocr/types"; +import { findControllerFor } from "../../testing/pdfjs"; +import { useOcrSearchRefresh } from "./useOcrSearchRefresh"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Props = Parameters[0]; +function Harness(props: Props) { + useOcrSearchRefresh(props); + return null; +} + +let root: Root; +let container: HTMLElement; +beforeEach(() => { + container = document.createElement("div"); + root = createRoot(container); +}); +afterEach(() => act(() => root.unmount())); + +const render = (props: Props) => act(async () => { root.render(); }); +const sessionAt = (version: number, id = "pdf") => ({ version, pdf: { id } }) as unknown as OcrSession & { version: number }; + +/** Stands in for PDFFindController, including what matters here: setDocument forgets the search. */ +function fakeFind(request: object | null, highlightMatches = true) { + const controller = { + highlightMatches, + state: request, + setDocument: vi.fn(() => { controller.state = null; controller.highlightMatches = false; }), + }; + return controller; +} +const SEARCH = { source: null, type: "again", query: "invoice", caseSensitive: true, entireWord: false, highlightAll: true, findPrevious: true, matchDiacritics: false }; + +/** What happens when the reader starts recognition: it runs, adds text, and ends. The hook only looks when the phase changes. */ +async function recognizeMore(props: Props, version: number, end: OcrPhase = "done") { + await render({ ...props, phase: "running" }); + (props.session as { version: number }).version = version; + await render({ ...props, phase: end }); +} + +function setup(overrides: Partial = {}, request: object | null = SEARCH, highlightMatches = true) { + const controller = fakeFind(request, highlightMatches); + const dispatch = vi.fn(); + const props: Props = { + session: sessionAt(0), searchOpen: true, phase: "done" as OcrPhase, + find: { current: controller as never }, bus: { current: { dispatch } as never }, ...overrides, + }; + return { controller, dispatch, props }; +} + +describe("useOcrSearchRefresh", () => { + it("leaves find alone when there is no document", async () => { + const { controller, props } = setup({ session: null }); + await render(props); + expect(controller.setDocument).not.toHaveBeenCalled(); + }); + + it("leaves find alone while nothing has been recognized since it last looked", async () => { + const { controller, props } = setup(); + await render(props); + await render({ ...props, phase: "running" }); + await render({ ...props, phase: "done" }); + expect(controller.setDocument).not.toHaveBeenCalled(); + }); + + it("points find at the document again once new text exists, and runs the search again", async () => { + const { controller, dispatch, props } = setup(); + const session = props.session!; + await render(props); + await recognizeMore(props, 3); + expect(controller.setDocument).toHaveBeenCalledTimes(1); + expect(controller.setDocument).toHaveBeenCalledWith(session.pdf); + expect(dispatch).toHaveBeenCalledTimes(1); + // Same words and options, but as a new forward search: not a repeat, and not backwards. + expect(dispatch).toHaveBeenCalledWith("find", { ...SEARCH, type: "", findPrevious: false }); + }); + + it("reads the search before find forgets it, and runs it only after find has been reset", async () => { + const { controller, dispatch, props } = setup(); + await render(props); + await recognizeMore(props, 1); + expect(controller.state).toBeNull(); + expect(controller.setDocument.mock.invocationCallOrder[0]).toBeLessThan(dispatch.mock.invocationCallOrder[0]); + }); + + it("waits while recognition is running, then refreshes once", async () => { + // The find bar is opened mid-run, after some pages already have text: find would only be reset again by the next page. + const { controller, dispatch, props } = setup({ phase: "running", searchOpen: false }, SEARCH, false); + await render(props); + (props.session as { version: number }).version = 2; + await render({ ...props, searchOpen: true }); + expect(controller.setDocument).not.toHaveBeenCalled(); + (props.session as { version: number }).version = 5; + await render({ ...props, searchOpen: true, phase: "done" }); + expect(controller.setDocument).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); // opened after the search was closed: nothing is showing to bring back + await render({ ...props, searchOpen: true, phase: "done" }); + expect(controller.setDocument).toHaveBeenCalledTimes(1); + }); + + it("brings the search back once a run that was going when it started has ended", async () => { + const { controller, dispatch, props } = setup({ phase: "running" }); + await render(props); + (props.session as { version: number }).version = 2; + await render({ ...props, phase: "running" }); + expect(controller.setDocument).not.toHaveBeenCalled(); + await render({ ...props, phase: "done" }); // the pages read so far are searchable + expect(controller.setDocument).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it("refreshes again for text that arrives later", async () => { + const { controller, props } = setup(); + await render(props); + await recognizeMore(props, 1); + await recognizeMore(props, 2); + expect(controller.setDocument).toHaveBeenCalledTimes(2); + }); + + it("holds off while the find bar is closed, and catches up when it opens", async () => { + const { controller, dispatch, props } = setup({ searchOpen: false }, SEARCH, false); + await render(props); + await recognizeMore(props, 4); + expect(controller.setDocument).not.toHaveBeenCalled(); + await render({ ...props, searchOpen: true }); + expect(controller.setDocument).toHaveBeenCalledTimes(1); + // The search that was last typed belongs to a find bar that has since been closed: the new one starts empty. + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("does not bring back a search that was never typed", async () => { + for (const request of [null, { ...SEARCH, query: "" }]) { + const { controller, dispatch, props } = setup({}, request); + await render(props); + await recognizeMore(props, 1); + expect(controller.setDocument).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + await act(async () => root.unmount()); + root = createRoot(container); + } + }); + + it("starts a new document from the text it was opened with", async () => { + const { controller, props } = setup({ session: sessionAt(0, "first") }); + await render(props); + // The second document was opened with text already recognized (restored from an earlier visit): find has seen it all. + const second = sessionAt(7, "second"); + await render({ ...props, session: second }); + await render({ ...props, session: second, phase: "running" }); + await render({ ...props, session: second, phase: "done" }); + expect(controller.setDocument).not.toHaveBeenCalled(); + await recognizeMore({ ...props, session: second }, 8); + expect(controller.setDocument).toHaveBeenCalledTimes(1); + expect(controller.setDocument).toHaveBeenCalledWith(second.pdf); + }); +}); + +describe("with the real find controller", () => { + /** A two-page document whose text can change after it was searched, like a scan being recognized. */ + function documentWith(text: Record) { + return { + numPages: 2, + getPage: async (n: number) => ({ getTextContent: async () => ({ items: text[n] ? [{ str: text[n], hasEOL: false }] : [] }) }), + }; + } + const query = (eventBus: { dispatch(name: string, data: object): void }, q: string) => + eventBus.dispatch("find", { source: null, type: "", query: q, caseSensitive: false, entireWord: false, highlightAll: true, findPrevious: false, matchDiacritics: false }); + + it("finds words recognized after the search began, without the query being typed again", async () => { + const text: Record = { 1: "A typed cover page", 2: "" }; + const pdf = documentWith(text); + const { controller, eventBus } = await findControllerFor(pdf as never); + const session = sessionAt(0); + (session as { pdf: unknown }).pdf = pdf; + const props: Props = { session, searchOpen: true, phase: "running", find: { current: controller }, bus: { current: eventBus } }; + await render(props); + + query(eventBus, "invoice"); + await vi.waitFor(() => expect(controller.pageMatches).toHaveLength(2)); + expect(controller.pageMatches!.flat()).toEqual([]); + + // Page 2 is recognized. + text[2] = "Invoice number 42"; + (session as { version: number }).version = 1; + await render({ ...props, phase: "done" }); + + await vi.waitFor(() => expect(controller.pageMatches![1]).toEqual([0])); + expect(controller.pageMatchesLength![1]).toEqual([7]); + expect((controller as unknown as { state: { query: string } }).state.query).toBe("invoice"); + }); +}); diff --git a/src/hooks/ocr/useOcrSearchRefresh.ts b/src/hooks/ocr/useOcrSearchRefresh.ts new file mode 100644 index 0000000..4c55c5e --- /dev/null +++ b/src/hooks/ocr/useOcrSearchRefresh.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, type RefObject } from "react"; +import type { OcrSession } from "../../lib/ocr/session/OcrSession"; +import type { OcrPhase } from "../../lib/ocr/types"; +import type { EventBus, PDFFindController } from "../../viewer"; + +/** The request pdf.js find is working on (its `state`), as the find bar sent it. */ +type FindRequest = { query?: unknown }; + +/** + * pdf.js find reads every page's text once and keeps it, so words recognized after a search began are missing from its + * results. When recognition has stopped or finished and the search is open, find is pointed at the document again + * (which empties it), and the search that was showing is run again so the reader doesn't have to type it twice. As + * with typing it again, the view moves to the first match from the page being read. + */ +export function useOcrSearchRefresh({ session, searchOpen, phase, version, find, bus }: { + session: OcrSession | null; + searchOpen: boolean; + phase: OcrPhase; + version?: number; + find: RefObject; + bus: RefObject; +}) { + // The text version find has seen, per document: a new document starts from whatever it was opened with. + const seen = useRef<{ session: OcrSession; version: number } | null>(null); + useEffect(() => { + if (!session) return; + if (seen.current?.session !== session) seen.current = { session, version: session.version }; + // Mid-run, each page would reset find again, so it waits for a pause. + if (!searchOpen || session.version === seen.current.version || phase === "running") return; + seen.current.version = session.version; + const controller = find.current!; + // `highlightMatches` is true while a search is showing and false once the find bar was closed (its last query is + // then stale), and has to be read before setDocument clears both. + const request = controller.highlightMatches ? (controller as unknown as { state: FindRequest | null }).state : null; + controller.setDocument(session.pdf); + // A fresh forward search, not a repeat of the last Enter or Shift+Enter. + if (request?.query) bus.current!.dispatch("find", { ...request, type: "", findPrevious: false }); + }, [session, searchOpen, phase, version, find, bus]); +} diff --git a/src/lib/ocr/export/copyWithText.test.ts b/src/lib/ocr/export/copyWithText.test.ts new file mode 100644 index 0000000..fbde825 --- /dev/null +++ b/src/lib/ocr/export/copyWithText.test.ts @@ -0,0 +1,198 @@ +import { PDFDocument, PDFHexString, PDFName, PDFString } from "pdf-lib"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { protectedByOwner, protectedWithPassword } from "../../../testing/fixtures"; +import { openPdf } from "../../../testing/pdfjs"; +import type { OcrSession } from "../session/OcrSession"; +import type { TextItem } from "../types"; +import { copyWithText, savedMessage } from "./copyWithText"; + +// The real embedder, except where a test makes it fail. +vi.mock("./embedText", async (importOriginal) => { + const real = await importOriginal(); + return { embedRecognizedText: vi.fn(real.embedRecognizedText) }; +}); +import { embedRecognizedText } from "./embedText"; + +const word: TextItem = { str: "recognized", dir: "ltr", fontName: "ocr", hasEOL: false, transform: [12, 0, 0, 12, 72, 700], width: 70, height: 12, inkWidth: 70 }; + +async function blank() { + const doc = await PDFDocument.create(); + doc.addPage([612, 792]); + return doc.save(); +} + +interface FakeDocument { info?: object; fields?: Map | null; reading?: Error } +/** A session over a document that answers the questions about encryption and signatures. */ +function sessionFor({ info = {}, fields = null, reading }: FakeDocument = {}, pages: [number, unknown[]][] = [[1, [word]]], embedded: number[] = []) { + const pdf = { + getMetadata: vi.fn(async () => { if (reading) throw reading; return { info }; }), + getFieldObjects: vi.fn(async () => fields), + }; + return { pdf, pages: new Map(pages), embedded: new Set(embedded) } as unknown as OcrSession & { pdf: typeof pdf }; +} + +// (Braces: a function returned from beforeEach would be run afterwards as cleanup, and this one would be the mock itself.) +beforeEach(() => { vi.mocked(embedRecognizedText).mockClear(); }); + +describe("copyWithText", () => { + it("hands back the bytes untouched when nothing was recognized", async () => { + const bytes = await blank(); + expect(await copyWithText(null, bytes)).toEqual({ bytes, embedded: false }); + expect(await copyWithText(sessionFor({}, []), bytes)).toEqual({ bytes, embedded: false }); + // A page that was read and gave nothing new doesn't need anything added. + expect(await copyWithText(sessionFor({}, [[1, []], [2, []]]), bytes)).toEqual({ bytes, embedded: false }); + expect(embedRecognizedText).not.toHaveBeenCalled(); + }); + + it("puts the recognized text into the copy", async () => { + const bytes = await blank(); + const session = sessionFor({}, [[1, [word]]], [4]); + const copy = await copyWithText(session, bytes); + expect(copy.embedded).toBe(true); + expect(copy.problem).toBeUndefined(); + expect(embedRecognizedText).toHaveBeenCalledWith(bytes, session.pages, session.embedded); + const page = await (await openPdf(copy.bytes)).getPage(1); + expect(((await page.getTextContent()).items as { str: string }[]).map((i) => i.str)).toEqual(["recognized"]); + }); + + it("keeps recognized words and metadata on the corresponding reordered pages", async () => { + const doc = await PDFDocument.create(); + doc.addPage([612, 792]); + doc.addPage([612, 792]); + const session = sessionFor({}, [[1, [{ ...word, str: "first" }]], [2, [{ ...word, str: "second" }]]], [1]); + const copy = await copyWithText(session, await doc.save(), [2, 1]); + const pdf = await openPdf(copy.bytes); + const page1 = await (await pdf.getPage(1)).getTextContent(); + const page2 = await (await pdf.getPage(2)).getTextContent(); + expect(page1.items.map(item => "str" in item ? item.str : "").join("")).toContain("second"); + expect(page2.items.map(item => "str" in item ? item.str : "").join("")).toContain("first"); + expect(embedRecognizedText).toHaveBeenCalledWith(expect.any(Uint8Array), expect.any(Map), new Set([2])); + }); + + it("remaps embedded page metadata even when no newly recognized text exists", async () => { + const doc = await PDFDocument.create(); + doc.addPage([612, 792]); + doc.addPage([612, 792]); + const copy = await copyWithText(sessionFor({}, [], [1]), await doc.save(), [2, 1]); + expect(copy.embedded).toBe(true); + expect(embedRecognizedText).toHaveBeenCalledWith(expect.any(Uint8Array), new Map(), new Set([2])); + }); + + it("goes ahead for a document with form fields that aren't signatures, or none", async () => { + const bytes = await blank(); + expect((await copyWithText(sessionFor({ fields: new Map([["name", [{ type: "text" }]]]) }), bytes)).embedded).toBe(true); + expect((await copyWithText(sessionFor({ fields: new Map() }), bytes)).embedded).toBe(true); + expect((await copyWithText(sessionFor({ fields: null }), bytes)).embedded).toBe(true); + }); + + it("leaves the text out of an encrypted file, which can't be rewritten", async () => { + const bytes = await blank(); + const copy = await copyWithText(sessionFor({ info: { EncryptFilterName: "Standard" } }), bytes); + expect(copy).toEqual({ bytes, embedded: false, problem: "protected" }); + expect(embedRecognizedText).not.toHaveBeenCalled(); + }); + + it("leaves the text out of a file with a signature field, because rewriting it could break a signature", async () => { + const bytes = await blank(); + const fields = new Map([["name", [{ type: "text" }]], ["sig", [{ type: "button" }, { type: "signature" }]]]); + const copy = await copyWithText(sessionFor({ fields }), bytes); + expect(copy).toEqual({ bytes, embedded: false, problem: "signature" }); + expect(embedRecognizedText).not.toHaveBeenCalled(); + }); + + it("keeps the original copy and explains when no page can take an accurate text layer", async () => { + const bytes = await blank(); + const session = sessionFor({}, [[1, [{ ...word, str: "日本語" }]]]); + const copy = await copyWithText(session, bytes); + expect(copy).toEqual({ bytes, embedded: false, problem: "characters" }); + expect(savedMessage({ markup: false, ...copy })).toMatch(/saved without an OCR text layer/); + }); + + it("still returns the copy, and says so, when the text can't be added", async () => { + const bytes = await blank(); + vi.mocked(embedRecognizedText).mockRejectedValueOnce(new Error("could not parse")); + expect(await copyWithText(sessionFor(), bytes)).toEqual({ bytes, embedded: false, problem: "failed" }); + }); + + it("does the same when it can't tell whether the file is protected or has a signature", async () => { + const bytes = await blank(); + expect(await copyWithText(sessionFor({ reading: new Error("no info") }), bytes)).toEqual({ bytes, embedded: false, problem: "failed" }); + }); +}); + +describe("savedMessage", () => { + it("is the plain message when there is nothing to report", () => { + expect(savedMessage({ markup: false, embedded: false })).toBe("Download started"); + }); + + it("says what the copy carries", () => { + expect(savedMessage({ markup: true, embedded: false })).toBe("Saved a copy with your highlights and notes"); + expect(savedMessage({ markup: false, embedded: true })).toBe("Saved a copy with the recognized text"); + expect(savedMessage({ markup: true, embedded: true })).toBe("Saved a copy with your highlights and notes and the recognized text"); + }); + + it("says why the recognized text was left out", () => { + expect(savedMessage({ markup: false, embedded: false, problem: "protected" })).toBe("Download started. The recognized text can't be added to a protected file."); + expect(savedMessage({ markup: true, embedded: false, problem: "signature" })).toBe("Saved a copy with your highlights and notes. The recognized text was left out because this file has a signature field, and rewriting it could break the signature."); + expect(savedMessage({ markup: false, embedded: false, problem: "failed" })).toBe("Download started. Couldn't add the recognized text."); + }); +}); + +// The guards above are only as good as the shapes they expect, so these run them against what the real pdf.js +// reports for real files (a stand-in could only repeat what we assumed). +describe("copyWithText with real files", () => { + const realSession = async (bytes: Uint8Array, password?: string) => + ({ pdf: await openPdf(bytes, password), pages: new Map([[1, [word]]]), embedded: new Set() }) as unknown as OcrSession; + + /** A page with a signature field: blank, or holding a signature (a placeholder: nothing checks it). */ + async function withSignatureField(signed: boolean) { + const doc = await PDFDocument.create(); + const page = doc.addPage([612, 792]); + const { context } = doc; + const field: Record = { Type: "Annot", Subtype: "Widget", FT: "Sig", T: PDFString.of("Signature1"), Rect: [50, 50, 200, 100], F: 132, P: page.ref }; + if (signed) { + field.V = context.register(context.obj({ + Type: "Sig", Filter: "Adobe.PPKLite", SubFilter: "adbe.pkcs7.detached", ByteRange: [0, 10, 20, 30], + Contents: PDFHexString.of("00".repeat(64)), M: PDFString.of("D:20260101000000Z"), + })); + } + const ref = context.register(context.obj(field as never)); + page.node.set(PDFName.of("Annots"), context.obj([ref])); + doc.catalog.set(PDFName.of("AcroForm"), context.obj({ Fields: [ref], SigFlags: signed ? 3 : 1 })); + return doc.save(); + } + + it("adds the text to an ordinary file", async () => { + const copy = await copyWithText(await realSession(await blank()), await blank()); + expect(copy.embedded).toBe(true); + }); + + it("adds it to a form that has fields but no signature", async () => { + const doc = await PDFDocument.create(); + doc.getForm().createTextField("name").addToPage(doc.addPage([612, 792]), { x: 50, y: 50, width: 100, height: 20 }); + const bytes = await doc.save(); + const session = await realSession(bytes); + expect([...(await session.pdf.getFieldObjects())!.keys()]).toEqual(["name"]); // there are fields, and they are not signatures + expect((await copyWithText(session, bytes)).embedded).toBe(true); + }); + + it.each([{ kind: "a signed file", signed: true }, { kind: "a signature field that is still blank", signed: false }])( + "leaves the text out of $kind", async ({ signed }) => { + const bytes = await withSignatureField(signed); + const copy = await copyWithText(await realSession(bytes), bytes); + expect(copy).toEqual({ bytes, embedded: false, problem: "signature" }); + expect(embedRecognizedText).not.toHaveBeenCalled(); + }); + + it("leaves the text out of a file that needs a password to open", async () => { + const copy = await copyWithText(await realSession(protectedWithPassword, "folio"), protectedWithPassword); + expect(copy).toEqual({ bytes: protectedWithPassword, embedded: false, problem: "protected" }); + expect(embedRecognizedText).not.toHaveBeenCalled(); + }); + + it("leaves the text out of a file that opens without a password but is encrypted all the same", async () => { + const copy = await copyWithText(await realSession(protectedByOwner), protectedByOwner); + expect(copy).toEqual({ bytes: protectedByOwner, embedded: false, problem: "protected" }); + expect(embedRecognizedText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ocr/export/copyWithText.ts b/src/lib/ocr/export/copyWithText.ts new file mode 100644 index 0000000..a078ff7 --- /dev/null +++ b/src/lib/ocr/export/copyWithText.ts @@ -0,0 +1,57 @@ +// "Save a copy" for a document with recognized text: put the text into the copy when that is safe, and say why when not. +import type { PDFDocumentProxy } from "../../../pdf"; +import type { OcrSession } from "../session/OcrSession"; +import { embedRecognizedText } from "./embedText"; + +/** Why a copy went without the recognized text. */ +export type TextProblem = "protected" | "signature" | "characters" | "failed"; + +export interface SavedCopy { + bytes: Uint8Array; + /** The recognized text is in `bytes`. */ + embedded: boolean; + problem?: TextProblem; +} + +/** + * Whether the document can't take the text: it is encrypted (pdf-lib can't read it), or has a signature field (rewriting + * the file could break a signature). pdf.js doesn't say whether a signature field is filled in, and a blank one looks + * just like a signed one, so any signature field counts. + */ +async function whyNot(pdf: PDFDocumentProxy): Promise { + const { info } = await pdf.getMetadata(); + if ((info as { EncryptFilterName?: string | null }).EncryptFilterName) return "protected"; + const fields = await pdf.getFieldObjects(); + for (const field of fields?.values() ?? []) { + if (field.some((f) => (f as { type?: string }).type === "signature")) return "signature"; + } +} + +/** `bytes` with the session's recognized text added; `bytes` itself, unchanged, when there is none or it can't be added. */ +export async function copyWithText(session: OcrSession | null, bytes: Uint8Array, order?: readonly number[]): Promise { + if (!session || (![...session.pages.values()].some((items) => items.length) && !(order && session.embedded.size))) return { bytes, embedded: false }; + try { + const problem = await whyNot(session.pdf); + if (problem) return { bytes, embedded: false, problem }; + const pages = order ? new Map(order.flatMap((source, i) => session.pages.has(source) ? [[i + 1, session.pages.get(source)!] as const] : [])) : session.pages; + const embedded = order ? new Set(order.flatMap((source, i) => session.embedded.has(source) ? [i + 1] : [])) : session.embedded; + const result = await embedRecognizedText(bytes, pages, embedded); + return { bytes: result.bytes, embedded: result.embedded, ...(result.unsupported && { problem: "characters" as const }) }; + } catch { + return { bytes, embedded: false, problem: "failed" }; + } +} + +const REASONS: Record = { + protected: "The recognized text can't be added to a protected file.", + signature: "The recognized text was left out because this file has a signature field, and rewriting it could break the signature.", + characters: "Pages with characters the export font can't write were saved without an OCR text layer.", + failed: "Couldn't add the recognized text.", +}; + +/** What to tell the reader once a copy is saved. */ +export function savedMessage({ markup, embedded, problem }: { markup: boolean } & Omit): string { + const extras = [markup && "your highlights and notes", embedded && "the recognized text"].filter(Boolean); + const saved = extras.length ? `Saved a copy with ${extras.join(" and ")}` : "Download started"; + return problem ? `${saved}. ${REASONS[problem]}` : saved; +} diff --git a/src/lib/ocr/export/embedText.test.ts b/src/lib/ocr/export/embedText.test.ts new file mode 100644 index 0000000..1fcb05e --- /dev/null +++ b/src/lib/ocr/export/embedText.test.ts @@ -0,0 +1,217 @@ +import { PDFArray, PDFDocument, PDFName, StandardFonts, decodePDFRawStream, type PDFRawStream } from "pdf-lib"; +import { describe, expect, it } from "vitest"; +import { protectedByOwner, protectedWithPassword } from "../../../testing/fixtures"; +import { openPdf } from "../../../testing/pdfjs"; +import type { TextItem } from "../types"; +import { embedRecognizedText } from "./embedText"; + +/** A recognized word: where its baseline starts, the width of its ink (and the box it takes up), and its size. */ +const word = (str: string, x: number, y: number, width: number, size = 12, over: Partial = {}): TextItem => ({ + str, dir: "ltr", fontName: "ocr", hasEOL: false, transform: [size, 0, 0, size, x, y], width, height: size, inkWidth: width, ...over, +}); + +async function blank(pages = 2, prepare?: (doc: PDFDocument) => void | Promise) { + const doc = await PDFDocument.create(); + for (let i = 0; i < pages; i++) doc.addPage([612, 792]); + await prepare?.(doc); + return doc.save(); +} + +/** The page's drawing instructions as written in the file, all of its content streams together. */ +async function contentOf(bytes: Uint8Array, n = 0) { + const doc = await PDFDocument.load(bytes); + const contents = doc.getPage(n).node.Contents(); + const streams = (contents instanceof PDFArray ? contents.asArray().map((ref) => doc.context.lookup(ref)) : [contents]) as PDFRawStream[]; + return streams.map((stream) => new TextDecoder("latin1").decode(decodePDFRawStream(stream).decode())).join("\n"); +} + +async function textOn(bytes: Uint8Array, n = 1) { + const page = await (await openPdf(bytes)).getPage(n); + const { items } = await page.getTextContent({ disableNormalization: true }); + // pdf.js marks some line ends with an item of no text. + return (items as { str: string; transform: number[]; width: number; height: number; hasEOL: boolean }[]).filter((i) => i.str !== ""); +} +const flat = (items: { str: string }[]) => items.map((i) => i.str).join("").replace(/\s+/g, " ").trim(); +const embed = async (bytes: Uint8Array, pages: [number, TextItem[]][], embedded: number[] = []) => + (await embedRecognizedText(bytes, new Map(pages), new Set(embedded))).bytes; + +describe("embedRecognizedText", () => { + it("lays each word over the page where it was recognized, as wide as its ink", async () => { + const out = await embed(await blank(), [[1, [ + word("Hello ", 72, 700, 90, 14, { width: 100 }), word("world", 172, 700, 80, 14), + word("second line", 72, 650, 150, 14), + ]]]); + const items = await textOn(out); + // Each word is its own piece of text, so each can be found and selected where it is on the scan, and the words of a + // line read as separated by a space. + expect(items.map((i) => i.str)).toEqual(["Hello", " ", "world", "second line"]); + const [hello, , world, second] = items; + expect(hello.transform.slice(4)).toEqual([72, 700]); + expect(hello.width).toBeCloseTo(90, 0); + expect(hello.height).toBeCloseTo(14, 1); + expect(world.transform.slice(4)).toEqual([172, 700]); + expect(world.width).toBeCloseTo(80, 0); + expect(second.transform.slice(4)).toEqual([72, 650]); + expect(second.width).toBeCloseTo(150, 0); + }); + + it("draws the words invisibly, so the scan looks exactly as it did", async () => { + const { OPS } = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const out = await embed(await blank(1), [[1, [word("hidden", 72, 700, 50), word("also", 72, 650, 40)]]]); + const { fnArray, argsArray } = await (await (await openPdf(out)).getPage(1)).getOperatorList(); + const modes = fnArray.flatMap((fn, i) => (fn === OPS.setTextRenderingMode ? [argsArray[i][0]] : [])); + expect(modes).toEqual([3]); // 3 is "neither fill nor stroke" + expect(fnArray).toContain(OPS.showText); + // Whatever the text changes is undone afterwards: every save has its restore (pdf.js would quietly make up the + // missing ones, so this reads the instructions as they are in the file). + const tokens = (await contentOf(out)).split(/\s+/); + expect(tokens.filter((t) => t === "q").length).toBeGreaterThan(0); + expect(tokens.filter((t) => t === "q")).toHaveLength(tokens.filter((t) => t === "Q").length); + // Nothing is painted: the text is the only thing on the page. + for (const painted of [OPS.fill, OPS.stroke, OPS.paintImageXObject, OPS.constructPath]) expect(fnArray).not.toContain(painted); + }); + + it("writes a space after a word that has one, for the readers that look for it between words", async () => { + const { OPS } = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const out = await embed(await blank(1), [[1, [word("one ", 72, 700, 40, 12, { width: 50 }), word("two", 122, 700, 40)]]]); + const { fnArray, argsArray } = await (await (await openPdf(out)).getPage(1)).getOperatorList(); + const shown = fnArray.flatMap((fn, i) => { + if (fn !== OPS.showText) return []; + return [(argsArray[i][0] as ({ unicode?: string } | number)[]).map((glyph) => (typeof glyph === "number" ? "" : glyph.unicode)).join("")]; + }); + expect(shown).toEqual(["one", " ", "two"]); + }); + + it("keeps the direction of a line that runs up the page", async () => { + const out = await embed(await blank(1), [[1, [word("upwards", 300, 200, 90, 12, { transform: [0, 12, -12, 0, 300, 200] })]]]); + const [item] = await textOn(out); + expect(item.str).toBe("upwards"); + expect(item.transform[0]).toBeCloseTo(0, 3); + expect(item.transform[1]).toBeGreaterThan(0); + expect(item.transform[2]).toBeLessThan(0); + expect(item.transform.slice(4)).toEqual([300, 200]); + }); + + it("leaves the page's own text where it was, with the recognized words beside it", async () => { + const bytes = await blank(1, async (doc) => { + doc.getPage(0).drawText("Typed stamp", { x: 50, y: 100, size: 10, font: await doc.embedFont(StandardFonts.Courier) }); + }); + const out = await embed(bytes, [[1, [word("scanned", 72, 700, 70)]]]); + expect((await textOn(out)).map((i) => i.str).sort()).toEqual(["Typed stamp", "scanned"]); + }); + + it("leaves pages without recognized words alone", async () => { + const bytes = await blank(3, async (doc) => { + doc.getPage(1).drawText("Page two is typed", { x: 50, y: 700, size: 12, font: await doc.embedFont(StandardFonts.Helvetica) }); + }); + const out = await embed(bytes, [[1, [word("one", 72, 700, 40)]], [2, []], [3, [word("three", 72, 700, 50)]]]); + expect(flat(await textOn(out, 1))).toBe("one"); + expect(flat(await textOn(out, 2))).toBe("Page two is typed"); + expect(flat(await textOn(out, 3))).toBe("three"); + }); + + it("keeps the text in place when the page's own drawing leaves a transform applied", async () => { + const bytes = await blank(1, (doc) => { + // Some scanners write "scale and move" without ever restoring it. + const stream = doc.context.stream("2 0 0 2 300 300 cm\n0 0 50 50 re f\n"); + doc.getPage(0).node.set(PDFName.of("Contents"), doc.context.register(stream)); + }); + const [item] = await textOn(await embed(bytes, [[1, [word("steady", 72, 700, 60)]]])); + expect(item.transform.slice(4)).toEqual([72, 700]); + expect(item.width).toBeCloseTo(60, 0); + }); + + it("works on a page that takes its resources from the page tree", async () => { + const bytes = await blank(1, (doc) => { + const page = doc.getPage(0); + const resources = page.node.get(PDFName.of("Resources")); + page.node.delete(PDFName.of("Resources")); + doc.catalog.Pages().set(PDFName.of("Resources"), resources!); + }); + expect(flat(await textOn(await embed(bytes, [[1, [word("inherited", 72, 700, 80)]]])))).toBe("inherited"); + }); + + it("keeps supported pages searchable and leaves other scans available for recognition", async () => { + const bytes = await blank(2); + const result = await embedRecognizedText(bytes, new Map([ + [1, [word("مرحبا", 72, 700, 100)]], [2, [word("naïve", 72, 700, 50)]], + ]), new Set()); + expect(result.unsupported).toBe(true); + expect(result.embedded).toBe(true); + expect(await textOn(result.bytes, 1)).toEqual([]); + expect(flat(await textOn(result.bytes, 2))).toBe("naïve"); + const { info } = await (await openPdf(result.bytes)).getMetadata(); + expect((info as { Custom: Map }).Custom.get("FolioOcrPages")).toBe("2"); + }); + + it("skips words it can't place, and adds no font to a page with none", async () => { + const out = await embed(await blank(2), [[1, [word("", 72, 700, 40), word("flat", 72, 650, 0)]], [2, [word("ok", 72, 700, 20)]]]); + expect(await textOn(out, 1)).toEqual([]); + const reloaded = await PDFDocument.load(out); + const fonts = (n: number) => String(reloaded.getPage(n).node.Resources()?.lookup(PDFName.of("Font"))); + expect(fonts(0)).not.toContain("FolioOcr"); + expect(fonts(1)).toContain("FolioOcr"); + }); + + it("ignores pages the document doesn't have", async () => { + const out = await embed(await blank(2), [[0, [word("zero", 72, 700, 40)]], [3, [word("three", 72, 700, 40)]], [2, [word("two", 72, 700, 40)]]]); + expect(flat(await textOn(out, 2))).toBe("two"); + }); + + it("does not use up the file's size: the font is one of the standard ones that readers already have", async () => { + const bytes = await blank(1); + const out = await embed(bytes, [[1, [word("one", 72, 700, 40), word("two", 120, 700, 40)]]]); + expect(out.length - bytes.length).toBeLessThan(3000); + }); + + it("does not change the bytes it was given", async () => { + const bytes = await blank(1); + const before = bytes.slice(); + await embed(bytes, [[1, [word("one", 72, 700, 40)]]]); + expect(bytes).toEqual(before); + }); + + it("records which pages have recognized text, including any a copy already had", async () => { + const out = await embed(await blank(6), [[1, [word("a", 72, 700, 10)]], [2, []], [3, [word("c", 72, 700, 10)]]], [5]); + const { info } = await (await openPdf(out)).getMetadata(); + // pdf.js hands the document info's own entries over as a Map. + expect((info as { Custom: Map }).Custom.get("FolioOcrPages")).toBe("1,3,5"); + }); + + it("keeps the document's own details, and adds the record to a file that had none", async () => { + const withInfo = await blank(1, (doc) => { doc.setTitle("Contract"); doc.setProducer("Scanner 9000"); }); + const { info } = await (await openPdf(await embed(withInfo, [[1, [word("a", 72, 700, 10)]]]))).getMetadata(); + expect(info).toMatchObject({ Title: "Contract", Producer: "Scanner 9000" }); + + const withoutInfo = await blank(1, (doc) => { doc.context.trailerInfo.Info = undefined; }); + const meta = await (await openPdf(await embed(withoutInfo, [[1, [word("a", 72, 700, 10)]]]))).getMetadata(); + expect((meta.info as { Custom: Map }).Custom.get("FolioOcrPages")).toBe("1"); + }); + + it("keeps notes and highlights saved with pdf.js", async () => { + const pdf = await openPdf(await blank(1)); + pdf.annotationStorage.setValue("pdfjs_internal_editor_0", { + annotationType: 15, color: [255, 0, 0], thickness: 4, opacity: 1, pageIndex: 0, rect: [90, 90, 210, 130], rotation: 0, structTreeParentId: null, + paths: { lines: [[100, 100, 150, 120, 200, 100]], points: [[100, 100, 150, 120, 200, 100]] }, + }); + const saved = await pdf.saveDocument(); + const out = await embed(saved, [[1, [word("kept", 72, 700, 40)]]]); + const page = await (await openPdf(out)).getPage(1); + expect((await page.getAnnotations()).map((a: { subtype: string }) => a.subtype)).toEqual(["Ink"]); + expect(flat(await textOn(out))).toBe("kept"); + }); + + it("refuses an encrypted file rather than writing something readers can't open", async () => { + const bytes = await blank(1, (doc) => { doc.context.trailerInfo.Encrypt = doc.context.register(doc.context.obj({ Filter: "Standard" })); }); + await expect(embed(bytes, [[1, [word("a", 72, 700, 10)]]])).rejects.toThrow(/encrypted/); + }); + + it.each([{ kind: "needs a password", bytes: protectedWithPassword }, { kind: "only has an owner password", bytes: protectedByOwner }])( + "refuses a real encrypted file that $kind", async ({ bytes }) => { + await expect(embed(bytes, [[1, [word("a", 72, 700, 10)]]])).rejects.toThrow(/encrypted/); + }); + + it("fails, rather than saving a broken file, when the bytes aren't a PDF", async () => { + await expect(embed(new Uint8Array([1, 2, 3]), [[1, [word("a", 72, 700, 10)]]])).rejects.toThrow(); + }); +}); diff --git a/src/lib/ocr/export/embedText.ts b/src/lib/ocr/export/embedText.ts new file mode 100644 index 0000000..59539f4 --- /dev/null +++ b/src/lib/ocr/export/embedText.ts @@ -0,0 +1,65 @@ +// Recognized text in a saved copy: the words go in as an invisible text layer on top of each scanned page, the way +// OCR tools produce searchable PDFs, so any PDF reader can search and select them. pdf-lib is loaded only when a copy +// with recognized text is saved. +import type { TextItem } from "../types"; +import { formatPages, PAGES_KEY } from "./pageList"; +import { placeWords } from "./placeWords"; + +/** The name the text's font goes by in each page's resources. */ +const FONT_TAG = "FolioOcr"; + +/** + * Returns `bytes` with the words of `pages` (page number to recognized text items) laid invisibly over those pages. + * `embedded` lists pages that already carry text from an earlier copy, so the list the copy records stays complete. + * + * The file is rewritten, not appended to, so anything that depends on its exact bytes (a digital signature) would + * stop being valid, and an encrypted file can't be read at all: the caller checks for both first (see copyWithText). + */ +export async function embedRecognizedText(bytes: Uint8Array, pages: ReadonlyMap, embedded: ReadonlySet) { + const lib = await import("pdf-lib"); + const doc = await lib.PDFDocument.load(bytes, { updateMetadata: false }); + const font = await doc.embedFont(lib.StandardFonts.Helvetica); + const supported = new Set(font.getCharacterSet()); + const widthOfEm = (text: string) => font.widthOfTextAtSize(text, 1); + const space = font.encodeText(" "); + + const written = new Set(embedded); + let unsupported = false; + for (const [n, items] of pages) { + if (n < 1 || n > doc.getPageCount()) continue; + const runs = placeWords(items as TextItem[], widthOfEm, supported); + if (runs === null) { unsupported = true; continue; } + if (!runs.length) continue; + written.add(n); + const page = doc.getPage(n - 1); + const operators = [ + lib.pushGraphicsState(), lib.beginText(), + lib.setTextRenderingMode(lib.TextRenderingMode.Invisible), + lib.setFontAndSize(page.node.newFontDictionary(FONT_TAG, font.ref), 1), + ]; + for (const run of runs) { + const [a, b, c, d, e, f] = run.matrix; + operators.push(lib.setTextMatrix(a, b, c, d, e, f), lib.setCharacterSqueeze(run.squeeze), lib.showText(font.encodeText(run.text))); + if (run.gap !== undefined) operators.push(lib.setCharacterSqueeze(run.gap), lib.showText(space)); + } + // pdf-lib wraps what the page already draws in a save/restore pair first, so a scanner that left a transform + // applied can't move the text. + page.pushOperators(...operators, lib.endText(), lib.popGraphicsState()); + } + + // Which pages have recognized text, so Folio doesn't offer to read them again when the copy is opened. + const { context } = doc; + let info = context.lookupMaybe(context.trailerInfo.Info, lib.PDFDict); + const pageList = lib.PDFName.of(PAGES_KEY); + if (!written.size) { + if (!info?.has(pageList)) return { bytes, embedded: false, unsupported }; + info.delete(pageList); + } else { + if (!info) { + info = context.obj({}); + context.trailerInfo.Info = context.register(info); + } + info.set(pageList, lib.PDFString.of(formatPages(written))); + } + return { bytes: await doc.save(), embedded: written.size > 0, unsupported }; +} diff --git a/src/lib/ocr/export/pageList.test.ts b/src/lib/ocr/export/pageList.test.ts new file mode 100644 index 0000000..6c09f2e --- /dev/null +++ b/src/lib/ocr/export/pageList.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { formatPages, parsePages } from "./pageList"; + +describe("formatPages", () => { + it("writes runs of pages as ranges", () => { + expect(formatPages([1, 2, 3, 5, 7, 8])).toBe("1-3,5,7-8"); + }); + + it("writes a single page, and nothing for no pages", () => { + expect(formatPages([4])).toBe("4"); + expect(formatPages([])).toBe(""); + }); + + it("ignores order and repeats", () => { + expect(formatPages(new Set([9, 3, 4, 3, 1]))).toBe("1,3-4,9"); + expect(formatPages([2, 1, 2, 1])).toBe("1-2"); + }); + + it("keeps two pages that are not neighbours apart", () => { + expect(formatPages([1, 3])).toBe("1,3"); + }); +}); + +describe("parsePages", () => { + it("reads what formatPages wrote", () => { + for (const pages of [[1, 2, 3, 5, 7, 8], [4], [], [1, 3], [10, 11, 12, 40]]) { + expect([...parsePages(formatPages(pages), 100)]).toEqual(pages); + } + }); + + it("tolerates spaces", () => { + expect([...parsePages(" 1 - 2 , 5 ", 10)]).toEqual([1, 2, 5]); + }); + + it("skips anything that isn't a page or a range", () => { + expect([...parsePages("2,abc,,3-x,-4,5-,6.5,7", 10)]).toEqual([2, 7]); + }); + + it("stays within the document", () => { + expect([...parsePages("0,1-3,8,9-99999999999", 4)]).toEqual([1, 2, 3]); + expect([...parsePages("0-2", 4)]).toEqual([1, 2]); + }); + + it("is not held up by a huge range", () => { + // Walking this range would take minutes; it is cut to the pages the document has. + expect([...parsePages("1-4000000000", 3)]).toEqual([1, 2, 3]); + }); + + it("only reads so much of a list", () => { + // 120,000 characters of nothing useful, then a page: past the limit, so it is not read. + expect(parsePages("x,".repeat(60_000) + "2", 3).size).toBe(0); + expect([...parsePages("2," + "x,".repeat(20_000), 3)]).toEqual([2]); + }); + + it("reads a range that runs backwards as empty", () => { + expect(parsePages("5-2", 10).size).toBe(0); + }); +}); diff --git a/src/lib/ocr/export/pageList.ts b/src/lib/ocr/export/pageList.ts new file mode 100644 index 0000000..62572eb --- /dev/null +++ b/src/lib/ocr/export/pageList.ts @@ -0,0 +1,34 @@ +// Which pages of a saved copy carry recognized text, written into the PDF's document info as "1-3,5". +// A copy opened again reads this back, so those pages aren't offered for recognition a second time. + +/** The document info entry that holds the list. */ +export const PAGES_KEY = "FolioOcrPages"; + +/** More than any real list needs; a PDF is untrusted, so what is read from it is bounded. */ +const MAX_LIST_LENGTH = 100_000; + +/** Page numbers as the shortest list of ranges: [1, 2, 3, 5] is "1-3,5". */ +export function formatPages(pages: Iterable): string { + const sorted = [...new Set(pages)].sort((a, b) => a - b); + const ranges: string[] = []; + for (let i = 0; i < sorted.length;) { + let end = i; + while (sorted[end + 1] === sorted[end] + 1) end++; + ranges.push(end === i ? `${sorted[i]}` : `${sorted[i]}-${sorted[end]}`); + i = end + 1; + } + return ranges.join(","); +} + +/** The page numbers in a list, within 1 to `total`. Anything that isn't a number or a range is skipped. */ +export function parsePages(list: string, total: number): Set { + const pages = new Set(); + for (const part of list.slice(0, MAX_LIST_LENGTH).split(",")) { + const match = /^\s*(\d+)\s*(?:-\s*(\d+)\s*)?$/.exec(part); + if (!match) continue; + const first = Number(match[1]); + const last = match[2] === undefined ? first : Number(match[2]); + for (let n = Math.max(1, first); n <= Math.min(last, total); n++) pages.add(n); + } + return pages; +} diff --git a/src/lib/ocr/export/placeWords.test.ts b/src/lib/ocr/export/placeWords.test.ts new file mode 100644 index 0000000..adea351 --- /dev/null +++ b/src/lib/ocr/export/placeWords.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import type { TextItem } from "../types"; +import { placeWords, writable } from "./placeWords"; + +// A font in which every letter is half an em wide and a space a quarter. +const widthOfEm = (text: string) => [...text].reduce((sum, c) => sum + (c === " " ? 0.25 : 0.5), 0); +const supported = new Set([..." abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?.,"].map((c) => c.codePointAt(0)!).concat(0xe9, 0xa0)); + +const item = (str: string, over: Partial = {}): TextItem => ({ + str, dir: "ltr", fontName: "ocr", hasEOL: false, transform: [10, 0, 0, 10, 100, 200], width: 50, height: 10, ...over, +}); + +describe("writable", () => { + it("keeps what the font can write", () => { + expect(writable("Hello, world 42 café", supported)).toBe("Hello, world 42 café"); + }); + + it("writes a character the font lacks as the plain letters it is made of", () => { + expect(writable("final", supported)).toBe("final"); + expect(writable("ǎ", supported)).toBeNull(); + expect(writable("²", supported)).toBe("2"); + }); + + it("refuses to replace letters or symbols with question marks", () => { + for (const text of ["مرحبا", "日本語", "Ω", "≥", "😀"]) expect(writable(text, supported)).toBeNull(); + }); + + it("preserves decomposed accents when their composed form is available", () => { + expect(writable("ét", supported)).toBe("ét"); + }); + + it("writes other kinds of space as a space", () => { + expect(writable("a\tb\nc d", supported)).toBe("a b c d"); + }); +}); + +describe("placeWords", () => { + it("places a word at its own transform, squeezed so its advance is the width of its ink", () => { + // 4 letters at size 10 are 20 wide; the ink is 50 wide, so the text is stretched to 250%. + const [run] = placeWords([item("word", { inkWidth: 50 })], widthOfEm, supported)!; + expect(run).toEqual({ matrix: [10, 0, 0, 10, 100, 200], text: "word", squeeze: 250 }); + }); + + it("falls back to the item's width when it has no separate ink width", () => { + expect(placeWords([item("word", { width: 40 })], widthOfEm, supported)![0].squeeze).toBe(200); + }); + + it("follows a word that is followed by a space with a space that spans the gap", () => { + // The word's ink is 30 wide of the 50 the item takes; the 20 left over is bridged by a space (2.5 at size 10). + const [run] = placeWords([item("word ", { width: 50, inkWidth: 30 })], widthOfEm, supported)!; + expect(run.text).toBe("word"); + expect(run.squeeze).toBe(150); + expect(run.gap).toBe(800); + }); + + it("closes the space up when there is no gap, rather than leaving the word out", () => { + const [run] = placeWords([item("word ", { width: 30, inkWidth: 30 })], widthOfEm, supported)!; + expect(run.gap).toBe(1); + }); + + it("does not add a space after the last word of a line", () => { + expect(placeWords([item("word", { width: 50, inkWidth: 30 })], widthOfEm, supported)![0].gap).toBeUndefined(); + }); + + it("keeps the slant and direction of a line that is rotated or skewed", () => { + const transform = [0, 10, -10, 0, 300, 400]; // reading upwards + const [run] = placeWords([item("up", { transform, inkWidth: 25 })], widthOfEm, supported)!; + expect(run.matrix).toEqual(transform); + expect(run.squeeze).toBe(250); // size is the length of the first axis, not just its x part + }); + + it("refuses a partial layer on a page with unencodable text", () => { + expect(placeWords([item("English"), item("a≥b")], widthOfEm, supported)).toBeNull(); + }); + + it("leaves out items that can't be drawn", () => { + const bad = [ + item(""), item(" "), // nothing to write + item("zero", { transform: [0, 0, 0, 0, 1, 1] }), // no size + item("flat", { inkWidth: 0 }), item("neg", { width: -5 }), // no width + item("nan", { inkWidth: NaN }), item("inf", { width: Infinity }), item("nan2", { transform: [10, 0, 0, 10, NaN, 0] }), + ]; + expect(placeWords(bad, widthOfEm, supported)).toEqual([]); + }); + + it("leaves out an item whose text has no width in the font", () => { + expect(placeWords([item("word")], () => 0, supported)).toEqual([]); + }); + + it("keeps the good items around the bad", () => { + const runs = placeWords([item("one", { inkWidth: 30 }), item(""), item("two", { inkWidth: 30 })], widthOfEm, supported)!; + expect(runs.map((r) => r.text)).toEqual(["one", "two"]); + }); +}); diff --git a/src/lib/ocr/export/placeWords.ts b/src/lib/ocr/export/placeWords.ts new file mode 100644 index 0000000..b697ec4 --- /dev/null +++ b/src/lib/ocr/export/placeWords.ts @@ -0,0 +1,52 @@ +// Turning recognized words into text that sits where the words are on the scan, for the invisible text layer of a saved copy. +import type { TextItem } from "../types"; + +/** One piece of text to draw: where, how far to squeeze it sideways to fit the word's box, and the space after it. */ +export interface Run { + /** The text matrix: the word's own transform, which already carries its size and slant. */ + matrix: number[]; + text: string; + /** Horizontal scaling in percent (the PDF `Tz` operator) that makes the text exactly as wide as the word's ink. */ + squeeze: number; + /** Set when a space follows: its scaling, which makes it span the gap to the next word. */ + gap?: number; +} + +/** Return null when the export font would lose text, so the page stays available for recognition on reopen. */ +export function writable(text: string, supported: ReadonlySet): string | null { + let out = ""; + for (const char of text.normalize("NFC")) { + if (supported.has(char.codePointAt(0)!)) out += char; + else if (/\s/u.test(char)) out += " "; + else { + const plain = char.normalize("NFKC"); + if (![...plain].every(c => supported.has(c.codePointAt(0)!))) return null; + out += plain; + } + } + return out; +} + +/** + * `widthOfEm(text)` is how wide the font draws `text` at a size of 1. A word is drawn at size 1 under its own + * transform (which holds the real size), squeezed so that its advance is the width of its ink; the space that follows + * it (the recognized item's text ends in one) is drawn squeezed to fill the gap up to the next word. + */ +export function placeWords(items: TextItem[], widthOfEm: (text: string) => number, supported: ReadonlySet): Run[] | null { + const runs: Run[] = []; + for (const item of items) { + const [a, b] = item.transform; + const size = Math.hypot(a, b); + const ink = item.inkWidth ?? item.width; + if (size <= 0 || ink <= 0 || ![...item.transform, ink, item.width].every(Number.isFinite)) continue; + const word = writable(item.str.trim(), supported); + if (word === null) return null; + const natural = word ? widthOfEm(word) : 0; + if (natural <= 0) continue; + const run: Run = { matrix: item.transform, text: word, squeeze: (100 * ink) / (size * natural) }; + // (With no gap to bridge the space still has to be drawn, so it is squeezed to almost nothing rather than left out.) + if (/\s$/.test(item.str)) run.gap = Math.max(1, (100 * (item.width - ink)) / (size * widthOfEm(" "))); + runs.push(run); + } + return runs; +} diff --git a/src/lib/ocr/index.ts b/src/lib/ocr/index.ts new file mode 100644 index 0000000..4462141 --- /dev/null +++ b/src/lib/ocr/index.ts @@ -0,0 +1,4 @@ +// Recognized text session and searchable-copy export. +export { OcrSession, ocrSession } from "./session/OcrSession"; +export { copyWithText, savedMessage } from "./export/copyWithText"; +export type { OcrPhase } from "./types"; diff --git a/src/lib/ocr/session/OcrSession.test.ts b/src/lib/ocr/session/OcrSession.test.ts new file mode 100644 index 0000000..5ad644b --- /dev/null +++ b/src/lib/ocr/session/OcrSession.test.ts @@ -0,0 +1,277 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getOcrPages: vi.fn(async (_id: string, _languages: string[]) => new Map()), + saveOcrPage: vi.fn(async (_id: string, _page: number, _items: unknown[]) => {}), + createWorker: vi.fn(), +})); +vi.mock("../storage/ocrStore", () => ({ getOcrPages: mocks.getOcrPages, saveOcrPage: mocks.saveOcrPage, ocrLanguageKey: (languages: string[]) => [...new Set(languages)].join("+") || "eng" })); +vi.mock("../../../pdf", () => ({ + pdfjs: { OPS: { save: 10, restore: 11, transform: 12, paintImageXObject: 85, paintInlineImageXObject: 86, paintImageMaskXObject: 83 } }, +})); +vi.mock("tesseract.js", () => ({ createWorker: mocks.createWorker })); + +import { OcrSession, ocrSession } from "./OcrSession"; + +// ── Fakes ────────────────────────────────────────────────────────────────────────────────── +// A page is 612 x 792 points; at 300 dpi it is drawn 4.1667 pixels to the point. +const SCALE = 300 / 72; + +interface PageOptions { + /** Text the PDF itself carries. */ + items?: object[]; + /** A picture covering the whole page. */ + image?: boolean; +} + +const textItem = (x: number, y: number, width: number, height: number, str = "text") => ({ + str, dir: "ltr", fontName: "f", hasEOL: false, transform: [height, 0, 0, height, x, y], width, height, +}); +/** Enough text to be a typed page (about 13% of it). */ +const TYPED = [textItem(0, 0, 612, 100)]; +/** A small stamp, like the one on a scan with a filing number. */ +const STAMP = [textItem(50, 700, 50, 10)]; + +function makePage({ items = [], image = false }: PageOptions = {}) { + return { + view: [0, 0, 612, 792], + streamTextContent: vi.fn(() => new ReadableStream({ start(controller) { if (items.length) controller.enqueue({ items, styles: {} }); controller.close(); } })), + getOperatorList: vi.fn(async () => (image ? { fnArray: [12, 85], argsArray: [[612, 0, 0, 792, 0, 0], ["img"]] } : { fnArray: [], argsArray: [] })), + getViewport: ({ scale }: { scale: number }) => ({ width: 612 * scale, height: 792 * scale, convertToPdfPoint: (x: number, y: number) => [x / scale, 792 - y / scale] }), + render: vi.fn((_params: { canvas: { width: number; height: number }; background?: string }) => ({ promise: Promise.resolve() })), + cleanup: vi.fn(), + }; +} +type FakePage = ReturnType; + +/** `info` is what the document's info holds (or the error from reading it); documents without any can't be asked. */ +function makePdf(pages: FakePage[], info?: object | Error) { + const getMetadata = async () => { if (info instanceof Error) throw info; return { info }; }; + return { numPages: pages.length, getPage: async (n: number) => pages[n - 1], ...(info ? { getMetadata } : {}) } as unknown as ConstructorParameters[0]; +} +/** The document info of a copy Folio saved with recognized text on these pages. pdf.js gives non-standard entries as a Map. */ +const savedCopyInfo = (pages: string) => ({ Custom: new Map([["FolioOcrPages", pages]]) }); + +/** What Tesseract returns for one line of words, each given as [text, x0, x1, confidence] in pixels. */ +function recognized(...words: [string, number, number, number?][]) { + return { + data: { blocks: [{ paragraphs: [{ lines: [{ + words: words.map(([text, x0, x1, confidence = 95]) => ({ text, confidence, bbox: { x0, y0: 80, x1, y1: 110 } })), + bbox: { x0: 0, y0: 80, x1: 400, y1: 110 }, baseline: { x0: 0, y0: 100, x1: 400, y1: 100 }, + }] }] }] }, + }; +} +const nothing = { data: { blocks: null } }; + +function makeWorker(result: object = recognized(["Hello", 20, 100], ["world", 120, 220])) { + const worker = { recognize: vi.fn(async (..._args: unknown[]) => result), terminate: vi.fn(async () => {}) }; + mocks.createWorker.mockResolvedValue(worker); + return worker; +} + +const readAll = async (stream: ReadableStream) => { + const out: { items: { str: string }[]; lang?: string }[] = []; + const reader = stream.getReader(); + for (;;) { const { value, done } = await reader.read(); if (done) return out; out.push(value); } +}; + +const deferred = () => { let resolve!: () => void; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }; + +let canvases: { width: number; height: number }[]; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.getOcrPages.mockResolvedValue(new Map()); + mocks.createWorker.mockReset(); + canvases = []; + vi.stubGlobal("document", { createElement: () => { const canvas = { width: 0, height: 0 }; canvases.push(canvas); return canvas; } }); + vi.stubGlobal("location", { href: "http://localhost/" }); + vi.stubGlobal("navigator", { hardwareConcurrency: 4 }); +}); +afterEach(() => vi.unstubAllGlobals()); + +// ── Tests ────────────────────────────────────────────────────────────────────────────────── +describe("the page text override", () => { + it("leaves a page's own text alone when nothing was recognized", async () => { + const page = makePage({ items: STAMP }); + const pdf = makePdf([page]); + new OcrSession(pdf, "k"); + const chunks = await readAll((await pdf.getPage(1)).streamTextContent()); + expect(chunks.map((c) => c.items.length)).toEqual([1]); + }); + + it("puts recognized words after the page's own text", async () => { + const pdf = makePdf([makePage({ items: STAMP })]); + const session = new OcrSession(pdf, "k"); + session.pages.set(1, [{ str: "recognized" }]); + const chunks = await readAll((await pdf.getPage(1)).streamTextContent()); + expect(chunks.map((c) => c.items.map((i) => i.str))).toEqual([["text"], ["recognized"]]); + expect(chunks[1].lang).toBeNull(); + }); + + it("serves a page with no text of its own from the recognized words alone", async () => { + const pdf = makePdf([makePage()]); + new OcrSession(pdf, "k").pages.set(1, [{ str: "only" }]); + const chunks = await readAll((await pdf.getPage(1)).streamTextContent()); + expect(chunks.map((c) => c.items.map((i) => i.str))).toEqual([["only"]]); + }); + + it("ignores a page that was read and had nothing to add", async () => { + const pdf = makePdf([makePage({ items: STAMP })]); + new OcrSession(pdf, "k").pages.set(1, []); + expect(await readAll((await pdf.getPage(1)).streamTextContent())).toHaveLength(1); + }); + + it("is installed once per page, however often the page is asked for", async () => { + const pdf = makePdf([makePage({ items: STAMP })]); + new OcrSession(pdf, "k").pages.set(1, [{ str: "r" }]); + await pdf.getPage(1); + const again = await pdf.getPage(1); + expect(await readAll(again.streamTextContent())).toHaveLength(2); + }); + + it("is looked up from the document", () => { + const pdf = makePdf([makePage()]); + const session = new OcrSession(pdf, "k"); + expect(ocrSession(pdf)).toBe(session); + expect(ocrSession(makePdf([makePage()]))).toBeUndefined(); + }); +}); + +describe("restore", () => { + it("brings back pages recognized earlier and marks the text as changed", async () => { + mocks.getOcrPages.mockResolvedValue(new Map([[1, [{ str: "a" }]], [3, []]])); + const session = new OcrSession(makePdf([makePage(), makePage(), makePage()]), "doc-key"); + await session.restore(); + expect(mocks.getOcrPages).toHaveBeenCalledWith("doc-key", ["eng"]); + expect([...session.pages.keys()]).toEqual([1]); + expect(session.version).toBe(1); + }); + + it("leaves the version alone when there is nothing saved", async () => { + const session = new OcrSession(makePdf([makePage()]), "doc-key"); + await session.restore(); + expect(session.version).toBe(0); + }); + + it("does not touch storage for a document that isn't stored", async () => { + const session = new OcrSession(makePdf([makePage()]), null); + await session.restore(); + expect(mocks.getOcrPages).not.toHaveBeenCalled(); + }); + + it("learns which pages a copy saved by Folio already carries recognized text for", async () => { + const session = new OcrSession(makePdf([makePage(), makePage(), makePage(), makePage()], savedCopyInfo("1-2,4")), "k"); + await session.restore(); + expect([...session.embedded]).toEqual([1, 2, 4]); + expect(session.pages.size).toBe(0); + expect(session.version).toBe(0); // it is ordinary text in the PDF, which pdf.js already serves + }); + + it("learns it for a document that isn't stored too", async () => { + const session = new OcrSession(makePdf([makePage(), makePage()], savedCopyInfo("2")), null); + await session.restore(); + expect([...session.embedded]).toEqual([2]); + }); + + it("ignores a list that names pages the document doesn't have", async () => { + const session = new OcrSession(makePdf([makePage(), makePage()], savedCopyInfo("1-99")), "k"); + await session.restore(); + expect([...session.embedded]).toEqual([1, 2]); + }); + + it("finds nothing embedded when the document info doesn't say so", async () => { + for (const info of [{}, { Custom: new Map() }, { Custom: new Map([["Other", "1"]]) }, { Custom: new Map([["FolioOcrPages", 3]]) }]) { + const session = new OcrSession(makePdf([makePage(), makePage()], info), "k"); + await session.restore(); + expect(session.embedded.size).toBe(0); + } + }); + + it("carries on when the document info can't be read", async () => { + mocks.getOcrPages.mockResolvedValue(new Map([[1, [{}]]])); + const session = new OcrSession(makePdf([makePage()], new Error("bad info")), "k"); + await session.restore(); + expect(session.embedded.size).toBe(0); + expect(session.pages.size).toBe(1); + }); +}); + +describe("recording the reader OCR result", () => { + it("persists and exposes recognized text once", async () => { + const pdf = makePdf([makePage()]); + const session = new OcrSession(pdf, "key"); + const listener = vi.fn(); + session.onPage(listener); + session.record(1, [{ str: "recognized" }]); + session.record(1, [{ str: "duplicate" }]); + expect(mocks.saveOcrPage).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledOnce(); + expect(session.version).toBe(1); + expect((await readAll((await pdf.getPage(1)).streamTextContent()))[0].items[0].str).toBe("recognized"); + }); + it("does not keep an empty result that would block a later successful read", () => { + const session = new OcrSession(makePdf([makePage()]), null); + session.record(1, []); + session.record(1, [{ str: "later" }]); + expect(session.pages.get(1)).toEqual([{ str: "later" }]); + }); + it("does not retain late results after closing", () => { + const session = new OcrSession(makePdf([makePage()]), "key"); + session.destroy(); + session.record(1, [{ str: "late" }]); + expect(session.pages.size).toBe(0); + expect(mocks.saveOcrPage).not.toHaveBeenCalled(); + }); +}); + + +describe("changing recognition languages", () => { + it("clears the old text immediately, restores only the selected language, and notifies readers", async () => { + const session = new OcrSession(makePdf([makePage()]), "key"); + session.record(1, [{ str: "English" }]); + const listener = vi.fn(); + session.onPage(listener); + mocks.getOcrPages.mockResolvedValue(new Map([[1, [{ str: "Deutsch" }]]])); + const changing = session.setLanguages(["deu"]); + expect(session.pages.size).toBe(0); + expect(listener).toHaveBeenCalledWith(1); + await changing; + expect(mocks.getOcrPages).toHaveBeenLastCalledWith("key", ["deu"]); + expect(session.pages.get(1)).toEqual([{ str: "Deutsch" }]); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("does not let an older restore overwrite a newer language selection", async () => { + const session = new OcrSession(makePdf([makePage()]), "key"); + let finish!: (pages: Map) => void; + mocks.getOcrPages.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const old = session.setLanguages(["deu"]); + mocks.getOcrPages.mockResolvedValueOnce(new Map([[1, [{ str: "Spanish" }]]])); + await session.setLanguages(["spa"]); + finish(new Map([[1, [{ str: "German" }]]])); + await old; + expect(session.pages.get(1)).toEqual([{ str: "Spanish" }]); + }); + + it("does not restore into a closed session", async () => { + const session = new OcrSession(makePdf([makePage()]), "key"); + let finish!: (pages: Map) => void; + mocks.getOcrPages.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const restoring = session.setLanguages(["deu"]); + session.destroy(); + finish(new Map([[1, [{ str: "late" }]]])); + await restoring; + expect(session.pages.size).toBe(0); + }); + + it("keeps embedded PDF text and does not read storage for private documents", async () => { + const session = new OcrSession(makePdf([makePage()], savedCopyInfo("1")), null); + await session.restore(); + session.pages.set(2, [{ str: "old" }]); + await session.setLanguages(["deu"]); + expect(session.pages.size).toBe(0); + expect([...session.embedded]).toEqual([1]); + expect(mocks.getOcrPages).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ocr/session/OcrSession.ts b/src/lib/ocr/session/OcrSession.ts new file mode 100644 index 0000000..6c90ccd --- /dev/null +++ b/src/lib/ocr/session/OcrSession.ts @@ -0,0 +1,121 @@ +// Stores and exposes the reader’s recognized text for search, AI, and searchable PDF copies. +import type { PDFDocumentProxy } from "../../../pdf"; +import { PAGES_KEY, parsePages } from "../export/pageList"; +import { ocrEpoch } from "../../../recent"; +import type { ScanLanguage } from "../../../scanLanguage"; +import { getOcrPages, saveOcrPage, ocrLanguageKey } from "../storage/ocrStore"; +import { withRecognized } from "../text/pages"; +import { STYLES } from "../text/words"; + +const sessions = new WeakMap(); +export const ocrSession = (pdf: PDFDocumentProxy) => sessions.get(pdf); + +export class OcrSession { + readonly pdf: PDFDocumentProxy; + /** Pages with recognized text, by page number. */ + readonly pages = new Map(); + /** Pages whose recognized text is already inside the PDF itself, because it is a copy that Folio saved. */ + readonly embedded = new Set(); + /** Bumped whenever a page gains text, so caches built from page text know to refresh. */ + version = 0; + #key: string | null; + #epoch: string | null; + #pageListeners = new Set<(page: number) => void>(); + #destroyed = false; + #languages: ScanLanguage[]; + #generation = 0; + /** `key` is the document's content id; without one (password-protected files) results stay in memory only. */ + constructor(pdf: PDFDocumentProxy, key: string | null, languages: ScanLanguage[] = ["eng"]) { + this.pdf = pdf; + this.#languages = [...languages]; + this.#key = key; + this.#epoch = key ? ocrEpoch(key) : ""; + sessions.set(pdf, this); + // Install the override before the viewer asks for any page. + const getPage = pdf.getPage.bind(pdf); + const patched = new WeakSet(); + pdf.getPage = async (n: number) => { + const page = await getPage(n); + if (!patched.has(page)) { + patched.add(page); + const original = page.streamTextContent.bind(page) as () => ReadableStream; + page.streamTextContent = ((params?: object) => { + const items = this.pages.get(n); + const source = (original as (p?: object) => ReadableStream)(params); + // The PDF's own text (a stamp, a header) stays; recognized words follow it. + return items?.length ? withRecognized(source, items, STYLES) : source; + }) as typeof page.streamTextContent; + } + return page; + }; + } + + onPage(listener: (page: number) => void) { + this.#pageListeners.add(listener); + return () => { this.#pageListeners.delete(listener); }; + } + + /** Save the result of the reader's single OCR pass for search and export. */ + record(page: number, items: unknown[]) { + if (this.#destroyed || !items.length || this.pages.has(page) || this.embedded.has(page)) return; + this.pages.set(page, items); + if (this.#key) void saveOcrPage(this.#key, page, items, this.#epoch, this.#languages); + this.version++; + for (const listener of this.#pageListeners) listener(page); + } + + /** Brings back pages recognized in an earlier session. Call before the document is shown. */ + async restore() { + const generation = this.#generation; + await this.#readEmbedded(); + if (this.#destroyed || generation !== this.#generation) return; + await this.#restorePages(generation); + } + + /** Stop the old recognizer before changing languages; it must not record into the new selection. */ + async setLanguages(languages: ScanLanguage[]) { + if (this.#destroyed || ocrLanguageKey(languages) === ocrLanguageKey(this.#languages)) return; + this.#languages = [...languages]; + const generation = ++this.#generation; + const removed = [...this.pages.keys()]; + this.pages.clear(); + this.version++; + for (const page of removed) for (const listener of this.#pageListeners) listener(page); + await this.#restorePages(generation); + } + + async #restorePages(generation: number) { + if (!this.#key) return; + const pages = await getOcrPages(this.#key, this.#languages); + if (this.#destroyed || generation !== this.#generation || this.#epoch !== ocrEpoch(this.#key)) return; + const restored: number[] = []; + for (const [page, items] of pages) { + if (items.length && page >= 1 && page <= this.pdf.numPages && !this.embedded.has(page) && !this.pages.has(page)) { + this.pages.set(page, items); + restored.push(page); + } + } + if (restored.length) { + this.version++; + for (const page of restored) for (const listener of this.#pageListeners) listener(page); + } + } + + /** A copy saved by Folio lists, in its document info, the pages it carries recognized text for. */ + async #readEmbedded() { + try { + const { info } = await this.pdf.getMetadata(); + // pdf.js hands the info's own (non-standard) entries over as a Map. + const list = (info as { Custom?: Map }).Custom?.get(PAGES_KEY); + if (typeof list === "string") for (const n of parsePages(list, this.pdf.numPages)) this.embedded.add(n); + } catch { + /* no readable document info: nothing was embedded */ + } + } + + destroy() { + this.#destroyed = true; + this.#pageListeners.clear(); + sessions.delete(this.pdf); + } +} diff --git a/src/lib/ocr/session/restore.test.ts b/src/lib/ocr/session/restore.test.ts new file mode 100644 index 0000000..2cce901 --- /dev/null +++ b/src/lib/ocr/session/restore.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { restoreForOpen } from "./restore"; + +describe("OCR restore during document opening", () => { + it("destroys the old session if a newer document opens during restore", async () => { + let finish!: () => void; + const session = { restore: () => new Promise(resolve => { finish = resolve; }), destroy: vi.fn() }; + const controller = new AbortController(); + const old = restoreForOpen(session, controller.signal); + controller.abort(); finish(); + await expect(old).rejects.toMatchObject({ name: "AbortError" }); + expect(session.destroy).toHaveBeenCalledOnce(); + }); + it("cleans up if restore fails and leaves a successful session alive", async () => { + const failing = { restore: vi.fn().mockRejectedValue(new Error("failed")), destroy: vi.fn() }; + await expect(restoreForOpen(failing, new AbortController().signal)).rejects.toThrow("failed"); + expect(failing.destroy).toHaveBeenCalledOnce(); + const good = { restore: vi.fn().mockResolvedValue(undefined), destroy: vi.fn() }; + await restoreForOpen(good, new AbortController().signal); + expect(good.destroy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ocr/session/restore.ts b/src/lib/ocr/session/restore.ts new file mode 100644 index 0000000..d447cf1 --- /dev/null +++ b/src/lib/ocr/session/restore.ts @@ -0,0 +1,11 @@ +// An abandoned document must not keep its OCR hooks after another open begins. +export async function restoreForOpen(session: { restore(): Promise; destroy(): void }, signal: AbortSignal) { + try { + signal.throwIfAborted(); + await session.restore(); + signal.throwIfAborted(); + } catch (error) { + session.destroy(); + throw error; + } +} diff --git a/src/lib/ocr/storage/ocrStore.test.ts b/src/lib/ocr/storage/ocrStore.test.ts new file mode 100644 index 0000000..f075576 --- /dev/null +++ b/src/lib/ocr/storage/ocrStore.test.ts @@ -0,0 +1,275 @@ +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// recent.ts keeps its database connection at module level, so each test gets a fresh module and a fresh database. +async function freshStore() { + vi.resetModules(); + vi.stubGlobal("indexedDB", new IDBFactory()); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = new Map(); + vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value) }); + return { ...(await import("../../../recent")), ...(await import("./ocrStore")) }; +} + +beforeEach(() => vi.unstubAllGlobals()); +afterEach(() => vi.unstubAllGlobals()); + +describe("recognized text storage", () => { + it("saves a page and reads it back", async () => { + const { saveOcrPage, getOcrPages } = await freshStore(); + const items = [{ str: "Hello ", transform: [1, 0, 0, 1, 2, 3] }]; + await saveOcrPage("doc", 1, items); + expect(await getOcrPages("doc")).toEqual(new Map([[1, items]])); + }); + + it("returns every saved page of a document, with its number", async () => { + const { saveOcrPage, getOcrPages } = await freshStore(); + await saveOcrPage("doc", 1, [{ str: "a" }]); + await saveOcrPage("doc", 12, [{ str: "b" }]); + await saveOcrPage("doc", 100, [{ str: "c" }]); + const pages = await getOcrPages("doc"); + expect([...pages.keys()].sort((a, b) => a - b)).toEqual([1, 12, 100]); + expect(pages.get(12)).toEqual([{ str: "b" }]); + }); + + it("returns nothing for a document it has never seen", async () => { + const { getOcrPages } = await freshStore(); + expect((await getOcrPages("unknown")).size).toBe(0); + }); + + it("keeps a page that gave nothing new, as empty, so it isn't read again", async () => { + const { saveOcrPage, getOcrPages } = await freshStore(); + await saveOcrPage("doc", 3, []); + const pages = await getOcrPages("doc"); + expect(pages.has(3)).toBe(true); + expect(pages.get(3)).toEqual([]); + }); + + it("replaces a page saved before", async () => { + const { saveOcrPage, getOcrPages } = await freshStore(); + await saveOcrPage("doc", 1, [{ str: "old" }]); + await saveOcrPage("doc", 1, [{ str: "new" }]); + expect(await getOcrPages("doc")).toEqual(new Map([[1, [{ str: "new" }]]])); + }); + + it("keeps documents apart, including ids that start the same way", async () => { + const { saveOcrPage, getOcrPages } = await freshStore(); + await saveOcrPage("sha256:abc:10", 1, [{ str: "short" }]); + await saveOcrPage("sha256:abc:100", 1, [{ str: "long" }]); + await saveOcrPage("sha256:abc:100", 2, [{ str: "long 2" }]); + expect([...(await getOcrPages("sha256:abc:10")).values()]).toEqual([[{ str: "short" }]]); + expect((await getOcrPages("sha256:abc:100")).size).toBe(2); + }); + + it("removes recognized text when a document is removed from Recent", async () => { + const { saveOcrPage, getOcrPages, removeRecent } = await freshStore(); + await saveOcrPage("doc", 1, [{ str: "private" }]); + await saveOcrPage("other", 1, [{ str: "keep" }]); + await removeRecent("doc"); + expect((await getOcrPages("doc")).size).toBe(0); + expect((await getOcrPages("other")).size).toBe(1); + }); + + it("clears saved OCR text when Recent is cleared", async () => { + const { saveOcrPage, getOcrPages, clearRecent } = await freshStore(); + await saveOcrPage("doc", 1, [{ str: "private" }]); + await clearRecent(); + expect((await getOcrPages("doc")).size).toBe(0); + }); + + it("clears recognized text independently without clearing notes", async () => { + const { saveOcrPage, getOcrPages, clearRecognizedText, saveNotes, getNotes } = await freshStore(); + await saveOcrPage("doc", 1, [{ str: "private" }]); + await saveNotes({ id: "doc", note: "keep", marks: [], updatedAt: 1 }, 0); + await clearRecognizedText(); + expect((await getOcrPages("doc")).size).toBe(0); + expect((await getNotes("doc")).value?.note).toBe("keep"); + }); + + it("does not persist OCR when storage invalidation cannot be checked", async () => { + const { saveOcrPage, getOcrPages } = await freshStore(); + vi.stubGlobal("localStorage", { getItem: () => { throw new Error("unavailable"); } }); + await saveOcrPage("doc", 1, [{ str: "private" }]); + expect((await getOcrPages("doc")).size).toBe(0); + }); + + it("reports an invalidation failure instead of falsely claiming deletion", async () => { + const { clearRecognizedText } = await freshStore(); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => { throw new Error("quota"); } }); + await expect(clearRecognizedText()).rejects.toThrow("quota"); + }); + + it("does not resurrect OCR from a reader open before data was cleared", async () => { + const values = new Map(); + vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) }); + const store = await freshStore(); + vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) }); + const { saveOcrPage, getOcrPages, clearRecent, ocrEpoch } = store; + const previousEpoch = ocrEpoch("doc"); + await clearRecent(); + await saveOcrPage("doc", 1, [{ str: "old session" }], previousEpoch); + expect((await getOcrPages("doc")).size).toBe(0); + await saveOcrPage("doc", 1, [{ str: "new session" }], ocrEpoch("doc")); + expect((await getOcrPages("doc")).size).toBe(1); + }); + + it("is stored with the other per-document data, not instead of it", async () => { + const { saveOcrPage, saveNotes, getNotes } = await freshStore(); + await saveNotes({ id: "doc", note: "keep me", marks: [], updatedAt: 1 }, 0); + await saveOcrPage("doc", 1, [{ str: "a" }]); + expect((await getNotes("doc")).value?.note).toBe("keep me"); + }); +}); + +describe("upgrading an existing library", () => { + it.each(["main", "ocr"])("upgrades the version 5 %s schema and retains existing data", async (schema) => { + vi.resetModules(); + const factory = new IDBFactory(); + vi.stubGlobal("indexedDB", factory); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = new Map(); + vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value) }); + await new Promise((resolve, reject) => { + const request = factory.open("folio", 5); + request.onupgradeneeded = () => { + const db = request.result; + for (const name of ["recent", "notes", "ai"]) db.createObjectStore(name, { keyPath: "id" }); + if (schema === "main") { + db.createObjectStore("versions", { keyPath: "id" }).createIndex("byFamily", ["family", "number"], { unique: true }); + db.createObjectStore("versionPdfs"); + } else db.createObjectStore("ocr", { keyPath: "id" }); + }; + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction("notes", "readwrite"); + tx.objectStore("notes").put({ id: "doc", note: "keep", marks: [], updatedAt: 1 }); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + }; + request.onerror = () => reject(request.error); + }); + const { getNotes, openFolioDatabase } = await import("../../../recent"); + const { saveOcrPage, getOcrPages } = await import("./ocrStore"); + expect((await getNotes("doc")).value?.note).toBe("keep"); + const db = await openFolioDatabase(); + expect(["ocr", "versions", "versionPdfs"].every(name => db.objectStoreNames.contains(name))).toBe(true); + expect(db.transaction("versions").objectStore("versions").indexNames.contains("byFamily")).toBe(true); + await saveOcrPage("doc", 1, [{ str: "saved" }]); + expect((await getOcrPages("doc")).get(1)).toEqual([{ str: "saved" }]); + }); + + it.each([3, 4])("upgrades a version %i library without losing notes", async (version) => { + vi.resetModules(); + const factory = new IDBFactory(); + vi.stubGlobal("indexedDB", factory); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + + // Both pre-migration and currently shipping libraries can contain notes. + await new Promise((resolve, reject) => { + const request = factory.open("folio", version); + request.onupgradeneeded = () => { + for (const name of ["recent", "notes", "ai"]) request.result.createObjectStore(name, { keyPath: "id" }); + }; + request.onsuccess = () => { + const db = request.result; + const tx = db.transaction(["notes", "recent"], "readwrite"); + tx.objectStore("notes").put({ id: "old-doc", note: "from before", marks: [{ id: "m" }], updatedAt: 1 }); + if (version === 4) tx.objectStore("recent").put({ id: "recent-doc", name: "saved.pdf", openedAt: 1, data: new Blob(["pdf"]) }); + tx.oncomplete = () => { db.close(); resolve(); }; + tx.onerror = () => reject(tx.error); + }; + request.onerror = () => reject(request.error); + }); + + const storage = new Map(); + vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value) }); + const { getNotes, getRecent } = await import("../../../recent"); + const { saveOcrPage, getOcrPages } = await import("./ocrStore"); + expect((await getNotes("old-doc")).value?.note).toBe("from before"); + if (version === 4) expect((await getRecent("recent-doc"))?.name).toBe("saved.pdf"); + await saveOcrPage("old-doc", 1, [{ str: "new" }]); + expect((await getOcrPages("old-doc")).get(1)).toEqual([{ str: "new" }]); + }); +}); + +describe("when storage isn't available", () => { + it("reads as empty and doesn't throw on save", async () => { + vi.resetModules(); + vi.stubGlobal("indexedDB", { open() { throw new Error("blocked"); } }); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const { saveOcrPage, getOcrPages } = await import("./ocrStore"); + await expect(saveOcrPage("doc", 1, [])).resolves.toBeUndefined(); + expect((await getOcrPages("doc")).size).toBe(0); + }); +}); + + +describe("language-aware recognized text", () => { + it("keeps English legacy keys separate from other language selections", async () => { + const { saveOcrPage, getOcrPages, tx, OCR } = await freshStore(); + await tx("readwrite", s => s.put({ id: "doc#1", items: [{ str: "legacy English" }] }), OCR); + await saveOcrPage("doc", 1, [{ str: "Deutsch" }], undefined, ["deu"]); + await saveOcrPage("doc", 1, [{ str: "mixed" }], undefined, ["eng", "deu"]); + expect((await getOcrPages("doc")).get(1)).toEqual([{ str: "legacy English" }]); + expect((await getOcrPages("doc", ["deu"])).get(1)).toEqual([{ str: "Deutsch" }]); + expect((await getOcrPages("doc", ["eng", "deu"])).get(1)).toEqual([{ str: "mixed" }]); + expect((await getOcrPages("doc", ["spa"])).size).toBe(0); + }); + + it("removes every language when a recent document is removed", async () => { + const { saveOcrPage, getOcrPages, removeRecent } = await freshStore(); + await saveOcrPage("doc", 1, [{ str: "English" }]); + await saveOcrPage("doc", 1, [{ str: "Deutsch" }], undefined, ["deu"]); + await removeRecent("doc"); + expect((await getOcrPages("doc")).size).toBe(0); + expect((await getOcrPages("doc", ["deu"])).size).toBe(0); + }); + + it.each(["main", "ocr"])("upgrades version 6 %s without losing notes, chats or generations", async schema => { + vi.resetModules(); + const factory = new IDBFactory(); + vi.stubGlobal("indexedDB", factory); + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = new Map(); + vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value) }); + await new Promise((resolve, reject) => { + const request = factory.open("folio", 6); + request.onupgradeneeded = () => { + const db = request.result; + for (const name of ["recent", "notes", "ai"]) db.createObjectStore(name, { keyPath: "id" }); + db.createObjectStore("versions", { keyPath: "id" }).createIndex("byFamily", ["family", "number"], { unique: true }); + db.createObjectStore("versionPdfs"); + if (schema === "main") db.createObjectStore("clearGenerations"); + else db.createObjectStore("ocr", { keyPath: "id" }); + }; + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction(schema === "main" ? ["notes", "ai", "clearGenerations"] : ["notes", "ai"], "readwrite"); + transaction.objectStore("notes").put({ id: "doc", note: "keep", marks: [], updatedAt: 1 }); + transaction.objectStore("ai").put({ id: "doc", messages: [{ role: "user", content: "keep chat" }] }); + if (schema === "main") transaction.objectStore("clearGenerations").put(3, "notes"); + transaction.oncomplete = () => { db.close(); resolve(); }; + transaction.onabort = () => reject(transaction.error); + }; + request.onerror = () => reject(request.error); + }); + const { openFolioDatabase, getNotes, getAiHistory, saveAiHistory, saveNotes, clearStoredData } = await import("../../../recent"); + const { saveOcrPage, getOcrPages } = await import("./ocrStore"); + const db = await openFolioDatabase(); + expect(db.version).toBe(7); + expect([...db.objectStoreNames]).toEqual(expect.arrayContaining(["ocr", "clearGenerations", "versions", "versionPdfs"])); + const before = await getNotes("doc"); + expect(before.value?.note).toBe("keep"); + expect(before.generation).toBe(schema === "main" ? 3 : 0); + const chat = await getAiHistory("doc"); + expect(chat.messages[0].content).toBe("keep chat"); + await clearStoredData("ai"); + await saveAiHistory("doc", chat.messages, chat.generation); + expect((await getAiHistory("doc")).messages).toEqual([]); + await clearStoredData("notes"); + await saveNotes(before.value!, before.generation); + expect((await getNotes("doc")).value).toBeUndefined(); + await saveOcrPage("doc", 1, [{ str: "Deutsch" }], undefined, ["deu"]); + expect((await getOcrPages("doc", ["deu"])).size).toBe(1); + }); +}); diff --git a/src/lib/ocr/storage/ocrStore.ts b/src/lib/ocr/storage/ocrStore.ts new file mode 100644 index 0000000..6259ada --- /dev/null +++ b/src/lib/ocr/storage/ocrStore.ts @@ -0,0 +1,44 @@ +// Recognized text, kept locally in the same IndexedDB as Recent and notes (recent.ts owns the database and its schema). +import type { ScanLanguage } from "../../../scanLanguage"; +import { OCR, ocrEpoch, tx } from "../../../recent"; + +// One record per scanned page, keyed by the document's content id, so a scan is only read once. + +export interface StoredOcrPage { + id: string; + items: unknown[]; +} + +// English keeps the original key so existing caches remain useful. Other ordered language +// selections live below the same document prefix, so Remove from Recent deletes all of them. +export const ocrLanguageKey = (languages: ScanLanguage[]) => [...new Set(languages)].join("+") || "eng"; +const prefix = (docId: string, languages: ScanLanguage[]) => { + const key = ocrLanguageKey(languages); + return key === "eng" ? `${docId}#` : `${docId}#${key}#`; +}; +const ocrId = (docId: string, page: number, languages: ScanLanguage[]) => `${prefix(docId, languages)}${page}`; + +export async function getOcrPages(docId: string, languages: ScanLanguage[] = ["eng"]): Promise> { + const pages = new Map(); + try { + const start = prefix(docId, languages); + const all = await tx("readonly", (s) => s.getAll(IDBKeyRange.bound(start, `${start}\uffff`)), OCR); + for (const record of all) { + const suffix = record.id.slice(start.length); + // English's range also contains language-specific records; never interpret those as English. + if (/^[1-9]\d*$/.test(suffix)) pages.set(Number(suffix), record.items); + } + } catch { + /* best-effort, like notes */ + } + return pages; +} + +export async function saveOcrPage(docId: string, page: number, items: unknown[], epoch = ocrEpoch(docId), languages: ScanLanguage[] = ["eng"]) { + try { + if (epoch === null) return; + await tx("readwrite", (s) => epoch === ocrEpoch(docId) ? s.put({ id: ocrId(docId, page, languages), items }) : s.get(ocrId(docId, page, languages)), OCR); + } catch { + /* best-effort, like notes */ + } +} diff --git a/src/lib/ocr/text/geometry.test.ts b/src/lib/ocr/text/geometry.test.ts new file mode 100644 index 0000000..b7a6a63 --- /dev/null +++ b/src/lib/ocr/text/geometry.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { Box, TextItem } from "../types"; +import { area, boxOf, dropDuplicates, overlap } from "./geometry"; + +const item = (over: Partial = {}): TextItem => ({ + str: "word", dir: "ltr", fontName: "f", hasEOL: false, transform: [10, 0, 0, 10, 0, 0], width: 20, height: 10, ...over, +}); + +describe("boxOf", () => { + it("covers the width and height of unrotated text from its origin", () => { + expect(boxOf(item({ transform: [10, 0, 0, 10, 5, 7], width: 20, height: 10 }))).toEqual([5, 7, 25, 17]); + }); + + it("follows text rotated a quarter turn", () => { + // Text runs up the page: along = (0, width), up = (-height, 0). + expect(boxOf(item({ transform: [0, 10, -10, 0, 50, 7], width: 20, height: 10 }))).toEqual([40, 7, 50, 27]); + }); + + it("does not produce NaN for a degenerate transform", () => { + const box = boxOf(item({ transform: [0, 0, 0, 0, 3, 4], width: 20, height: 10 })); + expect(box.every(Number.isFinite)).toBe(true); + }); +}); + +describe("area and overlap", () => { + it("measures a box and treats inverted boxes as empty", () => { + expect(area([0, 0, 4, 5])).toBe(20); + expect(area([4, 0, 0, 5])).toBe(0); + }); + + it("measures how much two boxes share", () => { + expect(overlap([0, 0, 10, 10], [5, 5, 15, 15])).toBe(25); + expect(overlap([0, 0, 10, 10], [20, 20, 30, 30])).toBe(0); + expect(overlap([0, 0, 10, 10], [2, 2, 4, 4])).toBe(4); + }); +}); + +describe("dropDuplicates", () => { + const existing: Box[] = [[0, 0, 20, 10]]; + + it("drops recognized words that sit on the PDF's own text", () => { + const onTop = item({ transform: [10, 0, 0, 10, 0, 0], width: 20, height: 10 }); + expect(dropDuplicates([onTop], existing)).toEqual([]); + }); + + it("keeps words elsewhere on the page", () => { + const elsewhere = item({ transform: [10, 0, 0, 10, 100, 100] }); + expect(dropDuplicates([elsewhere], existing)).toEqual([elsewhere]); + }); + + it("drops a word that is exactly half covered, and keeps one that is less", () => { + const half = item({ transform: [10, 0, 0, 10, 10, 0], width: 20, height: 10 }); // x 10..30, half inside 0..20 + const less = item({ transform: [10, 0, 0, 10, 12, 0], width: 20, height: 10 }); // x 12..32, 40% inside + expect(dropDuplicates([half], existing)).toEqual([]); + expect(dropDuplicates([less], existing)).toEqual([less]); + }); + + it("is not fooled by the gap after a word (a table column) when judging overlap", () => { + // The word's box runs 100 wide to reach the next word, but its own ink is only 20 wide and sits on the PDF's text. + const stretched = item({ transform: [10, 0, 0, 10, 0, 0], width: 100, height: 10, inkWidth: 20 }); + expect(dropDuplicates([stretched], existing)).toEqual([]); + expect(dropDuplicates([{ ...stretched, inkWidth: undefined }], existing)).toEqual([{ ...stretched, inkWidth: undefined }]); + }); + + it("keeps everything when the PDF has no text of its own", () => { + const items = [item(), item({ str: "other" })]; + expect(dropDuplicates(items, [])).toEqual(items); + }); +}); diff --git a/src/lib/ocr/text/geometry.ts b/src/lib/ocr/text/geometry.ts new file mode 100644 index 0000000..dd18592 --- /dev/null +++ b/src/lib/ocr/text/geometry.ts @@ -0,0 +1,25 @@ +// Where text sits on a page: boxes, overlap, and spotting recognized words that repeat the PDF's own text. +import type { Box, TextItem } from "../types"; + +/** Recognized words that sit at least this much on top of text the PDF already has are duplicates. */ +export const DUPLICATE_OVERLAP = 0.5; + +/** Axis-aligned box of a text item in page space (good enough for overlap tests, including rotated text). */ +export function boxOf(item: TextItem): Box { + const [a, b, c, d, e, f] = item.transform; + const u = Math.hypot(a, b) || 1, v = Math.hypot(c, d) || 1; + const along = [(a / u) * item.width, (b / u) * item.width], up = [(c / v) * item.height, (d / v) * item.height]; + const xs = [e, e + along[0], e + up[0], e + along[0] + up[0]], ys = [f, f + along[1], f + up[1], f + along[1] + up[1]]; + return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]; +} +export const area = (b: Box) => Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]); +export const overlap = (a: Box, b: Box) => area([Math.max(a[0], b[0]), Math.max(a[1], b[1]), Math.min(a[2], b[2]), Math.min(a[3], b[3])]); + +/** Drops items that mostly sit on top of text the PDF already has. */ +export function dropDuplicates(items: TextItem[], existing: Box[]): TextItem[] { + return items.filter((item) => { + // Judged by the word's own ink: its stretched width would count a wide gap (a table column) as uncovered. + const box = boxOf(item.inkWidth === undefined ? item : { ...item, width: item.inkWidth }); + return !existing.some((other) => overlap(box, other) >= DUPLICATE_OVERLAP * area(box)); + }); +} diff --git a/src/lib/ocr/text/pages.test.ts b/src/lib/ocr/text/pages.test.ts new file mode 100644 index 0000000..1bdf301 --- /dev/null +++ b/src/lib/ocr/text/pages.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; +import { withRecognized } from "./pages"; + +describe("withRecognized", () => { + const streamOf = (chunks: unknown[], onCancel?: () => void) => { + let i = 0; + return new ReadableStream({ + pull(controller) { if (i < chunks.length) controller.enqueue(chunks[i++]); else controller.close(); }, + cancel: onCancel, + }); + }; + const readAll = async (stream: ReadableStream) => { + const out: unknown[] = []; + const reader = stream.getReader(); + for (;;) { const { value, done } = await reader.read(); if (done) return out; out.push(value); } + }; + + it("passes the page's own chunks through, then adds one chunk of recognized items", async () => { + const own = [{ items: [1], styles: {} }, { items: [2], styles: {} }]; + const styles = { ocr: {} }; + expect(await readAll(withRecognized(streamOf(own), ["r"], styles))).toEqual([...own, { items: ["r"], styles, lang: null }]); + }); + + it("still yields the recognized chunk when the page has no text of its own", async () => { + expect(await readAll(withRecognized(streamOf([]), ["r"], {}))).toEqual([{ items: ["r"], styles: {}, lang: null }]); + }); + + it("cancels the page's own stream when the reader gives up", async () => { + const cancelled = vi.fn(); + const stream = withRecognized(streamOf([{ items: [] }, { items: [] }], cancelled), ["r"], {}); + await stream.cancel("done"); + expect(cancelled).toHaveBeenCalledWith("done"); + }); +}); diff --git a/src/lib/ocr/text/pages.ts b/src/lib/ocr/text/pages.ts new file mode 100644 index 0000000..f23ec6b --- /dev/null +++ b/src/lib/ocr/text/pages.ts @@ -0,0 +1,15 @@ +// How recognized text joins a page’s own text. + +/** The page's own text chunks first, then one chunk of recognized items. */ +export function withRecognized(source: ReadableStream, items: unknown[], styles: object): ReadableStream { + const reader = source.getReader(); + return new ReadableStream({ + async pull(controller) { + const { value, done } = await reader.read(); + if (!done) return controller.enqueue(value); + controller.enqueue({ items, styles, lang: null }); + controller.close(); + }, + cancel: (reason) => reader.cancel(reason), + }); +} diff --git a/src/lib/ocr/text/scan.test.ts b/src/lib/ocr/text/scan.test.ts new file mode 100644 index 0000000..8415c03 --- /dev/null +++ b/src/lib/ocr/text/scan.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Box, TextItem } from "../types"; +import { imageCover, isScanLike, textCover, type ImageOps } from "./scan"; + +const item = (over: Partial = {}): TextItem => ({ + str: "word", dir: "ltr", fontName: "f", hasEOL: false, transform: [10, 0, 0, 10, 0, 0], width: 20, height: 10, ...over, +}); + +describe("imageCover", () => { + const ops: ImageOps = { save: 10, restore: 11, transform: 12, paintImageXObject: 85, paintInlineImageXObject: 86, paintImageMaskXObject: 83, paintImageXObjectRepeat: 87, paintImageMaskXObjectRepeat: 88, paintImageMaskXObjectGroup: 89, paintInlineImageXObjectGroup: 90 }; + const page: Box = [0, 0, 612, 792]; + const run = (program: [number, unknown][], view: Box = page) => + imageCover(program.map(([fn]) => fn), program.map(([, args]) => args), ops, view); + + it("is 1 for a picture that fills the page", () => { + expect(run([[ops.save, null], [ops.transform, [612, 0, 0, 792, 0, 0]], [ops.paintImageXObject, ["img"]], [ops.restore, null]])).toBeCloseTo(1); + }); + + it("is the share of the page a smaller picture covers", () => { + expect(run([[ops.transform, [306, 0, 0, 792, 0, 0]], [ops.paintImageXObject, ["img"]]])).toBeCloseTo(0.5); + }); + + it("does not count the part of a picture that hangs off the page", () => { + expect(run([[ops.transform, [1224, 0, 0, 792, -306, 0]], [ops.paintImageXObject, ["img"]]])).toBeCloseTo(1); + }); + + it("ignores the drawing operators that aren't about pictures or transforms", () => { + const other = 99; // a fill, a stroke, a text operator... + expect(run([[other, null], [ops.transform, [612, 0, 0, 792, 0, 0]], [other, [1, 2]], [ops.paintImageXObject, ["img"]], [other, null]])).toBeCloseTo(1); + }); + + it("returns to the earlier transform when a save is restored", () => { + const inside = run([ + [ops.save, null], [ops.transform, [612, 0, 0, 792, 0, 0]], [ops.restore, null], + [ops.paintImageXObject, ["img"]], // drawn with the identity transform: a 1 x 1 point square + ]); + expect(inside).toBeCloseTo(1 / (612 * 792)); + }); + + it("applies a later transform inside an earlier one, the way pdf.js composes them", () => { + // Translate by (100, 0), then scale by 50: the unit square lands at x 100..150, y 0..50 (not at x 5000). + const share = run([[ops.transform, [1, 0, 0, 1, 100, 0]], [ops.transform, [50, 0, 0, 50, 0, 0]], [ops.paintImageXObject, ["img"]]]); + expect(share).toBeCloseTo((50 * 50) / (612 * 792)); + }); + + it("handles a picture turned a quarter", () => { + expect(run([[ops.transform, [0, 792, -612, 0, 612, 0]], [ops.paintImageXObject, ["img"]]])).toBeCloseTo(1); + }); + + it("takes the largest picture, not the sum, and counts inline images and image masks", () => { + expect(run([ + [ops.save, null], [ops.transform, [306, 0, 0, 396, 0, 0]], [ops.paintInlineImageXObject, [{}]], [ops.restore, null], + [ops.save, null], [ops.transform, [306, 0, 0, 792, 0, 0]], [ops.paintImageMaskXObject, [{}]], [ops.restore, null], + ])).toBeCloseTo(0.5); + }); + + it("measures repeated images and masks with their individual transforms", () => { + expect(run([[ops.paintImageXObjectRepeat!, ["img", 612, 792, new Float32Array([0, 0])]]])).toBeCloseTo(1); + expect(run([[ops.paintImageMaskXObjectRepeat!, [{}, 612, 0, 0, 792, new Float32Array([0, 0])]]])).toBeCloseTo(1); + expect(run([[ops.paintImageMaskXObjectGroup!, [[{ transform: [612, 0, 0, 792, 0, 0] }]]]])).toBeCloseTo(1); + expect(run([[ops.paintInlineImageXObjectGroup!, [{}, [{ transform: [612, 0, 0, 792, 0, 0] }]]]])).toBeCloseTo(1); + expect(run([[ops.transform, [1, 0, 0, 1, 100, 0]], [ops.paintImageXObjectRepeat!, ["img", 100, 100, [0, 0, 200, 0]]]])) + .toBeCloseTo((100 * 100) / (612 * 792)); + }); + + it("measures against a page whose box doesn't start at the origin", () => { + expect(run([[ops.transform, [612, 0, 0, 792, 10, 20]], [ops.paintImageXObject, ["img"]]], [10, 20, 622, 812])).toBeCloseTo(1); + }); + + it("is 0 when nothing is drawn, and ignores a restore with nothing saved", () => { + expect(run([])).toBe(0); + expect(run([[ops.restore, null]])).toBe(0); + }); +}); + +describe("textCover", () => { + it("adds up the boxes of the text on a page", () => { + const view: Box = [0, 0, 100, 100]; + expect(textCover([item({ width: 10, height: 10 }), item({ width: 10, height: 10 })], view)).toBeCloseTo(0.02); + expect(textCover([], view)).toBe(0); + }); +}); + +describe("isScanLike", () => { + it("is true for a page with no text, without measuring pictures", async () => { + const image = vi.fn(() => 0); + expect(await isScanLike(0, 0, image)).toBe(true); + expect(image).not.toHaveBeenCalled(); + }); + + it("is false for a page with plenty of text, without measuring pictures", async () => { + const image = vi.fn(() => 1); + expect(await isScanLike(300, 0.25, image)).toBe(false); + expect(image).not.toHaveBeenCalled(); + }); + + it("is true for a full-page picture with only a little stamped text", async () => { + expect(await isScanLike(130, 0.045, () => 1)).toBe(true); + }); + + it("is false for sparse text with no big picture (a CAD sheet, a cover page)", async () => { + expect(await isScanLike(20, 0.006, () => 0.02)).toBe(false); + }); + + it("uses the stated limits: text at 10% is too much, a picture at 60% is enough", async () => { + expect(await isScanLike(50, 0.1, () => 1)).toBe(false); + expect(await isScanLike(50, 0.0999, () => 1)).toBe(true); + expect(await isScanLike(50, 0.05, () => 0.6)).toBe(true); + expect(await isScanLike(50, 0.05, () => 0.5999)).toBe(false); + }); + + it("accepts an async picture measurement", async () => { + expect(await isScanLike(5, 0.01, async () => 0.9)).toBe(true); + }); +}); diff --git a/src/lib/ocr/text/scan.ts b/src/lib/ocr/text/scan.ts new file mode 100644 index 0000000..794c53f --- /dev/null +++ b/src/lib/ocr/text/scan.ts @@ -0,0 +1,73 @@ +// Telling a picture of text from a page that really has text on it. +import type { Box, TextItem } from "../types"; +import { area, boxOf, overlap } from "./geometry"; + +/** A picture over this share of the page makes it a candidate scan... */ +export const SCAN_IMAGE_COVER = 0.6; +/** ...unless the PDF's own text covers more than this share of it (a stamp or header covers a few percent; typed pages far more). */ +export const SCAN_TEXT_COVER = 0.1; + +/** The drawing operators that matter for finding pictures (pdf.js's OPS numbers; passed in so this file needs no pdf.js). */ +export interface ImageOps { + save: number; + restore: number; + transform: number; + paintImageXObject: number; + paintInlineImageXObject: number; + paintImageMaskXObject: number; + paintImageXObjectRepeat?: number; + paintImageMaskXObjectRepeat?: number; + paintImageMaskXObjectGroup?: number; + paintInlineImageXObjectGroup?: number; +} + +/** Same composition as pdf.js's Util.transform: `m2` is applied first, then `m1`. */ +const multiply = (m1: number[], m2: number[]) => [ + m1[0] * m2[0] + m1[2] * m2[1], m1[1] * m2[0] + m1[3] * m2[1], + m1[0] * m2[2] + m1[2] * m2[3], m1[1] * m2[2] + m1[3] * m2[3], + m1[0] * m2[4] + m1[2] * m2[5] + m1[4], m1[1] * m2[4] + m1[3] * m2[5] + m1[5], +]; + +/** Share of the page (`view`) covered by its largest picture, found by following the page's drawing operators. */ +export function imageCover(fnArray: ArrayLike, argsArray: ArrayLike, ops: ImageOps, view: Box): number { + let ctm = [1, 0, 0, 1, 0, 0]; + const saved: number[][] = []; + let best = 0; + const measure = (matrix: number[]) => { + const xs = [0, 1].flatMap(x => [0, 1].map(y => matrix[0] * x + matrix[2] * y + matrix[4])); + const ys = [0, 1].flatMap(x => [0, 1].map(y => matrix[1] * x + matrix[3] * y + matrix[5])); + best = Math.max(best, overlap([Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)], view)); + }; + for (let i = 0; i < fnArray.length; i++) { + const fn = fnArray[i]; + const args = argsArray[i] as unknown[]; + if (fn === ops.save) saved.push(ctm); + else if (fn === ops.restore) ctm = saved.pop() ?? ctm; + else if (fn === ops.transform) ctm = multiply(ctm, args as number[]); + else if (fn === ops.paintImageXObject || fn === ops.paintInlineImageXObject || fn === ops.paintImageMaskXObject) measure(ctm); + else if (ops.paintImageXObjectRepeat !== undefined && fn === ops.paintImageXObjectRepeat) { + const [, sx, sy, positions] = args as [unknown, number, number, ArrayLike]; + for (let j = 0; j + 1 < positions.length; j += 2) measure(multiply(ctm, [sx, 0, 0, sy, positions[j], positions[j + 1]])); + } else if (ops.paintImageMaskXObjectRepeat !== undefined && fn === ops.paintImageMaskXObjectRepeat) { + const [, sx, skx, sky, sy, positions] = args as [unknown, number, number, number, number, ArrayLike]; + for (let j = 0; j + 1 < positions.length; j += 2) measure(multiply(ctm, [sx, skx, sky, sy, positions[j], positions[j + 1]])); + } else if (ops.paintImageMaskXObjectGroup !== undefined && fn === ops.paintImageMaskXObjectGroup) { + for (const image of args[0] as { transform: number[] }[]) measure(multiply(ctm, image.transform)); + } else if (ops.paintInlineImageXObjectGroup !== undefined && fn === ops.paintInlineImageXObjectGroup) { + for (const image of args[1] as { transform: number[] }[]) measure(multiply(ctm, image.transform)); + } + } + return best / area(view); +} + +/** Share of the page (`view`) covered by the boxes of its own text items. */ +export const textCover = (items: TextItem[], view: Box) => items.reduce((sum, item) => sum + area(boxOf(item)), 0) / area(view); + +/** + * True for pages that are pictures of text: nothing embedded, or a picture over most of the page with little text on it. + * `imageShare` is only asked for when the text alone doesn't settle it, because measuring pictures means parsing the page. + */ +export async function isScanLike(embeddedCount: number, textShare: number, imageShare: () => number | Promise): Promise { + if (!embeddedCount) return true; + return textShare < SCAN_TEXT_COVER && (await imageShare()) >= SCAN_IMAGE_COVER; +} diff --git a/src/lib/ocr/text/words.test.ts b/src/lib/ocr/text/words.test.ts new file mode 100644 index 0000000..efc58fa --- /dev/null +++ b/src/lib/ocr/text/words.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it } from "vitest"; +import { boxOf } from "./geometry"; +import { keepWord, keepWords, toItems, type Line, type Word } from "./words"; + +describe("keepWord", () => { + const word = (text: string, confidence: number): Word => ({ text, confidence, bbox: { x0: 0, y0: 0, x1: 1, y1: 1 } }); + + it.each([ + ["a confident word", "Engine", 96, true], + ["a word right at the confidence floor", "ok", 30, true], + ["a short word below the floor (a speck)", "t", 20, false], + ["a two-character guess below the floor", "~/", 25, false], + ["a long word below the floor (a damaged real word)", "Image+Text", 22, true], + ["a four-letter word just below the floor", "Text", 29, true], + ["a three-letter word just below the floor", "Tex", 29, false], + ["punctuation only, however long", "......", 10, false], + ["a stray colon from a speck on the paper", ":", 48, false], + ["a stray bar or equals sign from a speck", "|", 54, false], + ["a symbol Tesseract is quite sure of (a bullet or dash)", "—", 90, true], + ["a symbol right at the symbol threshold", "•", 80, true], + ["a symbol just under it", "•", 79, false], + ["a speck that scored higher than most", ":", 73, false], + ["accented letters count as letters", "résumé", 20, true], + ["digits count too", "12345", 20, true], + ["an empty string", "", 99, false], + ["whitespace only", " ", 99, false], + ])("%s", (_name, text, confidence, expected) => { + expect(keepWord(word(text, confidence))).toBe(expected); + }); +}); + +describe("keepWords", () => { + const box = (text: string, confidence: number, w: number, h: number): Word => ({ text, confidence, bbox: { x0: 0, y0: 0, x1: w, y1: h } }); + const text = (t: string) => box(t, 95, 100, 40); // a word 40 px tall + + it("drops a tiny symbol Tesseract is confident about, next to full-size text", () => { + expect(keepWords([text("Scanned"), box("|", 85, 3, 1), text("page")]).map((w) => w.text)).toEqual(["Scanned", "page"]); + expect(keepWords([text("Folio"), box(":", 83, 2, 3)]).map((w) => w.text)).toEqual(["Folio"]); + }); + + it("keeps a real dash, a real vertical bar, and a bullet", () => { + expect(keepWords([text("Item"), box("-", 90, 45, 4)]).map((w) => w.text)).toEqual(["Item", "-"]); // wide, though flat + expect(keepWords([box("|", 90, 3, 40), text("Name")]).map((w) => w.text)).toEqual(["|", "Name"]); // tall, though thin + expect(keepWords([box("•", 92, 12, 12), text("Point")]).map((w) => w.text)).toEqual(["•", "Point"]); + }); + + it("judges size against the text on the same line, so the same mark can stay on a line of small text", () => { + const small = (t: string) => box(t, 95, 40, 12); + expect(keepWords([small("fine"), box("-", 90, 4, 3)]).map((w) => w.text)).toEqual(["fine", "-"]); // 4 px is a third of 12 px + expect(keepWords([text("big"), box("-", 90, 4, 3)]).map((w) => w.text)).toEqual(["big"]); // 4 px is a tenth of 40 px + }); + + it("applies the size test at exactly a quarter of the text height", () => { + expect(keepWords([text("a"), box("-", 90, 10, 2)]).map((w) => w.text)).toEqual(["a", "-"]); + expect(keepWords([text("a"), box("-", 90, 9.9, 2)]).map((w) => w.text)).toEqual(["a"]); + }); + + it("keeps a confident symbol when the line has no text to compare it with (a rule or separator)", () => { + expect(keepWords([box("-----", 90, 300, 4)]).map((w) => w.text)).toEqual(["-----"]); + }); + + it("still applies the confidence rules first", () => { + expect(keepWords([text("ok"), box("•", 40, 12, 12)]).map((w) => w.text)).toEqual(["ok"]); + expect(keepWords([box("", 99, 5, 5), box(" ", 99, 5, 5)])).toEqual([]); + }); + + it("returns nothing for an empty line", () => { + expect(keepWords([])).toEqual([]); + }); +}); + +describe("toItems", () => { + // 2 pixels per point, page 800 points tall, y flipped like a real unrotated viewport. + const upright = { convertToPdfPoint: (x: number, y: number) => [x / 2, 800 - y / 2] }; + const word = (text: string, x0: number, x1: number, confidence = 95): Word => ({ text, confidence, bbox: { x0, y0: 80, x1, y1: 110 } }); + const line = (words: Word[]): Line => ({ words, bbox: { x0: words[0]?.bbox.x0 ?? 0, y0: 80, x1: 400, y1: 110 }, baseline: { x0: 0, y0: 100, x1: 400, y1: 100 } }); + + it("turns each word into a text item in page space", () => { + const [first] = toItems([line([word("Hello", 20, 100), word("world", 120, 220)])], upright); + expect(first.str).toBe("Hello "); + // 30 px tall = 15 pt, baseline 100 px = 750 pt. Compared with a tolerance because flipping a zero gives -0. + [15, 0, 0, 15, 10, 750].forEach((expected, i) => expect(first.transform[i]).toBeCloseTo(expected, 9)); + expect(first.dir).toBe("ltr"); + expect(first.fontName).toBe("ocr"); + }); + + it("lets a word's width run on to the next word, so the gap belongs to it", () => { + const [first, second] = toItems([line([word("Hello", 20, 100), word("world", 120, 220)])], upright); + expect(first.width).toBe(50); // 20..120 px + expect(second.width).toBe(50); // 120..220 px, the last word keeps its own box + }); + + it("remembers each word's own width, apart from the gap its box is stretched over", () => { + const [first, second] = toItems([line([word("Hello", 20, 100), word("world", 120, 220)])], upright); + expect(first.inkWidth).toBeCloseTo(40); // 20..100 px = 40 pt, though width runs on to 120 px + expect(second.inkWidth).toBeCloseTo(second.width); + }); + + it("separates words with a space and ends the line on the last one", () => { + const items = toItems([line([word("one", 20, 60), word("two", 80, 120), word("three", 140, 200)])], upright); + expect(items.map((i) => i.str)).toEqual(["one ", "two ", "three"]); + expect(items.map((i) => i.hasEOL)).toEqual([false, false, true]); + }); + + it("copes with a baseline that has no length", () => { + const flat: Line = { ...line([word("dot", 20, 60)]), baseline: { x0: 50, y0: 100, x1: 50, y1: 100 } }; + const [item] = toItems([flat], upright); + expect(item.str).toBe("dot"); + expect(Number.isFinite(item.transform[5])).toBe(true); + }); + + it("joins into the sentence that was printed when read the way find does", () => { + const items = toItems([line([word("A", 20, 40), word("sample", 60, 160), word("page.", 180, 260)])], upright); + expect(items.map((i) => i.str + (i.hasEOL ? "\n" : "")).join("")).toBe("A sample page.\n"); + }); + + it("leaves out the stray symbols that specks on the paper produce at the ends of a line", () => { + const items = toItems([line([word(":", 10, 14, 48), word("Please", 20, 80), word("bring", 100, 160), word(";", 180, 184, 41)])], upright); + expect(items.map((i) => i.str.trim())).toEqual(["Please", "bring"]); + expect(items.at(-1)?.hasEOL).toBe(true); + }); + + it("sizes a line from Tesseract's row height, even when a word box has swollen to take in a speck", () => { + // "to" came back 65 px tall (a speck above it) on a line whose text is about 41 px; the row model says 40. + const swollen: Word = { text: "to", confidence: 95, bbox: { x0: 140, y0: 40, x1: 180, y1: 105 } }; + const [first] = toItems([{ words: [word("This", 20, 100), swollen], bbox: { x0: 0, y0: 40, x1: 400, y1: 105 }, baseline: { x0: 0, y0: 100, x1: 400, y1: 100 }, rowAttributes: { rowHeight: 40 } }], upright); + expect(first.height).toBeCloseTo(20); // 40 px at 2 px per point + }); + + it("falls back to the kept words when there is no usable row height", () => { + for (const rowAttributes of [undefined, { rowHeight: 0 }, { rowHeight: NaN }, { rowHeight: -5 }]) { + const [first] = toItems([{ ...line([word("Hello", 20, 100)]), rowAttributes }], upright); + expect(first.height).toBeCloseTo(15); // the word is 80..110 = 30 px + } + }); + + it("sizes a line from the words kept, not from a line box that specks have stretched", () => { + // Tesseract's line box reaches from y 0 to 200 because of a speck far above and below; the real words are 30 px tall. + const stretched: Line = { words: [word("Hello", 20, 100), word("world", 120, 220)], bbox: { x0: 0, y0: 0, x1: 400, y1: 200 }, baseline: { x0: 0, y0: 100, x1: 400, y1: 100 } }; + const [first] = toItems([stretched], upright); + expect(first.height).toBeCloseTo(15); // 30 px at 2 px per point, not 100 pt + }); + + it("sizes a line to fit its tallest kept word, so ascenders and descenders are inside the box", () => { + const tall: Word = { text: "Ty", confidence: 95, bbox: { x0: 20, y0: 70, x1: 60, y1: 112 } }; + const short: Word = { text: "a", confidence: 95, bbox: { x0: 80, y0: 90, x1: 100, y1: 110 } }; + const [first] = toItems([{ words: [tall, short], bbox: { x0: 0, y0: 70, x1: 400, y1: 112 }, baseline: { x0: 0, y0: 100, x1: 400, y1: 100 } }], upright); + expect(first.height).toBeCloseTo(21); // 70..112 px = 42 px = 21 pt + }); + + it("leaves out confident but tiny specks too", () => { + const speck = (text: string, x: number): Word => ({ text, confidence: 85, bbox: { x0: x, y0: 90, x1: x + 3, y1: 91 } }); + const items = toItems([line([speck("|", 10), word("Scanned", 20, 120), word("page", 140, 200), speck(":", 230)])], upright); + expect(items.map((i) => i.str.trim())).toEqual(["Scanned", "page"]); + }); + + it("drops specks but keeps a long word that Tesseract was unsure of", () => { + const items = toItems([line([word("This", 20, 80), word("~", 90, 100, 10), word("Image+Text", 120, 300, 22)])], upright); + expect(items.map((i) => i.str.trim())).toEqual(["This", "Image+Text"]); + }); + + it("marks the last word that survived as the end of the line, even if the printed last word was dropped", () => { + const items = toItems([line([word("kept", 20, 80), word("~", 90, 100, 5)])], upright); + expect(items).toHaveLength(1); + expect(items[0].hasEOL).toBe(true); + expect(items[0].str).toBe("kept"); + }); + + it("returns nothing for a line with no usable words, and for no lines", () => { + expect(toItems([line([word("", 20, 40)])], upright)).toEqual([]); + expect(toItems([], upright)).toEqual([]); + }); + + it("skips a word with no width", () => { + expect(toItems([line([word("flat", 50, 50)])], upright)).toEqual([]); + }); + + it("follows the slope of a tilted baseline", () => { + const tilted: Line = { words: [word("tilt", 0, 100)], bbox: { x0: 0, y0: 80, x1: 400, y1: 110 }, baseline: { x0: 0, y0: 100, x1: 400, y1: 140 } }; + const [out] = toItems([tilted], { convertToPdfPoint: (x, y) => [x, -y] }); + // The baseline falls 10 px over 100 px, so the text direction tilts the same way (y is flipped by the viewport). + expect(out.transform[1]).toBeLessThan(0); + expect(out.transform[0]).toBeGreaterThan(0); + expect(Math.hypot(out.transform[0], out.transform[1])).toBeCloseTo(out.height, 5); + }); + + it("writes text rotated by the viewport as rotated text, with box and direction agreeing", () => { + // A page shown turned a quarter: page-space x comes from the pixel y, and y from the pixel x. + const turned = { convertToPdfPoint: (x: number, y: number) => [y / 2, x / 2] }; + const [out] = toItems([line([word("up", 20, 100)])], turned); + expect(out.transform[0]).toBeCloseTo(0, 5); + expect(out.transform[1]).toBeGreaterThan(0); + const [left, , right, top] = boxOf(out); + expect(right - left).toBeCloseTo(out.height, 5); + expect(top).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/ocr/text/words.ts b/src/lib/ocr/text/words.ts new file mode 100644 index 0000000..315b4c5 --- /dev/null +++ b/src/lib/ocr/text/words.ts @@ -0,0 +1,72 @@ +// Turning what Tesseract reads (words in pixels) into the text items pdf.js understands. +import type { TextItem } from "../types"; + +/** Below this, Tesseract is guessing. */ +export const MIN_CONFIDENCE = 30; +/** ...but a long word it is unsure of is usually a real one on a damaged scan ("Image+Text" scored 22), unlike the one- and two-character specks. */ +export const KEEP_LONG_WORD = 4; +/** A word with no letters or digits (":", "|", "=") is usually a speck on the paper, so it has to be near-certain to stay. Specks scored up to 73 on a noisy test scan. */ +export const SYMBOL_MIN_CONFIDENCE = 80; +/** ...and a symbol much smaller than the text beside it is a speck however sure Tesseract is: specks of 1 to 4 px scored 80 to 85 beside text 40 px tall. */ +export const SYMBOL_MIN_SIZE = 0.25; + +export const STYLES = { ocr: { fontFamily: "sans-serif", ascent: 0.8, descent: -0.2, vertical: false } }; + +export interface Word { text: string; confidence: number; bbox: { x0: number; y0: number; x1: number; y1: number } } +export interface Line { + words: Word[]; + bbox: Word["bbox"]; + baseline: Word["bbox"]; + /** Tesseract's own estimate of the line's height, from its row model. */ + rowAttributes?: { rowHeight: number }; +} + +const letterCount = (text: string) => text.replace(/[^\p{L}\p{N}]/gu, "").length; + +/** Whether one word is worth keeping, judged on its own (how sure Tesseract is, and what it is made of). */ +export function keepWord(w: Word): boolean { + if (!w.text.trim()) return false; + const letters = letterCount(w.text); + if (letters === 0) return w.confidence >= SYMBOL_MIN_CONFIDENCE; + return w.confidence >= MIN_CONFIDENCE || letters >= KEEP_LONG_WORD; +} + +/** The words of a line worth keeping: each one passes keepWord, and no symbol is a speck next to the line's text. */ +export function keepWords(words: Word[]): Word[] { + const kept = words.filter(keepWord); + const textHeight = Math.max(0, ...kept.filter((w) => letterCount(w.text) > 0).map((w) => w.bbox.y1 - w.bbox.y0)); + return kept.filter((w) => letterCount(w.text) > 0 || Math.max(w.bbox.x1 - w.bbox.x0, w.bbox.y1 - w.bbox.y0) >= SYMBOL_MIN_SIZE * textHeight); +} + +/** Tesseract reports pixels from the top left; pdf.js text items live in the page's own (unrotated, y-up) space. */ +export function toItems(lines: Line[], viewport: { convertToPdfPoint(x: number, y: number): number[] }): TextItem[] { + const items: TextItem[] = []; + for (const line of lines) { + const words = keepWords(line.words); + const { baseline } = line; + const run = baseline.x1 - baseline.x0 || 1; + const baselineAt = (x: number) => baseline.y0 + ((baseline.y1 - baseline.y0) * (x - baseline.x0)) / run; + // Tesseract's row height is steady, while word and line boxes swell to take in specks near them (a 41 px word came back 65 px tall). + // Without it, fall back to the extent of the words kept. + const rowHeight = line.rowAttributes?.rowHeight; + const sizePx = Math.max(4, rowHeight && Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight + : Math.max(0, ...words.map((w) => w.bbox.y1)) - Math.min(Infinity, ...words.map((w) => w.bbox.y0))); + words.forEach((word, i) => { + const last = i === words.length - 1; + // A word's box ends at its last glyph; stretching it to the next word lets the space belong to it, + // which is what pdf.js find and copy expect (words separated by whitespace in the text itself). + const x0 = word.bbox.x0; + const x1 = last ? word.bbox.x1 : Math.max(word.bbox.x1, words[i + 1].bbox.x0); + const [ax, ay] = viewport.convertToPdfPoint(x0, baselineAt(x0)); + const [bx, by] = viewport.convertToPdfPoint(x1, baselineAt(x1)); + const length = Math.hypot(bx - ax, by - ay); + if (!length) return; + const [ux, uy] = [(bx - ax) / length, (by - ay) / length]; + const size = sizePx * (length / (x1 - x0)); + items.push({ str: last ? word.text : word.text + " ", dir: "ltr", fontName: "ocr", hasEOL: last, + transform: [ux * size, uy * size, -uy * size, ux * size, ax, ay], width: length, height: size, + inkWidth: (length * (word.bbox.x1 - x0)) / (x1 - x0) }); + }); + } + return items; +} diff --git a/src/lib/ocr/types.ts b/src/lib/ocr/types.ts new file mode 100644 index 0000000..0f26eb3 --- /dev/null +++ b/src/lib/ocr/types.ts @@ -0,0 +1,19 @@ +// Shared shapes for text recognition. + +/** A pdf.js text item, as produced by PDFPageProxy.streamTextContent. */ +export interface TextItem { + str: string; + dir: "ltr"; + transform: number[]; + width: number; + height: number; + fontName: string; + hasEOL: boolean; + /** Recognized words only: the width of the word's own ink, before `width` was stretched over the gap that follows it. */ + inkWidth?: number; +} + +/** Left, bottom, right, top in page space. */ +export type Box = [number, number, number, number]; + +export type OcrPhase = "running" | "done"; diff --git a/src/lib/search/index.ts b/src/lib/search/index.ts new file mode 100644 index 0000000..d519f50 --- /dev/null +++ b/src/lib/search/index.ts @@ -0,0 +1,4 @@ +// Search results: what is shown around a match. The find bar and the results list are in src/SearchBar.tsx and +// src/SearchResults.tsx. +export { pageText, snippetAt } from "./snippet"; +export type { PageText, Snippet, TextPiece } from "./snippet"; diff --git a/src/lib/search/snippet.test.ts b/src/lib/search/snippet.test.ts new file mode 100644 index 0000000..c947e92 --- /dev/null +++ b/src/lib/search/snippet.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment jsdom +import { PDFDocument, StandardFonts } from "pdf-lib"; +import { describe, expect, it } from "vitest"; +import { findControllerFor, openPdf } from "../../testing/pdfjs"; +import { pageText, snippetAt, type TextPiece } from "./snippet"; + +const piece = (str: string, hasEOL = false): TextPiece => ({ str, hasEOL }); + +describe("pageText", () => { + it("joins items with nothing between them, so offsets match what pdf.js find reports", () => { + expect(pageText([piece("Hel"), piece("lo "), piece("world")]).text).toBe("Hello world"); + }); + + it("puts a space where a line ends", () => { + const { text, breaks } = pageText([piece("our identification.", true), piece("Invoice number")]); + expect(text).toBe("our identification. Invoice number"); + // The space goes after the 19th character of the text pdf.js find sees. + expect(breaks).toEqual([19]); + }); + + it("does not add a second space when the line already ends or starts with one", () => { + expect(pageText([piece("one ", true), piece("two")]).text).toBe("one two"); + expect(pageText([piece("one", true), piece(" two")]).text).toBe("one two"); + expect(pageText([piece("one ", true), piece(" two")]).breaks).toEqual([]); + }); + + it("adds one space for a run of blank line-end items", () => { + const { text, breaks } = pageText([piece("one", true), piece("", true), piece("", true), piece("two")]); + expect(text).toBe("one two"); + expect(breaks).toEqual([3]); + }); + + it("adds nothing at the start or end of the page", () => { + expect(pageText([piece("", true), piece("first"), piece("last", true)]).text).toBe("firstlast"); + expect(pageText([piece("only", true)])).toEqual({ text: "only", breaks: [] }); + expect(pageText([])).toEqual({ text: "", breaks: [] }); + }); + + it("copes with marked-content markers, which carry no text", () => { + expect(pageText([{}, piece("a", true), {}, piece("b")]).text).toBe("a b"); + }); +}); + +describe("snippetAt", () => { + const plain = pageText([piece("The quick brown fox jumps over the lazy dog")]); + + it("returns the match with the text on either side", () => { + expect(snippetAt(plain, 10, 5)).toEqual({ before: "The quick ", match: "brown", after: " fox jumps over the lazy dog" }); + }); + + it("marks where the text was cut", () => { + const long = pageText([piece("a".repeat(60) + "MATCH" + "b".repeat(120))]); + const snippet = snippetAt(long, 60, 5); + expect(snippet.before).toBe("..." + "a".repeat(55)); + expect(snippet.match).toBe("MATCH"); + expect(snippet.after).toBe("b".repeat(100) + "..."); + }); + + it("does not mark a cut when the text is short", () => { + const snippet = snippetAt(pageText([piece("x".repeat(55) + "M" + "y".repeat(100))]), 55, 1); + expect(snippet.before).toBe("x".repeat(55)); + expect(snippet.after).toBe("y".repeat(100)); + }); + + it("keeps a space added before the match out of it", () => { + const page = pageText([piece("identification.", true), piece("Invoice")]); + expect(snippetAt(page, 15, 7)).toEqual({ before: "identification. ", match: "Invoice", after: "" }); + }); + + it("keeps a space added after the match out of it", () => { + const page = pageText([piece("identification.", true), piece("Invoice")]); + expect(snippetAt(page, 0, 15)).toEqual({ before: "", match: "identification.", after: " Invoice" }); + }); + + it("keeps a space inside the match when the match runs over a line end", () => { + const page = pageText([piece("alpha beta", true), piece("gamma delta")]); + expect(snippetAt(page, 6, 9)).toEqual({ before: "alpha ", match: "beta gamma", after: " delta" }); + }); + + it("finds a match after several line ends", () => { + const page = pageText([piece("one", true), piece("two", true), piece("three", true), piece("four")]); + expect(page.text).toBe("one two three four"); + expect(snippetAt(page, 11, 4).match).toBe("four"); + expect(snippetAt(page, 3, 3).match).toBe("two"); + }); +}); + +describe("against pdf.js find", () => { + const lines = ["alpha beta gamma", "delta epsilon", "zeta eta theta", "iota kappa lambda", "mu nu xi omicron"]; + + async function search(query: string) { + const bytes = await (async () => { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([400, 400]); + lines.forEach((line, i) => page.drawText(line, { x: 20, y: 350 - i * 30, size: 14, font })); + return doc.save(); + })(); + const pdf = await openPdf(bytes); + const { controller, eventBus } = await findControllerFor(pdf); + await new Promise((resolve) => { + eventBus.on("updatefindmatchescount", () => resolve()); + eventBus.dispatch("find", { source: null, type: "", query, caseSensitive: false, entireWord: false, highlightAll: true, findPrevious: false, matchDiacritics: false }); + }); + const content = await (await pdf.getPage(1)).getTextContent({ disableNormalization: true }); + const text = pageText(content.items as TextPiece[]); + return { text, offset: controller.pageMatches![0][0] as number, length: controller.pageMatchesLength![0][0] as number }; + } + + // The shift this guards against is one character per line end before the match, so a match on a late line shows it. + it.each(["gamma", "epsilon", "theta", "lambda", "omicron"])("shows %s exactly, wherever it is on the page", async (query) => { + const { text, offset, length } = await search(query); + expect(snippetAt(text, offset, length).match).toBe(query); + }); + + it("shows a match that runs over a line end with the space between its words", async () => { + const { text, offset, length } = await search("gamma delta"); + const snippet = snippetAt(text, offset, length); + expect(snippet.match).toBe("gamma delta"); + expect(snippet.before).toBe("alpha beta "); + expect(snippet.after).toBe(" epsilon zeta eta theta iota kappa lambda mu nu xi omicron"); + }); +}); diff --git a/src/lib/search/snippet.ts b/src/lib/search/snippet.ts new file mode 100644 index 0000000..aab3b32 --- /dev/null +++ b/src/lib/search/snippet.ts @@ -0,0 +1,67 @@ +// The words around a search match, for the results list. + +/** The parts of a pdf.js text item that matter here (marked-content markers have neither). */ +export interface TextPiece { + str?: string; + hasEOL?: boolean; +} + +export interface PageText { + /** The page's text for reading: the items joined, plus a space where a line ends. */ + text: string; + /** Where those added spaces go, as offsets in the text *without* them (the offsets pdf.js find reports), ascending. */ + breaks: number[]; +} + +export interface Snippet { + before: string; + match: string; + after: string; +} + +const BEFORE = 55; +const AFTER = 100; + +/** + * Items are joined with nothing between them, because that is the string pdf.js find reports offsets in (it reads the + * page with `disableNormalization` and drops the line ends again when it maps a match back). A line end then reads as + * "identification.Invoice", so a space goes in, unless the text already has one there. + */ +export function pageText(items: TextPiece[]): PageText { + let text = ""; + let plain = 0; + let lineEnded = false; + const breaks: number[] = []; + for (const { str = "", hasEOL } of items) { + if (str) { + if (lineEnded && text && !/\s$/.test(text) && !/^\s/.test(str)) { + text += " "; + breaks.push(plain); + } + lineEnded = false; + } + text += str; + plain += str.length; + if (hasEOL) lineEnded = true; + } + return { text, breaks }; +} + +/** How many added spaces sit before `offset`, counting one exactly at it only when asked. */ +function added(breaks: number[], offset: number, atOffset: boolean) { + let count = 0; + while (count < breaks.length && (atOffset ? breaks[count] <= offset : breaks[count] < offset)) count++; + return count; +} + +/** The match at `offset` (as pdf.js find reports it) with some text on each side. */ +export function snippetAt({ text, breaks }: PageText, offset: number, length: number): Snippet { + // A space added right before the match stays out of it; one right after it stays out too. One inside it is part of it. + const start = offset + added(breaks, offset, true); + const end = offset + length + added(breaks, offset + length, false); + return { + before: (start > BEFORE ? "..." : "") + text.slice(Math.max(0, start - BEFORE), start), + match: text.slice(start, end), + after: text.slice(end, end + AFTER) + (text.length > end + AFTER ? "..." : ""), + }; +} diff --git a/src/ocrWorker.test.ts b/src/ocrWorker.test.ts new file mode 100644 index 0000000..af83756 --- /dev/null +++ b/src/ocrWorker.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ createWorker: vi.fn(), installed: vi.fn() })); +vi.mock("tesseract.js", () => ({ createWorker: mocks.createWorker, OEM: { LSTM_ONLY: 1 }, PSM: { AUTO: 3 } })); +vi.mock("./scanLanguagePacks", () => ({ installedScanLanguages: mocks.installed, OCR_CACHE_PATH: "local-ocr" })); +import { startOcrWorker } from "./ocrWorker"; + +class FakeWorker { + terminate = vi.fn(); + constructor(_url: string | URL, _options?: WorkerOptions) {} +} +let browserWorker: FakeWorker; +beforeEach(() => { vi.stubGlobal("Worker", FakeWorker); mocks.createWorker.mockReset(); mocks.installed.mockResolvedValue(new Set(["eng", "deu"])); }); +afterEach(() => vi.unstubAllGlobals()); + +describe("OCR worker startup", () => { + it("uses selected languages and the installed language cache", async () => { + const worker = { setParameters: vi.fn().mockResolvedValue(undefined), terminate: vi.fn() }; + mocks.createWorker.mockResolvedValue(worker); + await startOcrWorker(new AbortController().signal, ["deu", "eng"]); + expect(mocks.createWorker).toHaveBeenCalledWith(["deu", "eng"], 1, + expect.objectContaining({ cachePath: "local-ocr", cacheMethod: "readOnly" })); + }); + + it("explains a missing language pack without starting a worker", async () => { + mocks.installed.mockResolvedValue(new Set(["eng"])); + await expect(startOcrWorker(new AbortController().signal, ["deu"])).rejects.toThrow("Download the German pack"); + expect(mocks.createWorker).not.toHaveBeenCalled(); + }); + + it("terminates the native worker and rejects when language initialization reports an error", async () => { + mocks.createWorker.mockImplementation((_language, _mode, options) => { + browserWorker = new Worker("worker.js") as unknown as FakeWorker; + queueMicrotask(() => options.errorHandler(new Error("language unavailable"))); + return new Promise(() => {}); + }); + await expect(startOcrWorker(new AbortController().signal)).rejects.toThrow("language unavailable"); + expect(browserWorker.terminate).toHaveBeenCalledOnce(); + expect(globalThis.Worker).toBe(FakeWorker); + }); + + it("releases a worker if the document closes during initialization", async () => { + mocks.createWorker.mockImplementation(() => { + browserWorker = new Worker("worker.js") as unknown as FakeWorker; + return new Promise(() => {}); + }); + const controller = new AbortController(); + const starting = startOcrWorker(controller.signal); + await vi.waitFor(() => expect(mocks.createWorker).toHaveBeenCalledOnce()); + controller.abort(); + await expect(starting).rejects.toMatchObject({ name: "AbortError" }); + expect(browserWorker.terminate).toHaveBeenCalledOnce(); + }); + + it("settles startup when the document closes during configuration", async () => { + let release!: () => void; + const config = new Promise(resolve => { release = resolve; }); + const worker = { setParameters: vi.fn(() => config), terminate: vi.fn().mockResolvedValue(undefined) }; + mocks.createWorker.mockImplementation(() => Promise.resolve(worker)); + const controller = new AbortController(); + const starting = startOcrWorker(controller.signal); + await vi.waitFor(() => expect(worker.setParameters).toHaveBeenCalledOnce()); + controller.abort(); + await expect(starting).rejects.toMatchObject({ name: "AbortError" }); + expect(worker.terminate).toHaveBeenCalledOnce(); + release(); + }); + + it("terminates an initialized worker if configuring it fails", async () => { + const worker = { setParameters: vi.fn().mockRejectedValue(new Error("configuration failed")), terminate: vi.fn().mockResolvedValue(undefined) }; + mocks.createWorker.mockImplementation(() => { + browserWorker = new Worker("worker.js") as unknown as FakeWorker; + return Promise.resolve(worker); + }); + await expect(startOcrWorker(new AbortController().signal)).rejects.toThrow("configuration failed"); + expect(worker.terminate).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/ocrWorker.ts b/src/ocrWorker.ts new file mode 100644 index 0000000..3c2373a --- /dev/null +++ b/src/ocrWorker.ts @@ -0,0 +1,78 @@ +import { SCAN_LANGUAGES, type ScanLanguage } from "./scanLanguage"; +import { installedScanLanguages, OCR_CACHE_PATH } from "./scanLanguagePacks"; +export class MissingScanPack extends Error {} +import type { Worker as OcrWorker } from "tesseract.js"; + +/** Start Tesseract and own the browser worker even if initialization never resolves. */ +export async function startOcrWorker(signal: AbortSignal, languages: ScanLanguage[] = ["eng"]): Promise { + if (languages.some(code => code !== "eng")) { + const installed = await installedScanLanguages(); + signal.throwIfAborted(); + const missing = SCAN_LANGUAGES.find(({ id }) => languages.includes(id) && !installed.has(id)); + if (missing) throw new MissingScanPack(`Download the ${missing.name} pack in Settings to read this scan.`); + } + const { createWorker, OEM, PSM } = await import("tesseract.js"); + signal.throwIfAborted(); + const base = `${import.meta.env.BASE_URL}ocr`; + const NativeWorker = globalThis.Worker; + let raw: Worker | undefined; + let rejected = false; + let initializing = true; + let abort: (() => void) | undefined; + let creation: Promise; + try { + // Tesseract creates the native worker synchronously but doesn't expose it until initialization completes. + // Capture that worker only during the synchronous call, then immediately restore the global constructor. + globalThis.Worker = class extends NativeWorker { + constructor(url: string | URL, options?: WorkerOptions) { + super(url, options); + raw = this; + } + }; + creation = new Promise((resolve, reject) => { + const fail = (error: unknown) => { + if (!initializing) return; + rejected = true; + raw?.terminate(); + reject(error); + }; + abort = () => fail(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + try { + createWorker(languages, OEM.LSTM_ONLY, { + workerPath: `${base}/worker.min.js`, workerBlobURL: false, corePath: base, langPath: base, + cachePath: OCR_CACHE_PATH, cacheMethod: "readOnly", errorHandler: fail, + }).then(worker => { + if (rejected) void worker.terminate(); + else resolve(worker); + }, fail); + } catch (error) { fail(error); } + }); + } finally { + globalThis.Worker = NativeWorker; + } + try { + const worker = await creation; + try { + signal.throwIfAborted(); + let stopConfig: (() => void) | undefined; + try { + await Promise.race([worker.setParameters({ tessedit_pageseg_mode: PSM.AUTO }), new Promise((_, reject) => { + stopConfig = () => reject(signal.reason); + if (signal.aborted) stopConfig(); + else signal.addEventListener("abort", stopConfig, { once: true }); + })]); + } finally { + if (stopConfig) signal.removeEventListener("abort", stopConfig); + } + signal.throwIfAborted(); + initializing = false; + return worker; + } catch (error) { + await worker.terminate(); + throw error; + } + } finally { + if (abort) signal.removeEventListener("abort", abort); + } +} diff --git a/src/pageOcr.ts b/src/pageOcr.ts index ee979d9..ab2ab34 100644 --- a/src/pageOcr.ts +++ b/src/pageOcr.ts @@ -1,18 +1,14 @@ +import type { ScanLanguage } from "./scanLanguage"; import { AnnotationMode, OPS, type PDFPageProxy } from "pdfjs-dist"; import type { Worker as OcrWorker } from "tesseract.js"; -import { SCAN_LANGUAGES, type ScanLanguage } from "./scanLanguage"; -import { installedScanLanguages, OCR_CACHE_PATH } from "./scanLanguagePacks"; +import { startOcrWorker, MissingScanPack } from "./ocrWorker"; +import { ocrSession } from "./lib/ocr/session/OcrSession"; +import { toItems } from "./lib/ocr/text/words"; +import { area, boxOf, dropDuplicates } from "./lib/ocr/text/geometry"; +import { imageCover, isScanLike, SCAN_TEXT_COVER, textCover } from "./lib/ocr/text/scan"; +import type { Box, TextItem } from "./lib/ocr/types"; import type { EventBus, PDFPageView, PDFViewer } from "./viewer"; -interface Word { - text: string; - left: number; - top: number; - height: number; - scaleX: number; - endOfLine: boolean; -} - const IMAGE_OPS = new Set([ OPS.paintImageXObject, OPS.paintInlineImageXObject, OPS.paintImageMaskXObject, OPS.paintInlineImageXObjectGroup, OPS.paintImageMaskXObjectGroup, OPS.paintImageXObjectRepeat, OPS.paintImageMaskXObjectRepeat, @@ -25,28 +21,7 @@ async function hasPageImage(page: PDFPageProxy) { return fnArray.some(op => IMAGE_OPS.has(op)); } -async function startWorker(languages: ScanLanguage[]) { - if (languages.some(code => code !== "eng")) { - const installed = await installedScanLanguages(); - const missing = SCAN_LANGUAGES.find(({ id }) => languages.includes(id) && !installed.has(id)); - if (missing) throw new MissingScanPack(`Download the ${missing.name} pack in Settings to read this scan.`); - } - const { createWorker, OEM, PSM } = await import("tesseract.js"); - const base = `${import.meta.env.BASE_URL}ocr`; - // Language initialization errors in Tesseract are delivered to errorHandler. - const worker = await new Promise((resolve, reject) => { - createWorker(languages, OEM.LSTM_ONLY, { - workerPath: `${base}/worker.min.js`, workerBlobURL: false, corePath: base, langPath: base, - cachePath: OCR_CACHE_PATH, cacheMethod: "readOnly", errorHandler: reject, - }).then(resolve, reject); - }); - await worker.setParameters({ tessedit_pageseg_mode: PSM.AUTO }); - return worker; -} - -class MissingScanPack extends Error {} - -async function readWords(page: PDFPageProxy, worker: OcrWorker, signal: AbortSignal): Promise { +async function readWords(page: PDFPageProxy, worker: OcrWorker, signal: AbortSignal): Promise> { signal.throwIfAborted(); // Read at a consistent resolution even in fit-page mode. Bound both dimensions on oversized scans. const base = page.getViewport({ scale: 1 }); @@ -72,87 +47,52 @@ async function readWords(page: PDFPageProxy, worker: OcrWorker, signal: AbortSig signal.throwIfAborted(); const { data } = await worker.recognize(bytes, {}, { blocks: true, text: false }); signal.throwIfAborted(); - const ctx = canvas.getContext("2d")!; - ctx.font = "100px sans-serif"; - const [xMin, yMin, xMax, yMax] = page.view; - const words: Word[] = []; const lines = (data.blocks ?? []).flatMap(block => block.paragraphs.flatMap(p => p.lines)); - for (const line of lines) { - const height = line.bbox.y1 - line.bbox.y0; - for (const [i, word] of line.words.entries()) { - const width = ctx.measureText(word.text).width; - if (!width || !height) continue; - // pdf.js sizes its text layer in unrotated PDF units and rotates the whole layer for display. - const [x, y] = viewport.convertToPdfPoint(word.bbox.x0, line.bbox.y0); - words.push({ - text: word.text, endOfLine: i === line.words.length - 1, - left: 100 * (x - xMin) / (xMax - xMin), top: 100 * (yMax - y) / (yMax - yMin), - height: height / (scale * page.userUnit), scaleX: 100 * (word.bbox.x1 - word.bbox.x0) / (width * height), - }); - } - } - return words; + return toItems(lines, viewport); } finally { signal.removeEventListener("abort", cancel); canvas.width = canvas.height = 0; } } -function addWords(view: PDFPageView, words: Word[]) { - const layer = view.textLayer?.div; - const end = layer?.querySelector(":scope > .endOfContent"); - if (!end || layer?.textContent.trim()) return; - const fragment = document.createDocumentFragment(); - let lineStart = true; - for (const word of words) { - const span = document.createElement("span"); - span.className = lineStart ? "ocr-word ocr-line-start" : "ocr-word"; - span.textContent = word.text + (word.endOfLine ? "" : " "); - span.style.left = `${word.left}%`; - span.style.top = `${word.top}%`; - span.style.fontFamily = "sans-serif"; - span.style.setProperty("--font-height", `${word.height}px`); - span.style.setProperty("--scale-x", String(word.scaleX)); - span.style.setProperty("--rotate", `${-view.pdfPage.rotate}deg`); - fragment.append(span); - if (word.endOfLine) { - const br = document.createElement("br"); - br.className = "ocr-break"; - fragment.append(br); - } - lineStart = word.endOfLine; - } - end.before(fragment); -} - -/** Consume the stream one chunk at a time without retaining a whole page's text and fonts. */ -async function hasEmbeddedText(page: PDFPageProxy, signal: AbortSignal) { +/** Retain only sparse native text; typed pages can be skipped without accumulating their entire text. */ +async function sparseNativeText(page: PDFPageProxy, signal: AbortSignal): Promise { const reader = page.streamTextContent().getReader(); - let found = false; + const items: TextItem[] = []; + let coverage = 0; + let typed = false; + const pageArea = area(page.view as Box); try { - while (!signal.aborted) { - const { done, value } = await reader.read(); - if (done) return found; - found ||= value.items.some((item: { str?: string }) => item.str?.trim()); + for (;;) { + signal.throwIfAborted(); + const { value, done } = await reader.read(); + if (done) return typed ? null : items; + if (typed) continue; + for (const item of value.items) { + if (!("str" in item) || !item.str.trim()) continue; + const text = item as TextItem; + items.push(text); + coverage += area(boxOf(text)); + if (pageArea > 0 && coverage / pageArea >= SCAN_TEXT_COVER) { + items.length = 0; + typed = true; + break; + } + } } - throw signal.reason; } finally { reader.releaseLock(); } } /** Prepare the document sequentially, giving the reader's current page and its successor the next turns. */ -export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, languages: ScanLanguage[], onStatus: (text: string | null) => void) { +export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, languages: ScanLanguage[], onStatus: (text: string | null) => void, onPhase: (phase: "running" | "done") => void) { const pdf = viewer.pdfDocument!; const controller = new AbortController(); const { signal } = controller; const pending = new Set(Array.from({ length: pdf.numPages }, (_, i) => i + 1)); - // Keep small documents ready for the entire session. On huge scans, evicted pages can be read again on demand. - const cache = new Map(); - const evicted = new Set(); const notices = new Map(); let nextPage = viewer.currentPageNumber; - let cachedWords = 0; let active = 0; let recognizing = false; let timer: number | undefined; @@ -164,7 +104,8 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language const n = viewer.currentPageNumber; const view: PDFPageView = viewer.getPageView(n - 1); const layer = view.textLayer?.div; - if (layer?.isConnected && !layer.hidden && layer.textContent.trim()) onStatus(null); + if (notices.get(n) === "ocrError") onStatus(missingPackMessage ?? `Text unavailable on page ${n}.`); + else if (layer?.isConnected && !layer.hidden && layer.textContent.trim()) onStatus(null); else if (notices.get(n) === "noWords") onStatus(`No readable text found on page ${n}.`); else if (notices.has(n)) onStatus(missingPackMessage ?? `Text unavailable on page ${n}.`); else if (active === n && recognizing) onStatus(`Recognizing text on page ${n}…`); @@ -173,18 +114,6 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language } else onStatus(null); } - function remember(n: number, words: Word[]) { - cache.set(n, words); - cachedWords += words.length; - for (const [old, entry] of cache) { - if (cachedWords <= 100_000) break; - if (old === n || Math.abs(old - viewer.currentPageNumber) <= 1) continue; - cache.delete(old); - cachedWords -= entry.length; - evicted.add(old); - } - } - function schedule() { if (!signal.aborted && !window.frameElement?.hasAttribute("inert") && !active && timer === undefined && pending.size) { timer = window.setTimeout(prepareNext, 0); @@ -205,9 +134,17 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language try { page = await pdf.getPage(n); signal.throwIfAborted(); - if (await hasEmbeddedText(page, signal)) return; + const session = ocrSession(pdf); + if (session?.pages.has(n) || session?.embedded.has(n)) return; + const native = await sparseNativeText(page, signal); + if (!native) return; signal.throwIfAborted(); if (!(await hasPageImage(page))) return; + const scan = await isScanLike(native.length, textCover(native, page.view as Box), async () => { + const { fnArray, argsArray } = await page!.getOperatorList({ annotationMode: AnnotationMode.DISABLE }); + return imageCover(fnArray, argsArray, OPS, page!.view as Box); + }); + if (!scan) return; signal.throwIfAborted(); if (n !== viewer.currentPageNumber && pending.has(viewer.currentPageNumber)) { pending.add(n); @@ -215,16 +152,17 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language } recognizing = true; if (n === viewer.currentPageNumber) updateStatus(); - worker ??= startWorker(languages).catch(error => { - workerStartFailed = true; - missingPackMessage = error instanceof MissingScanPack ? error.message : undefined; - throw error; - }); - const words = await readWords(page, await worker, signal); + worker ??= startOcrWorker(signal, languages).catch(error => { workerStartFailed = true; missingPackMessage = error instanceof MissingScanPack ? error.message : undefined; throw error; }); + const items = await readWords(page, await worker, signal); + const newItems = dropDuplicates(items, native.map(boxOf)); signal.throwIfAborted(); - if (words.length) { - remember(n, words); - addWords(view, words); + if (newItems.length && session) { + session.record(n, newItems); + // Rebuild through pdf.js so its text spans and find-highlighter map stay in sync. + if (view.renderingState !== 0) { + view.reset({ keepAnnotationLayer: true, keepAnnotationEditorLayer: true, keepCanvasWrapper: true }); + viewer.update(); + } } else { notices.set(n, "noWords"); } @@ -240,6 +178,7 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language // Background renders aren't in the viewer's page buffer, so it won't release their images for us. if (!viewer.getCachedPageViews().has(view)) page?.cleanup(); if (n === viewer.currentPageNumber) updateStatus(); + if (!pending.size) onPhase("done"); schedule(); } } @@ -252,10 +191,7 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language return; } if (notices.get(n) === "layerError") notices.delete(n); - const words = cache.get(n); - if (words) addWords(view, words); - else if (evicted.delete(n)) pending.add(n); - else if (view.textLayer?.div.textContent.trim() && !view.textLayer.div.querySelector(".ocr-word")) pending.delete(n); + if (ocrSession(pdf)?.pages.has(n)) view.textLayer?.div.classList.add("ocr-text"); if (n === viewer.currentPageNumber) updateStatus(); schedule(); }, { signal }); @@ -266,25 +202,33 @@ export function recognizeScannedPages(bus: EventBus, viewer: PDFViewer, language if (workerStartFailed) { worker = undefined; workerStartFailed = false; } if (pageNumber !== active) pending.add(pageNumber); } - if (evicted.delete(pageNumber)) pending.add(pageNumber); - if (evicted.delete(pageNumber + 1)) pending.add(pageNumber + 1); updateStatus(); schedule(); }, { signal }); window.addEventListener("folio:tab-visibility", schedule, { signal }); - updateStatus(); - schedule(); - return () => { + const stop = () => { controller.abort(); window.clearTimeout(timer); - cache.clear(); pending.clear(); - // A language change replaces only the text Folio added to the open document. - // In one-page scrolling mode some rendered layers are detached from the viewer. - for (const view of viewer.getCachedPageViews() as Set) { - view.textLayer?.div.querySelectorAll(".ocr-word, .ocr-break").forEach(node => node.remove()); - } onStatus(null); void worker?.then(w => w.terminate()).catch(() => {}); }; + onPhase("running"); + updateStatus(); + schedule(); + return { + stop, + retry() { + if (signal.aborted) return; + if (workerStartFailed) { worker = undefined; workerStartFailed = false; } + for (let n = 1; n <= pdf.numPages; n++) { + if (n !== active && !ocrSession(pdf)?.pages.has(n) && !ocrSession(pdf)?.embedded.has(n)) pending.add(n); + if (notices.get(n) === "ocrError") notices.delete(n); + } + nextPage = viewer.currentPageNumber; + if (pending.size || active) onPhase("running"); + updateStatus(); + schedule(); + }, + }; } diff --git a/src/pdf.ts b/src/pdf.ts index e8a1175..63515f4 100644 --- a/src/pdf.ts +++ b/src/pdf.ts @@ -8,6 +8,7 @@ pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; const base = `${import.meta.env.BASE_URL}pdfjs/`; +export { pdfjs }; export type { PDFDocumentProxy }; export async function loadPdf(data: Uint8Array, signal: AbortSignal, password?: string) { diff --git a/src/recent.ts b/src/recent.ts index 1d4a636..b49ad6b 100644 --- a/src/recent.ts +++ b/src/recent.ts @@ -16,6 +16,7 @@ const STORE = "recent"; const LEGACY = "recentByName"; const NOTES = "notes"; const AI = "ai"; +export const OCR = "ocr"; const MAX_RECENT = 8; /** Bigger files aren't kept in Recent: each entry stores the whole PDF, so a few 300 MB downloads would be gigabytes of copies on disk. */ export const MAX_RECENT_FILE_BYTES = 50 * 1024 * 1024; @@ -24,7 +25,7 @@ let connection: Promise | null = null; export function openFolioDatabase(): Promise { if (connection) return connection; connection = new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 6); + const req = indexedDB.open(DB_NAME, 7); req.onupgradeneeded = event => { const database = req.result; // Older cards can pair one file's preview and position with another file's bytes. @@ -34,11 +35,10 @@ export function openFolioDatabase(): Promise { } if (!database.objectStoreNames.contains(NOTES)) database.createObjectStore(NOTES, { keyPath: "id" }); if (!database.objectStoreNames.contains(AI)) database.createObjectStore(AI, { keyPath: "id" }); - if (event.oldVersion < 5) { - database.createObjectStore("versions", { keyPath: "id" }).createIndex("byFamily", ["family", "number"], { unique: true }); - database.createObjectStore("versionPdfs"); - } - database.createObjectStore("clearGenerations"); + if (!database.objectStoreNames.contains(OCR)) database.createObjectStore(OCR, { keyPath: "id" }); + if (!database.objectStoreNames.contains("versions")) database.createObjectStore("versions", { keyPath: "id" }).createIndex("byFamily", ["family", "number"], { unique: true }); + if (!database.objectStoreNames.contains("versionPdfs")) database.createObjectStore("versionPdfs"); + if (!database.objectStoreNames.contains("clearGenerations")) database.createObjectStore("clearGenerations"); }; req.onsuccess = () => { const database = req.result; @@ -52,7 +52,7 @@ export function openFolioDatabase(): Promise { return connection; } -async function tx(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest, store = STORE): Promise { +export async function tx(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest, store = STORE): Promise { const db = await openFolioDatabase(); return new Promise((resolve, reject) => { const transaction = db.transaction(store, mode); @@ -153,26 +153,53 @@ export async function updateLastPage(id: string, lastPage: number) { } catch {} } +// Active readers must not write old OCR back after the user removes it in another tab. +export function ocrEpoch(id: string) { + try { return `${localStorage.getItem("folio:ocr:all") ?? ""}|${localStorage.getItem(`folio:ocr:${id}`) ?? ""}`; } + catch { return null; } +} +function invalidateOcr(id?: string) { + // When storage cannot be read, OCR writes are already disabled. If it can be read but not changed, fail + // rather than allowing an open reader to restore data after deletion. + if (ocrEpoch(id ?? "") === null) return; + localStorage.setItem(id ? `folio:ocr:${id}` : "folio:ocr:all", `${Date.now()}-${Math.random()}`); +} + +/** Removes saved OCR without deleting recent documents, annotations, or conversations. */ +export async function clearRecognizedText() { + invalidateOcr(); + await tx("readwrite", s => s.clear(), OCR); +} + export async function clearRecent() { + invalidateOcr(); try { const db = await openFolioDatabase(); - const stores = db.objectStoreNames.contains(LEGACY) ? [STORE, LEGACY] : [STORE]; + const stores = db.objectStoreNames.contains(LEGACY) ? [STORE, LEGACY, OCR] : [STORE, OCR]; await new Promise((resolve, reject) => { const transaction = db.transaction(stores, "readwrite"); for (const name of stores) transaction.objectStore(name).clear(); transaction.oncomplete = () => resolve(); transaction.onabort = () => reject(transaction.error); }); - } catch { - /* ignore */ + } catch (error) { + throw error; } } export async function removeRecent(id: string) { + invalidateOcr(id); try { - await tx("readwrite", (s) => s.delete(id)); - } catch { - /* ignore */ + const db = await openFolioDatabase(); + await new Promise((resolve, reject) => { + const transaction = db.transaction([STORE, OCR], "readwrite"); + transaction.objectStore(STORE).delete(id); + transaction.objectStore(OCR).delete(IDBKeyRange.bound(`${id}#`, `${id}#\uffff`)); + transaction.oncomplete = () => resolve(); + transaction.onabort = () => reject(transaction.error); + }); + } catch (error) { + throw error; } } diff --git a/src/styles.css b/src/styles.css index 7d02b86..693c4b1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -599,6 +599,7 @@ kbd { font-size: 12px; pointer-events: none; } +.page-text-status button { margin-left: 8px; pointer-events: auto; } .page-text-status:empty { opacity: 0; } /* Only the page drawing is darkened; images are restored on top by an unfiltered overlay (nightImages.ts). */ .app[data-night] .pdfViewer .page .canvasWrapper > canvas:not(.folio-image-overlay), @@ -613,20 +614,22 @@ kbd { background-color: #1e1e1e; } -/* Let a drag start beside the scanned ink without enlarging the rectangles used for highlights. */ -.textLayer > .ocr-word::before, -.textLayer > .ocr-word::after { +/* Extend OCR hit targets so selection can start beside the scanned ink. */ +.textLayer.ocr-text > span::before, +.textLayer.ocr-text > span::after { content: ""; position: absolute; inset: -0.2em 50% -0.2em 0; z-index: -1; } -.textLayer > .ocr-word::after { +.textLayer.ocr-text > span::after { left: 50%; right: -0.7em; } -.textLayer > .ocr-line-start::before { - left: -0.7em; +.textLayer.ocr-text > span:first-child::before, +.textLayer.ocr-text > .textLayerImages + span::before, +.textLayer.ocr-text > br + span::before { + inset: -0.7em 50% -0.7em -0.7em; } /* Selection colours inside the text layer */ @@ -918,6 +921,19 @@ kbd { .search-status.miss { color: var(--accent-text); } +.search-progress { + position: absolute; + top: calc(100% + 6px); + right: 0; + max-width: min(300px, 90vw); + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + color: var(--text-2); + box-shadow: var(--shadow-md); + font-size: 12px; +} .chip { height: 26px; min-width: 28px; diff --git a/src/testing/deferred.ts b/src/testing/deferred.ts new file mode 100644 index 0000000..440e143 --- /dev/null +++ b/src/testing/deferred.ts @@ -0,0 +1,6 @@ +/** A promise settled from outside, for tests that need to decide exactly when something finishes. */ +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} diff --git a/src/testing/fixtures.ts b/src/testing/fixtures.ts new file mode 100644 index 0000000..96eecbb --- /dev/null +++ b/src/testing/fixtures.ts @@ -0,0 +1,72 @@ +// Real encrypted PDFs, so the guard that keeps Folio from rewriting a protected file is tested against what pdf.js +// really reports for one, not against a stand-in. Each is a one-page PDF (made with pdf-lib: the word "Folio") that +// Apple's PDFKit wrote with standard encryption (RC4, 128 bit): +// +// PDFDocument(url: plain).write(to: out, withOptions: [.userPasswordOption: "folio", .ownerPasswordOption: "owner-secret"]) +// PDFDocument(url: plain).write(to: out, withOptions: [.ownerPasswordOption: "owner-secret"]) + +const bytes = (base64: string) => Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + +/** Needs the password "folio" to open. */ +export const protectedWithPassword = bytes( + "JVBERi0xLjcKJcTl8uXrp/Og0MTGCjEgMCBvYmoKPDwgL0NvdW50IDEgL0tpZHMgWyAyIDAgUiBdIC9UeXBlIC9QYWdlcyA+PgplbmRvYmoKMy" + + "AwIG9iago8PCAvUGFnZXMgMSAwIFIgL1R5cGUgL0NhdGFsb2cgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0NyZWF0aW9uRGF0ZSAoNmliYLM3P1ww" + + "MjaUUlwwMDNEXDAzMo1x170pIC9DcmVhdG9yIDw4Y2FjNTAyMDgxNjUwZTQwYTc0ZjMyMWYyOWQyNDI4NmU3ODg4ZmVmOTI0ZjZiZGY4YzdjNT" + + "hjNGMyMjM1Y2JlYzYxYjQ1MGExMDEwZTZlYzg3ZTQxNzRlMmVjNjRjZjVhZGU3MWNlNWY0MGRjZGEyYThlZDAwNjExY2NlZWVlMjljZWIyN2U2" + + "OTVmY2I0MDlmOWJlMzhkNDFhNTk5MjU1MzRmZjA1M2Q2NTdkNWVhOGRlZGM+Ci9Nb2REYXRlICg2aWJgszc/XDAyNpRSXDAwM0RcMDMyjXHXvS" + + "kgL1Byb2R1Y2VyIDw4Y2FjNTAyMDgxNjUwZTQwYTc0ZjMyMWYyOWQyNDI4NmU3ODg4ZmVmOTI0ZjZiZGY4YzdjNThjNGMyMjM1Y2JlYzYxYjQ1" + + "MGExMDEwZTZlYzg3ZTQxNzRlMmVjNjRjZjVhZGU3MWNlNWY0MGRjZGEyYThlZDAwNjExY2NlZWVlMjljZWIyN2U2OTVmY2I0MDlmOWJlMzhkND" + + "FhNTk5MjU1MzRmZjA1M2Q2NTdkNWVhOGRlZGM+Cj4+CmVuZG9iagoyIDAgb2JqCjw8IC9Db250ZW50cyBbIDUgMCBSIF0gL01lZGlhQm94IFsg" + + "MCAwIDIwMCAyMDAgXSAvUmVzb3VyY2VzIDw8IC9FeHRHU3RhdGUKPDwgPj4gL1hPYmplY3QgPDwgPj4gL0ZvbnQgPDwgL0hlbHZldGljYS03MD" + + "k4NDgwNzg5IDYgMCBSID4+ID4+IC9Bbm5vdHMgWwpdIC9QYXJlbnQgMSAwIFIgL1R5cGUgL1BhZ2UgPj4KZW5kb2JqCjYgMCBvYmoKPDwgL0Vu" + + "Y29kaW5nIC9XaW5BbnNpRW5jb2RpbmcgL0Jhc2VGb250IC9IZWx2ZXRpY2EgL1N1YnR5cGUgL1R5cGUxIC9UeXBlIC9Gb250Cj4+CmVuZG9iag" + + "o1IDAgb2JqCjw8IC9MZW5ndGggODggL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4gCnN0cmVhbQp6daadfI+Lnq6QG1NaqbmMwf8SzWI4Bj5UH7b9" + + "cwZR3lxVjlFO0tqxvgNxXCBpo2fAADCtpZfOvGfl+WpIlSDb91W9ipYsh6EgT7KR20DaPnhI57NT0pNQCmVuZHN0cmVhbQplbmRvYmoKNyAwIG" + + "9iago8PCAvRmlyc3QgMjYgL0xlbmd0aCAzNjAgL1R5cGUgL09ialN0bSAvTiA1IC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4+IApzdHJlYW0K2CbS" + + "X6GlRCyItbB97Cb+BzYQmvuePoA7zzMbaW/TVjQ7UW99sabHtlE0UAKimVLawOD3aYMLHVOlEXQG7XEGvtBtYl0YlAIPu2vv1TqU9T7Z/r24cV" + + "eMK6/rhtiQd8O0f3p7Ti7+pzHPl12kQ+A550dJONQLXspJWNQe45BEiIcc50UZiBtz2d/gHj/Jhzh6zWq059WqdGaS4ZEwE5n5dK2ns5VvH3sl" + + "W/Fqb1WhfSaFVm5seClcC/ye/rm90pminn2W6GsAeMLuWWWdmf1+AhMNtKK0WHPjCbp0f8YqxUqam9a73y3RLe1wOHI8IUZakhvXYgqJ9f+xTE" + + "pOeQ5E94To3E08kvDmKeWcY+IqQ2k+Y8jvIwaqnfRoSExbKSwjVkoqa/4nIEr79fJnlGUjPLBEAJRRzk1kMEbF2c0nKjfB2cJWAv34UJ09mJ4D" + + "bx4MpSGbmpYR9kLI7OiXq9/LRv9MdtaeO5a6CmVuZHN0cmVhbQplbmRvYmoKOCAwIG9iago8PCAvSW5kZXggWyAwIDkgXSAvVyBbIDEgMiAyIF" + + "0gL0xlbmd0aCA0MCAvRmlsdGVyIC9GbGF0ZURlY29kZSAvSW5mbyA0IDAgUgovU2l6ZSA5IC9UeXBlIC9YUmVmIC9Sb290IDMgMCBSID4+IApz" + + "dHJlYW0KO21Mk+OYvNGinpyTLKzED4GCPmz/rWsOZjVT5n9+EoQFuCHbGDxw4wplbmRzdHJlYW0KZW5kb2JqCjkgMCBvYmoKPDwgL1AgLTQgL1" + + "UgPGU4MzhmZTNkNDlmYTIyNjcwNDE4NTJiODgzOTQzYTRkMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA+Ci9WIDIgL08gPGRmZWIy" + + "NTA4N2EzOGEwNDNiMTIzOTlmZGQ3MDkxYjUxMDNhZjk3MjM3OTQwZWZmNTU5MWRhMzU2NTg2OTI0ZmQ+Ci9MZW5ndGggMTI4IC9SIDMgL0ZpbH" + + "RlciAvU3RhbmRhcmQgPj4KZW5kb2JqCnhyZWYKMCAxMAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwMDYx" + + "NyAwMDAwMCBuIAowMDAwMDAwMDgxIDAwMDAwIG4gCjAwMDAwMDAxMzAgMDAwMDAgbiAKMDAwMDAwMDkwOCAwMDAwMCBuIAowMDAwMDAwODExID" + + "AwMDAwIG4gCjAwMDAwMDEwNjggMDAwMDAgbiAKMDAwMDAwMTUzMCAwMDAwMCBuIAowMDAwMDAxNzE0IDAwMDAwIG4gCgp0cmFpbGVyCjw8IC9F" + + "bmNyeXB0IDkgMCBSIC9JbmZvIDQgMCBSIC9JRCBbIDxlMjcwYzA1NGYyNTVjMTZjMmFiNDgwN2Q1NmY0MDlkMj4gPGUyNzBjMDU0ZjI1NWMxNm" + + "MyYWI0ODA3ZDU2ZjQwOWQyPgpdIC9Sb290IDMgMCBSIC9TaXplIDEwID4+IApzdGFydHhyZWYKMTkyMQolJUVPRgo=", +); + +/** Opens without a password (the user password is empty), but is encrypted all the same: only the owner can change it. */ +export const protectedByOwner = bytes( + "JVBERi0xLjcKJcTl8uXrp/Og0MTGCjEgMCBvYmoKPDwgL0NvdW50IDEgL0tpZHMgWyAyIDAgUiBdIC9UeXBlIC9QYWdlcyA+PgplbmRvYmoKMy" + + "AwIG9iago8PCAvUGFnZXMgMSAwIFIgL1R5cGUgL0NhdGFsb2cgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0NyZWF0aW9uRGF0ZSAoI1wwMDMqSZOI" + + "nENUmaE6LFwwMjY4sq4pIC9DcmVhdG9yIDw5OWM2MTgwOWExZGFhZDE1Njc4NDkwNjExZjQ5MGJlM2Y0YmJmOTIxMDgxMzE3N2FkOTEyZDMwYj" + + "BjZThkZTQ5NGIxYTg4NzBiN2I4ZjI3NTkyY2Y1NzkxM2NkNmE5ZDc3MDhjMDRkZmE0NzMzM2FmYzc3NmVmZTJiODQxN2ExZmRhMTk3NjBkMDky" + + "NzE3MTcyMWVmNmRmMDE5Njg5ZGY1OWQxMGE3M2NmYzU1NDIxZGIyMzM+Ci9Nb2REYXRlICgjXDAwMypJk4icQ1SZoTosXDAyNjiyrikgL1Byb2" + + "R1Y2VyIDw5OWM2MTgwOWExZGFhZDE1Njc4NDkwNjExZjQ5MGJlM2Y0YmJmOTIxMDgxMzE3N2FkOTEyZDMwYjBjZThkZTQ5NGIxYTg4NzBiN2I4" + + "ZjI3NTkyY2Y1NzkxM2NkNmE5ZDc3MDhjMDRkZmE0NzMzM2FmYzc3NmVmZTJiODQxN2ExZmRhMTk3NjBkMDkyNzE3MTcyMWVmNmRmMDE5Njg5ZG" + + "Y1OWQxMGE3M2NmYzU1NDIxZGIyMzM+Cj4+CmVuZG9iagoyIDAgb2JqCjw8IC9Db250ZW50cyBbIDUgMCBSIF0gL01lZGlhQm94IFsgMCAwIDIw" + + "MCAyMDAgXSAvUmVzb3VyY2VzIDw8IC9FeHRHU3RhdGUKPDwgPj4gL1hPYmplY3QgPDwgPj4gL0ZvbnQgPDwgL0hlbHZldGljYS03MDk4NDgwNz" + + "g5IDYgMCBSID4+ID4+IC9Bbm5vdHMgWwpdIC9QYXJlbnQgMSAwIFIgL1R5cGUgL1BhZ2UgPj4KZW5kb2JqCjYgMCBvYmoKPDwgL0VuY29kaW5n" + + "IC9XaW5BbnNpRW5jb2RpbmcgL0Jhc2VGb250IC9IZWx2ZXRpY2EgL1N1YnR5cGUgL1R5cGUxIC9UeXBlIC9Gb250Cj4+CmVuZG9iago1IDAgb2" + + "JqCjw8IC9MZW5ndGggODggL0ZpbHRlciAvRmxhdGVEZWNvZGUgPj4gCnN0cmVhbQp6LSMXiySIv/z3YqUVt2j/hYwd2a2X1pVYMc8+Yj2iKQu+" + + "K6Wwy/FhfFfcZx3yGq7oOXv0WafscBHToJ7pR1+BIjXE1hV81WQDScyWqgOnNQRuagW/v3wTCmVuZHN0cmVhbQplbmRvYmoKNyAwIG9iago8PC" + + "AvRmlyc3QgMjYgL0xlbmd0aCAzNjAgL1R5cGUgL09ialN0bSAvTiA1IC9GaWx0ZXIgL0ZsYXRlRGVjb2RlID4+IApzdHJlYW0KdwOXUW1cmyeQ" + + "W93E+/4821jF3GKvAMYNppKcW8MgLD/CifinsuHDkkzGG5abkSRRTZQgaj+8LQQelUQEktTRnZ5YSCIwgT4sAAxW9BytUiYGkhlzpKNVyjb0Gf" + + "TfFljRo7eiG2xt+DeUf6iodxwmhWWYx3qWVJS5XiopNRM6WLm9WVZr2hsEyHrlhXToC4rvtCCXE1u6nOUeP8pwoyQwp1HrGV2ed8Rw62Wr+aQZ" + + "/Q5OYQChh2cvI6EOjd8I34tx9d1CWQTSe+SSQkwCGjEXm2g37u+xo8LHMR2LQRQ74M3jRgYRLF74i9wjaUD9XPv4ourYv70kyv6RLrA51qn42N" + + "fB23+HCn4MlQi6Z7qep5o1HGAyPMtHnLFqUmNG4KCoOAcCw3OEpOkeColyMESFSnQkxFPoSSe44yRQh1sN0YfV+5ZE5xMOqtTNoq//vbwEqnUI" + + "PzrJe5ukxTFHT9GdtfetLnzj3Jc3CmVuZHN0cmVhbQplbmRvYmoKOCAwIG9iago8PCAvSW5kZXggWyAwIDkgXSAvVyBbIDEgMiAyIF0gL0xlbm" + + "d0aCA0MCAvRmlsdGVyIC9GbGF0ZURlY29kZSAvSW5mbyA0IDAgUgovU2l6ZSA5IC9UeXBlIC9YUmVmIC9Sb290IDMgMCBSID4+IApzdHJlYW0K" + + "VcD0DfuQZr7t7OtL1zSupLScUW4+1wrBz6XtcIx3mcEHi432bGTIDwplbmRzdHJlYW0KZW5kb2JqCjkgMCBvYmoKPDwgL1AgLTQgL1UgPDMzMW" + + "Y2NjEwMjNkZjk1MjMwYTZkYmQ0NGUxZWM2YzkwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA+Ci9WIDIgL08gPDkxM2IwNzNmNWI2" + + "NTk1NGM4YjZkYTIyMTY5OTcxYTE3N2I3ZTZkOTRhMTA2ZmY3NWMwYzE2Mjk2YmMxNTQxMmU+Ci9MZW5ndGggMTI4IC9SIDMgL0ZpbHRlciAvU3" + + "RhbmRhcmQgPj4KZW5kb2JqCnhyZWYKMCAxMAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMjIgMDAwMDAgbiAKMDAwMDAwMDYxMSAwMDAw" + + "MCBuIAowMDAwMDAwMDgxIDAwMDAwIG4gCjAwMDAwMDAxMzAgMDAwMDAgbiAKMDAwMDAwMDkwMiAwMDAwMCBuIAowMDAwMDAwODA1IDAwMDAwIG" + + "4gCjAwMDAwMDEwNjIgMDAwMDAgbiAKMDAwMDAwMTUyNCAwMDAwMCBuIAowMDAwMDAxNzA4IDAwMDAwIG4gCgp0cmFpbGVyCjw8IC9FbmNyeXB0" + + "IDkgMCBSIC9JbmZvIDQgMCBSIC9JRCBbIDxmMmYzNzQ0MTQzMDBiYzAwZmIxNTIxM2Q2M2Y5NTkxND4gPGYyZjM3NDQxNDMwMGJjMDBmYjE1Mj" + + "EzZDYzZjk1OTE0PgpdIC9Sb290IDMgMCBSIC9TaXplIDEwID4+IApzdGFydHhyZWYKMTkxNQolJUVPRgo=", +); diff --git a/src/testing/pdfjs.ts b/src/testing/pdfjs.ts new file mode 100644 index 0000000..6d085ff --- /dev/null +++ b/src/testing/pdfjs.ts @@ -0,0 +1,49 @@ +// Real pdf.js (document loader and find controller) for tests that need the genuine article: what an offset or a +// text item means is decided by pdf.js, and a fake can only repeat what we already assumed. +// +// pdf.js 6 uses a few things Node only has from 22 on (which CI and the Electron toolchain use). Older Node gets them +// here so the tests still run there. +const promise = Promise as { withResolvers?: unknown }; +if (!promise.withResolvers) { + promise.withResolvers = function () { + let resolve!: (value: T | PromiseLike) => void, reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; + }; +} +const buffer = ArrayBuffer.prototype as { transferToFixedLength?: unknown }; +if (!buffer.transferToFixedLength) { + // (Without the part that detaches the original, which nothing here relies on.) + buffer.transferToFixedLength = function (this: ArrayBuffer, length = this.byteLength) { + const copy = new Uint8Array(length); + copy.set(new Uint8Array(this).subarray(0, length)); + return copy.buffer; + }; +} + +/** The core library, which runs in plain Node. */ +async function loadCore() { + return import("pdfjs-dist/legacy/build/pdf.mjs"); +} + +/** The viewer (find controller, event bus), which needs a DOM: use it from a test file that runs in jsdom. */ +async function loadViewer() { + // pdf_viewer.mjs reads the core library from globalThis.pdfjsLib at import time (see src/pdfjs-global.ts). + (globalThis as { pdfjsLib?: unknown }).pdfjsLib = await loadCore(); + return import("pdfjs-dist/legacy/web/pdf_viewer.mjs"); +} + +export async function openPdf(bytes: Uint8Array, password?: string) { + const pdfjsLib = await loadCore(); + return pdfjsLib.getDocument({ data: bytes.slice(), password, useSystemFonts: true }).promise; +} + +/** A find controller wired to a document the way the app does it (event bus, and a link service that knows the page count). */ +export async function findControllerFor(pdf: Awaited>) { + const viewer = await loadViewer(); + const eventBus = new viewer.EventBus(); + const linkService = { pagesCount: pdf.numPages, page: 1 }; + const controller = new viewer.PDFFindController({ eventBus, linkService: linkService as never }); + controller.setDocument(pdf); + return { controller, eventBus, linkService }; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5653518 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts"], + environment: "node", + }, +});