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

+84
View File
@@ -81,6 +81,8 @@ interface ServerNote {
created_at: string;
updated_at?: string;
deleted_at?: string;
type?: string;
pdf_attachment_id?: string | null;
}
interface ServerNoteBody {
@@ -190,6 +192,26 @@ const api = {
return this.req<T>("DELETE", p);
},
/** Sends raw bytes; req() would JSON-encode them and inflate the payload. */
async postBinary<T>(path: string, body: Blob, contentType: string): Promise<T> {
const headers: Record<string, string> = { "Content-Type": contentType, "X-Device-Id": DEVICE_ID };
if (this.token) headers["Authorization"] = "Bearer " + this.token;
// No timeout here: a large upload on a slow line is not a hung request.
const r = await fetch(this.base + path, { method: "POST", headers, body });
if (r.status === 401) {
this.forgetToken();
throw new SyncError("AUTH");
}
let data: unknown = {};
try {
data = await r.json();
} catch {
/* an error page without JSON still needs a sensible failure */
}
if (!r.ok) throw new SyncError("REQUEST_FAILED", (data as { error?: string })?.error || "Upload failed");
return data as T;
},
/** Binary responses cannot go through req(), which always parses JSON. */
async getBlob(path: string): Promise<Blob> {
const headers: Record<string, string> = { "X-Device-Id": DEVICE_ID };
@@ -427,6 +449,8 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
trashed,
createdAt: n.created_at,
updatedAt: n.updated_at || n.deleted_at || n.created_at,
type: n.type === "pdf" ? "pdf" : "text",
pdfAttachmentId: n.pdf_attachment_id ?? null,
};
}
@@ -991,6 +1015,66 @@ export async function fetchAttachmentBlob(id: string): Promise<Blob> {
return api.getBlob(`/attachments/${id}`);
}
/* ─── PDFs ───
*
* A PDF rides on the attachment machinery — encrypted at rest, named by content
* hash, shared between notes holding the same file — but takes its own route up,
* as raw bytes rather than base64 inside JSON. */
export async function uploadPdf(file: Blob, filename?: string): Promise<AttachmentInfo> {
const query = filename ? `?filename=${encodeURIComponent(filename)}` : "";
return api.postBinary<AttachmentInfo>(`/attachments/pdf${query}`, file, "application/pdf");
}
/**
* The bytes of a PDF, from the local cache when possible.
*
* Cached on first read, so a document stays readable offline and a reopened note
* does not re-download tens of megabytes. Content-addressed storage means the
* bytes behind an id never change, so a cached copy can never be stale.
*/
export async function loadPdfBytes(attachmentId: string): Promise<Uint8Array> {
const cached = await db.getAttachmentBlob(attachmentId);
if (cached) return new Uint8Array(await cached.arrayBuffer());
const blob = await api.getBlob(`/attachments/${attachmentId}`);
try {
await db.putAttachmentBlob(attachmentId, blob);
} catch {
/* a full quota costs us the cache, not the document */
}
return new Uint8Array(await blob.arrayBuffer());
}
/** Creates a note that holds a PDF. */
export async function createPdfNote(
folderId: string | null,
title: string,
pdfAttachmentId: string
): Promise<NoteMeta> {
const r = await api.post<{ id: string }>("/notes", {
type: "pdf",
title,
notebookId: folderId,
pdfAttachmentId,
});
const now = new Date().toISOString();
const meta: NoteMeta = {
id: r.id,
title,
folderId,
starred: false,
trashed: false,
createdAt: now,
updatedAt: now,
type: "pdf",
pdfAttachmentId,
};
await db.putNote(meta);
notifyData("notes", null);
return meta;
}
/* ─── Content prefetch ───
*
* Note bodies are normally fetched only when a note is opened, which leaves