Export notes as Markdown or PDF

The reason to build this is not a missing feature — it is that trusting a
program you maintain alone with years of notes should not mean the notes
can only ever be read by that program.

Reachable from a note's toolbar, a notebook's context menu, and Account →
Alle Notizen exportieren.

Markdown carries over everything with an equivalent: headings, lists,
quotes, code, tables, and task state as `- [x]`. A link to another note in
the same export becomes a relative link to its file; a link to a note
outside it degrades to the plain title rather than leaving a dead
reference. Notes with images or a drawing arrive as a ZIP so the files
travel along, and a multi-note export gets a README index.

PDF goes through the browser's print dialog rather than a bundled PDF
library — the browser already has a good one, and it is the only format
where drawings sit inside the document. Markdown gets them as a PNG
beside the text, with a line saying so, because dropping them silently
would be the wrong kind of quiet.

Two decisions worth recording:

- the ZIP writer is eighty lines here rather than a dependency. Note text
  is small and images are already compressed, so compression bought
  nothing, and stored entries are a handful of fixed-layout records.
- Markdown escaping only covers what changes meaning mid-line, plus a
  leading list or heading marker. Escaping every character Markdown ever
  uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the
  point of choosing a readable format.

42 unit tests on the serialiser and the archive format, plus a browser
suite that unpacks a real download and reads the bytes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 14:46:53 +02:00
1 parent 311e992496
commit 2611d87f8c
14 files changed
+1707 -2

No files matched your search

+268
View File
@@ -0,0 +1,268 @@
/**
* Turning notes into files on disk.
*
* The point of this is that the notes are not hostage to this program. A single
* note becomes one Markdown file; a notebook becomes a ZIP with the notes, their
* images, and any drawings rendered as PNG — everything readable without InkNote
* ever running again.
*/
import * as sync from "../syncEngine";
import { parseNoteContent } from "../editor/content";
import { parsingExtensions } from "../editor/extensions.js";
import { CANON_W, CANON_H, paintStrokeDoc, sizeCanvas, strokeDocOf } from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown";
import { printNotes, type PrintableNote } from "./print";
import { bytesToBlob, createZip, download, type ZipEntry } from "./zip";
/** Width the exported drawing is rendered at; the page ratio is kept. */
const DRAWING_EXPORT_W = 1200;
export interface ExportProgress {
done: number;
total: number;
label: string;
}
type Loaded = { meta: NoteMeta; content: NoteContent };
/* ── pieces ── */
/** Renders a stroke document to PNG bytes, so a drawing survives as a picture. */
async function drawingToPng(strokes: unknown): Promise<Uint8Array | null> {
const doc = strokeDocOf(strokes);
if (!doc?.strokes?.length) return null;
const canvas = document.createElement("canvas");
const height = Math.round((DRAWING_EXPORT_W * CANON_H) / CANON_W);
const { bw, bh } = sizeCanvas(canvas, DRAWING_EXPORT_W, height);
const ctx = canvas.getContext("2d");
if (!ctx) return null;
paintStrokeDoc(ctx, doc, bw, bh);
// Strokes are drawn on nothing, which would export as a transparent PNG and
// leave black ink invisible in a dark image viewer. The paper goes in behind
// afterwards, because paintStrokeDoc begins by clearing the canvas.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, bw, bh);
ctx.globalCompositeOperation = "source-over";
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, "image/png"));
if (!blob) return null;
return new Uint8Array(await blob.arrayBuffer());
}
/** Every attachment id the document refers to. */
function attachmentIds(doc: unknown): string[] {
const ids = new Set<string>();
const walk = (n: any) => {
if (!n) return;
if (n.type === "image" && n.attrs?.attachmentId) ids.add(n.attrs.attachmentId);
if (Array.isArray(n.content)) n.content.forEach(walk);
};
walk(doc);
return [...ids];
}
async function loadNote(meta: NoteMeta): Promise<Loaded> {
return { meta, content: await sync.loadNoteContent(meta.id) };
}
/** Unique filename per note — two notes may well share a title. */
function nameFor(meta: NoteMeta, taken: Set<string>): string {
const base = safeFilename(meta.title, "Notiz");
let name = base;
for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${base} (${i})`;
taken.add(name.toLowerCase());
return name;
}
/* ── single note ── */
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
const ids = attachmentIds(doc);
const md = noteToMarkdown(doc, {
title: meta.title,
updatedAt: meta.updatedAt,
hasDrawing,
attachmentPath: (id) => `${safeFilename(meta.title)}-bilder/${id}.png`,
notePath: () => null, // a lone note has nothing to link to
});
// Only worth an archive if there is something to put beside the text.
if (ids.length === 0 && !hasDrawing) {
download(new Blob([md], { type: "text/markdown;charset=utf-8" }), `${safeFilename(meta.title)}.md`);
return;
}
const folder = safeFilename(meta.title);
const entries: ZipEntry[] = [{ name: `${folder}.md`, data: md }];
for (const id of ids) {
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `${folder}-bilder/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `${folder}-zeichnung.png`, data: png });
download(createZip(entries), `${folder}.zip`);
}
/* ── PDF ── */
const blobToDataUrl = (blob: Blob): Promise<string> =>
new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error);
r.readAsDataURL(blob);
});
/**
* Prepares notes for the print dialog.
*
* Images have to travel as data URLs: the print document lives in its own
* iframe, where a blob: URL minted by the app is not resolvable, and an
* authenticated request is not something a printed page can make.
*/
export async function exportNotesAsPdf(
metas: NoteMeta[],
title: string,
onProgress?: (p: ExportProgress) => void
): Promise<void> {
const printable: PrintableNote[] = [];
const total = metas.length;
for (const [i, meta] of metas.entries()) {
onProgress?.({ done: i, total, label: meta.title });
try {
const content = await sync.loadNoteContent(meta.id);
const doc = parseNoteContent(content.content, parsingExtensions);
const images = new Map<string, string>();
for (const id of attachmentIds(doc)) {
try {
images.set(id, await blobToDataUrl(await sync.fetchAttachmentBlob(id)));
} catch {
/* a missing image should not stop the rest of the page */
}
}
const png = await drawingToPng(content.drawingStrokes);
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
printable.push({ meta, content, drawing, images });
} catch {
/* an unreadable note is left out rather than aborting the whole print */
}
}
onProgress?.({ done: total, total, label: "Druckansicht wird geöffnet" });
await printNotes(printable, title);
}
async function fetchAttachment(id: string): Promise<Uint8Array | null> {
try {
const blob = await sync.fetchAttachmentBlob(id);
return new Uint8Array(await blob.arrayBuffer());
} catch {
// One unreachable image must not cost the whole export.
return null;
}
}
/* ── many notes ── */
export interface ExportSetOptions {
/** Shown in the archive name and its index file. */
label: string;
notes: NoteMeta[];
notebooks: Notebook[];
onProgress?: (p: ExportProgress) => void;
}
export async function exportNotesAsZip({
label,
notes,
notebooks,
onProgress,
}: ExportSetOptions): Promise<void> {
const total = notes.length + 1;
const entries: ZipEntry[] = [];
const taken = new Set<string>();
const filenames = new Map<string, string>();
// Names are settled first, so a link from the first note to the last one can
// already point at the right file.
for (const meta of notes) filenames.set(meta.id, nameFor(meta, taken));
const notePath = (id: string) => {
const name = filenames.get(id);
return name ? `${encodeURIComponent(name)}.md` : null;
};
const index: string[] = [
`# ${label}`,
"",
`${notes.length} Notizen, exportiert am ${new Date().toLocaleString("de-DE")}`,
"",
];
const byNotebook = new Map<string | null, NoteMeta[]>();
for (const n of notes) {
const key = n.folderId ?? null;
if (!byNotebook.has(key)) byNotebook.set(key, []);
byNotebook.get(key)!.push(n);
}
let done = 0;
for (const meta of notes) {
onProgress?.({ done, total, label: meta.title });
const name = filenames.get(meta.id)!;
try {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
entries.push({
name: `${name}.md`,
data: noteToMarkdown(doc, {
title: meta.title,
updatedAt: meta.updatedAt,
hasDrawing,
attachmentPath: (id) => `bilder/${id}.png`,
notePath,
}),
});
for (const id of attachmentIds(doc)) {
if (entries.some((e) => e.name === `bilder/${id}.png`)) continue;
const bytes = await fetchAttachment(id);
if (bytes) entries.push({ name: `bilder/${id}.png`, data: bytes });
}
const png = await drawingToPng(content.drawingStrokes);
if (png) entries.push({ name: `zeichnungen/${name}.png`, data: png });
} catch (err) {
// A note that cannot be read is named in the index rather than skipped in
// silence — an export that quietly drops things is worse than useless.
entries.push({
name: `${name}.md`,
data: `# ${meta.title}\n\n> Diese Notiz konnte beim Export nicht gelesen werden.\n> (${(err as Error).message})\n`,
});
}
done++;
}
for (const [notebookId, group] of byNotebook) {
const nb = notebooks.find((b) => b.id === notebookId);
index.push("", `## ${nb?.name || "Ohne Notizbuch"}`, "");
for (const n of group) index.push(`- [${n.title || "Untitled"}](${notePath(n.id)})`);
}
entries.unshift({ name: "README.md", data: index.join("\n") + "\n" });
onProgress?.({ done: total, total, label: "Archiv wird gepackt" });
download(createZip(entries), `${safeFilename(label, "InkNote")}.zip`);
}