Proposal: Files (PDF) — Read's fourth collection¶
Status: Shipped (four-modes.md Phase 6, 2026-07-15) — 99 e2e green, verified against the production build. Sync (30112) remains open, see below · Last updated: 2026-07-15
Discharges read-mode.md Phase 9 ("PDF/files support: own proposal needed"). Lands as Phase 6 of four-modes.md.
Summary¶
Add a Files collection to Read holding PDFs, imported and read locally, not highlightable. PDFs get their own addressable kind (30112), their own DB stores, and their own renderer — deliberately sharing nothing with the EPUB engine.
The "not highlightable" constraint isn't a shortcut. It's what makes this proposal small enough to build.
Why annotations are out of scope (the blocker)¶
EPUB CFI is the wire format of kind 30104. Annotation.cfiRange maps 1:1 onto published event content, per the Nostr Event Model — and PlebChat is not the only reader of those events (vibereader-era annotations resolve under our renderer today; NIP-84 highlights are exported to the open network).
A PDF has no CFI. There is no "spine position + character offset" in a fixed-layout page-image format. So annotating a PDF means inventing a second anchor scheme (page + rect) and teaching kind 30104 to carry it — a protocol change to a published kind, not an internal refactor. Every CFI call site assumes a parseable value: applyAnnotation, the marks map, compareCfi → CFI.compare, BrowseView's shelf sort, BookChatThread.context.cfiRange.
Dropping annotations dissolves the blocker completely. A PDF becomes a file you can read, not a document you can mark up. If highlighting PDFs is wanted later, it earns its own proposal, and that proposal is mostly about 30104's shape.
Decisions¶
- Own kind, own stores, own renderer. Nothing shared with EPUB.
pdfjs-distdirectly, dynamically imported. Do NOT restore foliate'spdf.js. (See below.)- Canvas + text layer. Selection, ⌘F, and copy work; highlighting does not. Canvas-only would read as broken rather than minimal, and the text layer is ~20 lines.
- No progress kind. Page-remember is device-local.
- Sync is out of scope for v1. 30112 is defined but not wired into
read/nostr/sync.ts.
Why not foliate's pdf.js¶
Foliate's pdf.js exists to make a PDF quack like a foliate book: it fabricates a per-page spine, synthesizes CFIs, and feeds the paginator + overlayer so highlights anchor. Every one of those is machinery this proposal explicitly rejects. Restoring it would:
- Re-couple PDFs to
src/lib/read/epub/service.ts— the file CLAUDE.md calls "the ONLY renderer contact; the exported contract survived a full renderer swap, keep it that way." A format with no CFI, no annotations, and no paginator has no business widening that contract. - Reinstate VENDORED.md patch #2, deliberately removed (upstream imports a ~13 MB pdfjs build), and pin us to whatever pdfjs the pinned foliate commit expects — making the vendor-bump ritual strictly worse.
- Buy us pagination and CFI anchoring we are not shipping.
Direct pdf.js is ~150 lines and owes nothing to Read's engine.
Kind 30112¶
{
"kind": 30112,
"tags": [["d", "<sha256>"], ["x", "<sha256>"]], // x = Blossom hash, as 30101
"content": "<nip44-to-self( JSON below )>"
}
// body — the ONE canonical camelCase shape, mirrored 1:1 by PdfDoc in db/types.ts
{ "sha256", "title", "author"?, "pageCount"?, "size", "mimeType": "application/pdf",
"blossomUrls"?: string[], "addedAt", "updatedAt" }
Allocation: 30101 book · 30102 progress · 30103 settings · 30104 annotation · 30105–30106 reserved for chat sync · 30107 news feed · 30108 saved article · 30109 feedback triage · 30110 relay health · 30111 settings → 30112 is the next free.
No 30113 progress kind. Page-remember lives in a device-local store. This preserves two invariants at once: the record↔event 1:1 rule (no unsynced field smuggled into the body type), and 30102's reason for existing (reading position never leaks when a document is shared).
DB (v10)¶
pdfs → PdfDoc { sha256, title, author?, pageCount?, size, mimeType, addedAt, updatedAt }
pdfFiles → PdfFile { sha256, blob, mimeType } ← RAW PUT, never clone()
pdfProgress → PdfProgress { sha256, page, updatedAt } ← device-local, no kind
Separate stores, not books/bookFiles. Reusing them would drag PDFs into deleteBookCascade's annotation/chat cascade (db/index.ts:269-298), the Blossom cover-restore path, and 30101/30102 sync — the exact machinery this proposal exists to avoid. The clone() used for $state-stripping destroys Blobs; pdfFiles uses raw put, like bookFiles/covers.
Renderer¶
// src/lib/read/pdf/service.ts — plain module state, NEVER $state
// (same rune-proxy hazard as epub/service.ts: pdf.js hands back live proxied objects)
let pdfjs: typeof import('pdfjs-dist') | null = null;
async function load() {
if (!pdfjs) {
pdfjs = await import('pdfjs-dist'); // Vite code-splits here
pdfjs.GlobalWorkerOptions.workerPort = new Worker(
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url), { type: 'module' });
}
return pdfjs;
}
pdfjs-distas a real npm dependency — versioned and upgradeable, not vendored.import()inside both the viewer andpdf/import.ts(metadata extraction needs it too) puts them in one lazy chunk. Nothing loads until a PDF is opened or imported.- Scrolling column,
IntersectionObserver-lazy per page: canvas + text layer (page.getTextContent()+renderTextLayer). No annotation layer. - The worker URL must be the
new URL(..., import.meta.url)form, never a CDN — no-backend hygiene, and Pages serves it from our origin. - No thumbnails in v1; the grid shows a typographic card. (A cover would mean rendering page 1 at import — acceptable, since import already loads pdf.js — but it adds a
covers-shaped store. Defer.)
Import¶
New src/lib/read/import.ts — one entry point routing by extension:
export async function importDroppedFiles(files: Iterable<File>) {
for (const file of files) {
if (/\.pdf$/i.test(file.name)) → importPdfFile(file) → ui.collection = 'files'
else if (SUPPORTED_EXTENSIONS.test(file.name)) → library.importFiles([file])
else toast.error(`Unsupported format: ${file.name}`)
}
}
Used by both LibraryView's and FilesView's drop zones, so dropping a PDF on the eBooks grid does the right thing instead of toast.error.
src/lib/read/pdf/import.ts mirrors epub/import.ts: sha256Hex(buffer) → dedup (DuplicatePdfError) → getDocument → getMetadata()/numPages → one-transaction write of pdfs + pdfFiles (blob raw, everything else clone()d). The sha256 is the identity, exactly as for books — that part of import.ts's header comment ("renderer-independent, format-independent") was always true.
New files¶
src/lib/read/import.ts (extension router)
src/lib/read/pdf/import.ts
src/lib/read/pdf/service.ts (lazy pdf.js loader + render helpers)
src/lib/read/stores/files.svelte.ts (mirrors library.svelte.ts)
src/lib/read/components/collections/FilesView.svelte (grid + import + drop)
src/lib/read/components/files/PdfCard.svelte
src/lib/read/components/files/PdfViewer.svelte (immersive; ui.view = 'pdf')
PdfViewer's root must pad with padding-top: var(--inset-top); padding-bottom: var(--inset-bottom) — never --shell-top. Immersive hides FloatingBar, which is the normal source of top safe-area padding. This is the Dynamic Island bug from live feedback d616bc48.
Verification¶
pnpm check + pnpm test:unit + pnpm test:e2e, then the /verify skill — canvas rendering and immersive insets are exactly the things that pass headless and look wrong on device.
pnpm build must show pdfjs in its own chunk with no growth in the main bundle.
New e2e/files.test.ts: import a fixture PDF → card appears → open → canvas renders → text is selectable → page-remember survives reload → delete. Fixture: a hand-written minimal single-page PDF (~1 KB of literal bytes) in e2e/fixtures/ — no new dependency.
What the build found (2026-07-15)¶
Three things the plan didn't know:
- pdf.js needs its font and cmap data served from somewhere. PDFs routinely reference Helvetica/Times/Courier without embedding them; absent
standardFontDataUrl, pdf.js can't draw their glyphs and most real documents render with broken text. The plan's "~150 lines and no vendoring" was true of the code and wrong about the assets.scripts/copy-pdfjs-assets.mjsstagesstandard_fonts+cmaps(2.4 MB) out ofnode_modulesintostatic/pdf/— copied at build, gitignored, so they track the installed pdfjs instead of rotting as committed binaries. Fetched by URL on demand: nothing enters the bundle. - It's a Vite
buildStarthook, NOT aprebuildscript. pnpm doesn't run pre/post scripts by default (enable-pre-post-scripts=false), so aprebuildwould silently never fire — and the failure would appear only in CI builds, as broken glyphs.buildStartcoversvite devandvite buildalike. doc.destroy()doesn't exist in pdfjs 6 — teardown isdoc.loadingTask.destroy(). Wrapped ascloseDocument()inservice.tsso no caller has to know that.
Confirmed as designed: pdfjs lands in its own 415 KB chunk with the 1.2 MB worker as a separate asset, and the main entry is untouched at 5 KB — nothing loads until a PDF is opened or imported.
A correction this forced upstream. Phase 5's lens said Files was locked because "PDFs are encrypted to their owner's key (kind 30112)". That was false — PDFs aren't published at all yet, encrypted or otherwise. LENS_SUPPORT.files.reason now says so plainly. A lock note that teaches a privacy property only works if the property is real; a plausible-sounding wrong reason is worse than none.
Open questions¶
- Sync (four-modes Phase 7) — wire 30112 into
read/nostr/sync.ts. Do PDFs take 30101's plaintext-when-shared stance? That would make Files lens-browsable. - Blossom backup for PDFs — the
xtag andblossomUrlsare in the kind's shape from day one, but the backup/restore UI is Phase 7 work. Note most public Blossom servers reject non-trivial MIME types;application/pdfneeds verification (nostr.download is the only working public default for EPUBs). - Highlighting PDFs — needs a second anchor scheme in kind 30104. Its own proposal, mostly about 30104's shape.
- Other formats. Markdown/text have no path in foliate at all (unlike PDF, which was a deliberate removal) and would need a book-shaped adapter written from scratch. Not scheduled.