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