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

+10 -34
View File
@@ -9,7 +9,11 @@ import {
check,
createNote,
createNotebook,
captureDownloads,
clearDownloads,
downloads,
insertTable,
lastDownloadName,
launch,
report,
setTitle,
@@ -20,34 +24,6 @@ import {
const PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAYAAABWKLW/AAAAFElEQVR42mP8z8BQz0AEYBxVSF+FABJADveWkH6oAAAAAElFTkSuQmCC";
/** Intercepts download() so the bytes stay in the page and can be inspected. */
async function captureDownloads(page) {
await page.evaluate(() => {
window.__downloads = [];
const realCreate = URL.createObjectURL.bind(URL);
URL.createObjectURL = (blob) => {
const url = realCreate(blob);
const reader = new FileReader();
reader.onload = () =>
window.__downloads.push({
url,
type: blob.type,
size: blob.size,
// base64 of the payload, so binary survives the trip out of the page
base64: String(reader.result).split(",")[1] || "",
});
reader.readAsDataURL(blob);
return url;
};
// A real navigation would abandon the test run.
HTMLAnchorElement.prototype.click = function () {
window.__lastDownloadName = this.download;
};
});
}
const downloads = (page) => page.evaluate(() => window.__downloads || []);
const lastName = (page) => page.evaluate(() => window.__lastDownloadName || "");
/** Unpacks a stored-only ZIP far enough to list names and read text entries. */
function readZip(bytes) {
@@ -130,7 +106,7 @@ try {
let files = await downloads(p);
check(files.length === 1, "a single note produces one download", `${files.length}`);
check(/\.md$/.test(await lastName(p)), "it is named .md", await lastName(p));
check(/\.md$/.test(await lastDownloadName(p)), "it is named .md", await lastDownloadName(p));
const md = Buffer.from(files[0].base64, "base64").toString("utf8");
check(md.startsWith("# Alles drin"), "the title became a heading");
@@ -161,7 +137,7 @@ try {
}, PNG);
await p.waitForTimeout(3500);
await p.evaluate(() => (window.__downloads = []));
await clearDownloads(p);
await openExportDialog(p);
await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
@@ -191,7 +167,7 @@ try {
await typeInEditor(p, "Text und Bild.");
await drawSomething(p);
await p.evaluate(() => (window.__downloads = []));
await clearDownloads(p);
await openExportDialog(p);
check(
(await p.getByText("Was soll mit?").count()) > 0,
@@ -223,7 +199,7 @@ try {
"and it really is a PNG",
Array.from(png.slice(0, 4)).join(",")
);
check(/\.png$/.test(await lastName(p)), "named .png", await lastName(p));
check(/\.png$/.test(await lastDownloadName(p)), "named .png", await lastDownloadName(p));
check(png.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`);
// back, then text only → Markdown must carry no drawing
@@ -231,7 +207,7 @@ try {
await p.waitForTimeout(500);
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
await p.waitForTimeout(500);
await p.evaluate(() => (window.__downloads = []));
await clearDownloads(p);
await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(800);
@@ -242,7 +218,7 @@ try {
await p.waitForTimeout(600);
/* ── whole notebook, via the sidebar's context menu ── */
await p.evaluate(() => (window.__downloads = []));
await clearDownloads(p);
await p.locator("[data-note-row]").first().click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Export...", { exact: true }).click();