5 Min. Lesezeit

Headless Chrome als Build-Tool – Screenshots ohne npm-Ballast

Headless Chrome als Build-Tool: Screenshots und PDFs aus HTML erzeugen – pixelgenau, ohne npm-Abhängigkeiten. Mit echtem Bash-Script aus der Praxis.

Irgendwann braucht man Bilder aus HTML. Social-Media-Grafiken, die zum Website-Design passen. Ein PDF aus einer gestylten Seite. Eine Vorschaukachel für jeden Blogpost. Der erste Reflex: ein npm-Paket suchen. puppeteer installieren – und schon zieht man 300 MB Chromium-Binary und einen Sack voll Dependencies in ein Projekt, das eigentlich nur ein paar PNGs braucht.

Dabei steht das Werkzeug schon auf fast jedem Rechner: Chrome selbst. Im Headless-Modus ist der Browser ein vollwertiges Render-Tool, das man direkt von der Kommandozeile fährt. Kein npm, keine Abhängigkeiten, kein Build-Schritt. So geht’s.

Die Grundidee

Chrome kann ohne Fenster starten, eine beliebige URL (auch eine lokale file://-Datei) rendern und das Ergebnis als PNG oder PDF speichern. Ein einziger Befehl:

chrome --headless --screenshot=out.png --window-size=1080,1080 \
       "file:///pfad/zu/grafik.html"

Das ist der Kern. Alles Weitere ist Komfort drumherum. Was hier passiert: Chrome lädt die HTML-Datei, rendert sie in ein virtuelles Fenster von 1080×1080 Pixeln und schießt einen Screenshot. Das PNG ist pixelgenau das, was du auch im echten Browser sehen würdest – inklusive Web-Fonts, CSS-Gradients, allem.


Chrome zuverlässig finden

Das erste Praxisproblem: Chrome heißt auf jedem System anders. Auf dem Mac liegt es tief in einem .app-Bundle, unter Linux gibt es google-chrome, chromium oder chromium-browser. Ein robustes Script probiert die Kandidaten der Reihe nach durch:

CHROME=""
for c in \
  "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  "/Applications/Chromium.app/Contents/MacOS/Chromium" \
  "$(command -v google-chrome 2>/dev/null || true)" \
  "$(command -v chromium 2>/dev/null || true)" \
  "$(command -v chromium-browser 2>/dev/null || true)"; do
  if [ -n "$c" ] && [ -x "$c" ]; then CHROME="$c"; break; fi
done

if [ -z "$CHROME" ]; then
  echo "✗ Kein Chrome/Chromium gefunden." >&2
  exit 1
fi

Der erste ausführbare Treffer gewinnt. Das || true verhindert, dass set -e über ein leeres command -v stolpert.


Die Flags, auf die es ankommt

Headless Chrome hat dutzende Optionen, aber eine Handvoll macht den Unterschied zwischen “läuft” und “läuft sauber”:

"$CHROME" --headless \
  --disable-gpu \                       # vermeidet GPU-Probleme im headless-Betrieb
  --no-sandbox \                        # nötig in CI/Containern ohne User-Namespaces
  --force-color-profile=srgb \          # konsistente Farben über Systeme hinweg
  --hide-scrollbars \                   # keine Scrollbalken im Bild
  --default-background-color=00000000 \ # transparenter Hintergrund (RGBA)
  --window-size=1080,1080 \
  --screenshot=out.png \
  "file://$PWD/grafik.html"

Drei davon sind die heimlichen Helden:

  • --force-color-profile=srgb – ohne das rendert Chrome auf einem Mac mit P3-Display andere Farben als auf einem Linux-Server. Dein Markenrot ist dann plötzlich nicht mehr dasselbe Rot. Das Flag zwingt überall denselben Farbraum.
  • --hide-scrollbars – sonst fotografiert Chrome bei knappem Layout die Scrollbalken mit ins Bild.
  • --default-background-color=00000000 – die acht Nullen sind RGBA-Hex für vollständig transparent. Praktisch für Logos und Grafiken mit Freisteller.

Eine Bühne, viele Assets

Jetzt wird es interessant. Statt für jede Grafik eine eigene HTML-Datei zu pflegen, baut man eine Datei, die alle Kacheln enthält – als Galerie zum Anschauen –, und exportiert per URL-Parameter gezielt eine einzelne davon.

Im HTML bekommt jede exportierbare Fläche Metadaten als data-Attribute:

<div class="canvas post" data-name="post_01" data-w="1080" data-h="1080">
  ... Inhalt ...
</div>

Ein kleines Script schaltet beim Export per ?export=N in den Einzelmodus: Es blendet alles aus außer der gewählten Kachel und setzt sie randlos nach oben links.

const params = new URLSearchParams(location.search);

// Ohne ?export: normale Galerie-Ansicht, nichts tun.
if (params.has('export')) {
  const idx = parseInt(params.get('export'), 10);
  const target = document.querySelectorAll('.canvas')[idx];

  // alles weg außer der Zielkachel, randlos
  const style = document.createElement('style');
  style.textContent = `
    body { margin:0 !important; padding:0 !important; }
    .canvas { display:none !important; }
    .canvas.__export { display:flex !important; position:absolute;
                       top:0; left:0; }
  `;
  document.head.appendChild(style);
  target.classList.add('__export');
}

So sieht man im Browser alle Grafiken nebeneinander – aber der Export liefert jede einzeln, pixelgenau in ihrer echten Größe.


Das Ganze automatisieren

Jetzt fügt sich alles zusammen. Das Script liest das gerenderte DOM aus (Chrome kann das mit --dump-dom), zieht aus jedem data-name/data-w/data-h die Maße und exportiert eine Kachel nach der anderen:

URL="file://$PWD/posts.html"

# gerendertes DOM holen, pro .canvas eine Zeile "name<TAB>w<TAB>h"
META="$("$CHROME" --headless --dump-dom "$URL" \
  | grep -oE 'data-name="[^"]+" data-w="[0-9]+" data-h="[0-9]+"' \
  | sed -E 's/.*name="([^"]+)" data-w="([0-9]+)" data-h="([0-9]+)"/\1\t\2\t\3/')"

idx=0
while IFS=$'\t' read -r name w h; do
  "$CHROME" --headless --disable-gpu --no-sandbox \
    --force-color-profile=srgb --hide-scrollbars \
    --window-size="${w},${h}" \
    --screenshot="export/${name}.png" \
    "${URL}?export=${idx}"
  echo "  ✓ ${name}.png (${w}×${h})"
  idx=$((idx+1))
done <<< "$META"

Ein Aufruf, alle Assets fallen raus. Ändert sich das Design, ändert man das HTML/CSS einmal und exportiert neu – kein Bild von Hand nachziehen.

⚠️ Eine Falle aus eigener Erfahrung: Wenn die Kacheln per JavaScript aus einem Array erzeugt werden, taucht im --dump-dom auch der ungerenderte Template-Quelltext im <script> auf – inklusive Platzhaltern wie ${n}. Das grep-Muster muss die rausfiltern, sonst exportiert man eine Datei namens post_${n}.png. Lösung: im Muster das Dollarzeichen ausschließen, etwa data-name="[^"$]+". Nur echte, gerenderte Attribute passieren den Filter.


Und ein PDF?

Derselbe Mechanismus, ein anderes Flag. Statt --screenshot nimmt man --print-to-pdf:

chrome --headless --print-to-pdf=lebenslauf.pdf \
       --no-pdf-header-footer \
       "file://$PWD/cv.html"

Damit wird aus einer druckoptimierten HTML-Seite (siehe @page-CSS und @media print) ein sauberes PDF – wieder ohne ein einziges npm-Paket.


Kurze Übersicht

chrome --headless --screenshot=X.png  → HTML zu PNG
chrome --headless --print-to-pdf=X.pdf → HTML zu PDF

Wichtige Flags:
  --window-size=W,H             Maße der Aufnahme
  --force-color-profile=srgb    konsistente Farben überall
  --hide-scrollbars             keine Balken im Bild
  --default-background-color=0  transparenter Hintergrund (RGBA-Hex)
  --dump-dom                    gerendertes DOM auslesen (für Automatisierung)

Pattern: eine HTML-Datei = Galerie, ?export=N isoliert eine Kachel

Nicht jedes Problem braucht eine npm-Abhängigkeit. Chrome ist ohnehin installiert, sein Headless-Modus ist eine ausgereifte Rendering-Engine, und ein paar Zeilen Bash verwandeln ihn in eine Asset-Pipeline, die man komplett versteht und besitzt. Kein node_modules, kein Versions-Roulette, kein Build-Schritt – nur HTML rein, PNG raus. Manchmal ist das beste Tool das, das schon da ist.