Files
InkNote/e2e/pdf.mjs
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

272 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Importing and reading a PDF.
*
* The interesting parts are not that a canvas appears: it is that the library
* stays out of the main bundle until a PDF is opened, that the pages come out at
* the right proportions, and that a document read once survives without the
* network — the offline promise is the whole reason the bytes are cached. */
import { writeFileSync } from "node:fs";
import { check, createNotebook, launch, report, withFreshUser } from "./lib.mjs";
const PDF_PATH = "/tmp/e2e-test.pdf";
/** A two-page PDF built here, so the suite depends on no fixture file. */
function writeTestPdf() {
const objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
null, // stream, filled below
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
null,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
const s1 = "BT /F1 24 Tf 72 760 Td (Seite eins Zebrastreifen) Tj ET";
const s2 = "BT /F1 24 Tf 72 520 Td (Seite zwei Dichtung) Tj ET";
objs[3] = `<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`;
objs[5] = `<< /Length ${s2.length} >>\nstream\n${s2}\nendstream`;
let out = "%PDF-1.4\n";
const offsets = [];
objs.forEach((o, i) => {
offsets.push(out.length);
out += `${i + 1} 0 obj\n${o}\nendobj\n`;
});
const xref = out.length;
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`;
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
writeFileSync(PDF_PATH, out, "latin1");
}
const browser = await launch();
let session;
try {
writeTestPdf();
session = await withFreshUser(browser, "pdf");
const p = session.page;
/* The library must not be in the bundle everyone downloads. */
const scriptsBefore = await p.evaluate(() =>
performance.getEntriesByType("resource").map((r) => r.name)
);
check(
!scriptsBefore.some((u) => /pdf[.-]/i.test(u)),
"pdf.js is not loaded before a PDF is opened",
scriptsBefore.filter((u) => /pdf/i.test(u)).join(", ") || "none"
);
await createNotebook(p, "PDF Buch");
/* ── import ── */
const chooser = p.waitForEvent("filechooser");
await p.getByTitle("Import PDF").click();
await (await chooser).setFiles(PDF_PATH);
await p.waitForTimeout(4000);
check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing");
check(
(await p.locator("[data-pdf-position]").innerText()).includes("/ 2"),
"both pages are counted",
await p.locator("[data-pdf-position]").innerText()
);
await p.screenshot({ path: "/out/pdf-imported.png" });
/* ── proportions ── */
const boxes = await p.$$eval("[data-pdf-page]", (els) =>
els.map((e) => ({ w: e.offsetWidth, h: e.offsetHeight }))
);
check(boxes.length === 2, "both pages are laid out", JSON.stringify(boxes));
// Page one is A4 portrait (595×842), page two the same turned on its side.
const ratio1 = boxes[0].h / boxes[0].w;
const ratio2 = boxes[1].h / boxes[1].w;
check(Math.abs(ratio1 - 842 / 595) < 0.05, "the portrait page keeps its proportions", ratio1.toFixed(2));
check(Math.abs(ratio2 - 595 / 842) < 0.05, "the landscape page is not stretched to match", ratio2.toFixed(2));
/* ── something was actually painted ── */
const inkedPixels = await p.evaluate(() => {
const c = document.querySelector("[data-pdf-page] canvas");
if (!c) return -1;
const ctx = c.getContext("2d");
const d = ctx.getImageData(0, 0, c.width, Math.min(c.height, 400)).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 200) dark++;
return dark;
});
check(inkedPixels > 50, "the page has actual content drawn on it", `${inkedPixels} dark pixels`);
const loadedAfter = await p.evaluate(() => performance.getEntriesByType("resource").map((r) => r.name));
check(
loadedAfter.some((u) => /pdf.*\.js|pdf\.worker/i.test(u)),
"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) => {
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
if (!c) return -1;
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let painted = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) painted++;
return painted;
}, n);
/** Draws a line across the page in fractions of its box, then waits for the save. */
const drawOn = async (pageNo, x1, y1, x2, y2) => {
const box = await p.locator(`[data-pdf-page="${pageNo}"]`).boundingBox();
await p.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
await p.mouse.down();
await p.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 });
await p.mouse.up();
await p.waitForTimeout(1600);
};
check((await inkOnPage(1)) === 0, "no ink before anything is drawn");
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`);
check((await inkOnPage(2)) === 0, "and only on the page it was drawn on");
await p.screenshot({ path: "/out/pdf-drawn.png" });
/* Where the stroke sits relative to the page, so zoom can be compared against it. */
const inkBounds = (n) =>
p.evaluate((page) => {
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let minX = 1e9, maxX = -1, minY = 1e9, maxY = -1;
for (let i = 3, px = 0; i < d.length; i += 4, px++) {
if (d[i] <= 40) continue;
const x = px % c.width, y = (px / c.width) | 0;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
// As fractions of the canvas, so they can be compared across zoom levels.
return { x: minX / c.width, y: minY / c.height, w: (maxX - minX) / c.width, h: (maxY - minY) / c.height };
}, n);
const before = await inkBounds(1);
await p.getByTitle("Größer").click();
await p.waitForTimeout(2500);
const after = await inkBounds(1);
const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
await p.getByTitle("Kleiner").click();
await p.waitForTimeout(1500);
/* ── hiding it ── */
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(600);
check((await inkOnPage(1)) >= 0, "hiding the drawing leaves the document readable");
const hiddenOpacity = await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity);
check(hiddenOpacity === "0", "the ink layer is hidden, not erased", hiddenOpacity);
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(600);
/* ── it survives ── */
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
await p.waitForTimeout(1200);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(4500);
const afterReload = await inkOnPage(1);
check(afterReload > 100, "the stroke survived a reload", `${afterReload} painted pixels`);
/* ── it must not be undone by the background poller ── */
await p.waitForTimeout(9000);
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
/* ── it stays readable offline ── */
// A reload lands on the dashboard, which has no note list; the notebook has
// to be opened first before a row exists to click.
const reopen = async () => {
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
await p.waitForTimeout(1200);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(4000);
};
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await reopen();
check((await p.locator("[data-pdf-page]").count()) > 0, "the document reopens after a reload");
await p.context().setOffline(true);
await p.reload({ waitUntil: "domcontentloaded" });
await p.waitForTimeout(3000);
await reopen();
check(
(await p.locator("[data-pdf-page]").count()) > 0,
"and still opens with the network switched off"
);
await p.screenshot({ path: "/out/pdf-offline.png" });
await p.context().setOffline(false);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}