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:
1 parent
53965281b4
commit
2986bcb620
16 files changed
+1079
-12
No files matched your search
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Loading pdf.js, once and only when a PDF is actually opened.
|
||||
*
|
||||
* The library is larger than the rest of the application put together, so it is
|
||||
* behind a dynamic import: a session that never opens a PDF never downloads it.
|
||||
* Vite gives it its own chunk automatically.
|
||||
*
|
||||
* The worker is bundled with the app rather than fetched from a CDN. pdf.js
|
||||
* documents the CDN route, but this instance is meant to run without reaching
|
||||
* out to anyone — and a viewer that stops working when a third party has an
|
||||
* outage is not one you can rely on offline.
|
||||
*/
|
||||
|
||||
import type { PDFDocumentProxy, PDFPageProxy } from "pdfjs-dist";
|
||||
|
||||
export type { PDFDocumentProxy, PDFPageProxy };
|
||||
|
||||
/* The legacy build, not the default one.
|
||||
*
|
||||
* pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers
|
||||
* have; on anything older the document fails to parse with a message that says
|
||||
* nothing about the cause. The legacy build compiles those away. It costs a
|
||||
* little size, and it is the difference between working everywhere and working
|
||||
* on this year's browsers. */
|
||||
type PdfjsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
|
||||
let loading: Promise<PdfjsModule> | null = null;
|
||||
|
||||
/** Resolves to the pdf.js module, fetching it on the first call. */
|
||||
export function loadPdfjs(): Promise<PdfjsModule> {
|
||||
if (!loading) {
|
||||
loading = (async () => {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
// `?url` keeps the worker a separate file instead of inlining it, which is
|
||||
// what a worker needs to be.
|
||||
const workerUrl = (await import("pdfjs-dist/legacy/build/pdf.worker.min.mjs?url")).default;
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
return pdfjs;
|
||||
})().catch((err) => {
|
||||
// A failed load must not be remembered as a resolved promise, or the
|
||||
// viewer would stay broken for the rest of the session.
|
||||
loading = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return loading;
|
||||
}
|
||||
|
||||
export interface PdfPageSize {
|
||||
/** Page width in PDF points at scale 1 — the space strokes are stored in. */
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LoadedPdf {
|
||||
doc: PDFDocumentProxy;
|
||||
pageCount: number;
|
||||
/** Size of every page, so the layout can be built before anything is rendered. */
|
||||
sizes: PdfPageSize[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a PDF from bytes.
|
||||
*
|
||||
* Takes the bytes rather than a URL because they come from the encrypted
|
||||
* attachment route or from the offline cache — pdf.js fetching the URL itself
|
||||
* would send no Authorization header and get a 401.
|
||||
*/
|
||||
export async function openPdf(bytes: Uint8Array): Promise<LoadedPdf> {
|
||||
const pdfjs = await loadPdfjs();
|
||||
// pdf.js takes ownership of the buffer it is given and detaches it; a copy
|
||||
// keeps the caller's cached bytes usable afterwards.
|
||||
const doc = await pdfjs.getDocument({ data: new Uint8Array(bytes) }).promise;
|
||||
|
||||
const sizes: PdfPageSize[] = [];
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
const page = await doc.getPage(n);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
sizes.push({ width: viewport.width, height: viewport.height });
|
||||
}
|
||||
return { doc, pageCount: doc.numPages, sizes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one page onto a canvas at the given CSS width.
|
||||
*
|
||||
* The canvas is sized in device pixels and scaled down by CSS, so the page is
|
||||
* sharp on a high-density screen instead of being upscaled from a low-res
|
||||
* bitmap. Returns a cancel function: scrolling quickly can start more renders
|
||||
* than finish, and pdf.js complains loudly if two run on one canvas.
|
||||
*/
|
||||
export function renderPage(
|
||||
page: PDFPageProxy,
|
||||
canvas: HTMLCanvasElement,
|
||||
cssWidth: number
|
||||
): { done: Promise<void>; cancel: () => void } {
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2); // beyond 2 costs memory for no visible gain
|
||||
const viewport = page.getViewport({ scale: (cssWidth / base.width) * dpr });
|
||||
|
||||
canvas.width = Math.round(viewport.width);
|
||||
canvas.height = Math.round(viewport.height);
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${Math.round(viewport.height / dpr)}px`;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return { done: Promise.resolve(), cancel: () => {} };
|
||||
|
||||
const task = page.render({ canvasContext: ctx, viewport, canvas });
|
||||
return {
|
||||
done: task.promise.catch((err: unknown) => {
|
||||
// A cancelled render is the normal outcome of scrolling, not a fault.
|
||||
if ((err as { name?: string })?.name !== "RenderingCancelledException") throw err;
|
||||
}),
|
||||
cancel: () => task.cancel(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user