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:
1 parent
7f9f39790f
commit
a32d6eba99
10 files changed
+407
-48
No files matched your search
+10
-34
@@ -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();
|
||||
|
||||
+32
@@ -125,6 +125,38 @@ export async function withFreshUser(browser, prefix = "e2e") {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts downloads so their bytes stay in the page and can be inspected.
|
||||
*
|
||||
* A real download would either navigate away or land somewhere the suite cannot
|
||||
* read; this keeps the blob and reports it as base64.
|
||||
*/
|
||||
export 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({
|
||||
type: blob.type,
|
||||
size: blob.size,
|
||||
base64: String(reader.result).split(",")[1] || "",
|
||||
});
|
||||
reader.readAsDataURL(blob);
|
||||
return url;
|
||||
};
|
||||
HTMLAnchorElement.prototype.click = function () {
|
||||
window.__lastDownloadName = this.download;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const downloads = (page) => page.evaluate(() => window.__downloads || []);
|
||||
export const lastDownloadName = (page) => page.evaluate(() => window.__lastDownloadName || "");
|
||||
export const clearDownloads = (page) => page.evaluate(() => (window.__downloads = []));
|
||||
|
||||
/* ── app actions the suites share ── */
|
||||
|
||||
export async function createNotebook(page, name) {
|
||||
|
||||
+112
-2
@@ -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.
|
||||
|
||||
Generated
+43
@@ -23,6 +23,7 @@
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"@tiptap/suggestion": "^2.27.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
@@ -1180,6 +1181,24 @@
|
||||
"node": "^22.20 || ^24.12 || >=25"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
@@ -3271,6 +3290,12 @@
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parse5": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||
@@ -3314,6 +3339,18 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "6.2.108",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
|
||||
@@ -3862,6 +3899,12 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@tiptap/react": "^2.27.2",
|
||||
"@tiptap/starter-kit": "^2.27.2",
|
||||
"@tiptap/suggestion": "^2.27.2",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^6.2.108",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { parsingExtensions } from "../editor/extensions.js";
|
||||
import { CANON_W, CANON_H, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
|
||||
import type { NoteContent, NoteMeta, Notebook } from "../types";
|
||||
import { noteToMarkdown, safeFilename } from "./markdown";
|
||||
import { asPdfStrokeDoc, totalStrokes } from "../features/pdf/pageStrokes";
|
||||
import { stampStrokesIntoPdf } from "../features/pdf/stampPdf";
|
||||
import { printNotes, type PrintableNote } from "./print";
|
||||
import { bytesToBlob, createZip, download, type ZipEntry } from "./zip";
|
||||
|
||||
@@ -80,6 +82,46 @@ function nameFor(meta: NoteMeta, taken: Set<string>): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
/* ── PDF notes ── */
|
||||
|
||||
/**
|
||||
* Exports the document behind a PDF note.
|
||||
*
|
||||
* `withInk` decides whether the drawings come along. Without it the bytes are
|
||||
* handed over exactly as imported — the original is never modified in storage,
|
||||
* so a clean copy stays available however often it has been drawn on.
|
||||
*/
|
||||
export async function exportPdfNote(meta: NoteMeta, withInk: boolean): Promise<void> {
|
||||
if (!meta.pdfAttachmentId) throw new Error("Zu dieser Notiz gehört keine PDF-Datei.");
|
||||
const bytes = await sync.loadPdfBytes(meta.pdfAttachmentId);
|
||||
const name = safeFilename(meta.title, "Dokument");
|
||||
|
||||
if (!withInk) {
|
||||
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { content } = await loadNote(meta);
|
||||
const ink = asPdfStrokeDoc(content.drawingStrokes);
|
||||
if (totalStrokes(ink) === 0) {
|
||||
download(bytesToBlob(bytes, "application/pdf"), `${name}.pdf`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stamped = await stampStrokesIntoPdf(bytes, ink);
|
||||
download(bytesToBlob(stamped, "application/pdf"), `${name} (mit Notizen).pdf`);
|
||||
}
|
||||
|
||||
/** True when the note's PDF carries any drawing, so the wizard can say so. */
|
||||
export async function pdfNoteHasInk(meta: NoteMeta): Promise<boolean> {
|
||||
try {
|
||||
const { content } = await loadNote(meta);
|
||||
return totalStrokes(asPdfStrokeDoc(content.drawingStrokes)) > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── single note ── */
|
||||
|
||||
/** What a single-note export should contain. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Writing the drawings back into the PDF.
|
||||
*
|
||||
* pdf.js can only read; producing a file needs a second library, loaded the same
|
||||
* way — behind a dynamic import, so the bundle everyone downloads does not carry
|
||||
* a PDF writer for the sake of the people who never export one.
|
||||
*
|
||||
* This is the moment the coordinate decision in `pageStrokes` pays off: strokes
|
||||
* were stored in page points all along, which is the only space a PDF
|
||||
* understands. Nothing has to be guessed back from screen pixels.
|
||||
*/
|
||||
|
||||
import type { Stroke } from "../../types";
|
||||
import { HIGHLIGHTER_ALPHA } from "../drawing/strokes.js";
|
||||
import type { PdfStrokeDoc } from "./pageStrokes";
|
||||
import { drawnPages } from "./pageStrokes";
|
||||
|
||||
/** `#rrggbb` → the 0–1 components pdf-lib wants. */
|
||||
function toRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
|
||||
if (!m) return { r: 0, g: 0, b: 0 };
|
||||
const n = parseInt(m[1]!, 16);
|
||||
return { r: ((n >> 16) & 255) / 255, g: ((n >> 8) & 255) / 255, b: (n & 255) / 255 };
|
||||
}
|
||||
|
||||
/** Highlighter strokes are drawn translucent on screen; the export matches. */
|
||||
const opacityFor = (tool: string) => (tool === "highlighter" ? parseInt(HIGHLIGHTER_ALPHA, 16) / 255 : 1);
|
||||
|
||||
/**
|
||||
* Stamps a note's strokes onto a copy of its PDF.
|
||||
*
|
||||
* The original bytes are never modified — pdf-lib builds a new document — so the
|
||||
* stored file stays exactly as it was imported and can be exported clean again
|
||||
* afterwards.
|
||||
*
|
||||
* Erased areas cannot come along. On screen an eraser stroke removes ink by
|
||||
* drawing in `destination-out`, which has no equivalent in a PDF content stream;
|
||||
* such strokes are dropped rather than being stamped as visible marks, which
|
||||
* would put grey scribbles where the user had rubbed something out.
|
||||
*/
|
||||
export async function stampStrokesIntoPdf(originalBytes: Uint8Array, ink: PdfStrokeDoc): Promise<Uint8Array> {
|
||||
const { PDFDocument, rgb } = await import("pdf-lib");
|
||||
const doc = await PDFDocument.load(originalBytes);
|
||||
const pages = doc.getPages();
|
||||
|
||||
for (const pageNo of drawnPages(ink)) {
|
||||
const page = pages[pageNo - 1];
|
||||
const stored = ink.pages[String(pageNo)];
|
||||
if (!page || !stored) continue;
|
||||
|
||||
// The page may have been stored at a different size than the PDF reports —
|
||||
// a rotated page, or a file edited elsewhere since. Scale rather than trust.
|
||||
const { width, height } = page.getSize();
|
||||
const sx = width / stored.w;
|
||||
const sy = height / stored.h;
|
||||
|
||||
for (const stroke of stored.strokes as Stroke[]) {
|
||||
if (stroke.t === "eraser") continue;
|
||||
const pts = stroke.p;
|
||||
if (!Array.isArray(pts) || pts.length < 4) continue;
|
||||
|
||||
const { r, g, b } = toRgb(stroke.c);
|
||||
const opacity = opacityFor(stroke.t);
|
||||
const thickness = stroke.s * ((sx + sy) / 2);
|
||||
|
||||
for (let i = 0; i + 3 < pts.length; i += 2) {
|
||||
page.drawLine({
|
||||
// PDF's origin is the bottom-left corner; the drawing's is the top-left.
|
||||
start: { x: pts[i]! * sx, y: height - pts[i + 1]! * sy },
|
||||
end: { x: pts[i + 2]! * sx, y: height - pts[i + 3]! * sy },
|
||||
thickness,
|
||||
color: rgb(r, g, b),
|
||||
opacity,
|
||||
lineCap: 1, // round, so segments of one stroke join without notches
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc.save();
|
||||
}
|
||||
@@ -67,12 +67,7 @@ export function contentToText(raw: string | null | undefined): string {
|
||||
* forgetting the other used to leave whichever ran last as the only searchable
|
||||
* half.
|
||||
*/
|
||||
export function setEntry(
|
||||
id: string,
|
||||
title: string,
|
||||
content?: string | null,
|
||||
attached?: string | null
|
||||
): void {
|
||||
export function setEntry(id: string, title: string, content?: string | null, attached?: string | null): void {
|
||||
const raw = contentToText(content);
|
||||
const attachedRaw = attached === undefined ? (_index.get(id)?.attachedRaw ?? "") : (attached ?? "");
|
||||
_index.set(id, {
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
exportNoteAsMarkdown,
|
||||
exportNotesAsPdf,
|
||||
exportNotesAsZip,
|
||||
exportPdfNote,
|
||||
noteHasDrawing,
|
||||
pdfNoteHasInk,
|
||||
} from "../export/exportNotes";
|
||||
import { I } from "./icons.jsx";
|
||||
import { FONT_SERIF, FONT_UI } from "./theme";
|
||||
@@ -23,8 +25,11 @@ import { FONT_SERIF, FONT_UI } from "./theme";
|
||||
*/
|
||||
export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
const single = notes.length === 1;
|
||||
// A PDF note exports the document itself, so the questions are different ones.
|
||||
const isPdfNote = single && notes[0]?.type === "pdf";
|
||||
|
||||
const [parts, setParts] = useState(single ? null : "both");
|
||||
const [hasInk, setHasInk] = useState(null);
|
||||
const [hasDrawing, setHasDrawing] = useState(null); // null = still looking
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [progress, setProgress] = useState(null);
|
||||
@@ -51,6 +56,14 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
useEffect(() => {
|
||||
if (!single) return;
|
||||
let cancelled = false;
|
||||
if (isPdfNote) {
|
||||
pdfNoteHasInk(notes[0]).then((yes) => {
|
||||
if (!cancelled) setHasInk(yes);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
noteHasDrawing(notes[0]).then((yes) => {
|
||||
if (cancelled) return;
|
||||
setHasDrawing(yes);
|
||||
@@ -59,7 +72,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [single, notes]);
|
||||
}, [single, isPdfNote, notes]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e) => e.key === "Escape" && !busy && onClose();
|
||||
@@ -73,7 +86,14 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
setBusy(kind);
|
||||
setProgress(null);
|
||||
try {
|
||||
if (kind === "png") {
|
||||
if (kind === "pdf-original" || kind === "pdf-stamped") {
|
||||
await exportPdfNote(notes[0], kind === "pdf-stamped");
|
||||
setDone(
|
||||
kind === "pdf-stamped"
|
||||
? "PDF mit den Zeichnungen wurde heruntergeladen."
|
||||
: "Das unveränderte Original wurde heruntergeladen."
|
||||
);
|
||||
} else if (kind === "png") {
|
||||
await exportDrawingAsPng(notes[0]);
|
||||
setDone("Zeichnung wurde als PNG heruntergeladen.");
|
||||
} else if (kind === "markdown") {
|
||||
@@ -122,6 +142,29 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
</button>
|
||||
);
|
||||
|
||||
/* ── a PDF note: the document, or the notes written beside it ── */
|
||||
const pdfOptions = () => (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll heraus?</div>
|
||||
{card(
|
||||
() => run("pdf-stamped"),
|
||||
busy === "pdf-stamped",
|
||||
"PDF mit den Zeichnungen",
|
||||
[
|
||||
"Deine Striche werden fest ins Dokument gerechnet.",
|
||||
hasInk === false ? "Auf diesem Dokument ist noch nichts gezeichnet." : null,
|
||||
],
|
||||
hasInk === false
|
||||
)}
|
||||
{card(() => run("pdf-original"), busy === "pdf-original", "PDF im Original", [
|
||||
"Genau die Datei, die du importiert hast — ohne deine Zeichnungen.",
|
||||
])}
|
||||
{card(() => run("markdown"), busy === "markdown", "Nur die Notizen daneben (Markdown)", [
|
||||
"Was du neben dem Dokument geschrieben hast, ohne das PDF selbst.",
|
||||
])}
|
||||
</>
|
||||
);
|
||||
|
||||
/* ── step one: what should come along ── */
|
||||
const stepParts = () => (
|
||||
<>
|
||||
@@ -275,7 +318,13 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "16px 22px 8px" }}>
|
||||
{single && hasDrawing === null ? (
|
||||
{isPdfNote ? (
|
||||
hasInk === null ? (
|
||||
<div style={{ fontSize: 13, color: T.muted, padding: "12px 0" }}>Dokument wird gelesen…</div>
|
||||
) : (
|
||||
pdfOptions()
|
||||
)
|
||||
) : single && hasDrawing === null ? (
|
||||
<div style={{ fontSize: 13, color: T.muted, padding: "12px 0" }}>Notiz wird gelesen…</div>
|
||||
) : parts === null ? (
|
||||
stepParts()
|
||||
@@ -284,7 +333,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{parts !== null && (progress || err || done) && (
|
||||
{(isPdfNote || parts !== null) && (progress || err || done) && (
|
||||
<div style={{ padding: "0 22px 8px" }}>
|
||||
{progress && (
|
||||
<div style={{ fontSize: 12, color: T.muted }}>
|
||||
|
||||
@@ -17,7 +17,7 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
|
||||
- **Undo/Redo** — Full history stack for drawing operations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **PDF Reading** — Import a PDF, read it, select its text, draw on it and write notes beside it; the file is cached so it stays readable offline
|
||||
- **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, write notes beside it, and export it with or without your marks
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
- **Dark & Light Mode** — Full theme support with refined color schemes
|
||||
|
||||
@@ -330,7 +330,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
| `pdf` | 32 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `pdf` | 41 | import, page proportions, lazy loading, and reading with the network off |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
|
||||
@@ -442,6 +442,36 @@ storage is content-addressed, a cached copy can never go stale.
|
||||
|
||||
---
|
||||
|
||||
## Exporting an annotated PDF
|
||||
|
||||
A PDF note offers three things rather than the note formats: the document with
|
||||
your drawings written into it, the untouched original, or just the notes you
|
||||
wrote beside it as Markdown.
|
||||
|
||||
pdf.js can only read, so producing a file needs a second library — `pdf-lib`,
|
||||
loaded the same way, behind a dynamic import, so the bundle everyone downloads
|
||||
carries no PDF writer for the sake of the people who never export one.
|
||||
|
||||
This is where the coordinate decision pays off. Strokes were stored in page
|
||||
points from the start, which is the only space a PDF understands: nothing has to
|
||||
be guessed back from screen pixels, and the stamping is a direct translation
|
||||
apart from flipping the origin, which PDF puts at the bottom-left.
|
||||
|
||||
**The stored file is never modified.** `pdf-lib` builds a new document, so a
|
||||
clean copy of the original stays available however often it has been drawn on —
|
||||
checked by the suite, which re-fetches the stored bytes after exporting and
|
||||
compares them.
|
||||
|
||||
Erased areas cannot come along. On screen an eraser removes ink by drawing in
|
||||
`destination-out`; a PDF content stream has no equivalent, so those strokes are
|
||||
dropped rather than stamped as visible grey scribbles where something was rubbed
|
||||
out.
|
||||
|
||||
The suite renders both files and counts dark pixels — 1633 against 2557 — because
|
||||
a larger file proves bytes were added, not that any ink can be seen.
|
||||
|
||||
---
|
||||
|
||||
## Notes beside a PDF
|
||||
|
||||
A PDF note is a note that happens to have a document attached, not a separate
|
||||
|
||||
Reference in new issue
Block a user