Stage five. pdf.js draws pages as pictures, so selecting means laying transparent spans over them exactly where each word was painted. The layer carries the same scale as the canvas — they drift apart otherwise and a selection lands on the wrong words. While drawing it stops taking pointer events entirely, or a stroke would come out as a selection. The text is also extracted and handed to the index that already holds the notes, so one search box finds a sentence whether it was typed or imported. Extraction runs after the document is on screen and yields between pages: it must never stand between the reader and the first page. The extracted text is deliberately not sent to the server. It is derivable from a file the server already holds, so storing it again costs space and a second thing to encrypt for nothing gained. The price, stated plainly in the readme, is that a PDF becomes searchable on a device once that device has opened it — the same shape as the limitation notes have. Text runs are joined on pdf.js's hasEOL rather than concatenated. Without it the last word of a line glues to the first of the next and matches nothing when searched for. The minimal text-layer CSS is written out rather than importing pdfjs-dist/web/pdf_viewer.css, which carries the whole reference viewer's toolbars and dialogs. Suite now checks the layer sits over the page rather than stacked in a corner, that the text is transparent, that dragging selects real words, that a word from inside the file finds the note and one that is not in it finds nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
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<string> {
|
|
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<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(),
|
|
};
|
|
}
|