
Ein Meeting ohne PDF-Bibliothek als PDF drucken
GeekBye exportiert ein Meeting als PDF, und nirgends im Code steckt eine PDF-Bibliothek. Es rendert HTML in einem unsichtbaren Browserfenster und druckt es. Diese Wahl ist die ganze Geschichte: sie machte das Feature leicht zu bauen und gab ihm jede Störung, die ein echter Browser hat — ein weißes Aufblitzen, ein URL-Längenlimit und einen Seitenumbruch, der Screenshots in der Mitte durchschnitt. Der Fix für den hässlichsten Bug war eine einzige Zeile CSS.
In GeekByes Button „dieses Meeting als PDF exportieren" versteckt sich ein befriedigender Trick: dahinter steckt keine PDF-Bibliothek. Kein jsPDF, kein pdfmake, kein pdfkit, kein headless puppeteer. Das ganze Feature ist ein versteckter Browser. Die App baut eine HTML-Seite, die das Meeting beschreibt — Transkript-Einträge, Screenshots, Zeitstempel — lädt sie in ein unsichtbares Electron-Fenster und bittet dieses Fenster, sich selbst zu PDF zu drucken. webContents.printToPDF erledigt den Rest. Diese eine architektonische Wahl ist die ganze Geschichte dieses Release, weil sie das Feature billig zu bauen machte und ihm jeden Bug übergab, den ein echter Browser hat.
Das Davor ist dasselbe Release
Normalerweise öffnen diese Beiträge mit „so lief es vorher". Diesmal gibt es kein Vorher. Das Changelog für v1.8.9 liest sich wie Wartung — "fixed page breaks splitting screenshots and filtered empty entries" — als wäre der PDF-Export ein bestehendes Feature, das gepatcht wird. War es nicht. Der erste Commit, der ihn einführt, feat: add PDF export via Electron printToPDF, landet drei Tage in denselben Release-Zyklus hinein, und jeder „Fix" im Changelog ist Iteration derselben Woche an Code, der Stunden alt war. Das Feature wurde geboren, brach und wurde gehärtet, alles innerhalb eines Tags. Das ist es wert, laut ausgesprochen zu werden, weil es die ehrliche Form von vielem beim Ausliefern ist: der geschliffene Punkt in den Release-Notes und die chaotische Geburt der Sache sind häufig dasselbe Release.
Warum überhaupt ein versteckter Browser
Der Mechanismus lohnt sich vor den Bugs zu verstehen, weil die Bugs alle von ihm abstammen. Alles lebt in einer einzigen Datei des Hauptprozesses, electron/ipcHandlers/pdfExportHandler.ts. Wenn der Renderer den IPC 'pdf:export-report' feuert, tut der Handler:
- Ruft
buildReportHtml()auf, um einen vollständigen HTML-String aus der Timeline des Meetings zusammenzusetzen — ein.item-Block pro Transkript-Eintrag oder Screenshot. - Schreibt dieses HTML in eine temporäre Datei in
os.tmpdir(). - Lädt die Datei in ein verstecktes Fenster —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Wartet, bis die Bilder geladen sind, und ruft dann
win.webContents.printToPDF({ printBackground: true, margins: { … } })auf. - Speichert das Ergebnis in
app.getPath('downloads')alsMeeting-Report-<timestamp>.pdf.
Der Reiz ist offensichtlich. Eine PDF-Bibliothek lässt dich alles von Hand positionieren — Text bei (x, y) zeichnen, ihn messen, den Cursor vorrücken, Seitenumbrüche selbst verwalten. Ein Browser tut all das bereits: du schreibst <div>s und CSS, und Chromiums Layout-Engine paginiert für dich. Du lernst keine Zeichen-API; du schreibst eine Webseite. Für ein Dokument, das meist gestylter Text und eingebettete Bilder ist, ist das eine riesige Abkürzung.
Der Haken ist, dass du auch einen Browser geerbt hast, mit all seinen Randfällen. Drei davon tauchten in den ersten vier Tagen auf.
Das weiße Aufblitzen. Ein verstecktes Fenster auf macOS ist nicht zuverlässig versteckt. Die ersten Versuche positionierten es außerhalb des Bildschirms (x: screenWidth + 1000), und es blitzte trotzdem für ein Frame ein weißes Rechteck auf, bevor gedruckt wurde. Der Fix, nach drei Anläufen, war, sich nicht mehr auf die Position oder show: false zu verlassen und opacity: 0 zu setzen — ein völlig transparentes Fenster blitzt nicht auf, selbst wenn der Compositor es kurz zeigt. Der Commit sagt es klar: "show:false with off-screen positioning still flashes on macOS."
Das URL-Längenlimit. Die erste Version bettete das gesamte HTML — Screenshots und alles — als data:-URL ein. Das funktioniert, bis ein Meeting ein paar base64-kodierte Screenshots enthält, ab dem die URL am ~2 MB-Data-URL-Limit von Chromium vorbeisegelt und der Ladevorgang glatt mit ERR_INVALID_URL fehlschlägt. Deshalb schreibt Schritt 2 oben eine temporäre Datei und macht loadFile() statt eine data URL zu laden: ein Dateipfad hat kein Längenlimit. (Diese ~2 MB sind die eine Zahl in dieser ganzen Geschichte, und es ist eine Inline-Schätzung in einem Code-Kommentar, nicht etwas, das jemand gemessen hat — behandle sie als „groß genug, um dagegenzustoßen", nicht als präzise Schwelle.)
Der nicht-escapte Text. Transkript-Text ist beliebiger Nutzerinhalt, und er wurde direkt in den HTML-String gekippt. Jedes Transkript, das zufällig etwas wie <div> oder <script> enthielt, würde die Seite brechen oder Schlimmeres. Also bekam der Handler eine escapeHtml()-Passe über jeden String, den er interpoliert. In dem Moment, in dem dein PDF eine Webseite ist, ist HTML-Injection auch dein Problem.
Die leeren Kästen, die nicht leer waren
Nun der Vorzeige-Bug, weil er der beste ist. Nutzer meldeten exportierte PDFs mit leeren Kästen — Einträge, die einen Zeitstempel und ein Badge zeigten, aber keinen Screenshot, nur Leerraum, wo das Bild sein sollte. Die naheliegende Lesart ist „der Export lässt Bilddaten fallen", und der naheliegende Fix ist „die leeren Einträge herausfiltern". Das Team fügte diesen Filter tatsächlich hinzu — dazu gleich mehr — aber er war nicht die echte Ursache.
Die echte Ursache war die Pagination. Jeder Eintrag ist ein .item-Container, der einen Zeitstempel, ein Badge und ein Bild hält. Chromium war beim Drucken zu PDF gerne bereit, eine Seite mitten in einem Item umzubrechen — den Zeitstempel und das Badge am Fuß einer Seite zu rendern und das Bild auf die nächste zu schieben. Was wie ein leerer Kasten aussah, war die obere Hälfte eines Items, dessen Bild auf die folgende Seite übergelaufen war. Die Daten waren alle da; das Layout hatte sie guillotiniert. Der Commit ist offen über die Fehldiagnose: "The empty boxes in exported PDFs were caused by Chromium's printToPDF splitting .item containers across pages — the time/badge rendered on one page while the image overflowed to the next."
Der Fix ist eine Zeile CSS:
.item {
break-inside: avoid;
}
break-inside: avoid sagt der Layout-Engine, ein Item ganz zu halten — wenn es nicht in den verbleibenden Platz einer Seite passt, das gesamte Item auf die nächste zu verschieben, statt es aufzuteilen. Das ist der Gewinn der Versteckter-Browser-Architektur in Miniatur ausgedrückt: der hässlichste, am meisten gemeldete Bug des Features wurde nicht mit Seitenhöhen-Arithmetik und manuellen addPage()-Aufrufen gefixt, sondern mit einer einzigen CSS-Eigenschaft, die der Browser bereits zu ehren wusste. Du hast keinen Paginierer geschrieben; du hast den geerbten gebeten, sich zu benehmen.
Zwei Schichten des Wirklich-leer-Filterns
Es gab auch wirklich leere Einträge — nur nicht die, die die Kästen verursachten. Leere Duplikate von einem schnellen doppelten Cmd+Enter, Screenshots ohne Vorschau, Transkript-Einträge, die nur Leerzeichen waren. Diese wurden gefiltert, und interessanterweise wurden sie zweimal gefiltert, auf zwei Schichten:
- Im Handler schützt
buildReportHtml()jedes Item —if (!item.content?.trim()) return ''für Text, und für Bilderconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— dann verwirft.filter(Boolean)die Leeren. Dieser letzte Check leistet Doppeldienst: er ist ein Gültigkeitstor und eine kleine Sicherheitsprüfung, dass nur echte Bilddaten die Seite erreichen. - An der Timeline-Quelle, im Renderer, wird dieselbe Leere gefiltert, bevor ein Item überhaupt hinzugefügt wird —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Das schöne Detail in der Historie ist die Methode. Der erste Commit fügte eine logger.debug('PDF timeline item', …)-Schleife hinzu, ausdrücklich „zum Debuggen leerer Einträge". Sobald dieses Logging enthüllte, woher die Leerstellen wirklich kamen — inklusive eines separaten Screenshot-Dedup-Bugs, bei dem schnelle Aufnahmen leere Duplikate erzeugten — fixte der Folge-Commit die Grundursache stromaufwärts und entfernte das Debug-Logging. Instrumentierung hinzufügen, um die Ursache zu finden, die Ursache fixen, die Instrumentierung löschen. Der Changelog-Punkt "filtered empty entries" enthält still diesen ganzen Hinzufügen-dann-Entfernen-Bogen.
v1.8.12: „excludes AI responses", lies den Diff
Drei Releases später eine weitere Änderung — und sie ist eine gute Lektion darüber, den Code statt des Changelogs zu lesen. Die Notiz zu v1.8.12 sagt, der PDF-Export „excludes AI responses for cleaner meeting records". Wahr, aber unvollständig.
Die Export-Timeline etikettiert jedes Item mit einem type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Eine gesprochene Meeting-Zeile ist ein transcript; eine Frage, die du ins Assistenten-Panel getippt hast, ist ein chat_user; die Antwort der KI ist ein chat_assistant. Die v1.8.12-Änderung ist eine neunzeilige Bearbeitung an einer Renderer-Datei, und die maßgebliche Zeile ist ein Filter, platziert direkt bevor das Export-Payload gebaut wird:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Das ist eine Allowlist, keine Blocklist. Sie entfernt nicht die chat_assistant-Items; sie behält nur transcript und screenshot und verwirft alles andere — was heißt, sie streicht die eigenen chat_user-Fragen des Nutzers zusammen mit den Antworten der KI. Der Commit-Text ist genauer als das Changelog: "PDF export keeps only transcripts and screenshots, no chat messages." Der exportierte Datensatz ist nun rein das Meeting — was gesagt wurde und was auf dem Bildschirm war — mit der gesamten Assistenten-Seitenpanel-Konversation daraus entfernt.
Und das ist produktseitig die richtige Entscheidung. GeekByes Chat ist ein privater Assistent, den du während eines Calls konsultierst — frag ihn nach einem Screenshot, bekomm eine schnelle Erklärung. Diese Austausche sind dein Notizzettel, kein Teil des Meetings. Ein Meeting-Datensatz, den du mit jemandem teilen würdest, sollte das Meeting enthalten, nicht deine Nebengespräche mit einer KI darüber. Die Allowlist macht „das Meeting" schlicht zur wörtlichen Definition dessen, was exportiert wird.
Drei Dinge, die uns dieses Release lehrte
- Ein Browser ist ein PDF-Renderer, den du schon hast. Wenn dein Dokument strukturierter Text und Bilder ist, gibt dir
printToPDFüber ein verstecktesBrowserWindowechte Pagination, echtes Font-Rendering und CSS-Layout gratis — keine PDF-Zeichen-API. Du tauschst eine Bibliotheks-Abhängigkeit gegen Browser-Randfälle, was oft der bessere Tausch ist. - Der Bug, den du siehst, und der Bug, der existiert, sind nicht immer derselbe. „Leere Kästen" schrie fehlende Daten; die Ursache war ein aufgeteilter Container. Instrumentiere, bevor du annimmst — das Debug-Logging des Teams ist das, was einen plausiblen-aber-falschen Fix (Leere filtern) in den echten (
break-inside: avoid) verwandelte. - Das Changelog ist eine Zusammenfassung; der Filter ist die Wahrheit. „Excludes AI responses" ist eine Allowlist, die auch deine eigenen Chat-Fragen verwirft. Wenn eine Release-Notiz eine Verhaltensänderung beschreibt, wird der Diff dir ihre genauen Kanten sagen — und hier ist die genaue Kante „nur das Meeting überlebt".
Für das vorige Kapitel der v1-Geschichte, von OCR zu Pixeln, ohne den Fallback zu verlieren (v1.8.6–v1.8.7); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.