Steven
Steven8 min læsning

At printe et møde til PDF uden et PDF-bibliotek

GeekBye eksporterer et møde som en PDF, og der er ikke noget PDF-bibliotek nogen steder i koden. Den renderer HTML i et usynligt browservindue og printer det. Det valg er hele historien: det gjorde funktionen let at bygge og gav den enhver fejl, en rigtig browser har — et hvidt glimt, en grænse for URL-længde og et sideskift, der skar skærmbilleder midt over. Rettelsen til den grimmeste bug var én linje CSS.

Udvikling
Electron
Desktop
GeekBye-releaser
At printe et møde til PDF uden et PDF-bibliotek

Der gemmer sig et tilfredsstillende trick i GeekByes "eksportér dette møde som en PDF"-knap: der er ikke noget PDF-bibliotek bag den. Ingen jsPDF, ingen pdfmake, ingen pdfkit, ingen headless puppeteer. Hele funktionen er en skjult browser. Appen bygger en HTML-side, der beskriver mødet — transskriptionsposter, skærmbilleder, tidsstempler — indlæser den i et usynligt Electron-vindue og beder det vindue om at printe sig selv til PDF. webContents.printToPDF gør resten. Det ene arkitekturvalg er hele historien om denne release, fordi det gjorde funktionen billig at bygge og rakte den enhver bug, en rigtig browser har.

Før er den samme release

Normalt åbner disse indlæg med "sådan virkede det før." Denne gang er der ikke noget før. Ændringsloggen for v1.8.9 læser som vedligeholdelse — "fixed page breaks splitting screenshots and filtered empty entries" — som om PDF-eksport var en eksisterende funktion, der bliver lappet. Det var den ikke. Den første commit, der introducerer den, feat: add PDF export via Electron printToPDF, lander tre dage inde i den samme releasecyklus, og hvert "fix" i ændringsloggen er iteration samme uge på kode, der var timer gammel. Funktionen blev født, gik i stykker og blev hærdet — alt sammen inden for ét tag. Det er værd at sige højt, fordi det er den ærlige form på megen levering: det polerede punkt i release-noterne og tingens rodede fødsel er ofte den samme release.

Hvorfor overhovedet en skjult browser

Mekanismen er værd at forstå før buggene, fordi alle bugs stammer fra den. Alt lever i én fil i hovedprocessen, electron/ipcHandlers/pdfExportHandler.ts. Når rendereren affyrer 'pdf:export-report'-IPC'en, gør handleren:

  1. Kalder buildReportHtml() for at samle en fuld HTML-streng fra mødets tidslinje — én .item-blok per transskriptionspost eller skærmbillede.
  2. Skriver den HTML til en midlertidig fil i os.tmpdir().
  3. Indlæser filen i et skjult vindue — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Venter på, at billederne indlæses, og kalder så win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Gemmer resultatet i app.getPath('downloads') som Meeting-Report-<timestamp>.pdf.

Tiltrækningen er indlysende. Et PDF-bibliotek tvinger dig til at placere alt i hånden — tegn tekst ved (x, y), mål den, ryk markøren, håndtér sideskift selv. En browser gør allerede alt det: du skriver <div>'er og CSS, og Chromiums layoutmotor paginerer for dig. Du lærer ikke et tegne-API; du skriver en webside. For et dokument, der mest er stylet tekst og indlejrede billeder, er det en enorm genvej.

Hagen er, at du også arvede en browser med alle dens grænsetilfælde. Tre af dem dukkede op inden for de første fire dage.

Det hvide glimt. Et skjult vindue på macOS er ikke pålideligt skjult. De første forsøg placerede det uden for skærmen (x: screenWidth + 1000), og det glimtede stadig et hvidt rektangel på skærmen i én frame før printet. Rettelsen, efter tre forsøg, var at holde op med at stole på position eller show: false og sætte opacity: 0 — et fuldstændig gennemsigtigt vindue glimter ikke, selv når kompositoren kort viser det. Commiten siger det lige ud: "show:false with off-screen positioning still flashes on macOS."

URL-længdegrænsen. Den første version indlejrede hele HTML'en — skærmbilleder og det hele — som en data: URL. Det virker, indtil et møde har et par base64-kodede skærmbilleder i sig, hvorpå URL'en sejler forbi Chromiums ~2 MB data-URL-grænse, og indlæsningen fejler direkte med ERR_INVALID_URL. Det er derfor, trin 2 ovenfor skriver en midlertidig fil og gør loadFile() i stedet for at indlæse en data-URL: en filsti har ingen længdegrænse. (Det ~2 MB er det ene tal i hele denne historie, og det er et inline-skøn i en kodekommentar, ikke noget, nogen benchmarkede — behandl det som "stort nok til at ramme," ikke en præcis tærskel.)

Den uescapede tekst. Transskriptionstekst er vilkårligt brugerindhold, og det blev droppet direkte ind i HTML-strengen. Enhver transskription, der tilfældigvis indeholdt noget som <div> eller <script>, ville bryde siden eller værre. Så handleren fik en escapeHtml()-gennemgang over hver streng, den interpolerer. I det øjeblik din PDF er en webside, er HTML-injektion også dit problem.

De tomme kasser, der ikke var tomme

Nu hovedbuggen, fordi den er den bedste. Brugere rapporterede eksporterede PDF'er med tomme kasser — poster, der viste et tidsstempel og en badge, men intet skærmbillede, bare tomt rum, hvor billedet burde være. Den oplagte læsning er "eksporten dropper billeddata," og den oplagte rettelse er "filtrér de tomme poster fra." Teamet tilføjede det filter — mere om det om et øjeblik — men det var ikke den rigtige årsag.

Den rigtige årsag var paginering. Hver post er en .item-beholder, der holder et tidsstempel, en badge og et billede. Chromium var, da den printede til PDF, gladeligt villig til at bryde en side midt i en post — og renderede tidsstemplet og badgen nederst på én side og skubbede billedet over på den næste. Det, der lignede en tom kasse, var den øverste halvdel af en post, hvis billede var flydt over til den følgende side. Dataen var der hele tiden; layoutet havde guillotineret den. Commiten er ærlig omkring fejldiagnosen: "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."

Rettelsen er én linje CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid fortæller layoutmotoren, at den skal holde en post hel — hvis den ikke kan være i den resterende plads på en side, så flyt hele posten til den næste side i stedet for at dele den. Dette er udbyttet af den skjulte-browser-arkitektur udtrykt i miniature: den grimmeste, mest rapporterede bug i funktionen blev ikke rettet med sidehøjde-aritmetik og manuelle addPage()-kald, men med en enkelt CSS-egenskab, som browseren allerede vidste, hvordan den skulle respektere. Du skrev ikke en paginator; du bad den, du arvede, om at opføre sig.

To lag af faktisk-tom filtrering

Der var også ægte tomme poster — bare ikke dem, der forårsagede kasserne. Tomme dubletter fra et hurtigt dobbelt Cmd+Enter, skærmbilleder uden forhåndsvisning, transskriptionsposter, der kun var mellemrum. Disse blev filtreret, og interessant nok blev de filtreret to gange, i to lag:

  • I handleren beskytter buildReportHtml() hver post — if (!item.content?.trim()) return '' for tekst, og for billeder const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — så dropper .filter(Boolean) de tomme. Det sidste tjek gør dobbelt arbejde: det er en gyldighedsport og et lille sikkerhedstjek af, at kun rigtige billeddata nogensinde når siden.
  • Ved tidslinjens kilde, i rendereren, filtreres den samme tomhed, før en post overhovedet tilføjes — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

Den fine detalje i historikken er metoden. Den første commit tilføjede en logger.debug('PDF timeline item', …)-løkke udtrykkeligt "for debugging empty entries." Da den logning afslørede, hvor de tomme faktisk kom fra — inklusive en separat bug i skærmbillede-deduplikering, hvor hurtige optagelser producerede tomme dubletter — rettede den efterfølgende commit grundårsagen opstrøms og fjernede debug-logningen. Tilføj instrumentering for at finde årsagen, ret årsagen, slet instrumenteringen. Ændringsloggens punkt "filtered empty entries" indeholder stille hele den tilføj-så-fjern-bue.

v1.8.12: "excludes AI responses," læs diffen

Tre releases senere, én ændring mere — og det er en god lektie i at læse koden i stedet for ændringsloggen. v1.8.12-noten siger, at PDF-eksport nu "excludes AI responses for cleaner meeting records." Sandt, men ufuldstændigt.

Eksporttidslinjen tagger hver post med en type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. En talt mødelinje er en transcript; et spørgsmål, du skrev ind i assistentpanelet, er en chat_user; AI'ens svar er en chat_assistant. v1.8.12-ændringen er en ni linjer lang redigering af én renderer-fil, og den afgørende linje er et filter placeret lige før eksport-payloaden bygges:

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

Det er en tilladelsesliste, ikke en blokeringsliste. Den fjerner ikke chat_assistant-poster; den beholder kun transcript og screenshot og dropper alt andet — hvilket betyder, at den skræller brugerens egne chat_user-spørgsmål af sammen med AI'ens svar. Commit-teksten er mere præcis end ændringsloggen: "PDF export keeps only transcripts and screenshots, no chat messages." Den eksporterede post er nu rent mødet — hvad der blev sagt, og hvad der var på skærmen — med hele assistentens sidepanelssamtale fjernet fra den.

Og det er det rigtige valg, produktmæssigt. GeekByes chat er en privat assistent, du rådfører dig med under et opkald — spørg den om et skærmbillede, få en hurtig forklaring. De udvekslinger er din kladdeblok, ikke en del af mødet. En mødepost, du ville dele med nogen, bør indeholde mødet, ikke dine sidesamtaler med en AI om det. Tilladelseslisten gør bare "mødet" til den bogstavelige definition af, hvad der bliver eksporteret.

Tre ting denne release lærte os

  1. En browser er en PDF-renderer, du allerede har. Hvis dit dokument er struktureret tekst og billeder, giver printToPDF over et skjult BrowserWindow dig rigtig paginering, rigtig fontrendering og CSS-layout gratis — intet PDF-tegne-API. Du bytter en biblioteksafhængighed for browserens grænsetilfælde, hvilket ofte er det bedre bytte.
  2. Buggen, du kan se, og buggen, der findes, er ikke altid den samme. "Tomme kasser" skreg manglende data; årsagen var en delt beholder. Instrumentér, før du antager — teamets debug-logning er, hvad der forvandlede en plausibel-men-forkert rettelse (filtrér tomme) til den rigtige (break-inside: avoid).
  3. Ændringsloggen er et resumé; filteret er sandheden. "Excludes AI responses" er en tilladelsesliste, der også dropper dine egne chatspørgsmål. Når en release-note beskriver en adfærdsændring, fortæller diffen dig dens præcise kanter — og her er den præcise kant "kun mødet overlever."

For det forrige kapitel i v1-historien, fra OCR til pixels uden at miste reserven (v1.8.6–v1.8.7); og for hele buen, anatomien i at shippe software til perfektion.

Relaterede artikler

De to fejltilstande hos et klik-igennem-overlay
Steven
Steven8 min læsning

De to fejltilstande hos et klik-igennem-overlay

GeekByes vindue svæver oven på alt og lader dine klik passere gennem det — undtagen der, hvor det har knapper. Det er en tosidet kontrakt, og v1.8.5 og v1.8.14 er, hvordan det ser ud, når hver side går i stykker: én release, hvor overlayet slugte en systemdialog, én hvor det stjal dine tastetryk. Den vindende rettelse til den anden var at slette kode.

Udvikling
Electron
Desktop
Født og fixet på tolv minutter
Steven
Steven7 min læsning

Født og fixet på tolv minutter

GeekBye v1.8.10s ændringslog siger, at den fixede et nedbrud under redigering af tastaturgenveje. Det gjorde den — men nedbruddet blev introduceret og fixet i den samme pull request, tolv minutter fra hinanden, og nåede aldrig en eneste bruger. Den rigtige historie er den pålidelighedskaskade, der producerede det: en lille, korrekt ændring af, hvornår genveje sættes på pause, og React-nedrivningsbuggen, der faldt ud af én linje, der skulle gøre editoren sikrere.

Udvikling
React
Desktop
Fra OCR til pixels uden at miste reserven
Steven
Steven7 min læsning

Fra OCR til pixels uden at miste reserven

GeekBye holdt op med at læse dine skærmbilleder med OCR og begyndte at sende selve billedet til visionsmodeller — hvilket er den nemme del. Den lærerige del er optimeringen, der sprang OCR helt over for visionsbrugere, stille brød reserven til ikke-visionsmodeller, og måtte trækkes tilbage til en version, hvor sikkerhedsnettet kører parallelt og koster ingenting.

Udvikling
AI
Desktop