/** * 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 | null = null; /** Resolves to the pdf.js module, fetching it on the first call. */ export function loadPdfjs(): Promise { 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 { 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 }; } /** * Lays the selectable text over a rendered page. * * pdf.js draws pages as pictures, so selecting means positioning transparent * spans exactly where the glyphs were painted. The scale must match the canvas * or the two drift apart and a selection lands on the wrong words. * * Returns a cancel function — a page can be re-rendered at a new zoom before the * previous text layer has finished building. */ export async function renderTextLayer( page: PDFPageProxy, container: HTMLElement, cssWidth: number ): Promise { const pdfjs = await loadPdfjs(); const base = page.getViewport({ scale: 1 }); const scale = cssWidth / base.width; const viewport = page.getViewport({ scale }); container.replaceChildren(); // Every span's transform is expressed against this, so the layer only lines up // with the canvas when it carries the same scale. container.style.setProperty("--scale-factor", String(scale)); const layer = new pdfjs.TextLayer({ textContentSource: await page.getTextContent(), container, viewport, }); await layer.render(); } /** * The words on a page, in reading order. * * pdf.js hands back positioned runs, not sentences: a line can arrive in several * pieces and `hasEOL` is the only marker of where one ends. Joining runs without * it glues the last word of a line to the first of the next, which then matches * nothing when searched for. */ export async function extractPageText(page: PDFPageProxy): Promise { const content = await page.getTextContent(); let out = ""; for (const item of content.items) { const run = item as { str?: string; hasEOL?: boolean }; if (typeof run.str !== "string") continue; out += run.str; if (run.hasEOL) out += "\n"; else if (run.str && !run.str.endsWith(" ")) out += " "; } return out.replace(/[ \t]+/g, " ").trim(); } /** * 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; 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(), }; }