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>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 21:49:01 +02:00
1 parent 7f9f39790f
commit a32d6eba99
10 files changed
+407 -48

No files matched your search

+112 -2
View File
@@ -4,8 +4,18 @@
* 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";
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";
@@ -276,6 +286,106 @@ try {
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.