diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs
index 54a74c1..4488ff3 100644
--- a/e2e/pdf.mjs
+++ b/e2e/pdf.mjs
@@ -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`);
diff --git a/frontend/src/features/pdf/PdfViewer.jsx b/frontend/src/features/pdf/PdfViewer.jsx
index 2b005c2..9f4a0d6 100644
--- a/frontend/src/features/pdf/PdfViewer.jsx
+++ b/frontend/src/features/pdf/PdfViewer.jsx
@@ -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 }) {
}}
>
+
{
// 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,
diff --git a/frontend/src/features/pdf/pdfText.ts b/frontend/src/features/pdf/pdfText.ts
new file mode 100644
index 0000000..79da138
--- /dev/null
+++ b/frontend/src/features/pdf/pdfText.ts
@@ -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 {
+ return (await db.getMeta(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 {
+ 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);
+}
diff --git a/frontend/src/features/pdf/pdfjs.ts b/frontend/src/features/pdf/pdfjs.ts
index 7dafde1..a7f0940 100644
--- a/frontend/src/features/pdf/pdfjs.ts
+++ b/frontend/src/features/pdf/pdfjs.ts
@@ -81,6 +81,60 @@ export async function openPdf(bytes: Uint8Array): Promise {
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.
*
diff --git a/frontend/src/features/pdf/textLayer.css b/frontend/src/features/pdf/textLayer.css
new file mode 100644
index 0000000..fecb55c
--- /dev/null
+++ b/frontend/src/features/pdf/textLayer.css
@@ -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;
+}
diff --git a/readme.md b/readme.md
index ba21fa2..e7add3c 100644
--- a/readme.md
+++ b/readme.md
@@ -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