Files
InkNote/frontend/src/export/exportNotes.ts
T
marcandClaude Opus 5 6438648313 Pin notes to passages in a PDF, and put the notes control with the rest
The panel beside a PDF is for notes about the document; a note that belongs to
one paragraph is a different thing, and one that has to begin "on page 14, third
paragraph" has already lost its place. Click Randnotiz, click the passage, write.

Pins ride in the same per-page overlay as the drawings, in PDF points, so one
stays on its sentence at every zoom and in the exported file. Living outside the
note body has consequences that each needed handling: nothing else would index
them for search, so the viewer feeds them in with their page numbers; clearing a
page's ink must not take them; and "is this overlay empty?" was answered by
counting strokes — in the client *and* independently in the server, so an
overlay holding only pinned notes was stored as NULL and lost every note on it.

They are stamped into the annotated export as a numbered marker plus a box of
text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so
characters outside it are replaced rather than failing the whole export.

The Notizen control moves into the viewer's toolbar. A second row for one button
cost reading height on every screen and put one of the document's controls
somewhere none of the others were. On a phone, where the viewer is not on screen
while the notes are, the way back sits in the panel.

Renaming a separator no longer shifts the row: the two rules always share what is
left over equally and the middle keeps one width in both states.

215 unit tests, 206 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:08:20 +02:00

336 lines
12 KiB
TypeScript

/**
* 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, docStrokes, paintStrokeDoc, sizeCanvas } from "../features/drawing/strokes.js";
import type { NoteContent, NoteMeta, Notebook } from "../types";
import { noteToMarkdown, safeFilename } from "./markdown";
import { asPdfStrokeDoc, hasOverlay } from "../features/pdf/pageStrokes";
import { stampStrokesIntoPdf } from "../features/pdf/stampPdf";
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(doc: unknown): Promise<Uint8Array | null> {
// `doc` is already a stroke document as stored — strokeDocOf() builds one from
// a bare array and would wrap this one a second time, hiding the strokes.
if (docStrokes(doc).length === 0) 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;
}
/* ── 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);
// Pinned notes count as much as strokes here: both are the reader's own work
// on the document, and an export that dropped either would lose it silently.
if (!hasOverlay(ink)) {
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 PDF carries drawings or pinned notes, so the wizard can say so. */
export async function pdfNoteHasInk(meta: NoteMeta): Promise<boolean> {
try {
const { content } = await loadNote(meta);
return hasOverlay(asPdfStrokeDoc(content.drawingStrokes));
} catch {
return false;
}
}
/* ── single note ── */
/** What a single-note export should contain. */
export type ExportParts = "text" | "drawing" | "both";
/** Whether this note has a drawing — decides which options the wizard offers. */
export async function noteHasDrawing(meta: NoteMeta): Promise<boolean> {
try {
const { content } = await loadNote(meta);
return docStrokes(content.drawingStrokes).length > 0;
} catch {
return false;
}
}
/** The drawing on its own, as a PNG on white paper. */
export async function exportDrawingAsPng(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const png = await drawingToPng(content.drawingStrokes);
if (!png) throw new Error("Diese Notiz enthält keine Zeichnung.");
download(bytesToBlob(png, "image/png"), `${safeFilename(meta.title)}.png`);
}
export async function exportNoteAsMarkdown(meta: NoteMeta): Promise<void> {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
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,
parts: ExportParts = "both"
): 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 = parts === "text" ? null : await drawingToPng(content.drawingStrokes);
const drawing = png ? await blobToDataUrl(bytesToBlob(png, "image/png")) : null;
printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" });
} 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 = docStrokes(content.drawingStrokes).length > 0;
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`);
}