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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 21:12:06 +02:00
1 parent 1be3a9b9ba
commit b12b0d6142
6 files changed
+303 -6

No files matched your search

+22 -4
View File
@@ -1,7 +1,9 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import * as sync from "../../syncEngine";
import { openPdf, renderPage } from "./pdfjs";
import { openPdf, renderPage, renderTextLayer } from "./pdfjs";
import { extractAndCache, indexPdfText } from "./pdfText";
import "./textLayer.css";
import { PdfDrawLayer } from "./PdfDrawLayer.jsx";
import { asPdfStrokeDoc, strokesForPage, totalStrokes, withPage } from "./pageStrokes";
import { COLORS } from "../../ui/constants.jsx";
@@ -21,8 +23,9 @@ const ZOOM_STEPS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
* list: a PDF page is expensive to draw and cheap to keep, and scrolling back up
* through a document should not repaint everything.
*/
function PdfPage({ doc, index, width, height, size, visible, ink }) {
function PdfPage({ doc, index, width, height, size, visible, ink, selectable }) {
const canvasRef = useRef(null);
const textRef = useRef(null);
const renderedAt = useRef(0);
useEffect(() => {
@@ -37,7 +40,11 @@ function PdfPage({ doc, index, width, height, size, visible, ink }) {
const { done, cancel } = renderPage(page, canvasRef.current, width);
cancelRender = cancel;
await done;
if (!cancelled) renderedAt.current = width;
if (cancelled) return;
renderedAt.current = width;
// Built after the picture, not before: it is only useful once there is
// something under it, and it must not delay the page appearing.
if (textRef.current) await renderTextLayer(page, textRef.current, width);
})().catch(() => {
/* a page that will not draw leaves its placeholder rather than the whole
document failing */
@@ -64,6 +71,7 @@ function PdfPage({ doc, index, width, height, size, visible, ink }) {
}}
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%" }} />
<div ref={textRef} className="pdf-text-layer" data-selectable={selectable ? "true" : "false"} />
<PdfDrawLayer
pageNo={index + 1}
pageSize={size}
@@ -165,6 +173,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
const scrollRef = useRef(null);
const innerRef = useRef(null);
const extractionRef = useRef(null);
const T = {
bg: dark ? "#0d1117" : "#f0f2f5",
@@ -191,6 +200,13 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
if (cancelled) return;
setState({ status: "ready", ...loaded });
onPagesReady?.(loaded);
// Text comes out after the document is on screen, so extraction never
// stands between the user and the first page.
const signal = { aborted: false };
extractionRef.current = signal;
const text = await extractAndCache(loaded.doc, note.pdfAttachmentId, signal);
if (text && !cancelled) indexPdfText(note.id, note.title || "", text);
})().catch((err) => {
// The message shown is deliberately plain; the reason belongs in the
// console, where it can be read when someone reports "it does not open".
@@ -208,8 +224,9 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
return () => {
cancelled = true;
if (extractionRef.current) extractionRef.current.aborted = true;
};
}, [note?.pdfAttachmentId, onPagesReady]);
}, [note?.pdfAttachmentId, note?.id, note?.title, onPagesReady]);
/* ── available width ── */
useLayoutEffect(() => {
@@ -489,6 +506,7 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
height={Math.round(size.height * scale)}
size={size}
visible={visible.has(i)}
selectable={!drawing}
ink={{
strokes: strokesForPage(ink, i + 1),
drawing,