Client-seitiges Markdown zu PDF: Von html2pdf.js zum nativen Browser-Druck
Markdown im Browser in PDF umzuwandeln klingt einfach, steckt aber voller Tücken. Dieser Artikel dokumentiert die technischen Entscheidungen, die Architekturentwicklung und die Implementierungsdetails hinter md-to.coms PDF-Export — von unserem ersten Versuch mit html2pdf.js bis zur endgültigen Migration zum nativen Browser-Druck, einschließlich aller Lektionen auf dem Weg.
Drei Wege zur clientseitigen PDF-Erzeugung
Unter der Einschränkung eines reinen Frontends (kein Server) gibt es drei Hauptansätze zur PDF-Erzeugung:
| Ansatz | Mechanismus | Bibliotheken |
|---|---|---|
| Serverseitiges Rendering | Headless Browser in Node.js starten, HTML rendern und PDF exportieren | Puppeteer, Playwright, wkhtmltopdf |
| Clientseitige JS-Erzeugung | HTML im Browser in Canvas rendern, dann in PDF konvertieren | html2pdf.js, jsPDF + html2canvas |
| Nativer Browser-Druck | window.print() aufrufen, Benutzer wählt „Als PDF speichern” im Druckdialog | Keine Drittanbieter-Bibliothek nötig |
md-to.com ist eine vollständig statische Website, bei der alle Konvertierungen lokal im Browser stattfinden, ohne Backend-Abhängigkeit. Das schließt serverseitige Ansätze vollständig aus und lässt uns zwischen html2pdf.js und nativem Browser-Druck wählen.
Version 1: Das html2pdf.js-Experiment und warum wir es aufgaben
Anfänglicher Ansatz
Wir haben zunächst html2pdf.js eingesetzt, das folgendermaßen funktioniert:
- HTML-Elemente an html2canvas übergeben, um sie als Canvas-Bitmap zu rendern
- jsPDF verwenden, um das Canvas in mehrseitige PDFs zu zerlegen
- Browser-Download auslösen
Die Kernkonfiguration:
const opt = {
margin: [0.5, 0.5],
filename: pdfFilename,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['pre', 'code', 'blockquote', 'img', '.katex-display',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
},
};
await html2pdf().set(opt).from(wrapper).save();
Die pagebreak.avoid-Konfiguration weist html2pdf.js an, innerhalb dieser Elemente keine Seitenumbrüche vorzunehmen — theoretisch sollte dies das Abschneideproblem lösen.
Aufgetretene Probleme
Der reale Einsatz offenbarte mehrere kritische Probleme:
1. Bitmap-Ausgabe — Text ist nicht auswählbar
html2pdf.js macht im Grunde „HTML → Canvas-Screenshot → zu PDF zusammenfügen”. Jede Seite ist ein JPEG-Bild. Text im PDF kann weder ausgewählt noch durchsucht werden, und alle Hyperlinks gehen verloren. Für technische Dokumentation ist das ein Ausschlusskriterium.
2. Seitenumbruch-Abschneidung schwer zu beheben
Während pagebreak.avoid einfache Fälle behandelt, schneidet html2pdf.js bei Codeblöcken, die eine Seite überschreiten, oder verschachtelten Listen, die über Seiten gehen, direkt durch die Mitte der Elemente — weil es auf Pixelhöhen-Basis berechnet, anders als eine Browser-Layout-Engine, die Inhaltsstrukturen versteht.
3. Leistungs- und Speicherprobleme
html2canvas muss den gesamten DOM-Baum in ein Canvas neu rendern. Bei langen Dokumenten (10+ Seiten) verbraucht dies enorme Mengen an Speicher und kann auf mobilen Geräten sogar die Seite zum Absturz bringen.
Version 2: Nativer Browser-Druck
Warum nativer Druck
Die Druckfunktion des Browsers ruft die Layout-Engine des Betriebssystems auf und erzeugt Vektor-PDFs — Text ist auswählbar und durchsuchbar, Hyperlinks bleiben klickbar und Dateigrößen sind kleiner.
| Dimension | html2pdf.js | Nativer Browser-Druck |
|---|---|---|
| Ausgabeformat | Bitmap (Canvas-Screenshot) | Vektor (Text auswählbar/durchsuchbar) |
| Seitenumbruch | JS-Pixelberechnung, anfällig für Abschneidung | CSS page-break-*, von Browser-Engine gehandhabt |
| Hyperlinks | Verloren | Erhalten |
| Leistung | Hoher Speicherverbrauch beim Canvas-Rendering | Ruft System-Druckkern auf, extrem schnell |
| Wartbarkeit | Bedenklich | Native Browser-Fähigkeit, zukunftssicher |
Der einzige „Nachteil” ist, dass Benutzer manuell „Als PDF speichern” im Druckdialog wählen müssen — dies erfordert gute UI-Anleitung.
Kernarchitektur: Overlay + IFrame
Die Gesamtarchitektur hat drei Schichten:
┌──────────────────────────────────┐
│ Toolbar │ ← Druck-Button, Abbrechen-Button
├──────────────────────────────────┤
│ │
│ ┌────────────────────┐ │
│ │ │ │
│ │ IFrame (Vorschau)│ │ ← Mit gestyltem HTML befüllt
│ │ width: 8.5in │ │
│ │ │ │
│ └────────────────────┘ │
│ │
│ Overlay (Vollbild) │ ← z-index: 70
└──────────────────────────────────┘
Arbeitsablauf
- Benutzer klickt auf „PDF herunterladen”
showPdfOverlay()wird aufgerufen und erstellt ein Vollbild-Overlay- Dynamischer Import des Vorlagensystems und Abruf der aktiven Vorlage
- Aufruf von
generatePrintStyles(template)zur Erzeugung vollständiger Druckstile - Erstellung eines IFrames und Einschreiben von Stilen + HTML-Inhalt
- Benutzer prüft das Ergebnis im Overlay, dann klickt er „Drucken / Als PDF speichern”
iframe.contentWindow.print()löst den System-Druckdialog aus
Warum ein IFrame
Das direkte Aufrufen von window.print() auf der aktuellen Seite würde die gesamte Seite drucken (einschließlich Editor, Seitenleiste und anderer UI). Ein IFrame ermöglicht es uns:
- Druckinhalt nur auf die gerenderte Markdown-Ausgabe zu beschränken
- Unabhängige Druckstile einzufügen, ohne die Hauptseite zu beeinflussen
- WYSIWYG-Vorschau bereitzustellen — was Sie im IFrame sehen, wird gedruckt
Kern-Implementierung:
const styles = generatePrintStyles(template);
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
${styles}
</head>
<body>
${currentHtml}
</body>
</html>
`);
doc.close();
}
Die IFrame-Breite ist auf 8.5in (Letter-Papierbreite) gesetzt, die Höhe wird dynamisch basierend auf dem Inhalt berechnet:
setTimeout(() => {
const bodyHeight = doc.body.scrollHeight;
iframe.style.height = Math.max(bodyHeight + 40, 800) + 'px';
}, 100);
CSS-Seitenumbruch-Strategien im Detail
Der Kern des nativen Druckansatzes liegt im CSS. @media print-Regeln weisen die Browser-Layout-Engine an, unserer Absicht beim Seitenumbruch zu folgen.
Inhaltsabschneidung verhindern
@media print {
tr, pre, blockquote, img, .katex-display {
page-break-inside: avoid;
break-inside: avoid;
}
}
Die Verwendung beider Syntaxen — page-break-inside (Legacy) und break-inside (modern) — gewährleistet maximale Kompatibilität. Die abgedeckten Elemente umfassen:
pre: Codeblöckeblockquote: Blockzitateimg: Bilder.katex-display: Mathematische Formelblöcketr: Tabellenzeilen
Tabellen über Seiten, aber Kopfzeilen wiederholen
@media print {
table {
page-break-inside: auto;
}
thead {
display: table-header-group;
}
}
Das ist eine clevere Kombination: table darf über Seiten gehen (da Tabellen sehr lang sein können), aber thead mit table-header-group lässt den Browser die Tabellenkopfzeile oben auf jeder Seite wiederholen. Äußerst nützlich für lange Tabellen.
Überschriften nicht am Seitenende verwaisen lassen
@media print {
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
}
Wenn eine Überschrift am Seitenende erscheint und der Fließtext erst auf der nächsten Seite beginnt, leidet das Leseerlebnis. page-break-after: avoid weist den Browser an: keinen Seitenumbruch nach einer Überschrift — die Überschrift und der folgende Inhalt sollen auf derselben Seite bleiben.
Hintergrundfarben beibehalten
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
Browser drucken standardmäßig keine Hintergrundfarben (um Tinte zu sparen). Diese beiden CSS-Zeilen erzwingen die Beibehaltung der Hintergrundfarben — entscheidend für Codeblock-Hintergründe, Tabellen-Zebrastreifen und Blockzitat-Hintergründe. Benutzer müssen im Druckdialog noch „Hintergrundgrafiken” aktivieren, damit dies wirksam wird.
Vorlagensystem und dynamische Stilerzeugung
md-to.com bietet über 20 Dokumentvorlagen, jede mit einem vollständigen Satz visueller Parameter:
interface DocumentTemplate {
fonts: { body: string; heading: string; code: string };
fontSizes: { body: number; h1: number; h2: number; /* ... */ code: number };
colors: {
text: string; heading: string; link: string;
codeBackground: string; codeText: string; codeBorder: string;
quoteBorder: string; quoteBackground: string; quoteText: string;
tableBorder: string; tableHeaderBg: string; tableHeaderText: string;
tableRowOdd: string; tableRowEven: string;
};
spacing: {
lineHeight: number;
headingBefore: number; headingAfter: number;
paragraphBefore: number; paragraphAfter: number;
};
}
Die Funktion generatePrintStyles(template) fügt Vorlagenparameter in eine CSS-Template-Zeichenkette ein und erzeugt ein vollständiges <style>-Tag. Das bedeutet, dass derselbe Markdown-Inhalt beim Wechsel der Vorlage ein völlig anderes PDF erzeugt — Schriften, Farben und Abstände folgen der Vorlage.
Code-Hervorhebungsstile (highlight.js) werden ebenfalls in das generierte CSS eingebettet, sodass Codeblöcke im PDF die Syntaxfärbung beibehalten.
Intelligente Dateinamen-Erzeugung
Heruntergeladene PDF-Dateinamen sind kein generisches download.pdf. Die Funktion getDownloadFileName() extrahiert die erste Überschrift aus dem Markdown-Quelltext:
export function getDownloadFileName(
mdText: string,
extension: string,
fallbackPrefix: string = 'markdown-export',
): string {
const lines = mdText.split(/\r?\n/);
const headingLine = lines.find((line) => /^\s{0,3}#{1,6}\s+/.test(line));
if (headingLine) {
const raw = headingLine
.replace(/^\s{0,3}#{1,6}\s+/, '')
.replace(/\s+#*\s*$/, '');
const cleaned = sanitizeFilename(raw);
if (cleaned) return `${cleaned}.${extension}`;
}
return `${fallbackPrefix}-${getDateStamp()}.${extension}`;
}
Die Logik:
- Markdown-Text zeilenweise aufteilen und die erste Zeile im Format
# Überschriftfinden #-Präfix und abschließende#-Dekorationen entfernen- Ungültige Zeichen (
\ / : * ? " < > |) übersanitizeFilename()bereinigen, Leerzeichen normalisieren und auf 80 Zeichen kürzen - Wenn keine Überschrift gefunden wird, auf das Format
markdown-to-pdf-20260311.pdfzurückfallen
Beispiel: Wenn ein Dokument mit # Technischer Projektvorschlag beginnt, wird die heruntergeladene Datei Technischer Projektvorschlag.pdf genannt.
Gelernte Lektionen
1. html2pdf.js Button-Status-Bug
Nachdem html2pdf.js einen Download abgeschlossen hatte, wurde der disabled-Status des Buttons nicht auf false zurückgesetzt. Nach dem ersten erfolgreichen Download blieb der Button ausgegraut. Der Erfolgspfad stellte nur den Button-Text wieder her, nicht das disabled-Attribut:
// Erfolgspfad — fehlend: btnDownloadPdf.disabled = false
await html2pdf().set(opt).from(wrapper).save();
showToast(texts.downloadStarted);
btnDownloadPdf.innerText = texts.downloadPdf;
Während der Fehlerpfad es korrekt handhabte:
// Fehlerpfad — korrekt wiederhergestellt
btnDownloadPdf.disabled = false;
btnDownloadPdf.innerText = texts.downloadPdf;
2. innerHTML verursacht XSS-Risiko
Eine frühere Version verwendete tipText.innerHTML = texts.tip zum Einfügen von Tooltip-Text. Obwohl texts.tip aus der i18n-Konfiguration stammt und nicht aus Benutzereingaben, ist innerHTML an sich eine gefährliche API. Nach Code-Review wurde es zu textContent geändert:
// Vorher: tipText.innerHTML = texts.tip;
tipText.textContent = texts.tip;
3. z-index-Missbrauch
Der z-index des Overlays war anfangs auf 99999 gesetzt, die Toolbar auf 100000. Dieser Brute-Force-Ansatz kollidiert leicht mit anderen Komponenten in komplexen Seiten. Nach der Optimierung wechselten wir zu semantischer Schichtung — das Overlay verwendet z-index: 70, und die Toolbar verwendet position: relative, um sich natürlich über dem Overlay-Inhalt zu stapeln, ohne einen eigenen z-index zu benötigen.
4. Schrittweise Migrationsstrategie
Nach der Migration zum nativen Druck haben wir den html2pdf.js-Code nicht gelöscht. Stattdessen wurde der Button per Kommentar versteckt:
// toolbar.appendChild(btnDownloadPdf); Download hat Bugs, Button vorerst versteckt
toolbar.appendChild(btnPrint);
Die html2pdf.js-Download-Logik, Konfiguration und Event-Handler sind alle erhalten — nur der Einstiegspunkt ist verborgen. Der Vorteil: Falls wir jemals „Ein-Klick-Download” (ohne Benutzerinteraktion im Druckdialog) anbieten müssen, können wir es schnell wiederherstellen.
Benutzer durch Druckeinstellungen führen
Die Benutzererfahrung des nativen Browser-Drucks hängt von den Druckdialog-Einstellungen ab. Klare UI-Anleitung ist essenziell:
- Ziel: „Als PDF speichern” wählen
- Hintergrundgrafiken: Muss aktiviert sein — sonst gehen Codeblock-Hintergründe, Tabellen-Zebrastreifen und Blockzitat-Hintergründe verloren
- Kopf- und Fußzeilen: Deaktivieren — unnötige URL und Datum oben im PDF entfernen
- Papierformat: A4 (international verbreitet) oder Letter (in Nordamerika üblich)
- Ränder: „Standard” wählen — CSS definiert bereits
padding: 20mm
Fazit
Rückblickend auf die gesamte technische Entwicklung:
- html2pdf.js löste das Problem „clientseitige PDF-Erzeugung”, aber die Ausgabequalität (Bitmap, Abschneidung, nicht durchsuchbarer Text) reichte nicht aus
- Nativer Browser-Druck nutzt die Betriebssystem-Layout-Engine für Vektor-PDFs mit CSS-gesteuertem Seitenumbruch und verbessert die Ausgabequalität dramatisch
- Die Overlay + IFrame-Architektur isoliert Druckinhalte von der Seiten-UI und bietet eine WYSIWYG-Vorschau
- CSS-Seitenumbruch-Regeln sind der Kern der Lösung —
page-break-inside: avoid,table-header-groupundpage-break-after: avoidlösen die drei Hauptprobleme: Code-Abschneidung, verlorene Tabellenkopfzeilen und verwaiste Überschriften
Wenn Sie eine ähnliche Frontend-PDF-Exportfunktion bauen, erwägen Sie zuerst den nativen Browser-Druck. Er benötigt keine Drittanbieter-Abhängigkeiten, erzeugt die beste Ausgabequalität und wird nie veralten — denn Drucken ist eine grundlegende Browser-Fähigkeit.
Verwandte Links:
Verwandte Tools
Markdown in PDF umwandeln – Kostenloser Online-Konverter
Markdown in PDF umwandeln – kostenlos online mit 20+ professionellen Vorlagen. Perfekt für Berichte, Dokumentation und Druck. Ohne Anmeldung, lokal im Browser.
Markdown in LaTeX umwandeln – Kostenloser Online-Konverter
Markdown in LaTeX umwandeln – kostenlos online. MD-Dokumente in TeX-Format für akademische Arbeiten und technische Dokumente transformieren. Ohne Anmeldung.