Steven
Steven9 min leestijd

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.

Engineering
Electron
Desktop
GeekBye Releases
Een meeting naar PDF printen zonder PDF-bibliotheek

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:

  1. 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.
  2. Schrijft die HTML naar een tijdelijk bestand in os.tmpdir().
  3. Laadt het bestand in een verborgen venster — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Wacht tot de afbeeldingen geladen zijn, en roept dan win.webContents.printToPDF({ printBackground: true, margins: { … } }) aan.
  5. Slaat het resultaat op in app.getPath('downloads') als Meeting-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 afbeeldingen const 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

  1. Een browser is een PDF-renderer die je al hebt. Als je document gestructureerde tekst en afbeeldingen is, geeft printToPDF over een verborgen BrowserWindow je 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.
  2. 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).
  3. 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.

Gerelateerde Artikelen

De twee faalmodi van een click-through-overlay
Steven
Steven9 min leestijd

De twee faalmodi van een click-through-overlay

GeekBye's venster zweeft boven alles en laat je klikken erdoorheen vallen — behalve waar het knoppen heeft. Dat is een tweezijdig contract, en v1.8.5 en v1.8.14 laten zien hoe het eruitziet als elke kant breekt: één release waarin de overlay een systeemdialoog opslokte, één waarin hij je toetsaanslagen stal. De winnende fix voor de tweede was code verwijderen.

Engineering
Electron
Desktop
Geboren en gefixt in twaalf minuten
Steven
Steven8 min leestijd

Geboren en gefixt in twaalf minuten

De changelog van GeekBye v1.8.10 zegt dat het een crash verhielp tijdens het bewerken van sneltoetsen. Dat klopt — maar de crash werd geïntroduceerd en verholpen in dezelfde pull request, twaalf minuten uit elkaar, en bereikte nooit een enkele gebruiker. Het echte verhaal is de betrouwbaarheidscascade die hem voortbracht: een kleine, correcte wijziging aan wanneer sneltoetsen worden opgeschort, en de React-teardown-bug die uit één regel viel die bedoeld was om de editor veiliger te maken.

Engineering
React
Desktop
Van OCR naar pixels zonder de fallback te verliezen
Steven
Steven8 min leestijd

Van OCR naar pixels zonder de fallback te verliezen

GeekBye stopte met het lezen van je screenshots via OCR en begon de daadwerkelijke afbeelding naar vision-modellen te sturen — en dat is het makkelijke deel. Het leerzame deel is de optimalisatie die OCR volledig oversloeg voor vision-gebruikers, ongemerkt de fallback naar niet-vision-modellen brak, en teruggedraaid moest worden naar een versie waarin het vangnet parallel draait en niets kost.

Engineering
AI
Desktop