Steven
Steven9 min lesing

Å skrive ut et møte til PDF uten et PDF-bibliotek

GeekBye eksporterer et møte som en PDF, og det finnes ikke noe PDF-bibliotek noe sted i koden. Den renderer HTML i et usynlig nettleservindu og skriver det ut. Det valget er hele historien: det gjorde funksjonen lett å bygge og ga den hver eneste feil en ekte nettleser har — et hvitt glimt, en grense for URL-lengde og et sideskift som kuttet skjermbilder i to. Fiksen for den styggeste buggen var én linje CSS.

Utvikling
Electron
Desktop
GeekBye-releaser
Å skrive ut et møte til PDF uten et PDF-bibliotek

Det gjemmer seg et tilfredsstillende triks i GeekByes "eksporter dette møtet som en PDF"-knapp: det finnes ikke noe PDF-bibliotek bak den. Ingen jsPDF, ingen pdfmake, ingen pdfkit, ingen headless puppeteer. Hele funksjonen er en skjult nettleser. Appen bygger en HTML-side som beskriver møtet — transkripsjonsoppføringer, skjermbilder, tidsstempler — laster den inn i et usynlig Electron-vindu og ber det vinduet om å skrive seg selv ut til PDF. webContents.printToPDF gjør resten. Det ene arkitekturvalget er hele historien om denne releasen, fordi det gjorde funksjonen billig å bygge og rakte den hver eneste bug en ekte nettleser har.

Før er den samme releasen

Vanligvis åpner disse innleggene med "slik virket det før." Denne gangen finnes det ikke noe før. Endringsloggen for v1.8.9 leser som vedlikehold — "fixed page breaks splitting screenshots and filtered empty entries" — som om PDF-eksport var en eksisterende funksjon som blir lappet. Det var den ikke. Den første commiten som introduserer den, feat: add PDF export via Electron printToPDF, lander tre dager inn i den samme releasesyklusen, og hver "fiks" i endringsloggen er iterasjon samme uke på kode som var timer gammel. Funksjonen ble født, gikk i stykker og ble herdet — alt sammen innenfor én tag. Det er verdt å si høyt, fordi det er den ærlige formen på mye levering: det polerte punktet i release-notatene og tingens rotete fødsel er ofte den samme releasen.

Hvorfor i det hele tatt en skjult nettleser

Mekanismen er verdt å forstå før buggene, fordi alle bugs stammer fra den. Alt bor i én fil i hovedprosessen, electron/ipcHandlers/pdfExportHandler.ts. Når rendereren avfyrer 'pdf:export-report'-IPC-en, gjør handleren:

  1. Kaller buildReportHtml() for å sette sammen en full HTML-streng fra møtets tidslinje — én .item-blokk per transkripsjonsoppføring eller skjermbilde.
  2. Skriver den HTML-en til en midlertidig fil i os.tmpdir().
  3. Laster filen inn i et skjult vindu — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Venter på at bildene lastes, og kaller så win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Lagrer resultatet i app.getPath('downloads') som Meeting-Report-<timestamp>.pdf.

Tiltrekningen er åpenbar. Et PDF-bibliotek tvinger deg til å plassere alt for hånd — tegn tekst ved (x, y), mål den, flytt markøren, håndter sideskift selv. En nettleser gjør allerede alt det: du skriver <div>-er og CSS, og Chromiums layoutmotor paginerer for deg. Du lærer ikke et tegne-API; du skriver en nettside. For et dokument som stort sett er stylet tekst og innebygde bilder, er det en enorm snarvei.

Haken er at du også arvet en nettleser, med alle dens kanttilfeller. Tre av dem dukket opp innen de første fire dagene.

Det hvite glimtet. Et skjult vindu på macOS er ikke pålitelig skjult. De første forsøkene plasserte det utenfor skjermen (x: screenWidth + 1000), og det glimtet fortsatt et hvitt rektangel på skjermen i én frame før utskriften. Fiksen, etter tre forsøk, var å slutte å stole på posisjon eller show: false og sette opacity: 0 — et helt gjennomsiktig vindu glimter ikke selv når komposittoren kort viser det. Commiten sier det rett ut: "show:false with off-screen positioning still flashes on macOS."

URL-lengdegrensen. Den første versjonen la hele HTML-en — skjermbilder og alt — inn som en data: URL. Det virker helt til et møte har et par base64-kodede skjermbilder i seg, hvorpå URL-en seiler forbi Chromiums ~2 MB data-URL-grense og innlastingen feiler rett ut med ERR_INVALID_URL. Det er derfor steg 2 ovenfor skriver en midlertidig fil og gjør loadFile() i stedet for å laste en data-URL: en filsti har ingen lengdegrense. (Det ~2 MB er det ene tallet i hele denne historien, og det er et inline-anslag i en kodekommentar, ikke noe noen benchmarket — behandle det som "stort nok til å treffe," ikke en presis terskel.)

Den uescapede teksten. Transkripsjonstekst er vilkårlig brukerinnhold, og det ble sluppet rett inn i HTML-strengen. Enhver transkripsjon som tilfeldigvis inneholdt noe som <div> eller <script> ville bryte siden eller verre. Så handleren fikk en escapeHtml()-gjennomgang over hver streng den interpolerer. I det øyeblikket PDF-en din er en nettside, er HTML-injeksjon også ditt problem.

De tomme boksene som ikke var tomme

Nå hovedbuggen, fordi den er den beste. Brukere rapporterte eksporterte PDF-er med tomme bokser — oppføringer som viste et tidsstempel og et merke, men ingen skjermbilde, bare tomt rom der bildet burde være. Den åpenbare lesningen er "eksporten dropper bildedata," og den åpenbare fiksen er "filtrer bort de tomme oppføringene." Teamet la til det filteret — mer om det om et øyeblikk — men det var ikke den virkelige årsaken.

Den virkelige årsaken var paginering. Hver oppføring er en .item-beholder som holder et tidsstempel, et merke og et bilde. Chromium var, da den skrev ut til PDF, gladelig villig til å bryte en side midt i en oppføring — og renderet tidsstemplet og merket nederst på én side og dyttet bildet over på den neste. Det som så ut som en tom boks, var den øvre halvdelen av en oppføring hvis bilde hadde flommet over til den følgende siden. Dataene var der hele tiden; layouten hadde giljotinert dem. Commiten er ærlig om feildiagnosen: "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."

Fiksen er én linje CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid forteller layoutmotoren at den skal holde en oppføring hel — hvis den ikke får plass i den gjenværende plassen på en side, flytt hele oppføringen til den neste siden i stedet for å dele den. Dette er utbyttet av den skjulte-nettleser-arkitekturen uttrykt i miniatyr: den styggeste, mest rapporterte buggen i funksjonen ble ikke fikset med sidehøyde-aritmetikk og manuelle addPage()-kall, men med en enkelt CSS-egenskap som nettleseren allerede visste hvordan den skulle respektere. Du skrev ingen paginator; du ba den du arvet om å oppføre seg.

To lag med faktisk-tom filtrering

Det fantes genuint tomme oppføringer også — bare ikke de som forårsaket boksene. Tomme duplikater fra et raskt dobbelt Cmd+Enter, skjermbilder uten forhåndsvisning, transkripsjonsoppføringer som bare var mellomrom. Disse ble filtrert, og interessant nok ble de filtrert to ganger, i to lag:

  • I handleren vokter buildReportHtml() hver oppføring — if (!item.content?.trim()) return '' for tekst, og for bilder const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — så dropper .filter(Boolean) de tomme. Den siste sjekken gjør dobbelt arbeid: den er en gyldighetsport og en liten sikkerhetssjekk på at bare ekte bildedata noensinne når siden.
  • Ved tidslinjens kilde, i rendereren, filtreres den samme tomheten før en oppføring i det hele tatt legges til — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

Den fine detaljen i historikken er metoden. Den første commiten la til en logger.debug('PDF timeline item', …)-løkke uttrykkelig "for debugging empty entries." Da den loggingen avslørte hvor de tomme faktisk kom fra — inkludert en separat bug i skjermbilde-deduplisering der raske opptak produserte tomme duplikater — fikset den påfølgende commiten grunnårsaken oppstrøms og fjernet debug-loggingen. Legg til instrumentering for å finne årsaken, fiks årsaken, slett instrumenteringen. Endringsloggens punkt "filtered empty entries" inneholder stille hele den legg-til-så-fjern-buen.

v1.8.12: "excludes AI responses," les diffen

Tre releaser senere, én endring til — og det er en god lekse i å lese koden i stedet for endringsloggen. v1.8.12-notatet sier at PDF-eksport nå "excludes AI responses for cleaner meeting records." Sant, men ufullstendig.

Eksporttidslinjen tagger hver oppføring med en type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. En talt møtelinje er en transcript; et spørsmål du skrev inn i assistentpanelet er en chat_user; AI-ens svar er en chat_assistant. v1.8.12-endringen er en ni linjer lang redigering av én renderer-fil, og den avgjørende linjen er et filter plassert rett før eksport-payloaden bygges:

timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')

Det er en tillatelsesliste, ikke en blokkeringsliste. Den fjerner ikke chat_assistant-oppføringer; den beholder bare transcript og screenshot og dropper alt annet — som betyr at den skreller bort brukerens egne chat_user-spørsmål sammen med AI-ens svar. Commit-teksten er mer nøyaktig enn endringsloggen: "PDF export keeps only transcripts and screenshots, no chat messages." Den eksporterte oppføringen er nå rent møtet — hva som ble sagt og hva som var på skjermen — med hele assistentens sidepanelsamtale fjernet fra den.

Og det er det riktige valget, produktmessig. GeekByes chat er en privat assistent du rådfører deg med under en samtale — spør den om et skjermbilde, få en rask forklaring. De utvekslingene er kladdeblokken din, ikke en del av møtet. En møteoppføring du ville delt med noen bør inneholde møtet, ikke sidesamtalene dine med en AI om det. Tillatelseslisten gjør bare "møtet" til den bokstavelige definisjonen av hva som blir eksportert.

Tre ting denne releasen lærte oss

  1. En nettleser er en PDF-renderer du allerede har. Hvis dokumentet ditt er strukturert tekst og bilder, gir printToPDF over et skjult BrowserWindow deg ekte paginering, ekte fontrendering og CSS-layout gratis — ingen PDF-tegne-API. Du bytter en biblioteksavhengighet mot nettleserens kanttilfeller, som ofte er det bedre byttet.
  2. Buggen du kan se og buggen som finnes er ikke alltid den samme. "Tomme bokser" skrek manglende data; årsaken var en delt beholder. Instrumentér før du antar — teamets debug-logging er det som gjorde en plausibel-men-feil fiks (filtrer tomme) om til den riktige (break-inside: avoid).
  3. Endringsloggen er et sammendrag; filteret er sannheten. "Excludes AI responses" er en tillatelsesliste som også dropper dine egne chatspørsmål. Når et release-notat beskriver en atferdsendring, forteller diffen deg de eksakte kantene — og her er den eksakte kanten "bare møtet overlever."

For det forrige kapittelet i v1-historien, fra OCR til piksler uten å miste reserven (v1.8.6–v1.8.7); og for hele buen, anatomien i å shippe programvare til perfeksjon.