Read imported PDFs in the app

Stage three: the viewer. Drawing on pages comes next.

pdf.js stays out of the bundle everyone downloads — it is larger than the
rest of the application put together, so it is behind a dynamic import and
a session that never opens a PDF never fetches it. Main bundle grew by
8 kB for the viewer shell; the 162 kB library arrives only when needed.
The worker is bundled rather than pulled from a CDN, because a viewer
that stops working during someone else's outage is not one you can rely
on offline.

Two things that only showed up once it ran in a browser:

- pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent
  browsers have. Anything older failed with "n.toHex is not a function",
  which says nothing about the cause. Now on the legacy build, which
  compiles those away — a little larger, and the difference between
  working everywhere and working on this year's browsers.
- nginx has no MIME type for .mjs and served the worker as
  octet-stream, which a browser refuses to run as a module.

Pages are laid out at their true proportions before anything is drawn, so
the scrollbar is honest immediately and a document mixing portrait and
landscape keeps both — the first version scaled every page to the widest
one, which the suite caught by checking the two aspect ratios differ.

Bytes come from the authenticated attachment route and are cached in
IndexedDB. Content-addressed storage means a cached copy can never be
stale, so a document opened once needs no connection again. Verified with
the network switched off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 20:36:04 +02:00
1 parent 53965281b4
commit 2986bcb620
16 files changed
+1079 -12

No files matched your search

+33
View File
@@ -17,6 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Undo/Redo** — Full history stack for drawing operations
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
- **Star/Favorite** — Quick access to important notes
- **PDF Reading** — Import a PDF and read it in the app; the file is cached so it stays readable offline
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
- **Dark & Light Mode** — Full theme support with refined color schemes
@@ -329,6 +330,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
| `pdf` | 10 | import, page proportions, lazy loading, and reading with the network off |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
@@ -409,6 +411,37 @@ a snippet around the first match.
---
## Reading PDFs
An imported PDF becomes a note of type `pdf` and is shown by a viewer instead of
the text editor. Pages are laid out at their true proportions before anything is
drawn, so the scrollbar is honest from the first frame; a document mixing
portrait and landscape keeps both, rather than stretching the narrow pages.
Only pages near the viewport are rendered, and each keeps its own canvas — a PDF
page is expensive to draw and cheap to hold, so scrolling back up repaints
nothing. Canvases are sized in device pixels and scaled down by CSS, which is
what keeps text sharp on a high-density screen.
**pdf.js is not in the main bundle.** It is larger than the rest of the
application put together, so it sits behind a dynamic import: a session that
never opens a PDF never downloads it. The worker is bundled too, not fetched
from a CDN — a viewer that stops working during someone else's outage is not one
you can rely on offline.
The **legacy** pdf.js build is used deliberately. Version 6 calls
`Uint8Array.prototype.toHex`, which only very recent browsers have; anything
older fails to parse the document with a message that says nothing about why.
Bytes are read through the authenticated attachment route and cached in
IndexedDB, so a document opened once stays readable with no connection. Because
storage is content-addressed, a cached copy can never go stale.
> nginx needs a MIME type for `.mjs` — without one it serves the worker as
> `application/octet-stream` and the browser refuses to run it as a module.
---
## PDF storage
An imported PDF is stored as an attachment: encrypted, named by the hash of its