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>
272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
/* 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);
|
||
}
|