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

+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 }}>