Zurück zum Blog

Client-seitiges Markdown zu PDF: Von html2pdf.js zum nativen Browser-Druck

MD-TO Team

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:

AnsatzMechanismusBibliotheken
Serverseitiges RenderingHeadless Browser in Node.js starten, HTML rendern und PDF exportierenPuppeteer, Playwright, wkhtmltopdf
Clientseitige JS-ErzeugungHTML im Browser in Canvas rendern, dann in PDF konvertierenhtml2pdf.js, jsPDF + html2canvas
Nativer Browser-Druckwindow.print() aufrufen, Benutzer wählt „Als PDF speichern” im DruckdialogKeine 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:

  1. HTML-Elemente an html2canvas übergeben, um sie als Canvas-Bitmap zu rendern
  2. jsPDF verwenden, um das Canvas in mehrseitige PDFs zu zerlegen
  3. 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.

Dimensionhtml2pdf.jsNativer Browser-Druck
AusgabeformatBitmap (Canvas-Screenshot)Vektor (Text auswählbar/durchsuchbar)
SeitenumbruchJS-Pixelberechnung, anfällig für AbschneidungCSS page-break-*, von Browser-Engine gehandhabt
HyperlinksVerlorenErhalten
LeistungHoher Speicherverbrauch beim Canvas-RenderingRuft System-Druckkern auf, extrem schnell
WartbarkeitBedenklichNative 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

  1. Benutzer klickt auf „PDF herunterladen”
  2. showPdfOverlay() wird aufgerufen und erstellt ein Vollbild-Overlay
  3. Dynamischer Import des Vorlagensystems und Abruf der aktiven Vorlage
  4. Aufruf von generatePrintStyles(template) zur Erzeugung vollständiger Druckstile
  5. Erstellung eines IFrames und Einschreiben von Stilen + HTML-Inhalt
  6. Benutzer prüft das Ergebnis im Overlay, dann klickt er „Drucken / Als PDF speichern”
  7. 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öcke
  • blockquote: Blockzitate
  • img: Bilder
  • .katex-display: Mathematische Formelblöcke
  • tr: 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:

  1. Markdown-Text zeilenweise aufteilen und die erste Zeile im Format # Überschrift finden
  2. #-Präfix und abschließende #-Dekorationen entfernen
  3. Ungültige Zeichen (\ / : * ? " < > |) über sanitizeFilename() bereinigen, Leerzeichen normalisieren und auf 80 Zeichen kürzen
  4. Wenn keine Überschrift gefunden wird, auf das Format markdown-to-pdf-20260311.pdf zurü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:

  1. Ziel: „Als PDF speichern” wählen
  2. Hintergrundgrafiken: Muss aktiviert sein — sonst gehen Codeblock-Hintergründe, Tabellen-Zebrastreifen und Blockzitat-Hintergründe verloren
  3. Kopf- und Fußzeilen: Deaktivieren — unnötige URL und Datum oben im PDF entfernen
  4. Papierformat: A4 (international verbreitet) oder Letter (in Nordamerika üblich)
  5. 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-group und page-break-after: avoid lö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: