Files
InkNote/frontend/src/features/pdf/pdfjs.ts
T
marcandClaude Opus 5 b12b0d6142 Select text in a PDF, and find PDFs by their contents
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>
2026-08-08 21:12:06 +02:00

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(),
};
}