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:
1 parent
1be3a9b9ba
commit
b12b0d6142
6 files changed
+303
-6
No files matched your search
+55
@@ -100,6 +100,52 @@ try {
|
||||
"pdf.js was fetched only once it was needed"
|
||||
);
|
||||
|
||||
/* ── selectable text ── */
|
||||
const layerText = await p
|
||||
.locator('[data-pdf-page="1"] .pdf-text-layer')
|
||||
.innerText()
|
||||
.catch(() => "");
|
||||
check(layerText.includes("Zebrastreifen"), "the page text is laid over the picture", layerText.slice(0, 60));
|
||||
|
||||
const spanBox = await p.locator('[data-pdf-page="1"] .pdf-text-layer span').first().boundingBox();
|
||||
const pageBox = await p.locator('[data-pdf-page="1"]').boundingBox();
|
||||
check(
|
||||
spanBox && spanBox.width > 4 && spanBox.x > pageBox.x && spanBox.y > pageBox.y,
|
||||
"and positioned over the page rather than stacked in a corner",
|
||||
JSON.stringify(spanBox && { x: Math.round(spanBox.x - pageBox.x), y: Math.round(spanBox.y - pageBox.y) })
|
||||
);
|
||||
const invisible = await p
|
||||
.locator('[data-pdf-page="1"] .pdf-text-layer span')
|
||||
.first()
|
||||
.evaluate((e) => getComputedStyle(e).color);
|
||||
check(invisible.includes("rgba(0, 0, 0, 0)"), "the overlaid text is transparent", invisible);
|
||||
|
||||
/* Select it the way a reader would: drag across the line. */
|
||||
await p.mouse.move(spanBox.x + 2, spanBox.y + spanBox.height / 2);
|
||||
await p.mouse.down();
|
||||
await p.mouse.move(spanBox.x + spanBox.width - 2, spanBox.y + spanBox.height / 2, { steps: 10 });
|
||||
await p.mouse.up();
|
||||
const selected = await p.evaluate(() => String(window.getSelection()));
|
||||
check(selected.trim().length > 0, "dragging across the line selects text", JSON.stringify(selected.slice(0, 40)));
|
||||
await p.screenshot({ path: "/out/pdf-selection.png" });
|
||||
|
||||
/* ── the document is findable ── */
|
||||
await p.waitForTimeout(2500); // extraction runs after the first page is shown
|
||||
await p.getByPlaceholder("Search notes...").fill("Zebrastreifen");
|
||||
await p.waitForTimeout(1500);
|
||||
check(
|
||||
(await p.locator("[data-note-row]").count()) === 1,
|
||||
"searching a word from inside the PDF finds the note",
|
||||
`${await p.locator("[data-note-row]").count()} rows`
|
||||
);
|
||||
await p.getByPlaceholder("Search notes...").fill("Wortdasnichtvorkommt");
|
||||
await p.waitForTimeout(1200);
|
||||
check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing");
|
||||
await p.getByPlaceholder("Search notes...").fill("");
|
||||
await p.waitForTimeout(1000);
|
||||
await p.locator("[data-note-row]").first().click();
|
||||
await p.waitForTimeout(3000);
|
||||
|
||||
/* ── drawing on a page ── */
|
||||
const inkOnPage = (n) =>
|
||||
p.evaluate((page) => {
|
||||
@@ -125,6 +171,15 @@ try {
|
||||
await p.locator("[data-pdf-mode]").click();
|
||||
check((await p.locator('[data-pdf-mode="draw"]').count()) === 1, "drawing mode can be switched on");
|
||||
|
||||
const textEvents = await p
|
||||
.locator('[data-pdf-page="1"] .pdf-text-layer')
|
||||
.evaluate((e) => getComputedStyle(e).pointerEvents);
|
||||
check(
|
||||
textEvents === "none",
|
||||
"while drawing, the text layer stops taking the pointer so a stroke is not a selection",
|
||||
textEvents
|
||||
);
|
||||
|
||||
await drawOn(1, 0.2, 0.25, 0.7, 0.3);
|
||||
const inkedFirst = await inkOnPage(1);
|
||||
check(inkedFirst > 100, "the stroke appears on the page", `${inkedFirst} painted pixels`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Making an imported PDF findable.
|
||||
*
|
||||
* The text is pulled out of the document once, cached locally, and handed to the
|
||||
* same index that holds the notes — so one search box finds a sentence whether
|
||||
* it was typed or imported.
|
||||
*
|
||||
* **The text is not sent to the server.** It is derivable from a file the server
|
||||
* already has, so storing it again would cost space and a second thing to
|
||||
* encrypt for no information gained. The price is that a PDF becomes searchable
|
||||
* on a device once that device has opened it. That is the same shape as the
|
||||
* limitation notes already have, and the honest trade for not duplicating data.
|
||||
*/
|
||||
|
||||
import * as db from "../../offlineStore";
|
||||
import * as searchIndex from "../../search";
|
||||
import type { PDFDocumentProxy } from "./pdfjs";
|
||||
import { extractPageText } from "./pdfjs";
|
||||
|
||||
/** Cache key. Keyed by attachment, not note: the same file in two notes is extracted once. */
|
||||
const keyFor = (attachmentId: string) => `pdftext:${attachmentId}`;
|
||||
|
||||
/** Extracted text is capped, so a thousand-page scan cannot fill the local store. */
|
||||
const MAX_TEXT_CHARS = 2_000_000;
|
||||
|
||||
export interface PdfText {
|
||||
/** Page number → text, for jumping to a hit later. */
|
||||
pages: string[];
|
||||
full: string;
|
||||
}
|
||||
|
||||
/** The cached text for a file, or null if it has never been extracted here. */
|
||||
export async function cachedPdfText(attachmentId: string): Promise<PdfText | null> {
|
||||
return (await db.getMeta<PdfText>(keyFor(attachmentId))) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts every page's text and remembers it.
|
||||
*
|
||||
* Deliberately sequential with a breath between pages: extraction competes with
|
||||
* rendering the page the user is actually looking at, and a document that opens
|
||||
* smoothly matters more than one that is searchable a second sooner.
|
||||
*/
|
||||
export async function extractAndCache(
|
||||
doc: PDFDocumentProxy,
|
||||
attachmentId: string,
|
||||
signal?: { aborted: boolean }
|
||||
): Promise<PdfText | null> {
|
||||
const existing = await cachedPdfText(attachmentId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pages: string[] = [];
|
||||
let total = 0;
|
||||
for (let n = 1; n <= doc.numPages; n++) {
|
||||
if (signal?.aborted) return null;
|
||||
try {
|
||||
const page = await doc.getPage(n);
|
||||
const text = await extractPageText(page);
|
||||
pages.push(total < MAX_TEXT_CHARS ? text : "");
|
||||
total += text.length;
|
||||
} catch {
|
||||
// A page whose text cannot be read still has to hold its place, or every
|
||||
// page after it would be numbered wrong.
|
||||
pages.push("");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
|
||||
const result: PdfText = { pages, full: pages.join("\n").slice(0, MAX_TEXT_CHARS) };
|
||||
try {
|
||||
await db.setMeta(keyFor(attachmentId), result);
|
||||
} catch {
|
||||
/* a full quota costs the cache, not the search */
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Puts a PDF's text into the search index under its note. */
|
||||
export function indexPdfText(noteId: string, title: string, text: PdfText): void {
|
||||
searchIndex.setEntry(noteId, title, text.full);
|
||||
}
|
||||
|
||||
/** Page numbers containing a term, so a hit can be jumped to. */
|
||||
export function pagesContaining(text: PdfText, query: string): number[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
return text.pages.map((page, i) => (page.toLowerCase().includes(needle) ? i + 1 : 0)).filter((n) => n > 0);
|
||||
}
|
||||
@@ -81,6 +81,60 @@ export async function openPdf(bytes: Uint8Array): Promise<LoadedPdf> {
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The invisible text over a rendered PDF page.
|
||||
*
|
||||
* pdf.js draws pages as pictures; selecting text means laying transparent,
|
||||
* absolutely positioned spans over them at the exact place each word sits. These
|
||||
* rules are what make that alignment hold — without them the spans stack in the
|
||||
* top-left corner and selection picks nonsense.
|
||||
*
|
||||
* Written out here rather than importing pdfjs-dist/web/pdf_viewer.css, which
|
||||
* carries the whole reference viewer's chrome: toolbars, sidebars, dialogs, none
|
||||
* of which this app uses.
|
||||
*/
|
||||
|
||||
.pdf-text-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: clip;
|
||||
line-height: 1;
|
||||
text-align: initial;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
z-index: 2;
|
||||
/* Set per page from the render scale; every span's transform is built on it. */
|
||||
--scale-factor: 1;
|
||||
}
|
||||
|
||||
.pdf-text-layer span,
|
||||
.pdf-text-layer br {
|
||||
position: absolute;
|
||||
white-space: pre;
|
||||
transform-origin: 0% 0%;
|
||||
/* Transparent, not hidden: the glyphs still have to occupy their real boxes
|
||||
for a selection to land on the right characters. */
|
||||
color: transparent;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.pdf-text-layer ::selection {
|
||||
background: rgba(56, 139, 253, 0.35);
|
||||
}
|
||||
|
||||
/* pdf.js appends this to catch a drag that runs off the end of the page. */
|
||||
.pdf-text-layer .endOfContent {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 100% 0 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* While drawing, the ink layer sits on top and the text must not fight it for
|
||||
the pointer — a stroke that selects a paragraph halfway through is useless. */
|
||||
.pdf-text-layer[data-selectable="false"] {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
||||
- **Undo/Redo** — Full history stack for drawing operations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **PDF Reading** — Import a PDF and read it in the app; the file is cached so it stays readable offline
|
||||
- **PDF Reading** — Import a PDF, read it, select its text, draw on it; the file is cached so it stays readable offline
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
- **Dark & Light Mode** — Full theme support with refined color schemes
|
||||
|
||||
@@ -330,7 +330,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
| `pdf` | 19 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 26 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
|
||||
@@ -442,6 +442,31 @@ storage is content-addressed, a cached copy can never go stale.
|
||||
|
||||
---
|
||||
|
||||
## Selecting and searching PDF text
|
||||
|
||||
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 — the two 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 text selection.
|
||||
|
||||
The text is also pulled out and handed to the same index that 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 not sent to the server.** It is derivable from a file the
|
||||
server already holds, so storing it again would cost space and a second thing to
|
||||
encrypt for nothing gained. The price is that a PDF becomes searchable on a
|
||||
device once that device has opened it — the same shape as the limitation notes
|
||||
already have, and the honest trade for not duplicating data.
|
||||
|
||||
The minimal text-layer CSS is written out in `textLayer.css` rather than
|
||||
importing `pdfjs-dist/web/pdf_viewer.css`, which carries the whole reference
|
||||
viewer's toolbars and dialogs.
|
||||
|
||||
---
|
||||
|
||||
## Drawing on a PDF
|
||||
|
||||
Strokes are stored **per page, in that page's own coordinate space** — PDF
|
||||
|
||||
Reference in new issue
Block a user