Stage six. What you write goes into the note's ordinary content, so it is encrypted, versioned, linkable and searchable like any other note — a PDF note is a note with a document attached, not a separate kind of thing. The panel is composed from the editor pieces rather than reusing NoteEditor, which carries a whole drawing mode with its own canvas, tools and undo stack. The PDF already has a drawing layer; a second one behind the panel would be two places to draw with one of them invisible. Side by side on a wide screen, two tabs on a narrow one, and either half can be hidden — the second of the two toggles asked for. Reading the original without one's own marks in the way is a normal thing to want. The suite caught a real defect while checking this. A PDF note has two sources of searchable text — what the reader typed and what the document says — written at different moments by different code, both calling setEntry. Whichever ran last was the only searchable half: typing a note dropped the document's text, and reopening the document dropped the note. The index now holds the two apart and replaces only the half it is given, with six unit tests covering each direction, plus the snippet falling back to the document so a hit inside a PDF still shows its sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
13 KiB
JavaScript
312 lines
13 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");
|
||
|
||
/* ── notes beside the document ── */
|
||
check((await p.locator("[data-pdf-notes]").count()) === 0, "the notes panel starts folded away");
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(1200);
|
||
check((await p.locator("[data-pdf-notes]").count()) === 1, "it opens beside the document");
|
||
check((await p.locator("[data-pdf-page]").count()) > 0, "and the document is still there next to it");
|
||
|
||
const noteText = "Wichtig auf Seite eins: Randbemerkung.";
|
||
await p.locator("[data-pdf-notes] .ProseMirror").click();
|
||
await p.keyboard.type(noteText);
|
||
await p.waitForTimeout(2200);
|
||
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(600);
|
||
check((await p.locator("[data-pdf-notes]").count()) === 0, "and folds away again without losing anything");
|
||
|
||
/* Written notes belong to the note, so they survive like any other text. */
|
||
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(4000);
|
||
await p.locator("[data-pdf-toggle-notes]").click();
|
||
await p.waitForTimeout(1500);
|
||
check(
|
||
(await p.locator("[data-pdf-notes] .ProseMirror").innerText()).includes("Randbemerkung"),
|
||
"the notes survived a reload"
|
||
);
|
||
await p.screenshot({ path: "/out/pdf-with-notes.png" });
|
||
|
||
/* And they are searchable, alongside the document's own text. */
|
||
await p.getByPlaceholder("Search notes...").fill("Randbemerkung");
|
||
await p.waitForTimeout(1500);
|
||
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
|
||
await p.getByPlaceholder("Search notes...").fill("");
|
||
await p.waitForTimeout(1000);
|
||
await p.locator("[data-note-row]").first().click();
|
||
await p.waitForTimeout(3000);
|
||
|
||
/* ── 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);
|
||
}
|