Ask what a single note's export should contain, and fix drawings never being in one

Two questions instead of one, because the answers depend on each other:
Markdown holds text only, a PNG holds the drawing only, PDF holds both.
Listing every format flat would have offered combinations that cannot
exist. A note without a drawing skips the first step rather than showing
it mostly greyed out.

While testing that, the drawings turned out never to have made it into
any export at all — not the ZIP, not the PDF. strokeDocOf() *builds* a
stroke document from a bare array, and it was being handed one that was
already a document, so the strokes ended up nested one level too deep and
every length check read undefined. Nothing complained; the export simply
came out without the drawing. It went unnoticed because the notes the
earlier suite exported had none.

Now uses docStrokes(), and the suite draws on a note before exporting it,
checks the PNG's magic bytes, and checks it is 159 KB rather than an empty
white page — a check on the file merely existing would have passed against
the broken version too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-08 17:37:22 +02:00
1 parent 2611d87f8c
commit 8c36c6121b
5 files changed
+284 -57

No files matched your search

+79 -3
View File
@@ -72,9 +72,29 @@ function readZip(bytes) {
const openExportDialog = async (page) => {
await page.getByTitle("Export").click();
await page.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await page.waitForTimeout(500);
await page.waitForTimeout(800);
};
/** Draws a couple of strokes so the note has something to export as a picture. */
async function drawSomething(page) {
await page.locator("button").filter({ hasText: "Draw" }).first().click();
await page.waitForTimeout(1200);
const box = await page.locator("canvas").first().boundingBox();
for (const [x1, y1, x2, y2] of [
[0.2, 0.25, 0.7, 0.4],
[0.3, 0.6, 0.7, 0.55],
]) {
await page.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
await page.mouse.down();
await page.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 12 });
await page.mouse.up();
await page.waitForTimeout(250);
}
await page.waitForTimeout(2500);
await page.locator("button").filter({ hasText: "Write" }).first().click();
await page.waitForTimeout(800);
}
const browser = await launch();
let session;
try {
@@ -104,7 +124,7 @@ try {
/* ── single note as Markdown ── */
await openExportDialog(p);
await p.getByRole("button", { name: /Als Markdown/ }).click();
await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(600);
@@ -143,7 +163,7 @@ try {
await p.evaluate(() => (window.__downloads = []));
await openExportDialog(p);
await p.getByRole("button", { name: /Als Markdown/ }).click();
await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(800);
files = await downloads(p);
@@ -165,6 +185,62 @@ try {
);
await p.screenshot({ path: `${OUT}/export-dialog.png` });
/* ── the wizard on a note that has a drawing ── */
await createNote(p);
await setTitle(p, "Mit Zeichnung");
await typeInEditor(p, "Text und Bild.");
await drawSomething(p);
await p.evaluate(() => (window.__downloads = []));
await openExportDialog(p);
check(
(await p.getByText("Was soll mit?").count()) > 0,
"a note with a drawing is asked what should come along"
);
check(
(await p.getByRole("button", { name: /Nur die Zeichnung/ }).count()) > 0,
"the drawing-only option is offered"
);
await p.screenshot({ path: `${OUT}/export-wizard.png` });
// drawing only → PNG
await p.getByRole("button", { name: /Nur die Zeichnung/ }).click();
await p.waitForTimeout(500);
check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing");
check(
(await p.getByRole("button", { name: /Markdown/ }).count()) === 0,
"Markdown is not offered for a drawing, since it cannot hold one"
);
await p.getByRole("button", { name: /PNG-Bild/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(800);
files = await downloads(p);
check(files.length === 1 && files[0].type === "image/png", "the PNG was produced", files[0]?.type);
const png = Buffer.from(files[0].base64, "base64");
check(
png[0] === 0x89 && png[1] === 0x50 && png[2] === 0x4e && png[3] === 0x47,
"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.length > 1000, "the image has actual content, not an empty canvas", `${png.length} bytes`);
// back, then text only → Markdown must carry no drawing
await p.getByRole("button", { name: /Zurück/ }).click();
await p.waitForTimeout(500);
await p.getByRole("button", { name: /Nur den Notiztext/ }).click();
await p.waitForTimeout(500);
await p.evaluate(() => (window.__downloads = []));
await p.getByRole("button", { name: /Markdown/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(800);
files = await downloads(p);
const textMd = Buffer.from(files[0].base64, "base64").toString("utf8");
check(textMd.includes("Text und Bild."), "the text-only export has the text");
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* ── whole notebook, via the sidebar's context menu ── */
await p.evaluate(() => (window.__downloads = []));
await p.locator("[data-note-row]").first().click({ button: "right" });
+32 -9
View File
@@ -10,7 +10,7 @@
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 { 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 { printNotes, type PrintableNote } from "./print";
@@ -30,9 +30,10 @@ 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;
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);
@@ -81,10 +82,31 @@ function nameFor(meta: NoteMeta, taken: Set<string>): string {
/* ── 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 = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
const ids = attachmentIds(doc);
const md = noteToMarkdown(doc, {
@@ -133,7 +155,8 @@ const blobToDataUrl = (blob: Blob): Promise<string> =>
export async function exportNotesAsPdf(
metas: NoteMeta[],
title: string,
onProgress?: (p: ExportProgress) => void
onProgress?: (p: ExportProgress) => void,
parts: ExportParts = "both"
): Promise<void> {
const printable: PrintableNote[] = [];
const total = metas.length;
@@ -153,10 +176,10 @@ export async function exportNotesAsPdf(
}
}
const png = await drawingToPng(content.drawingStrokes);
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 });
printable.push({ meta, content, drawing, images, includeText: parts !== "drawing" });
} catch {
/* an unreadable note is left out rather than aborting the whole print */
}
@@ -225,7 +248,7 @@ export async function exportNotesAsZip({
try {
const { content } = await loadNote(meta);
const doc = parseNoteContent(content.content, parsingExtensions);
const hasDrawing = !!strokeDocOf(content.drawingStrokes)?.strokes?.length;
const hasDrawing = docStrokes(content.drawingStrokes).length > 0;
entries.push({
name: `${name}.md`,
+8 -3
View File
@@ -138,21 +138,26 @@ const STYLES = `
export interface PrintableNote {
meta: NoteMeta;
content: NoteContent;
/** Drawing rendered as a data URL, if the note has one. */
/** Drawing rendered as a data URL, if the note has one and it was asked for. */
drawing?: string | null;
/** Attachment id → data URL, for images to survive into the print document. */
images?: Map<string, string>;
/** Omitted for a drawing-only export, where the text would be in the way. */
includeText?: boolean;
}
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") : "";
const body =
note.includeText === false
? ""
: blocks(parseNoteContent(note.content.content, parsingExtensions).content, resolve);
return `<article>
<h1>${esc(note.meta.title || "Untitled")}</h1>
${when ? `<div class="meta">${esc(when)}</div>` : ""}
${blocks(doc.content, resolve)}
${body}
${note.drawing ? `<div class="drawing"><img src="${note.drawing}" alt="Zeichnung"></div>` : ""}
</article>`;
}
+151 -41
View File
@@ -1,18 +1,31 @@
import { useEffect, useMemo, useState } from "react";
import { exportNoteAsMarkdown, exportNotesAsPdf, exportNotesAsZip } from "../export/exportNotes";
import {
exportDrawingAsPng,
exportNoteAsMarkdown,
exportNotesAsPdf,
exportNotesAsZip,
noteHasDrawing,
} from "../export/exportNotes";
import { I } from "./icons.jsx";
import { FONT_SERIF, FONT_UI } from "./theme";
/**
* Export as Markdown or PDF — used for a single note, a notebook, or everything.
* Export as Markdown, PDF or PNG.
*
* The formats are deliberately both offered rather than one being chosen for
* you: Markdown keeps the notes editable somewhere else, PDF keeps them looking
* the way they look here. Drawings only survive in PDF and as a PNG beside the
* Markdown, which the dialog says out loud rather than leaving to be discovered.
* A single note is asked two questions — what should come along, then in what
* form — because the two are not independent: Markdown is text only, a PNG is
* the drawing only, and PDF is the one format that holds both. Offering all
* combinations flat would list options that cannot exist.
*
* A selection of several notes skips the first question; drawings there always
* follow the format's own rule.
*/
export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
const single = notes.length === 1;
const [parts, setParts] = useState(single ? null : "both");
const [hasDrawing, setHasDrawing] = useState(null); // null = still looking
const [busy, setBusy] = useState(null);
const [progress, setProgress] = useState(null);
const [err, setErr] = useState("");
@@ -33,33 +46,44 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
[dark]
);
// A note without a drawing has nothing to choose, so the first step is skipped
// rather than shown with two thirds of it disabled.
useEffect(() => {
if (!single) return;
let cancelled = false;
noteHasDrawing(notes[0]).then((yes) => {
if (cancelled) return;
setHasDrawing(yes);
if (!yes) setParts("text");
});
return () => {
cancelled = true;
};
}, [single, notes]);
useEffect(() => {
const onKey = (e) => e.key === "Escape" && !busy && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [busy, onClose]);
const single = notes.length === 1;
const run = async (kind) => {
setErr("");
setDone("");
setBusy(kind);
setProgress(null);
try {
if (kind === "markdown") {
if (kind === "png") {
await exportDrawingAsPng(notes[0]);
setDone("Zeichnung wurde als PNG heruntergeladen.");
} else if (kind === "markdown") {
if (single) await exportNoteAsMarkdown(notes[0]);
else await exportNotesAsZip({ label, notes, notebooks, onProgress: setProgress });
setDone(single ? "Datei wurde heruntergeladen." : "Archiv wurde heruntergeladen.");
} else {
await exportNotesAsPdf(notes, label, setProgress);
await exportNotesAsPdf(notes, label, setProgress, parts || "both");
setDone("Druckansicht war geöffnet — wähle dort „Als PDF sichern“.");
}
setDone(
kind === "markdown"
? single
? "Datei wurde heruntergeladen."
: "Archiv wurde heruntergeladen."
: "Druckansicht war geöffnet — wähle dort „Als PDF sichern“."
);
} catch (e) {
setErr(e?.message || "Export fehlgeschlagen");
}
@@ -67,29 +91,30 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
setProgress(null);
};
const option = (kind, title, lines) => (
const card = (onClick, active, title, lines, disabled = false) => (
<button
onClick={() => run(kind)}
disabled={!!busy}
key={title}
onClick={onClick}
disabled={disabled || !!busy}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
width: "100%",
padding: "14px 16px",
padding: "13px 16px",
borderRadius: 12,
border: `1px solid ${T.border}`,
background: T.surface,
cursor: busy ? "default" : "pointer",
border: `1px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "14" : T.surface,
cursor: disabled || busy ? "default" : "pointer",
textAlign: "left",
opacity: busy && busy !== kind ? 0.45 : 1,
opacity: disabled ? 0.4 : busy && busy !== active ? 0.5 : 1,
marginBottom: 10,
fontFamily: FONT_UI,
}}
>
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{busy === kind ? "Läuft…" : title}</span>
{lines.map((l, i) => (
<span style={{ fontSize: 14, fontWeight: 600, color: T.text }}>{title}</span>
{lines.filter(Boolean).map((l, i) => (
<span key={i} style={{ fontSize: 12, color: T.muted, lineHeight: 1.5 }}>
{l}
</span>
@@ -97,6 +122,84 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</button>
);
/* ── step one: what should come along ── */
const stepParts = () => (
<>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>Was soll mit?</div>
{card(() => setParts("both"), parts === "both", "Notiz mit Zeichnung", [
"Text und Zeichnung in einem Dokument — nur als PDF möglich.",
])}
{card(() => setParts("text"), parts === "text", "Nur den Notiztext", [
"Ohne die Zeichnung. Als PDF oder als Markdown.",
])}
{card(() => setParts("drawing"), parts === "drawing", "Nur die Zeichnung", [
"Als PNG-Bild oder als PDF-Seite.",
])}
</>
);
/* ── step two: in what form ── */
const stepFormat = () => {
const options = [];
if (parts !== "drawing") {
options.push(
card(
() => run("markdown"),
busy === "markdown",
single ? "Markdown (.md)" : "Markdown-Archiv (ZIP)",
[
single
? "Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken kommen mit."
: "Je Notiz eine .md-Datei, dazu Bilder und ein Inhaltsverzeichnis.",
parts === "both" ? "Die Zeichnung liegt als PNG daneben — Markdown kann sie nicht halten." : null,
]
)
);
}
if (parts === "drawing") {
options.push(
card(() => run("png"), busy === "png", "PNG-Bild (.png)", [
"Die Zeichnung in voller Auflösung, auf weißem Grund.",
])
);
}
options.push(
card(() => run("pdf"), busy === "pdf", "PDF", [
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
parts === "both" ? "Die Zeichnung steht mit im Dokument." : null,
])
);
return (
<>
{single && hasDrawing && (
<button
onClick={() => setParts(null)}
disabled={!!busy}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: 0,
marginBottom: 12,
border: "none",
background: "transparent",
cursor: busy ? "default" : "pointer",
color: T.accent,
fontSize: 12,
fontWeight: 600,
}}
>
{I.back} Zurück
</button>
)}
<div style={{ fontSize: 12, color: T.muted, marginBottom: 10 }}>In welcher Form?</div>
{options}
</>
);
};
const subtitle = single ? label : `${label} · ${notes.length} Notiz${notes.length === 1 ? "" : "en"}`;
return (
<div
style={{
@@ -134,12 +237,21 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
borderBottom: `1px solid ${T.border}`,
}}
>
<div>
<div style={{ minWidth: 0 }}>
<h3 style={{ margin: 0, fontSize: 17, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
Exportieren
</h3>
<div style={{ fontSize: 12, color: T.muted, marginTop: 2 }}>
{single ? label : `${label} · ${notes.length} Notizen`}
<div
style={{
fontSize: 12,
color: T.muted,
marginTop: 2,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{subtitle}
</div>
</div>
<button
@@ -155,6 +267,7 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
background: "transparent",
cursor: busy ? "default" : "pointer",
color: T.muted,
flexShrink: 0,
}}
>
{I.x}
@@ -162,19 +275,16 @@ export function ExportDialog({ notes, notebooks, label, dark, onClose }) {
</div>
<div style={{ padding: "16px 22px 8px" }}>
{option("markdown", single ? "Als Markdown" : "Als Markdown-Archiv (ZIP)", [
single
? "Eine .md-Datei — Bilder und Zeichnung kommen als ZIP mit, falls vorhanden."
: "Je Notiz eine .md-Datei, dazu Bilder, Zeichnungen und ein Inhaltsverzeichnis.",
"Bleibt anderswo bearbeitbar. Tabellen und Aufgabenhaken bleiben erhalten.",
])}
{option("pdf", "Als PDF", [
"Öffnet den Druckdialog — dort „Als PDF sichern“ wählen.",
"Zeichnungen sind hier mit im Dokument.",
])}
{single && hasDrawing === null ? (
<div style={{ fontSize: 13, color: T.muted, padding: "12px 0" }}>Notiz wird gelesen…</div>
) : parts === null ? (
stepParts()
) : (
stepFormat()
)}
</div>
{(progress || err || done) && (
{parts !== null && (progress || err || done) && (
<div style={{ padding: "0 22px 8px" }}>
{progress && (
<div style={{ fontSize: 12, color: T.muted }}>
+14 -1
View File
@@ -332,7 +332,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 |
| `export` | 12 | the downloaded bytes are real Markdown and a real ZIP, unpacked and read back |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
Each suite creates a throwaway account through the admin API and deletes it
afterwards, so runs leave the database as they found it. They need an admin to
@@ -485,6 +485,19 @@ Notes should not be hostage to this program, so everything can leave it — from
single note up to every note in the account, reachable from the note's toolbar,
a notebook's context menu, and Account → *Alle Notizen exportieren*.
A single note is asked two questions rather than one, because the answers are
not independent — Markdown holds text only, a PNG holds the drawing only, and
PDF is the one format that holds both:
| Was soll mit? | In welcher Form? |
| --- | --- |
| Notiz mit Zeichnung | PDF *(Markdown puts the drawing in a PNG beside it)* |
| Nur den Notiztext | PDF · Markdown |
| Nur die Zeichnung | PNG · PDF |
A note without a drawing skips the first question instead of showing it with two
thirds greyed out.
**Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code,
tables and task state all have equivalents and are carried over; a link to
another exported note becomes a relative link to its file, and a link to a note