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`);
}
+218
View File
@@ -0,0 +1,218 @@
import { describe, it, expect } from "vitest";
import { noteToMarkdown, safeFilename } from "./markdown";
import type { DocNode } from "../editor/content";
const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content });
const para = (...content: DocNode[]): DocNode => ({ type: "paragraph", content });
const text = (t: string, marks?: { type: string; attrs?: Record<string, unknown> }[]): DocNode =>
({ type: "text", text: t, ...(marks ? { marks } : {}) }) as DocNode;
const md = (d: DocNode, o = {}) => noteToMarkdown(d, o);
describe("text and marks", () => {
it("writes a plain paragraph", () => {
expect(md(doc(para(text("Hallo Welt"))))).toBe("Hallo Welt");
});
it("renders bold, italic and strikethrough", () => {
expect(md(doc(para(text("fett", [{ type: "bold" }]))))).toBe("**fett**");
expect(md(doc(para(text("kursiv", [{ type: "italic" }]))))).toBe("*kursiv*");
expect(md(doc(para(text("weg", [{ type: "strike" }]))))).toBe("~~weg~~");
});
it("falls back to HTML for underline, which Markdown lacks", () => {
expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("<u>unter</u>");
});
it("keeps a web link", () => {
const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }]))));
expect(out).toBe("[hier](https://example.com)");
});
it("escapes characters that would otherwise become syntax mid-line", () => {
expect(md(doc(para(text("2 * 3 _und_ [mehr]"))))).toBe("2 \\* 3 \\_und\\_ \\[mehr\\]");
});
it("leaves ordinary punctuation alone, so the result stays readable", () => {
expect(md(doc(para(text("Hallo. Alles gut! Preis: 5-10 EUR (ca.)"))))).toBe(
"Hallo. Alles gut! Preis: 5-10 EUR (ca.)"
);
});
it("escapes a leading marker so a paragraph does not become a list", () => {
expect(md(doc(para(text("- kein Listenpunkt"))))).toBe("\\- kein Listenpunkt");
expect(md(doc(para(text("1. kein Punkt"))))).toBe("\\1. kein Punkt");
expect(md(doc(para(text("# keine Ueberschrift"))))).toBe("\\# keine Ueberschrift");
});
it("does not escape inside a code span, where backslashes are literal", () => {
expect(md(doc(para(text("a_b", [{ type: "code" }]))))).toBe("`a_b`");
});
it("turns a hard break into a Markdown line break", () => {
expect(md(doc(para(text("eins"), { type: "hardBreak" }, text("zwei"))))).toBe("eins \nzwei");
});
});
describe("blocks", () => {
it("writes headings at their level", () => {
expect(md(doc({ type: "heading", attrs: { level: 2 }, content: [text("Titel")] }))).toBe("## Titel");
});
it("clamps an out-of-range heading level instead of emitting nonsense", () => {
expect(md(doc({ type: "heading", attrs: { level: 99 }, content: [text("X")] }))).toBe("###### X");
});
it("writes a bullet list", () => {
const list = {
type: "bulletList",
content: [
{ type: "listItem", content: [para(text("eins"))] },
{ type: "listItem", content: [para(text("zwei"))] },
],
};
expect(md(doc(list))).toBe("- eins\n- zwei");
});
it("numbers an ordered list from its start attribute", () => {
const list = {
type: "orderedList",
attrs: { start: 3 },
content: [
{ type: "listItem", content: [para(text("drei"))] },
{ type: "listItem", content: [para(text("vier"))] },
],
};
expect(md(doc(list))).toBe("3. drei\n4. vier");
});
it("writes task state as a checkbox, so it survives the trip", () => {
const list = {
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: true }, content: [para(text("erledigt"))] },
{ type: "taskItem", attrs: { checked: false }, content: [para(text("offen"))] },
],
};
expect(md(doc(list))).toBe("- [x] erledigt\n- [ ] offen");
});
it("writes a quote with every line prefixed", () => {
const quote = { type: "blockquote", content: [para(text("zitiert"))] };
expect(md(doc(quote))).toBe("> zitiert");
});
it("fences a code block and keeps its language", () => {
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
});
it("writes a horizontal rule", () => {
expect(md(doc({ type: "horizontalRule" }))).toBe("---");
});
});
describe("tables", () => {
const cell = (t: string, type = "tableCell"): DocNode => ({ type, content: [para(text(t))] });
it("writes a header row and separator", () => {
const table = {
type: "table",
content: [
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
{ type: "tableRow", content: [cell("1"), cell("2")] },
],
};
expect(md(doc(table))).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
});
it("gives a headerless table an empty header, since Markdown has no other form", () => {
const table = {
type: "table",
content: [{ type: "tableRow", content: [cell("1"), cell("2")] }],
};
const out = md(doc(table)).split("\n");
expect(out[1]).toBe("| --- | --- |");
expect(out[2]).toBe("| 1 | 2 |");
});
it("escapes a pipe inside a cell so the table does not break apart", () => {
const table = {
type: "table",
content: [{ type: "tableRow", content: [cell("a|b", "tableHeader")] }],
};
expect(md(doc(table))).toContain("a\\|b");
expect(md(doc(table))).not.toContain("a\\\\|b");
});
it("pads a short row so the columns stay aligned", () => {
const table = {
type: "table",
content: [
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
{ type: "tableRow", content: [cell("1")] },
],
};
expect(md(doc(table))).toContain("| 1 | |");
});
});
describe("images and note links", () => {
it("points an attachment at its file in the archive", () => {
const img = { type: "image", attrs: { attachmentId: "abc", alt: "Bild" } };
const out = md(doc(img), { attachmentPath: (id: string) => `images/${id}.png` });
expect(out).toBe("![Bild](images/abc.png)");
});
it("links to another exported note", () => {
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
const out = md(doc(link), { notePath: (id: string) => `${id}.md` });
expect(out).toBe("[Andere Notiz](n1.md)");
});
it("degrades a link to a note that was not exported, rather than leaving a dead file link", () => {
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
expect(md(doc(link), { notePath: () => null })).toBe("Andere Notiz");
});
});
describe("document framing", () => {
it("puts the title in a heading", () => {
expect(md(doc(para(text("Text"))), { title: "Meine Notiz" })).toBe("# Meine Notiz\n\nText");
});
it("says when a drawing could not come along, instead of dropping it silently", () => {
const out = md(doc(para(text("Text"))), { hasDrawing: true });
expect(out).toContain("Zeichnung");
});
it("collapses the blank lines the block rules emit generously", () => {
const out = md(doc(para(text("a")), para(), para(), para(text("b"))));
expect(out).not.toMatch(/\n{3,}/);
});
it("survives an empty document", () => {
expect(md(doc())).toBe("");
expect(md({ type: "doc" })).toBe("");
});
it("keeps the children of a node type it does not know", () => {
const weird = { type: "somethingNew", content: [para(text("trotzdem da"))] };
expect(md(doc(weird))).toBe("trotzdem da");
});
});
describe("safeFilename", () => {
it("strips characters a filesystem would refuse", () => {
expect(safeFilename('a/b\\c:d*e?f"g<h>i|j')).toBe("a-b-c-d-e-f-g-h-i-j");
});
it("falls back when nothing usable is left", () => {
expect(safeFilename("")).toBe("Notiz");
expect(safeFilename("///")).toBe("---");
});
it("caps the length", () => {
expect(safeFilename("x".repeat(200)).length).toBe(80);
});
});
+237
View File
@@ -0,0 +1,237 @@
/**
* A note as Markdown.
*
* This exists so the notes are not trapped in one program. It is a plain
* function over the stored document — no editor, no DOM — which is also why it
* can be unit tested and reused by the PDF path.
*
* Where Markdown has no equivalent the export says so rather than dropping
* something silently: a drawing becomes a marker line, an attachment becomes a
* link to a file that sits beside the note in the archive.
*/
import type { DocNode } from "../editor/content";
export interface MarkdownOptions {
/** Heading line for the note, usually its title. Omit to export the body only. */
title?: string;
/** ISO timestamp rendered under the title. */
updatedAt?: string;
/** Note has a drawing that Markdown cannot hold. */
hasDrawing?: boolean;
/** Maps an attachment id to a path in the archive, e.g. `images/ab12.png`. */
attachmentPath?: (id: string) => string;
/** Maps a linked note id to a path, so links between exported notes still work. */
notePath?: (id: string) => string | null;
}
/* ── inline ── */
/**
* Escapes only what actually changes meaning mid-line.
*
* Escaping every punctuation mark that Markdown ever uses turns an ordinary
* sentence into `Hallo\. Welt\!`, which is unreadable — and readability is the
* whole point of exporting to Markdown rather than JSON. Characters that only
* matter at the start of a line are handled by `escapeLineStart` instead.
*/
const escapeText = (s: string) => s.replace(/([\\`*_[\]|])/g, "\\$1");
/** `- x`, `# x`, `1. x` and friends turn a paragraph into a block if left alone. */
const escapeLineStart = (line: string) =>
line.replace(/^(\s*)([-+>#]|\d+\.)(\s)/, (_m, pad, marker, space) => `${pad}\\${marker}${space}`);
function inline(nodes: DocNode[] | undefined, o: MarkdownOptions): string {
if (!Array.isArray(nodes)) return "";
return nodes.map((n) => inlineNode(n, o)).join("");
}
function inlineNode(n: DocNode, o: MarkdownOptions): string {
if (n.type === "hardBreak") return " \n";
if (n.type === "noteLink") {
const label = n.attrs?.label || "Untitled";
const target = o.notePath?.(n.attrs?.noteId);
// A link to a note that was not exported would be a dead file reference,
// so it degrades to the plain name it points at.
return target ? `[${escapeText(label)}](${target})` : escapeText(label);
}
if (n.type === "image") {
const alt = escapeText(n.attrs?.alt || "");
const id = n.attrs?.attachmentId;
const src = id ? o.attachmentPath?.(id) : n.attrs?.src;
return src ? `![${alt}](${src})` : `![${alt}]()`;
}
if (n.type !== "text" || !n.text) return "";
let out = escapeText(n.text);
// Applied innermost-first so `**_text_**` nests the way the marks were stored.
for (const mark of (n as any).marks || []) {
switch (mark.type) {
case "bold":
out = `**${out}**`;
break;
case "italic":
out = `*${out}*`;
break;
case "strike":
out = `~~${out}~~`;
break;
case "code":
// Backslash escapes are not honoured inside code spans.
out = `\`${n.text}\``;
break;
case "underline":
// Markdown has no underline; HTML is the least surprising stand-in.
out = `<u>${out}</u>`;
break;
case "link":
out = `[${out}](${mark.attrs?.href || ""})`;
break;
default:
break;
}
}
return out;
}
/* ── blocks ── */
function block(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
switch (n.type) {
case "paragraph": {
const text = inline(n.content, o);
return text.trim() ? [escapeLineStart(indent + text)] : [""];
}
case "heading": {
const level = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return [`${"#".repeat(level)} ${inline(n.content, o)}`];
}
case "blockquote":
return children(n, o)
.join("\n")
.split("\n")
.map((l) => (l ? `> ${l}` : ">"));
case "codeBlock": {
const lang = n.attrs?.language || "";
const body = (n.content || []).map((c) => c.text || "").join("");
return ["```" + lang, ...body.split("\n"), "```"];
}
case "horizontalRule":
return ["---"];
case "bulletList":
return list(n, o, indent, () => "- ");
case "orderedList": {
const start = Number(n.attrs?.start) || 1;
return list(n, o, indent, (i) => `${start + i}. `);
}
case "taskList":
return list(n, o, indent, (_i, item) => `- [${item.attrs?.checked ? "x" : " "}] `);
case "table":
return table(n, o);
case "image":
case "noteLink":
return [indent + inlineNode(n, o)];
default:
// An unknown node still contributes its children rather than vanishing.
return children(n, o);
}
}
function children(n: DocNode, o: MarkdownOptions, indent = ""): string[] {
return (n.content || []).flatMap((c) => block(c, o, indent));
}
/** Renders list items, indenting anything nested inside them. */
function list(
n: DocNode,
o: MarkdownOptions,
indent: string,
marker: (index: number, item: DocNode) => string
): string[] {
const out: string[] = [];
(n.content || []).forEach((item, i) => {
const bullet = marker(i, item);
const lines = (item.content || []).flatMap((c) => block(c, o, ""));
const [first = "", ...rest] = lines.filter((l, idx) => l !== "" || idx > 0);
out.push(indent + bullet + first.trim());
// Continuation lines line up under the text, not under the marker.
const pad = indent + " ".repeat(bullet.length);
for (const line of rest) out.push(line ? pad + line.trim() : "");
});
return out;
}
function table(n: DocNode, o: MarkdownOptions): string[] {
const rows = (n.content || []).map((row) =>
(row.content || []).map((cell) =>
// A cell may hold several paragraphs; Markdown tables are one line per row.
children(cell, o)
// Pipes are already escaped by escapeText; a table row just needs the
// cell flattened to a single line.
.join(" ")
.replace(/\s*\n\s*/g, " ")
.trim()
)
);
if (rows.length === 0) return [];
const width = Math.max(...rows.map((r) => r.length));
const pad = (r: string[]) => [...r, ...Array(width - r.length).fill("")];
const line = (r: string[]) => `| ${pad(r).join(" | ")} |`;
const headerIsReal = (n.content?.[0]?.content || []).some((c) => c.type === "tableHeader");
const [first, ...rest] = rows;
return headerIsReal
? [line(first!), `|${" --- |".repeat(width)}`, ...rest.map(line)]
: // Markdown has no headerless table; an empty header row keeps it a table.
[`|${" |".repeat(width)}`, `|${" --- |".repeat(width)}`, ...rows.map(line)];
}
/* ── entry point ── */
export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): string {
const lines: string[] = [];
if (options.title) {
lines.push(`# ${options.title}`, "");
if (options.updatedAt) {
lines.push(`*${new Date(options.updatedAt).toLocaleString("de-DE")}*`, "");
}
}
lines.push(...children(doc, options));
if (options.hasDrawing) {
lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann.");
lines.push("> Sie liegt als PNG neben der Datei.");
}
// Collapse runs of blank lines; the block rules emit them generously.
return lines
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/** Filename-safe version of a note title. */
export function safeFilename(title: string, fallback = "Notiz"): string {
const cleaned = (title || "")
.replace(/[/\\?%*:|"<>]/g, "-")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80);
return cleaned || fallback;
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Notes as PDF, by way of the browser's own print dialog.
*
* No PDF library: every browser already contains a good one behind "Save as
* PDF", and it handles fonts, page breaks and hyphenation better than anything
* that would fit in this project. What is built here is the page it prints.
*
* The document is written into a hidden iframe rather than a new window, so a
* pop-up blocker cannot swallow it and the app's own styles cannot leak in.
*/
import { parseNoteContent } from "../editor/content";
import { parsingExtensions } from "../editor/extensions.js";
import type { DocNode } from "../editor/content";
import type { NoteContent, NoteMeta } from "../types";
const esc = (s: unknown) =>
String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
/* ── document to print-ready HTML ── */
function inline(nodes: DocNode[] | undefined, resolveImage: (id: string) => string | null): string {
if (!Array.isArray(nodes)) return "";
return nodes
.map((n) => {
if (n.type === "hardBreak") return "<br>";
if (n.type === "noteLink") return `<span class="note-link">${esc(n.attrs?.label || "Untitled")}</span>`;
if (n.type === "image") {
const src = n.attrs?.attachmentId ? resolveImage(n.attrs.attachmentId) : n.attrs?.src;
return src ? `<img src="${esc(src)}" alt="${esc(n.attrs?.alt || "")}">` : "";
}
if (n.type !== "text" || !n.text) return "";
let html = esc(n.text);
for (const mark of (n as any).marks || []) {
if (mark.type === "bold") html = `<strong>${html}</strong>`;
else if (mark.type === "italic") html = `<em>${html}</em>`;
else if (mark.type === "underline") html = `<u>${html}</u>`;
else if (mark.type === "strike") html = `<s>${html}</s>`;
else if (mark.type === "code") html = `<code>${html}</code>`;
else if (mark.type === "link") html = `<a href="${esc(mark.attrs?.href)}">${html}</a>`;
}
return html;
})
.join("");
}
function blocks(nodes: DocNode[] | undefined, resolveImage: (id: string) => string | null): string {
if (!Array.isArray(nodes)) return "";
return nodes.map((n) => blockHtml(n, resolveImage)).join("\n");
}
function blockHtml(n: DocNode, img: (id: string) => string | null): string {
const kids = () => blocks(n.content, img);
switch (n.type) {
case "paragraph": {
const body = inline(n.content, img);
return body.trim() ? `<p>${body}</p>` : "<p>&nbsp;</p>";
}
case "heading": {
const l = Math.min(6, Math.max(1, Number(n.attrs?.level) || 1));
return `<h${l}>${inline(n.content, img)}</h${l}>`;
}
case "blockquote":
return `<blockquote>${kids()}</blockquote>`;
case "codeBlock":
return `<pre><code>${esc((n.content || []).map((c) => c.text || "").join(""))}</code></pre>`;
case "horizontalRule":
return "<hr>";
case "bulletList":
return `<ul>${kids()}</ul>`;
case "orderedList":
return `<ol${n.attrs?.start ? ` start="${Number(n.attrs.start)}"` : ""}>${kids()}</ol>`;
case "listItem":
return `<li>${kids()}</li>`;
case "taskList":
return `<ul class="tasks">${kids()}</ul>`;
case "taskItem":
return `<li class="task"><span class="box">${n.attrs?.checked ? "☑" : "☐"}</span><div>${kids()}</div></li>`;
case "table":
return `<table>${kids()}</table>`;
case "tableRow":
return `<tr>${kids()}</tr>`;
case "tableHeader":
return `<th>${kids()}</th>`;
case "tableCell":
return `<td>${kids()}</td>`;
case "image":
case "noteLink":
return `<p>${inline([n], img)}</p>`;
default:
return kids();
}
}
/* ── page ── */
const STYLES = `
@page { margin: 18mm 16mm; }
* { box-sizing: border-box; }
body {
font: 11pt/1.55 "DM Sans", -apple-system, Segoe UI, Roboto, sans-serif;
color: #111; margin: 0;
}
article { page-break-after: always; }
article:last-child { page-break-after: auto; }
h1 { font-family: Newsreader, Georgia, serif; font-size: 20pt; margin: 0 0 2mm; }
h2 { font-size: 14pt; margin: 6mm 0 2mm; }
h3 { font-size: 12pt; margin: 5mm 0 2mm; }
.meta { color: #666; font-size: 9pt; margin-bottom: 6mm;
border-bottom: 1px solid #ddd; padding-bottom: 2mm; }
p { margin: 0 0 2.6mm; orphans: 2; widows: 2; }
ul, ol { margin: 0 0 3mm; padding-left: 6mm; }
li { margin-bottom: 1mm; }
ul.tasks { list-style: none; padding-left: 1mm; }
li.task { display: flex; gap: 2mm; align-items: baseline; }
li.task .box { font-size: 12pt; line-height: 1; }
li.task div p { margin: 0; }
blockquote { margin: 0 0 3mm; padding-left: 4mm; border-left: 2px solid #ccc; color: #444; }
pre { background: #f4f4f4; padding: 3mm; border-radius: 2mm; overflow-wrap: anywhere;
white-space: pre-wrap; font-size: 9.5pt; }
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 9.5pt; }
table { border-collapse: collapse; width: 100%; margin: 0 0 3mm; page-break-inside: avoid; }
th, td { border: 1px solid #bbb; padding: 1.6mm 2.2mm; text-align: left; vertical-align: top; }
th { background: #f2f2f2; font-weight: 600; }
img { max-width: 100%; height: auto; page-break-inside: avoid; }
hr { border: none; border-top: 1px solid #ccc; margin: 4mm 0; }
.note-link { color: #0550ae; }
.note-link::before { content: "→ "; }
.drawing { margin-top: 5mm; page-break-inside: avoid; }
.drawing img { border: 1px solid #ddd; }
`;
export interface PrintableNote {
meta: NoteMeta;
content: NoteContent;
/** Drawing rendered as a data URL, if the note has one. */
drawing?: string | null;
/** Attachment id → data URL, for images to survive into the print document. */
images?: Map<string, string>;
}
function articleFor(note: PrintableNote): string {
const doc = parseNoteContent(note.content.content, parsingExtensions);
const resolve = (id: string) => note.images?.get(id) || null;
const when = note.meta.updatedAt ? new Date(note.meta.updatedAt).toLocaleString("de-DE") : "";
return `<article>
<h1>${esc(note.meta.title || "Untitled")}</h1>
${when ? `<div class="meta">${esc(when)}</div>` : ""}
${blocks(doc.content, resolve)}
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
</article>`;
}
export function buildPrintDocument(notes: PrintableNote[], title: string): string {
return `<!doctype html><html lang="de"><head><meta charset="utf-8">
<title>${esc(title)}</title><style>${STYLES}</style></head>
<body>${notes.map(articleFor).join("\n")}</body></html>`;
}
/**
* Opens the print dialog on the given notes.
*
* Resolves once printing has been dismissed, or after a timeout — browsers are
* inconsistent about `afterprint`, and a caller left waiting forever would be
* worse than one that cleans up slightly early.
*/
export function printNotes(notes: PrintableNote[], title: string): Promise<void> {
return new Promise((resolve) => {
const frame = document.createElement("iframe");
frame.setAttribute("aria-hidden", "true");
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
document.body.appendChild(frame);
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
setTimeout(() => frame.remove(), 500);
resolve();
};
frame.onload = () => {
const win = frame.contentWindow;
if (!win) return finish();
win.addEventListener("afterprint", finish);
// Give images in the document a moment to decode before printing them.
setTimeout(() => {
try {
win.focus();
win.print();
} catch {
/* nothing sensible to do; the cleanup below still runs */
}
setTimeout(finish, 60_000);
}, 350);
};
const doc = frame.contentDocument!;
doc.open();
doc.write(buildPrintDocument(notes, title));
doc.close();
});
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it, expect } from "vitest";
import { createZipBytes, __testing } from "./zip";
const { crc32, dosDateTime } = __testing;
const bytes = (s: string) => new TextEncoder().encode(s);
/** Reads the archive back through a DataView — the format is the contract. */
function parse(buf: Uint8Array) {
const view = new DataView(buf.buffer);
const dec = new TextDecoder();
// End-of-central-directory sits at the very end for archives without comments.
const endAt = buf.length - 22;
expect(view.getUint32(endAt, true)).toBe(0x06054b50);
const count = view.getUint16(endAt + 8, true);
const centralAt = view.getUint32(endAt + 16, true);
const entries: { name: string; content: string; crc: number; size: number }[] = [];
let p = centralAt;
for (let i = 0; i < count; i++) {
expect(view.getUint32(p, true)).toBe(0x02014b50);
const crc = view.getUint32(p + 16, true);
const size = view.getUint32(p + 24, true);
const nameLen = view.getUint16(p + 28, true);
const localAt = view.getUint32(p + 42, true);
const name = dec.decode(buf.slice(p + 46, p + 46 + nameLen));
expect(view.getUint32(localAt, true)).toBe(0x04034b50);
const localNameLen = view.getUint16(localAt + 26, true);
const dataAt = localAt + 30 + localNameLen + view.getUint16(localAt + 28, true);
const content = dec.decode(buf.slice(dataAt, dataAt + size));
entries.push({ name, content, crc, size });
p += 46 + nameLen + view.getUint16(p + 30, true) + view.getUint16(p + 32, true);
}
return entries;
}
describe("crc32", () => {
/* Against the values every reference implementation agrees on — if this drifts,
* archives still open but every entry reports as corrupt. */
it("matches the known checksum of a plain string", () => {
expect(crc32(bytes("hello world"))).toBe(0x0d4a1185);
});
it("is zero for no bytes", () => {
expect(crc32(new Uint8Array())).toBe(0);
});
it("changes when a single byte changes", () => {
expect(crc32(bytes("hello worlD"))).not.toBe(crc32(bytes("hello world")));
});
});
describe("dosDateTime", () => {
it("packs a date into the MS-DOS words", () => {
const { date, time } = dosDateTime(new Date(2026, 7, 8, 13, 30, 20));
expect((date >> 9) + 1980).toBe(2026);
expect((date >> 5) & 0xf).toBe(8);
expect(date & 0x1f).toBe(8);
expect(time >> 11).toBe(13);
expect((time >> 5) & 0x3f).toBe(30);
});
});
describe("createZip", () => {
it("round-trips a single text file", () => {
const zip = createZipBytes([{ name: "notiz.md", data: "# Hallo" }]);
const [entry] = parse(zip);
expect(entry!.name).toBe("notiz.md");
expect(entry!.content).toBe("# Hallo");
});
it("keeps several entries with their paths", () => {
const zip = createZipBytes([
{ name: "Buch/eins.md", data: "eins" },
{ name: "Buch/zwei.md", data: "zwei" },
{ name: "Buch/images/a.png", data: new Uint8Array([1, 2, 3]) },
]);
const entries = parse(zip);
expect(entries.map((e) => e.name)).toEqual(["Buch/eins.md", "Buch/zwei.md", "Buch/images/a.png"]);
expect(entries[1]!.content).toBe("zwei");
});
it("records a checksum matching the data, so archivers do not report corruption", () => {
const zip = createZipBytes([{ name: "a.txt", data: "some content" }]);
const [entry] = parse(zip);
expect(entry!.crc).toBe(crc32(bytes("some content")));
expect(entry!.size).toBe(12);
});
it("stores binary data unchanged", async () => {
const raw = new Uint8Array([0, 255, 128, 10, 13, 0]);
const zip = createZipBytes([{ name: "b.bin", data: raw }]);
// Data begins right after the local header and the filename.
const start = 30 + "b.bin".length;
expect(Array.from(zip.slice(start, start + raw.length))).toEqual(Array.from(raw));
});
it("writes umlauts in filenames as UTF-8", () => {
const zip = createZipBytes([{ name: "Übersicht.md", data: "x" }]);
const [entry] = parse(zip);
expect(entry!.name).toBe("Übersicht.md");
});
it("produces a valid empty archive", () => {
const zip = createZipBytes([]);
expect(parse(zip)).toEqual([]);
expect(zip.length).toBe(22);
});
});
+150
View File
@@ -0,0 +1,150 @@
/**
* A minimal ZIP writer — stored (uncompressed) entries only.
*
* Exporting a notebook means handing over many files at once, and the browser
* can only offer one download. That needs a container, and ZIP is the one every
* operating system opens without being asked twice.
*
* Written out rather than pulled in: the format's stored variant is a handful of
* fixed-layout records, which is less code than the dependency would be, and one
* fewer thing to keep updated in a project maintained by one person. Compression
* is skipped on purpose — note text is small, and images are already compressed.
*/
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[i] = c >>> 0;
}
return t;
})();
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]!)! & 0xff]! ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
/** ZIP stores timestamps as MS-DOS date and time words. */
function dosDateTime(d: Date): { date: number; time: number } {
return {
date: (((d.getFullYear() - 1980) & 0x7f) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
};
}
export interface ZipEntry {
/** Path inside the archive; forward slashes make folders. */
name: string;
data: Uint8Array | string;
}
const encoder = new TextEncoder();
const toBytes = (d: Uint8Array | string) => (typeof d === "string" ? encoder.encode(d) : d);
/**
* The archive as bytes.
*
* Kept separate from `createZip` so the format can be verified without a Blob —
* jsdom's has no `arrayBuffer`, and a pure byte function is the easier thing to
* reason about in any case.
*/
export function createZipBytes(entries: ZipEntry[], now = new Date()): Uint8Array {
const { date, time } = dosDateTime(now);
const chunks: Uint8Array[] = [];
const central: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
const name = encoder.encode(entry.name);
const data = toBytes(entry.data);
const crc = crc32(data);
const local = new DataView(new ArrayBuffer(30));
local.setUint32(0, 0x04034b50, true); // local file header
local.setUint16(4, 20, true); // version needed
local.setUint16(6, 0x0800, true); // UTF-8 names
local.setUint16(8, 0, true); // stored, no compression
local.setUint16(10, time, true);
local.setUint16(12, date, true);
local.setUint32(14, crc, true);
local.setUint32(18, data.length, true);
local.setUint32(22, data.length, true);
local.setUint16(26, name.length, true);
local.setUint16(28, 0, true); // extra field length
chunks.push(new Uint8Array(local.buffer), name, data);
const dir = new DataView(new ArrayBuffer(46));
dir.setUint32(0, 0x02014b50, true); // central directory header
dir.setUint16(4, 20, true); // version made by
dir.setUint16(6, 20, true); // version needed
dir.setUint16(8, 0x0800, true);
dir.setUint16(10, 0, true);
dir.setUint16(12, time, true);
dir.setUint16(14, date, true);
dir.setUint32(16, crc, true);
dir.setUint32(20, data.length, true);
dir.setUint32(24, data.length, true);
dir.setUint16(28, name.length, true);
dir.setUint32(42, offset, true); // where the local header sits
central.push(new Uint8Array(dir.buffer), name);
offset += 30 + name.length + data.length;
}
const centralSize = central.reduce((n, c) => n + c.length, 0);
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true); // end of central directory
end.setUint16(8, entries.length, true);
end.setUint16(10, entries.length, true);
end.setUint32(12, centralSize, true);
end.setUint32(16, offset, true);
const parts = [...chunks, ...central, new Uint8Array(end.buffer)];
const total = parts.reduce((n, p) => n + p.length, 0);
const out = new Uint8Array(total);
let at = 0;
for (const part of parts) {
out.set(part, at);
at += part.length;
}
return out;
}
/**
* Wraps bytes in a Blob.
*
* Goes through the underlying buffer rather than handing the view straight to
* `new Blob`, which TypeScript rejects because a view may sit on a shared
* buffer. The offsets are honoured, so a view over part of a larger buffer
* yields only its own bytes.
*/
export function bytesToBlob(bytes: Uint8Array, type: string): Blob {
// Copied into a plain ArrayBuffer: a view can sit on a SharedArrayBuffer,
// which Blob does not accept, and the copy also honours the view's offsets.
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer], { type });
}
export function createZip(entries: ZipEntry[], now = new Date()): Blob {
return bytesToBlob(createZipBytes(entries, now), "application/zip");
}
/** Hands a blob to the browser as a download. */
export function download(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// Freed on the next tick; revoking immediately cancels the download in Safari.
setTimeout(() => URL.revokeObjectURL(url), 10_000);
}
export const __testing = { crc32, dosDateTime };