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

+43
View File
@@ -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",
+1
View File
@@ -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"
+42
View File
@@ -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. */
+81
View File
@@ -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();
}
+1 -6
View File
@@ -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, {
+53 -4
View File
@@ -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 }}>