Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
5955078
Add text recognition (OCR) for scanned PDFs
damogallagher Oct 30, 2026
26bf9fe
Add screenshots of text recognition
damogallagher Oct 30, 2026
2c24356
Clarify that the index.ts rule is for feature folders
damogallagher Oct 30, 2026
9174ef9
Merge main into feature/ocr-scanned-pdfs
damogallagher Oct 30, 2026
39efee9
Resolve the text recognition follow-ups
damogallagher Oct 30, 2026
0dc63ef
Document saved copies with recognized text, and the reading resolution
damogallagher Oct 30, 2026
e1f8149
Test the save guards against real files, and word the signature one h…
damogallagher Oct 30, 2026
ff0e3f4
Merge main into feature/ocr-scanned-pdfs
damogallagher Oct 30, 2026
160f0b8
Say "protected file", not "password-protected", when a copy goes with…
damogallagher Oct 30, 2026
30c53f9
Merge main into feature/ocr-scanned-pdfs
damogallagher Oct 30, 2026
96c5ca8
Fix the two CI failures found on Windows and Linux
damogallagher Oct 30, 2026
e71576c
Merge main into feature/ocr-scanned-pdfs
damogallagher Oct 30, 2026
b5185ae
Address review: per-run cancellation, honest failure reporting, and c…
damogallagher Oct 30, 2026
f83fa82
Merge main into feature/ocr-scanned-pdfs
damogallagher Oct 30, 2026
a17da1d
Merge branch 'main' into feature/ocr-scanned-pdfs
damogallagher Oct 30, 2026
d07c694
Fix OCR review issues
damogallagher Oct 31, 2026
56d8cea
Merge main and fix OCR integration
damogallagher Oct 31, 2026
eb75235
Build searchable scan features on the reader OCR
damogallagher Nov 3, 2026
7e858e9
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
77ba61e
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
625cd76
Align reader security checks with document tabs
damogallagher Nov 3, 2026
4307e10
Allow text recognition to be retried
damogallagher Nov 3, 2026
31d158c
Make OCR copy reopening reliable in smoke tests
damogallagher Nov 3, 2026
47aab66
Make recognized text reliable and removable
damogallagher Nov 3, 2026
309c019
Handle OCR updates and repeated scan images
damogallagher Nov 3, 2026
dda4bbc
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
7cbf32d
Make the page navigation smoke check reliable
damogallagher Nov 3, 2026
3b79f2d
Update the reader security test for highlight mode
damogallagher Nov 3, 2026
eb58943
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
a60cd3a
Keep pull request screenshots local
damogallagher Nov 3, 2026
33cf264
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
b72ab07
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
acb93d5
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 3, 2026
4a0a26f
Rebuild text layers after scan recognition
damogallagher Nov 3, 2026
a8f5040
Open scan from library in smoke test
damogallagher Nov 3, 2026
831f16d
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 4, 2026
0ca1bf5
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 4, 2026
1d92925
Show incomplete search results during recognition
damogallagher Nov 4, 2026
0cf15d7
Merge main and keep both test commands
damogallagher Nov 5, 2026
6427b73
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 5, 2026
a18613a
Merge branch 'main' of https://github.com/Forward-Future/folio into f…
damogallagher Nov 5, 2026
3c61716
Merge latest main and preserve language-aware scan text
damogallagher Nov 7, 2026
5d1cdf0
Keep scans readable when the PDF export font cannot represent their text
mberman84 Nov 10, 2026
431bfd8
Merge latest main and fix language switch smoke test
damogallagher Nov 10, 2026
8eae22e
Merge remote-tracking branch 'origin/pr21' into codex/ocr-export-text
mberman84 Nov 11, 2026
4fcd31c
Keep recognized passage context across all Ask AI providers
mberman84 Nov 11, 2026
846bd16
Keep the OCR status from blocking clicks on the PDF
mberman84 Nov 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ release
release-test
release-*/
dev-app-update.yml
public/ocr
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 6 additions & 2 deletions electron/ai-prompt.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,24 @@ 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 }) },
...messages.map(({ role, content, context }) => ({
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 };
6 changes: 3 additions & 3 deletions electron/ai.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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 ?? "" }))
Expand Down
2 changes: 1 addition & 1 deletion electron/custom-endpoint.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion electron/openrouter.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 }] });
Expand Down
Loading
Loading