Files
InkNote/e2e/pdf.mjs
T
marcandClaude Opus 5 a32d6eba99 Export a PDF with the drawings written into it
Stage seven, and the last of the PDF work. A PDF note now offers the
document with your marks stamped in, the untouched original, or just the
notes written beside it.

pdf.js only reads, so writing needs pdf-lib — behind a dynamic import
like pdf.js, so the bundle everyone downloads carries no PDF writer for
the sake of people who never export one. Main bundle grew 6 kB; the
180 kB writer arrives only when used.

The coordinate decision from stage four is what makes this a translation
rather than a reconstruction: strokes were stored in page points from the
start, the only space a PDF understands, so nothing has to be guessed
back from screen pixels. Only the origin moves, PDF putting it at the
bottom-left.

The stored file is never modified — pdf-lib builds a new document — so a
clean copy stays available however often it has been drawn on. The suite
re-fetches the stored bytes after exporting and compares them, and checks
the plain export is byte-identical to what was imported.

Eraser strokes are dropped. On screen they remove ink by drawing in
destination-out; a PDF content stream has no equivalent, and stamping
them would put grey scribbles exactly where something had been rubbed
out.

The suite renders both files with pdf.js and counts dark pixels, 1633
against 2557. A larger file only proves bytes were added; this proves ink
can be seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:49:01 +02:00

422 lines
18 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 { readFileSync, writeFileSync } from "node:fs";
import {
captureDownloads,
check,
clearDownloads,
createNotebook,
downloads,
lastDownloadName,
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);
/* ── exporting the document ── */
await captureDownloads(p);
const originalBytes = readFileSync(PDF_PATH);
const openExport = async () => {
await p.getByTitle("Export").click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(1200);
};
await openExport();
check(
(await p.getByText("Was soll heraus?").count()) > 0,
"a PDF note is asked what should come out, not offered note formats"
);
await p.screenshot({ path: "/out/pdf-export-wizard.png" });
/* The untouched original. */
await clearDownloads(p);
await p.getByRole("button", { name: /PDF im Original/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(900);
let files = await downloads(p);
const plain = Buffer.from(files[0].base64, "base64");
check(files[0].type === "application/pdf", "the original comes out as a PDF", files[0].type);
check(
plain.equals(originalBytes),
"and is byte-identical to the file that was imported",
`${plain.length} vs ${originalBytes.length} bytes`
);
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* The same document with the strokes written in. */
await openExport();
await clearDownloads(p);
await p.getByRole("button", { name: /PDF mit den Zeichnungen/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(1500);
files = await downloads(p);
const stamped = Buffer.from(files[0].base64, "base64");
check(stamped.subarray(0, 5).toString() === "%PDF-", "the annotated file is a PDF", stamped.subarray(0, 8).toString());
check(stamped.length > originalBytes.length, "and carries more than the original did", `${stamped.length} vs ${originalBytes.length}`);
check(!stamped.equals(originalBytes), "so it is not simply a copy");
check(/mit Notizen/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
/* The stored file must still be the original — drawing on it changes nothing
on disk, or a clean copy could never be had again. */
const storedAgain = await p.evaluate(async () => {
const t = localStorage.getItem("inknote_token");
const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json();
const id = notes[0].pdf_attachment_id;
const r = await fetch("/api/attachments/" + id, { headers: { Authorization: "Bearer " + t } });
const buf = new Uint8Array(await r.arrayBuffer());
return Array.from(buf.slice(0, 16));
});
check(
Buffer.from(storedAgain).equals(originalBytes.subarray(0, 16)),
"the stored file was never modified by drawing on it"
);
/* Bytes added is not the same as ink visible. Render both files and compare
what is actually on page one. */
const inkVisible = await p.evaluate(async ([stampedB64, originalB64]) => {
const toBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
// The app has already loaded this chunk; importing the same URL reuses it.
const chunk = performance
.getEntriesByType("resource")
.map((r) => r.name)
.find((n) => /\/assets\/pdf-[A-Za-z0-9_-]+\.js$/.test(n));
if (!chunk) throw new Error("pdf.js chunk not found among loaded resources");
const pdfjs = await import(/* @vite-ignore */ chunk);
const countDark = async (bytes) => {
const doc = await pdfjs.getDocument({ data: bytes }).promise;
const page = await doc.getPage(1);
const vp = page.getViewport({ scale: 1 });
const c = document.createElement("canvas");
c.width = Math.round(vp.width);
c.height = Math.round(vp.height);
const ctx = c.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, c.width, c.height);
await page.render({ canvasContext: ctx, viewport: vp, canvas: c }).promise;
const d = ctx.getImageData(0, 0, c.width, c.height).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 200 || d[i + 1] < 200 || d[i + 2] < 200) dark++;
return dark;
};
return { stamped: await countDark(toBytes(stampedB64)), original: await countDark(toBytes(originalB64)) };
}, [files[0].base64, originalBytes.toString("base64")]);
check(
inkVisible.stamped > inkVisible.original * 1.2,
"the strokes are actually visible when the exported file is rendered",
`${inkVisible.original} → ${inkVisible.stamped} dark pixels`
);
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* ── 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);
}