Es gibt eine Sorte Arbeit, die jeder kennt, der beruflich mit Texten zu tun hat: Man hat etwas geschrieben — eine Anleitung, ein Protokoll, ein Konzept — und jemand hätte es bitte als PDF. Sauber gesetzt, mit Inhaltsverzeichnis, am besten mit den drei anderen Dokumenten zusammen in einer Datei.

Ich schreibe fast alles in Markdown. Das ist ein bewusst schlichtes Textformat: Eine Überschrift beginnt mit einem Rautenzeichen, ein **fett** gesetztes Wort steht zwischen Sternchen, mehr Regeln braucht es im Alltag kaum. Der Vorteil: So ein Text ist auch in zehn Jahren noch lesbar, weil er an kein bestimmtes Programm gebunden ist. Der Nachteil: Niemand möchte ein Protokoll in dieser Rohform zugeschickt bekommen.

Für genau diesen letzten Schritt — vom Rohtext zum fertigen PDF — habe ich mir vor einiger Zeit ein kleines Werkzeug gebaut. Es hieß immer schon md2pdf und lief jahrelang nur auf meinem eigenen Rechner. Seit gestern kann es jeder installieren, weil es öffentlich auf npm liegt: eine Art App-Store für Entwicklerwerkzeuge, über den sich Programme mit einem einzigen Befehl einrichten lassen. Es ist meine erste eigene Veröffentlichung dort.

Dieser Beitrag erzählt beides — was sich durch diesen Schritt geändert hat, und vor allem die zwei Funktionen, die für mich überhaupt der Grund waren, nicht einfach eines der vorhandenen Werkzeuge zu benutzen. Wer nur wissen will, ob das Ding für ihn nützlich ist, kann die beiden Abschnitte zu --merge und den Layout-Variablen lesen und den Rest überspringen. Ab dort wird es allerdings technisch.

Was das Ding überhaupt tut Link zu Überschrift

md2pdf wandelt Markdown in PDF um. Das ist an sich keine Sensation — dafür gibt es md-to-pdf, Pandoc und ein Dutzend andere. Der Unterschied liegt in dem, was drumherum passiert: Ein vorhandenes Inhaltsverzeichnis wird auf den neuesten Stand gebracht, Diagramme werden aus ihrer Textbeschreibung gezeichnet, Bilder werden eingebettet, und am Ende liegt ein PDF neben der Markdown-Datei. Ein Aufruf, keine Rückfragen:

md2pdf README.md

Unter der Haube arbeiten drei bekannte Werkzeuge zusammen — doctoc für das Inhaltsverzeichnis, mermaid-cli für die Diagramme und md-to-pdf für den eigentlichen Satz. md2pdf ist die Pipeline, die sie in der richtigen Reihenfolge aufruft, in einem isolierten temporären Arbeitsverzeichnis, und die im Fehlerfall etwas Verständlicheres ausgibt als einen Stacktrace. Argumente dürfen einzelne Dateien oder ganze Ordner sein, -R nimmt Unterordner mit.

Vom privaten Skript zum installierbaren Paket Link zu Überschrift

Ein Werkzeug, das nur auf dem eigenen Rechner funktioniert, ist kein Werkzeug, sondern eine Gewohnheit. Genau das war md2pdf lange: ein PowerShell-Wrapper um einen Klon des Repositorys, installiert über ein install.ps1, das einen Ordner in den PATH schrieb. Es funktionierte — solange man ich war, auf meinem Rechner, in meiner Ordnerstruktur.

Heute genügt eine Zeile, auf jedem Betriebssystem:

npm install --global @chrtmnn/md2pdf

Danach steht der Befehl md2pdf in jedem Verzeichnis zur Verfügung, unter Windows, macOS und Linux. Voraussetzung ist Node.js ab Version 22.12.

Was nach einer Formalie klingt, war der lehrreichste Teil des Projekts. Sobald ein Werkzeug nicht mehr im eigenen Projektordner startet, stimmen plötzlich Annahmen nicht mehr, die man nie bewusst getroffen hat — allen voran die, gegen welches Verzeichnis eigentlich ein relativer Pfad aufgelöst wird. Die Arbeit am Paket bestand darin, solche Fragen zu beantworten, nicht darin, npm publish zu tippen.

Bleiben die zwei Funktionen, die ich in dieser Kombination sonst nirgends gefunden habe — und die der eigentliche Grund für das Projekt sind.

--merge: ein PDF aus vielen Dateien Link zu Überschrift

Dokumentation lebt selten in einer einzigen Datei. Sie liegt in docs/ verstreut, zerfällt in Kapitel, hat ein CHANGELOG.md am Rand. Wer daraus ein Handbuch machen will, rendert normalerweise einzeln und klebt die PDFs anschließend zusammen — mit dem bekannten Ergebnis: sieben Inhaltsverzeichnisse, sieben Mal Seite 1.

--merge geht den anderen Weg:

md2pdf -R --merge handbook docs CHANGELOG.md

Das Zusammenführen passiert auf der Markdown-Ebene, vor dem Rendern. Alle aufgelösten Dateien werden zu einem temporären Dokument verkettet, und die normale Pipeline läuft genau einmal darüber. Es ist keine PDF-Bibliothek beteiligt, und es wurde dafür auch keine Abhängigkeit hinzugefügt.

Der Effekt ist der entscheidende Punkt: Weil nur ein Dokument gerendert wird, gelten alle anderen Optionen unverändert weiter. Vor allem erzeugt --toc ein Inhaltsverzeichnis über sämtliche Dokumente hinweg — was in der Praxis der häufigste Grund ist, überhaupt zusammenzuführen. Die Seitenzahlen laufen durch, die Links im Inhaltsverzeichnis funktionieren über Dateigrenzen hinweg.

Zwei Details, die mich beim Bauen mehr Zeit gekostet haben, als sie im Text hergeben:

  • Jedes Dokument beginnt auf einer neuen Seite. Der Umbruch kommt aus einer .document-break-Regel im mitgelieferten Stylesheet, nicht aus einer Überschrift — Überschriften haben standardmäßig break-before: auto, darauf ist kein Verlass. Wer das flach haben will, setzt --css-var document-break-before=auto.
  • Relative Bildpfade werden pro Quelldokument aufgelöst. Zwei Kapitel in verschiedenen Ordnern dürfen beide images/logo.png schreiben und bekommen trotzdem jeweils ihr eigenes Bild. Der Moment der Verkettung ist der letzte, in dem die Herkunft eines Abschnitts überhaupt noch bekannt ist.

Eine Einschränkung bleibt ehrlicherweise bestehen: relative Links zwischen den Dokumenten werden nicht umgeschrieben. Links werden beim Rendern nicht aufgelöst, also bleibt ein relativer Link relativ — und zeigt im zusammengeführten PDF unter Umständen ins Leere. md2pdf warnt, sobald die Eingaben aus mehr als einem Ordner stammen.

--css-var: Layout ohne Stylesheet-Bastelei Link zu Überschrift

Die zweite Funktion ist die, die ich selbst am häufigsten benutze. Das mitgelieferte Stylesheet definiert sein gesamtes Layout über CSS Custom Properties — Schriftarten, Seitenränder, Seitenformat, Umbruchverhalten. Und jede dieser Variablen lässt sich pro Aufruf auf der Kommandozeile überschreiben:

md2pdf --css-var page-size=A5 --css-var heading-break-before=page handbook.md

Die führenden Bindestriche sind optional, die Option ist wiederholbar. Die wichtigsten Variablen:

VariableStandardWirkung
--page-sizeA4Seitenformat, z. B. A5 oder A4 landscape
--page-margin-top / -right / -bottom / -left1.6cm / 1.6cm / 1.6cm / 2.4cmSeitenränder einzeln
--font-text"Aptos"Fließtextschrift
--font-code"JetBrains Mono", "Fira Code"Codeschrift
--heading-break-beforeautoSeitenumbruch vor jeder h1/h2
--document-break-beforepageUmbruch zwischen zusammengeführten Dokumenten

Der praktische Gewinn: Für „das gleiche Dokument, aber als A5-Broschüre mit Umbruch vor jedem Kapitel" muss ich kein Stylesheet kopieren, anpassen und irgendwo ablegen. Ein Aufruf, zwei Flags. Und weil ein Tippfehler in einem Variablennamen im Browser einfach ignoriert würde, warnt md2pdf, wenn eine übergebene Variable im verwendeten Stylesheet von keinem var() gelesen wird.

Für alles, was über einzelne Werte hinausgeht, gibt es persönliche Stylesheets in ~/.md2pdf (unter Windows %USERPROFILE%\.md2pdf), die -s über ihren Namen findet:

md2pdf -s letter invite.md      # nutzt ~/.md2pdf/letter.css

Eine default.css in diesem Ordner wird automatisch verwendet, wenn kein -s angegeben ist. Dass das überhaupt zuverlässig funktioniert, war die unerwartet aufwendigste Stelle des ganzen Projekts: md-to-pdf referenziert das Stylesheet nicht per Pfad, sondern liest die Datei und schiebt ihren Inhalt in ein <style>-Tag der gerenderten Seite. Jeder relative @import und jedes url() würde damit gegen die Adresse der Seite aufgelöst statt gegen das Verzeichnis des Stylesheets — und stillschweigend ins Leere laufen. md2pdf macht das Stylesheet deshalb vor dem Rendern selbsttragend: lokale Importe werden rekursiv eingesetzt, lokale url()-Ziele als data:-URI eingebettet, entfernte Importe wie Webfonts an den Anfang gezogen, weil ein Browser ein @import nur vor allen anderen Regeln beachtet.

Das ist die Art Arbeit, die niemand sieht. Sie ist der Unterschied zwischen „funktioniert bei mir" und „funktioniert".

Entstanden mit KI-Unterstützung Link zu Überschrift

Wie schon beim Desert-Bus-Projekt ist der Großteil dieses Codes in Zusammenarbeit mit Claude Code entstanden — allerdings mit einem anderen Arbeitsmodus als damals. Der wichtigste Unterschied: Im Repository liegt eine ausführliche AGENTS.md, die nicht beschreibt, was das Tool kann, sondern warum es so gebaut ist. Warum --basedir auf das Arbeitsverzeichnis zeigen muss. Warum execFileSync niemals eine Kommandozeile als String bauen darf (weil cmd.exe %VAR% selbst in Anführungszeichen expandiert und % in Windows-Dateinamen erlaubt ist). Warum die Fortschrittsanzeige kein Spinner sein kann.

Diese Datei ist im Lauf des Projekts von einer Notiz zur eigentlichen Architekturdokumentation geworden — und der Nebeneffekt ist der interessantere: Sie diszipliniert auch mich. Eine Entscheidung, die sich nicht in drei Sätzen begründen lässt, war meistens keine gute.

Dazu kommt eine Testabdeckung, die ich mir allein vermutlich nicht auferlegt hätte: Die reine Logik — Pfadregeln, Markdown-Scanning, CSS-Parsing — ist konsequent in eigene Module ausgelagert und läuft gegen Node’s eingebauten Test-Runner, ohne Framework-Abhängigkeit und ohne Netzwerk. Der Rest wird von einem Smoke-Test abgedeckt, der das gepackte Paket tatsächlich installiert und ein PDF erzeugt.

Ausblick Link zu Überschrift

Version 0.1.1 ist bewusst eine 0.x: Das Werkzeug tut, was es soll, aber die Kommandozeile darf sich noch bewegen. Auf der Liste stehen unter anderem eine Kopf- und Fußzeilen-Konfiguration, Link-Umschreibung beim Zusammenführen und ein genauerer Blick auf die Startzeit, die aktuell fast vollständig aus dem Chromium-Start besteht.

Wer es ausprobieren möchte:

Rückmeldungen, Fehlerberichte und Verbesserungsvorschläge sind ausdrücklich willkommen — am liebsten als Issue im Repository. Darauf bin ich ehrlich gesagt am meisten gespannt: Ein Werkzeug, das andere installieren können, bekommt Fragen gestellt, an die man selbst nie gedacht hat.