Steven
Steven8 min läsning

Att skriva ut ett möte till PDF utan ett PDF-bibliotek

GeekBye exporterar ett möte som en PDF, och det finns inget PDF-bibliotek någonstans i koden. Den renderar HTML i ett osynligt webbläsarfönster och skriver ut det. Det valet är hela historien: det gjorde funktionen lätt att bygga och gav den varje fel en riktig webbläsare har — en vit blinkning, en gräns för URL-längd och en sidbrytning som skar skärmdumpar mitt itu. Fixen för den fulaste buggen var en rad CSS.

Utveckling
Electron
Desktop
GeekBye-releaser
Att skriva ut ett möte till PDF utan ett PDF-bibliotek

Det gömmer sig ett tillfredsställande trick i GeekByes "exportera det här mötet som en PDF"-knapp: det finns inget PDF-bibliotek bakom den. Ingen jsPDF, ingen pdfmake, ingen pdfkit, ingen headless puppeteer. Hela funktionen är en dold webbläsare. Appen bygger en HTML-sida som beskriver mötet — transkriptionsposter, skärmdumpar, tidsstämplar — laddar in den i ett osynligt Electron-fönster och ber det fönstret att skriva ut sig självt till PDF. webContents.printToPDF gör resten. Det enda arkitekturvalet är hela historien om den här releasen, eftersom det gjorde funktionen billig att bygga och räckte den varje bugg en riktig webbläsare har.

Föret är samma release

Vanligtvis öppnar de här inläggen med "så här fungerade det förut." Den här gången finns inget förut. Ändringsloggen för v1.8.9 läser som underhåll — "fixed page breaks splitting screenshots and filtered empty entries" — som om PDF-export vore en befintlig funktion som lappas. Det var den inte. Den första commiten som introducerar den, feat: add PDF export via Electron printToPDF, landar tre dagar in i samma releasecykel, och varje "fix" i ändringsloggen är iteration samma vecka på kod som var timmar gammal. Funktionen föddes, gick sönder och härdades — allt inom en tagg. Det är värt att säga högt eftersom det är den ärliga formen på mycket leverans: den polerade punkten i release-anteckningarna och sakens stökiga födelse är ofta samma release.

Varför en dold webbläsare överhuvudtaget

Mekanismen är värd att förstå före buggarna, eftersom alla buggar härstammar från den. Allt lever i en fil i huvudprocessen, electron/ipcHandlers/pdfExportHandler.ts. När renderaren avfyrar 'pdf:export-report'-IPC:n gör hanteraren:

  1. Anropar buildReportHtml() för att sätta ihop en fullständig HTML-sträng från mötets tidslinje — ett .item-block per transkriptionspost eller skärmdump.
  2. Skriver den HTML:en till en temporär fil i os.tmpdir().
  3. Laddar filen i ett dolt fönster — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Väntar på att bilderna laddas, anropar sedan win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Sparar resultatet till app.getPath('downloads') som Meeting-Report-<timestamp>.pdf.

Lockelsen är uppenbar. Ett PDF-bibliotek tvingar dig att placera allt för hand — rita text vid (x, y), mät den, flytta markören, hantera sidbrytningar själv. En webbläsare gör redan allt det: du skriver <div>:ar och CSS, och Chromiums layoutmotor paginerar åt dig. Du lär dig inget ritnings-API; du skriver en webbsida. För ett dokument som mestadels är stilad text och inbäddade bilder är det en enorm genväg.

Haken är att du också ärvde en webbläsare, med alla dess specialfall. Tre av dem dök upp inom de första fyra dagarna.

Den vita blinkningen. Ett dolt fönster på macOS är inte pålitligt dolt. De första försöken placerade det utanför skärmen (x: screenWidth + 1000), och det blinkade fortfarande en vit rektangel på skärmen under en frame innan utskrift. Fixen, efter tre försök, var att sluta förlita sig på position eller show: false och sätta opacity: 0 — ett helt genomskinligt fönster blinkar inte ens när kompositorn kort visar det. Commiten säger det rent ut: "show:false with off-screen positioning still flashes on macOS."

URL-längdgränsen. Den första versionen infogade hela HTML:en — skärmdumpar och allt — som en data: URL. Det fungerar tills ett möte har några base64-kodade skärmdumpar i sig, varvid URL:en seglar förbi Chromiums ~2 MB data-URL-gräns och laddningen misslyckas rakt av med ERR_INVALID_URL. Det är därför steg 2 ovan skriver en temporär fil och gör loadFile() istället för att ladda en data-URL: en filsökväg har ingen längdgräns. (Det där ~2 MB är den enda siffran i hela den här historien, och det är en inline-uppskattning i en kodkommentar, inte något någon benchmarkade — behandla det som "stort nog att träffa," inte en exakt tröskel.)

Den oescapeade texten. Transkriptionstext är godtyckligt användarinnehåll, och det släpptes rakt in i HTML-strängen. Vilken transkription som helst som råkade innehålla något som <div> eller <script> skulle bryta sidan eller värre. Så hanteraren fick ett escapeHtml()-svep över varje sträng den interpolerar. I det ögonblick din PDF är en webbsida är HTML-injektion också ditt problem.

De tomma rutorna som inte var tomma

Nu huvudbuggen, eftersom den är den bästa. Användare rapporterade exporterade PDF:er med tomma rutor — poster som visade en tidsstämpel och en badge men ingen skärmdump, bara tomt utrymme där bilden borde vara. Den uppenbara läsningen är "exporten släpper bilddata," och den uppenbara fixen är "filtrera bort de tomma posterna." Teamet lade till det filtret — mer om det strax — men det var inte den verkliga orsaken.

Den verkliga orsaken var paginering. Varje post är en .item-behållare som håller en tidsstämpel, en badge och en bild. Chromium, när det skrev ut till PDF, var glatt villig att bryta en sida mitt i en post — och renderade tidsstämpeln och badgen längst ner på en sida och sköt bilden till nästa. Det som såg ut som en tom ruta var den övre halvan av en post vars bild hade svämmat över till följande sida. Datan fanns där hela tiden; layouten hade giljotinerat den. Commiten är uppriktig om feldiagnosen: "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."

Fixen är en rad CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid säger till layoutmotorn att hålla en post hel — om den inte får plats i det återstående utrymmet på en sida, flytta hela posten till nästa sida istället för att dela den. Detta är utdelningen av den dolda-webbläsararkitekturen uttryckt i miniatyr: den fulaste, mest rapporterade buggen i funktionen fixades inte med sidhöjdsaritmetik och manuella addPage()-anrop, utan med en enda CSS-egenskap som webbläsaren redan visste hur den skulle respektera. Du skrev ingen paginerare; du bad den du ärvde att uppföra sig.

Två lager av faktiskt-tom filtrering

Det fanns genuint tomma poster också — bara inte de som orsakade rutorna. Tomma dubbletter från ett snabbt dubbelt Cmd+Enter, skärmdumpar utan förhandsvisning, transkriptionsposter som bara var blanksteg. Dessa filtrerades, och intressant nog filtrerades de två gånger, i två lager:

  • I hanteraren skyddar buildReportHtml() varje post — if (!item.content?.trim()) return '' för text, och för bilder const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — sedan släpper .filter(Boolean) de tomma. Den sista kontrollen gör dubbel tjänst: den är en giltighetsgrind och en liten säkerhetskontroll att bara riktig bilddata någonsin når sidan.
  • Vid tidslinjens källa, i renderaren, filtreras samma tomhet innan en post ens läggs till — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

Den fina detaljen i historiken är metoden. Den första commiten lade till en logger.debug('PDF timeline item', …)-loop uttryckligen "for debugging empty entries." När den loggningen avslöjade var de tomma faktiskt kom ifrån — inklusive en separat bugg i skärmdumps-deduplicering där snabba fångster producerade tomma dubbletter — fixade den efterföljande commiten grundorsaken uppströms och tog bort debug-loggningen. Lägg till instrumentering för att hitta orsaken, fixa orsaken, ta bort instrumenteringen. Ändringsloggens punkt "filtered empty entries" innehåller tyst hela den där lägg-till-sen-ta-bort-bågen.

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

Tre releaser senare, en ändring till — och det är en bra läxa i att läsa koden istället för ändringsloggen. v1.8.12-anteckningen säger att PDF-export nu "excludes AI responses for cleaner meeting records." Sant, men ofullständigt.

Exporttidslinjen taggar varje post med en type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. En talad mötesrad är en transcript; en fråga du skrev in i assistentpanelen är en chat_user; AI:ns svar är en chat_assistant. v1.8.12-ändringen är en nio rader lång redigering av en renderarfil, och den avgörande raden är ett filter placerat precis innan exportnyttolasten byggs:

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

Det är en tillåtelselista, inte en blockeringslista. Den tar inte bort chat_assistant-poster; den behåller bara transcript och screenshot och släpper allt annat — vilket betyder att den skalar bort användarens egna chat_user-frågor tillsammans med AI:ns svar. Commit-texten är mer exakt än ändringsloggen: "PDF export keeps only transcripts and screenshots, no chat messages." Den exporterade posten är nu rent mötet — vad som sades och vad som var på skärmen — med hela assistentens sidopanelskonversation borttagen från den.

Och det är rätt beslut, produktmässigt. GeekByes chatt är en privat assistent du rådfrågar under ett samtal — fråga den om en skärmdump, få en snabb förklaring. De utbytena är ditt kladdblock, inte en del av mötet. En mötespost du skulle dela med någon bör innehålla mötet, inte dina sidosamtal med en AI om det. Tillåtelselistan gör bara "mötet" till den bokstavliga definitionen av vad som exporteras.

Tre saker den här releasen lärde oss

  1. En webbläsare är en PDF-renderare du redan har. Om ditt dokument är strukturerad text och bilder ger printToPDF över ett dolt BrowserWindow dig riktig paginering, riktig teckensnittsrendering och CSS-layout gratis — inget PDF-ritnings-API. Du byter ett biblioteksberoende mot webbläsarens specialfall, vilket ofta är det bättre bytet.
  2. Buggen du kan se och buggen som finns är inte alltid samma. "Tomma rutor" skrek saknad data; orsaken var en delad behållare. Instrumentera innan du antar — teamets debug-loggning är vad som förvandlade en rimlig-men-fel fix (filtrera tomma) till den riktiga (break-inside: avoid).
  3. Ändringsloggen är en sammanfattning; filtret är sanningen. "Excludes AI responses" är en tillåtelselista som också släpper dina egna chattfrågor. När en release-anteckning beskriver en beteendeförändring berättar diffen dess exakta kanter för dig — och här är den exakta kanten "bara mötet överlever."

För det föregående kapitlet i v1-historien, från OCR till pixlar utan att förlora reserven (v1.8.6–v1.8.7); och för hela bågen, anatomin i att skeppa mjukvara till perfektion.

Relaterade artiklar

De två fellägena hos ett klick-genom-överlägg
Steven
Steven8 min läsning

De två fellägena hos ett klick-genom-överlägg

GeekByes fönster svävar ovanpå allt och låter dina klick passera genom det — utom där det har knappar. Det är ett tvåsidigt kontrakt, och v1.8.5 och v1.8.14 är hur det ser ut när vardera sidan går sönder: en release där överlägget svalde en systemdialog, en där det stal dina tangenttryckningar. Den vinnande fixen för den andra var att radera kod.

Utveckling
Electron
Desktop
Född och fixad på tolv minuter
Steven
Steven7 min läsning

Född och fixad på tolv minuter

GeekBye v1.8.10:s ändringslogg säger att den fixade en krasch vid redigering av tangentbordsgenvägar. Det gjorde den — men kraschen introducerades och fixades i samma pull request, tolv minuter isär, och nådde aldrig en enda användare. Den verkliga historien är tillförlitlighetskaskaden som producerade den: en liten, korrekt ändring av när genvägar pausas, och React-nedmonteringsbuggen som föll ut ur en enda rad som var tänkt att göra editorn säkrare.

Utveckling
React
Desktop
Från OCR till pixlar utan att förlora reserven
Steven
Steven7 min läsning

Från OCR till pixlar utan att förlora reserven

GeekBye slutade läsa dina skärmdumpar med OCR och började skicka själva bilden till visionsmodeller — vilket är den lätta delen. Den lärorika delen är optimeringen som hoppade över OCR helt för visionsanvändare, tyst bröt reserven till icke-visionsmodeller, och fick backas till en version där skyddsnätet kör parallellt och kostar ingenting.

Utveckling
AI
Desktop