Files
InkNote/e2e/pdf.mjs
T
marcandClaude Opus 5 1be3a9b9ba Draw on PDF pages
Stage four. Strokes are kept per page in that page's own coordinate
space — PDF points at scale 1 — not in the fixed 1200x1600 space a note's
drawing uses. A PDF cannot use one space: every page has its own size,
and a stroke belongs to the page it was drawn on.

That is what keeps a mark on the word it was drawn over at any zoom and
on any screen, and it is the only form that can be stamped back into the
file in stage seven, where page points are the only coordinates that mean
anything. Measured rather than assumed: zooming moves a stroke by 0.001
of the page.

Pen sizes are scaled by the page width. The sizes are calibrated for the
1200-unit note page, so on a 595-point A4 page the same number would have
drawn twice as thick.

Reading and drawing are separate modes. While reading the ink layer takes
no pointer events, so scrolling and selecting text reach the page beneath
it; while drawing, a finger still scrolls and only a pen or mouse marks,
because a touch-drag on a phone means scroll far more often than it means
draw. The drawing can be hidden without being erased, for reading the
original clean.

The server accepted only the single-page shape; it now takes either and
counts strokes across pages for its limit.

Carried over from notes: the save token guard, so the six-second poller
adopting a response issued before the local save cannot undo what was
just drawn. The suite waits nine seconds and checks the ink is still
there — that bug has cost strokes twice before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:54:10 +02:00

217 lines
9.0 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"
);
/* ── 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");
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);
}