
Een meeting naar PDF printen zonder PDF-bibliotheek
GeekBye exporteert een meeting als PDF, en er staat nergens in de code een PDF-bibliotheek. Het rendert HTML in een onzichtbaar browservenster en print dat. Die keuze is het hele verhaal: het maakte de functie makkelijk te bouwen en gaf hem elke storing die een echte browser heeft — een witte flits, een limiet op de URL-lengte, en een pagina-einde dat screenshots doormidden sneed. De fix voor de lelijkste bug was één regel CSS.
Er schuilt een bevredigende truc in GeekBye's knop "exporteer deze meeting als PDF": er zit geen PDF-bibliotheek achter. Geen jsPDF, geen pdfmake, geen pdfkit, geen headless puppeteer. De hele functie is een verborgen browser. De app bouwt een HTML-pagina die de meeting beschrijft — transcript-entries, screenshots, timestamps — laadt die in een onzichtbaar Electron-venster, en vraagt dat venster om zichzelf naar PDF te printen. webContents.printToPDF doet de rest. Die ene architectuurkeuze is het hele verhaal van deze release, want het maakte de functie goedkoop te bouwen en gaf hem elke bug die een echte browser heeft.
Het daarvoor is dezelfde release
Meestal openen deze posts met "zo werkte het vroeger." Deze keer is er geen daarvoor. De changelog voor v1.8.9 leest als onderhoud — "fixed page breaks splitting screenshots and filtered empty entries" — alsof PDF-export een bestaande functie was die een patch kreeg. Dat was het niet. De eerste commit die hem introduceert, feat: add PDF export via Electron printToPDF, landt drie dagen in dezelfde release-cyclus, en elke "fix" in de changelog is iteratie van dezelfde week op code die uren oud was. De functie werd geboren, brak, en werd gehard, allemaal binnen één tag. Dat is het waard om hardop te zeggen, want het is de eerlijke vorm van veel uitbrengen: de gepolijste bullet in de release notes en de rommelige geboorte van het ding zijn vaak dezelfde release.
Waarom überhaupt een verborgen browser
Het mechanisme is de moeite waard om te begrijpen vóór de bugs, want de bugs stammen er allemaal van af. Alles leeft in één main-process-bestand, electron/ipcHandlers/pdfExportHandler.ts. Wanneer de renderer de 'pdf:export-report'-IPC afvuurt, doet de handler het volgende:
- Roept
buildReportHtml()aan om een volledige HTML-string samen te stellen uit de tijdlijn van de meeting — één.item-blok per transcript-entry of screenshot. - Schrijft die HTML naar een tijdelijk bestand in
os.tmpdir(). - Laadt het bestand in een verborgen venster —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Wacht tot de afbeeldingen geladen zijn, en roept dan
win.webContents.printToPDF({ printBackground: true, margins: { … } })aan. - Slaat het resultaat op in
app.getPath('downloads')alsMeeting-Report-<timestamp>.pdf.
De aantrekkingskracht is duidelijk. Een PDF-bibliotheek laat je alles met de hand positioneren — teken tekst op (x, y), meet het, verplaats de cursor, beheer de pagina-einden zelf. Een browser doet dat allemaal al: je schrijft <div>s en CSS, en Chromiums lay-out-engine pagineert voor je. Je leert geen tekenAPI; je schrijft een webpagina. Voor een document dat grotendeels opgemaakte tekst en ingesloten afbeeldingen is, is dat een enorme snelkoppeling.
De keerzijde is dat je ook een browser erfde, met al zijn randgevallen. Drie ervan doken op binnen de eerste vier dagen.
De witte flits. Een verborgen venster op macOS is niet betrouwbaar verborgen. De eerste pogingen positioneerden het buiten beeld (x: screenWidth + 1000), en het flitste nog steeds een witte rechthoek op het scherm voor één frame vóór het printen. De fix, na drie pogingen, was om te stoppen met leunen op positie of show: false en opacity: 0 in te stellen — een volledig transparant venster flitst niet, zelfs niet wanneer de compositor het kort toont. De commit zegt het onomwonden: "show:false with off-screen positioning still flashes on macOS."
De URL-lengtelimiet. De eerste versie zette de hele HTML — screenshots en al — inline als een data:-URL. Dat werkt totdat een meeting een paar base64-gecodeerde screenshots bevat, waarna de URL voorbij Chromiums ~2 MB-data-URL-limiet zeilt en het laden ronduit faalt met ERR_INVALID_URL. Daarom schrijft stap 2 hierboven een tijdelijk bestand en doet loadFile() in plaats van een data-URL te laden: een bestandspad heeft geen lengtelimiet. (Die ~2 MB is het enige getal in dit hele verhaal, en het is een inline-schatting in een codecommentaar, niet iets wat iemand heeft gebenchmarkt — behandel het als "groot genoeg om te raken," geen precieze drempel.)
De niet-ge-escapete tekst. Transcripttekst is willekeurige gebruikersinhoud, en die werd rechtstreeks in de HTML-string gedropt. Elk transcript dat toevallig iets als <div> of <script> bevatte, zou de pagina breken of erger. Dus de handler kreeg er een escapeHtml()-doorloop bij over elke string die hij interpoleert. Op het moment dat je PDF een webpagina is, is HTML-injectie ook jouw probleem.
De lege vakken die niet leeg waren
Nu de kopbug, want het is de beste. Gebruikers meldden geëxporteerde PDF's met lege vakken — entries die een timestamp en een badge toonden maar geen screenshot, alleen lege ruimte waar de afbeelding zou moeten zijn. De voor de hand liggende lezing is "de export laat afbeeldingsdata vallen," en de voor de hand liggende fix is "filter de lege entries eruit." Het team voegde die filter inderdaad toe — daarover zo meer — maar het was niet de echte oorzaak.
De echte oorzaak was paginering. Elke entry is een .item-container met een timestamp, een badge en een afbeelding. Chromium, printend naar PDF, brak gerust een pagina midden in een item — de timestamp en badge onderaan de ene pagina renderend en de afbeelding op de volgende duwend. Wat op een leeg vak leek, was de bovenste helft van een item waarvan de afbeelding naar de volgende pagina was overgelopen. De data was er allemaal; de lay-out had het geguillotineerd. De commit is openhartig over de verkeerde diagnose: "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."
De fix is één regel CSS:
.item {
break-inside: avoid;
}
break-inside: avoid vertelt de lay-out-engine om een item heel te houden — als het niet in de resterende ruimte op een pagina past, verplaats dan het hele item naar de volgende pagina in plaats van het te splitsen. Dit is de opbrengst van de verborgen-browser-architectuur in het klein gesteld: de lelijkste, meest-gemelde bug in de functie werd niet opgelost met pagina-hoogte-rekenwerk en handmatige addPage()-aanroepen, maar met één CSS-eigenschap die de browser al wist te honoreren. Je schreef geen pagineerder; je vroeg degene die je erfde om zich te gedragen.
Twee lagen van echt-lege filtering
Er waren ook echt lege entries — alleen niet degene die de vakken veroorzaakten. Lege duplicaten van een snelle dubbele Cmd+Enter, screenshots zonder preview, transcript-entries die alleen witruimte waren. Deze werden gefilterd, en interessant genoeg werden ze twee keer gefilterd, op twee lagen:
- In de handler bewaakt
buildReportHtml()elk item —if (!item.content?.trim()) return ''voor tekst, en voor afbeeldingenconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— waarna.filter(Boolean)de lege eruit gooit. Die laatste controle doet dubbel werk: het is een validiteitspoort én een kleine veiligheidscheck dat alleen echte afbeeldingsdata ooit de pagina bereikt. - Bij de tijdlijnbron, in de renderer, wordt dezelfde leegheid gefilterd voordat een item ooit wordt toegevoegd —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Het mooie detail in de geschiedenis is de methode. De eerste commit voegde een logger.debug('PDF timeline item', …)-loop toe, expliciet "for debugging empty entries." Zodra die logging onthulde waar de lege plekken werkelijk vandaan kwamen — inclusief een aparte screenshot-deduplicatiebug waarbij snelle opnames lege duplicaten produceerden — verhielp de vervolgcommit de grondoorzaak stroomopwaarts en verwijderde de debug-logging. Voeg instrumentatie toe om de oorzaak te vinden, verhelp de oorzaak, verwijder de instrumentatie. De changelog-bullet "filtered empty entries" bevat stilletjes die hele toevoegen-dan-verwijderen-boog.
v1.8.12: "excludes AI responses," lees de diff
Drie releases later, nog één verandering — en het is een goede les in het lezen van de code in plaats van de changelog. De v1.8.12-notitie zegt dat PDF-export nu "excludes AI responses for cleaner meeting records." Waar, maar onvolledig.
De export-tijdlijn tagt elk item met een type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Een gesproken meeting-regel is een transcript; een vraag die je in het assistent-paneel typte is een chat_user; het antwoord van de AI is een chat_assistant. De v1.8.12-wijziging is een bewerking van negen regels aan één renderer-bestand, en de operatieve regel is een filter die net vóór het bouwen van de export-payload wordt geplaatst:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Dat is een allowlist, geen blocklist. Het verwijdert geen chat_assistant-items; het behoudt alleen transcript en screenshot en laat al het andere vallen — wat betekent dat het de eigen chat_user-vragen van de gebruiker samen met de antwoorden van de AI wegstrijkt. De commit-body is nauwkeuriger dan de changelog: "PDF export keeps only transcripts and screenshots, no chat messages." Het geëxporteerde verslag is nu puur de meeting — wat er werd gezegd en wat er op het scherm stond — met het hele assistent-zijpaneel-gesprek eruit verwijderd.
En dat is de juiste keuze, productmatig. GeekBye's chat is een privé-assistent die je tijdens een gesprek raadpleegt — vraag hem iets over een screenshot, krijg een snelle uitleg. Die uitwisselingen zijn je kladblok, geen onderdeel van de meeting. Een meeting-verslag dat je met iemand zou delen, hoort de meeting te bevatten, niet je zijgesprekken met een AI erover. De allowlist maakt "de meeting" gewoon de letterlijke definitie van wat er geëxporteerd wordt.
Drie dingen die deze release ons leerde
- Een browser is een PDF-renderer die je al hebt. Als je document gestructureerde tekst en afbeeldingen is, geeft
printToPDFover een verborgenBrowserWindowje echte paginering, echte fontrendering, en CSS-lay-out gratis — geen PDF-tekenAPI. Je ruilt een bibliotheekafhankelijkheid in voor browser-randgevallen, wat vaak de betere ruil is. - De bug die je kunt zien en de bug die bestaat, zijn niet altijd dezelfde. "Lege vakken" schreeuwde ontbrekende data; de oorzaak was een gesplitste container. Instrumenteer voordat je aanneemt — de debug-logging van het team is wat een plausibele-maar-verkeerde fix (filter de lege) in de echte veranderde (
break-inside: avoid). - De changelog is een samenvatting; de filter is de waarheid. "Excludes AI responses" is een allowlist die ook je eigen chatvragen laat vallen. Wanneer een release-notitie een gedragswijziging beschrijft, vertelt de diff je de exacte randen ervan — en hier is de exacte rand "alleen de meeting overleeft."
Voor het vorige hoofdstuk in het v1-verhaal, van OCR naar pixels zonder de fallback te verliezen (v1.8.6–v1.8.7); en voor de hele boog, de anatomie van software tot in de perfectie uitbrengen.