Steven
Steven10 min de lectura

Imprimir una reunió a PDF sense biblioteca de PDF

GeekBye exporta una reunió com a PDF, i no hi ha cap biblioteca de PDF enlloc del codi. Renderitza HTML en una finestra de navegador invisible i la imprimeix. Aquella tria és tota la història: va fer la funció fàcil de construir i li va donar cada fallada que té un navegador de veritat — un flaix blanc, un límit de longitud d'URL, i un salt de pàgina que tallava els screenshots per la meitat. La solució per al bug més lleig va ser una línia de CSS.

Engineering
Electron
Desktop
GeekBye Releases
Imprimir una reunió a PDF sense biblioteca de PDF

Hi ha un truc satisfactori amagat al botó de GeekBye "exporta aquesta reunió com a PDF": no hi ha cap biblioteca de PDF al darrere. Ni jsPDF, ni pdfmake, ni pdfkit, ni puppeteer headless. Tota la funció és un navegador amagat. L'app construeix una pàgina HTML que descriu la reunió — entrades de transcripció, screenshots, marques de temps — la carrega en una finestra d'Electron invisible, i demana a aquella finestra que s'imprimeixi a si mateixa a PDF. webContents.printToPDF fa la resta. Aquella única tria arquitectònica és tota la història d'aquesta versió, perquè va fer la funció barata de construir i li va lliurar cada bug que té un navegador de veritat.

L'abans és la mateixa versió

Normalment aquests articles obren amb "així funcionava abans." Aquesta vegada no hi ha abans. El changelog de la v1.8.9 es llegeix com a manteniment — "fixed page breaks splitting screenshots and filtered empty entries" — com si l'exportació a PDF fos una funció existent rebent un pedaç. No ho era. El primer commit que la introdueix, feat: add PDF export via Electron printToPDF, aterra tres dies dins del mateix cicle de versió, i cada "fix" del changelog és iteració de la mateixa setmana sobre codi que tenia hores de vida. La funció va néixer, es va trencar, i es va endurir, tot dins d'una sola etiqueta. Val la pena dir-ho en veu alta perquè és la forma honesta de molt del que es publica: el punt polit de les notes de la versió i el naixement desordenat de la cosa són sovint la mateixa versió.

Per què un navegador amagat, per començar

Val la pena entendre el mecanisme abans dels bugs, perquè els bugs en descendeixen tots. Tot viu en un sol fitxer del procés principal, electron/ipcHandlers/pdfExportHandler.ts. Quan el renderer dispara l'IPC 'pdf:export-report', el handler:

  1. Crida buildReportHtml() per assemblar una cadena HTML completa a partir de la línia de temps de la reunió — un bloc .item per cada entrada de transcripció o screenshot.
  2. Escriu aquell HTML a un fitxer temporal a os.tmpdir().
  3. Carrega el fitxer en una finestra amagada — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Espera que les imatges es carreguin, i després crida win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Desa el resultat a app.getPath('downloads') com a Meeting-Report-<timestamp>.pdf.

L'atractiu és obvi. Una biblioteca de PDF et fa posicionar-ho tot a mà — dibuixa text a (x, y), mesura'l, avança el cursor, gestiona els salts de pàgina tu mateix. Un navegador ja fa tot això: escrius <div>s i CSS, i el motor de disposició de Chromium pagina per tu. No estàs aprenent una API de dibuix; estàs escrivint una pàgina web. Per a un document que és majoritàriament text amb estil i imatges incrustades, això és una drecera enorme.

El parany és que també vas heretar un navegador, amb tots els seus casos límit. Tres d'ells van aparèixer dins dels primers quatre dies.

El flaix blanc. Una finestra amagada a macOS no està amagada de manera fiable. Els primers intents la posicionaven fora de pantalla (x: screenWidth + 1000), i tot i així feia flaix d'un rectangle blanc a la pantalla durant un frame abans d'imprimir. La solució, després de tres intents, va ser deixar de dependre de la posició o de show: false i establir opacity: 0 — una finestra completament transparent no fa flaix ni tan sols quan el compositor la mostra breument. El commit ho diu clarament: "show:false with off-screen positioning still flashes on macOS."

El límit de longitud d'URL. La primera versió incrustava tot l'HTML — screenshots inclosos — com a URL data:. Això funciona fins que una reunió té uns quants screenshots codificats en base64, moment en què l'URL navega més enllà del límit d'URL de dades de ~2 MB de Chromium i la càrrega falla directament amb ERR_INVALID_URL. Per això el pas 2 de dalt escriu un fitxer temporal i fa loadFile() en comptes de carregar una URL de dades: un camí de fitxer no té límit de longitud. (Aquells ~2 MB són l'únic número de tota aquesta història, i és una estimació inline en un comentari de codi, no res que ningú hagi mesurat — tracta'l com a "prou gran per topar-hi," no com un llindar precís.)

El text sense escapar. El text de transcripció és contingut d'usuari arbitrari, i s'estava deixant caure directament dins de la cadena HTML. Qualsevol transcripció que casualment contingués una cosa com <div> o <script> trencaria la pàgina o pitjor. Així que el handler va guanyar una passada d'escapeHtml() sobre cada cadena que interpola. En el moment en què el teu PDF és una pàgina web, la injecció d'HTML també és el teu problema.

Les caixes buides que no eren buides

Ara el bug de portada, perquè és el millor. Els usuaris reportaven PDF exportats amb caixes buides — entrades que mostraven una marca de temps i una insígnia però cap screenshot, només espai en blanc on hauria d'anar la imatge. La lectura òbvia és "l'exportació està deixant caure dades d'imatge," i la solució òbvia és "filtra les entrades buides." L'equip sí que va afegir aquell filtre — més sobre això en un segon — però no era la causa real.

La causa real era la paginació. Cada entrada és un contenidor .item que conté una marca de temps, una insígnia i una imatge. Chromium, imprimint a PDF, trencava tan tranquil una pàgina al mig d'un ítem — renderitzant la marca de temps i la insígnia al final d'una pàgina i empenyent la imatge a la següent. El que semblava una caixa buida era la meitat superior d'un ítem la imatge del qual havia desbordat a la pàgina següent. Les dades hi eren totes; la disposició les havia guillotinat. El commit és sincer sobre el diagnòstic erroni: "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."

La solució és una línia de CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid diu al motor de disposició que mantingui un ítem sencer — si no cap a l'espai restant d'una pàgina, mou l'ítem sencer a la pàgina següent en comptes de partir-lo. Aquesta és la recompensa de l'arquitectura del navegador amagat expressada en miniatura: el bug més lleig i més reportat de la funció es va resoldre no amb aritmètica d'alçada de pàgina i crides manuals addPage(), sinó amb una sola propietat CSS que el navegador ja sabia honrar. No vas escriure un paginador; vas demanar al que vas heretar que es comportés.

Dues capes de filtratge de coses realment buides

que hi havia entrades genuïnament buides també — només que no les que causaven les caixes. Duplicats en blanc d'un doble Cmd+Enter ràpid, screenshots sense previsualització, entrades de transcripció que només eren espai en blanc. Aquestes es van filtrar, i curiosament es van filtrar dues vegades, a dues capes:

  • Al handler, buildReportHtml() protegeix cada ítem — if (!item.content?.trim()) return '' per al text, i per a les imatges const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — i després .filter(Boolean) deixa caure els buits. Aquesta última comprovació fa doble feina: és una porta de validesa i una petita comprovació de seguretat que només dades d'imatge reals arribin mai a la pàgina.
  • A la font de la línia de temps, al renderer, la mateixa buidor es filtra abans que un ítem s'afegeixi mai — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

El detall bonic de la història és el mètode. El primer commit va afegir un bucle logger.debug('PDF timeline item', …) explícitament "for debugging empty entries." Un cop aquell logging va revelar d'on venien realment els buits — incloent un bug separat de deduplicació de screenshots on captures ràpides produïen duplicats en blanc — el commit següent va arreglar la causa arrel aigües amunt i va eliminar el logging de depuració. Afegeix instrumentació per trobar la causa, arregla la causa, esborra la instrumentació. El punt del changelog "filtered empty entries" conté silenciosament tot aquell arc d'afegir-i-després-treure.

v1.8.12: "excludes AI responses," llegeix el diff

Tres versions després, un canvi més — i és una bona lliçó sobre llegir el codi en comptes del changelog. La nota de la v1.8.12 diu que l'exportació a PDF ara "excludes AI responses for cleaner meeting records." Cert, però incomplet.

La línia de temps d'exportació etiqueta cada ítem amb un type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Una línia de reunió parlada és un transcript; una pregunta que vas escriure al panell de l'assistent és un chat_user; la resposta de la IA és un chat_assistant. El canvi de la v1.8.12 és una edició de nou línies a un sol fitxer del renderer, i la línia operativa és un filtre col·locat just abans de construir el payload d'exportació:

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

Això és una allowlist, no una blocklist. No elimina els ítems chat_assistant; només conserva transcript i screenshot i deixa caure tota la resta — cosa que vol dir que elimina les pròpies preguntes chat_user de l'usuari juntament amb les respostes de la IA. El cos del commit és més precís que el changelog: "PDF export keeps only transcripts and screenshots, no chat messages." El registre exportat ara és purament la reunió — què es va dir i què hi havia a la pantalla — amb tota la conversa del panell lateral de l'assistent eliminada d'ell.

I aquesta és la decisió correcta, a nivell de producte. El xat de GeekBye és un assistent privat que consultes durant una trucada — pregunta-li sobre un screenshot, obtén una explicació ràpida. Aquells intercanvis són el teu bloc d'esborranys, no part de la reunió. Un registre de reunió que compartiries amb algú hauria de contenir la reunió, no les teves converses paral·leles amb una IA sobre ella. L'allowlist simplement fa de "la reunió" la definició literal del que s'exporta.

Tres coses que aquesta versió ens va ensenyar

  1. Un navegador és un renderitzador de PDF que ja tens. Si el teu document és text estructurat i imatges, printToPDF sobre un BrowserWindow amagat et dona paginació real, renderitzat de fonts real, i disposició CSS gratis — cap API de dibuix de PDF. Canvies una dependència de biblioteca per casos límit de navegador, cosa que sovint és el millor canvi.
  2. El bug que pots veure i el bug que existeix no sempre són el mateix. "Caixes buides" cridava dades que falten; la causa era un contenidor partit. Instrumenta abans d'assumir — el logging de depuració de l'equip és el que va convertir una solució plausible-però-errònia (filtrar els buits) en la real (break-inside: avoid).
  3. El changelog és un resum; el filtre és la veritat. "Excludes AI responses" és una allowlist que també deixa caure les teves pròpies preguntes de xat. Quan una nota de versió descriu un canvi de comportament, el diff et dirà les seves vores exactes — i aquí la vora exacta és "només la reunió sobreviu."

Per al capítol anterior de la història de la v1, d'OCR als píxels sense perdre el fallback (v1.8.6–v1.8.7); i per a tot l'arc, l'anatomia de publicar programari fins a la perfecció.

Articles Relacionats

Els dos modes de fallada d'un overlay click-through
Steven
Steven10 min de lectura

Els dos modes de fallada d'un overlay click-through

La finestra de GeekBye flota per damunt de tot i deixa que els teus clics la travessin — excepte on té botons. Això és un contracte de dues cares, i la v1.8.5 i la v1.8.14 mostren com és quan cada cara es trenca: una release on l'overlay es va empassar un diàleg del sistema, una altra on et robava les pulsacions de tecla. La solució guanyadora per a la segona va ser esborrar codi.

Engineering
Electron
Desktop
Nascut i arreglat en dotze minuts
Steven
Steven8 min de lectura

Nascut i arreglat en dotze minuts

El changelog de GeekBye v1.8.10 diu que va arreglar un crash mentre s'editaven les dreceres de teclat. Ho va fer — però el crash es va introduir i arreglar en la mateixa pull request, amb dotze minuts de diferència, i mai no va arribar a cap usuari. La història real és la cascada de fiabilitat que el va produir: un canvi petit i correcte sobre quan se suspenen les dreceres, i el bug de teardown de React que va caure d'una sola línia pensada per fer l'editor més segur.

Engineering
React
Desktop
D'OCR als píxels sense perdre el fallback
Steven
Steven8 min de lectura

D'OCR als píxels sense perdre el fallback

GeekBye va deixar de llegir els teus screenshots amb OCR i va començar a enviar la imatge real als models de visió — que és la part fàcil. La part instructiva és l'optimització que es va saltar l'OCR completament per als usuaris de visió, va trencar silenciosament el fallback als models sense visió, i es va haver de desfer fins a una versió on la xarxa de seguretat corre en paral·lel i no costa res.

Engineering
AI
Desktop