diff --git a/e2e/export.mjs b/e2e/export.mjs index 5f846b9..ae14c2d 100644 --- a/e2e/export.mjs +++ b/e2e/export.mjs @@ -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(); diff --git a/e2e/lib.mjs b/e2e/lib.mjs index 579572d..4432807 100644 --- a/e2e/lib.mjs +++ b/e2e/lib.mjs @@ -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) { diff --git a/e2e/pdf.mjs b/e2e/pdf.mjs index e0024ed..6ab1543 100644 --- a/e2e/pdf.mjs +++ b/e2e/pdf.mjs @@ -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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc89a62..e949080 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 7c63865..fca1010 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/export/exportNotes.ts b/frontend/src/export/exportNotes.ts index 694c09f..17aff8d 100644 --- a/frontend/src/export/exportNotes.ts +++ b/frontend/src/export/exportNotes.ts @@ -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 { 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 { + 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 { + try { + const { content } = await loadNote(meta); + return totalStrokes(asPdfStrokeDoc(content.drawingStrokes)) > 0; + } catch { + return false; + } +} + /* ── single note ── */ /** What a single-note export should contain. */ diff --git a/frontend/src/features/pdf/stampPdf.ts b/frontend/src/features/pdf/stampPdf.ts new file mode 100644 index 0000000..ccc0137 --- /dev/null +++ b/frontend/src/features/pdf/stampPdf.ts @@ -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 { + 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(); +} diff --git a/frontend/src/search.ts b/frontend/src/search.ts index 09bf556..3d23a0f 100644 --- a/frontend/src/search.ts +++ b/frontend/src/search.ts @@ -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, { diff --git a/frontend/src/ui/ExportDialog.jsx b/frontend/src/ui/ExportDialog.jsx index b0a573a..71bd5b8 100644 --- a/frontend/src/ui/ExportDialog.jsx +++ b/frontend/src/ui/ExportDialog.jsx @@ -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 }) { ); + /* ── a PDF note: the document, or the notes written beside it ── */ + const pdfOptions = () => ( + <> +
Was soll heraus?
+ {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 }) {
- {single && hasDrawing === null ? ( + {isPdfNote ? ( + hasInk === null ? ( +
Dokument wird gelesen…
+ ) : ( + pdfOptions() + ) + ) : single && hasDrawing === null ? (
Notiz wird gelesen…
) : parts === null ? ( stepParts() @@ -284,7 +333,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) { )}
- {parts !== null && (progress || err || done) && ( + {(isPdfNote || parts !== null) && (progress || err || done) && (
{progress && (
diff --git a/readme.md b/readme.md index 1558534..1f4b7f4 100644 --- a/readme.md +++ b/readme.md @@ -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