Steven
Steven9 Min. Lesezeit

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.

Technik
Electron
Desktop
GeekBye-Releases
Ein Meeting ohne PDF-Bibliothek als PDF drucken

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:

  1. Ruft buildReportHtml() auf, um einen vollständigen HTML-String aus der Timeline des Meetings zusammenzusetzen — ein .item-Block pro Transkript-Eintrag oder Screenshot.
  2. Schreibt dieses HTML in eine temporäre Datei in os.tmpdir().
  3. Lädt die Datei in ein verstecktes Fenster — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Wartet, bis die Bilder geladen sind, und ruft dann win.webContents.printToPDF({ printBackground: true, margins: { … } }) auf.
  5. Speichert das Ergebnis in app.getPath('downloads') als Meeting-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 Bilder const 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

  1. Ein Browser ist ein PDF-Renderer, den du schon hast. Wenn dein Dokument strukturierter Text und Bilder ist, gibt dir printToPDF über ein verstecktes BrowserWindow echte 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.
  2. 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.
  3. 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.

Ähnliche Artikel

Die zwei Fehlermodi eines Click-Through-Overlays
Steven
Steven9 Min. Lesezeit

Die zwei Fehlermodi eines Click-Through-Overlays

GeekByes Fenster schwebt über allem und lässt deine Klicks hindurchfallen — außer dort, wo es Buttons hat. Das ist ein zweiseitiger Vertrag, und v1.8.5 und v1.8.14 sind, wie es aussieht, wenn jede Seite bricht: ein Release, in dem das Overlay einen Systemdialog verschluckte, eines, in dem es deine Tastenanschläge stahl. Der siegreiche Fix für den zweiten war, Code zu löschen.

Technik
Electron
Desktop
Geboren und behoben in zwölf Minuten
Steven
Steven7 Min. Lesezeit

Geboren und behoben in zwölf Minuten

Das Changelog von GeekBye v1.8.10 sagt, es habe einen Crash beim Bearbeiten von Tastaturkürzeln behoben. Hat es — aber der Crash wurde im selben Pull Request eingeführt und behoben, zwölf Minuten auseinander, und erreichte keinen einzigen Nutzer. Die eigentliche Geschichte ist die Zuverlässigkeitskaskade, die ihn hervorbrachte: eine kleine, korrekte Änderung daran, wann Kürzel suspendiert werden, und der React-Teardown-Bug, der aus einer einzigen Zeile fiel, die den Editor sicherer machen sollte.

Technik
React
Desktop
Von OCR zu Pixeln, ohne den Fallback zu verlieren
Steven
Steven8 Min. Lesezeit

Von OCR zu Pixeln, ohne den Fallback zu verlieren

GeekBye hörte auf, deine Screenshots per OCR zu lesen, und begann, das echte Bild an Vision-Modelle zu schicken — was der leichte Teil ist. Der lehrreiche Teil ist die Optimierung, die OCR für Vision-Nutzer komplett übersprang, den Fallback zu Nicht-Vision-Modellen still zerbrach und bis zu einer Version zurückgenommen werden musste, in der das Sicherheitsnetz parallel läuft und nichts kostet.

Technik
AI
Desktop