Steven
Steven9 min di lettura

Stampare una riunione in PDF senza una libreria PDF

GeekBye esporta una riunione come PDF, e non c'è nessuna libreria PDF da nessuna parte nel codice. Rende dell'HTML in una finestra di browser invisibile e la stampa. Quella scelta è tutta la storia: ha reso la funzionalità facile da costruire e le ha dato ogni guasto che ha un browser vero — un lampo bianco, un limite di lunghezza dell'URL, e un'interruzione di pagina che tagliava gli screenshot a metà. La correzione del bug più brutto è stata una sola riga di CSS.

Ingegneria
Electron
Desktop
Release di GeekBye
Stampare una riunione in PDF senza una libreria PDF

C'è un trucco soddisfacente nascosto dietro il pulsante «esporta questa riunione come PDF» di GeekBye: non c'è nessuna libreria PDF dietro di esso. Niente jsPDF, niente pdfmake, niente pdfkit, niente puppeteer headless. L'intera funzionalità è un browser nascosto. L'app costruisce una pagina HTML che descrive la riunione — voci di trascrizione, screenshot, timestamp — la carica in una finestra di Electron invisibile, e chiede a quella finestra di stamparsi da sola in PDF. webContents.printToPDF fa il resto. Quella singola scelta architetturale è tutta la storia di questa release, perché ha reso la funzionalità economica da costruire e le ha consegnato ogni bug che ha un browser vero.

Il prima è la stessa release

Di solito questi articoli aprono con «ecco come funzionava prima». Stavolta non c'è un prima. Il changelog della v1.8.9 si legge come manutenzione — "fixed page breaks splitting screenshots and filtered empty entries" — come se l'export PDF fosse una funzionalità esistente che riceve una patch. Non lo era. Il primo commit che la introduce, feat: add PDF export via Electron printToPDF, atterra a tre giorni dall'inizio dello stesso ciclo di release, e ogni «correzione» del changelog è iterazione della stessa settimana su codice vecchio di ore. La funzionalità è nata, si è rotta, ed è stata indurita, tutto dentro un solo tag. Vale la pena dirlo ad alta voce perché è la forma onesta di molto di ciò che si spedisce: il punto elenco lucidato delle note di rilascio e la nascita disordinata della cosa sono spesso la stessa release.

Perché un browser nascosto, poi

Vale la pena capire il meccanismo prima dei bug, perché i bug discendono tutti da esso. Tutto vive in un unico file del processo principale, electron/ipcHandlers/pdfExportHandler.ts. Quando il renderer scatena l'IPC 'pdf:export-report', l'handler:

  1. Chiama buildReportHtml() per assemblare una stringa HTML completa dalla timeline della riunione — un blocco .item per ogni voce di trascrizione o screenshot.
  2. Scrive quell'HTML in un file temporaneo in os.tmpdir().
  3. Carica il file in una finestra nascosta — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Aspetta che le immagini si carichino, poi chiama win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Salva il risultato in app.getPath('downloads') come Meeting-Report-<timestamp>.pdf.

Il fascino è ovvio. Una libreria PDF ti fa posizionare tutto a mano — disegnare testo a (x, y), misurarlo, avanzare il cursore, gestire tu stesso le interruzioni di pagina. Un browser fa già tutto questo: scrivi dei <div> e del CSS, e il motore di layout di Chromium impagina per te. Non stai imparando una API di disegno; stai scrivendo una pagina web. Per un documento fatto per lo più di testo stilizzato e immagini incorporate, è una scorciatoia enorme.

Il rovescio è che hai anche ereditato un browser, con tutti i suoi casi limite. Tre di essi sono spuntati nei primi quattro giorni.

Il lampo bianco. Una finestra nascosta su macOS non è nascosta in modo affidabile. I primi tentativi la posizionavano fuori schermo (x: screenWidth + 1000), e lampeggiava comunque un rettangolo bianco a schermo per un fotogramma prima di stampare. La correzione, dopo tre tentativi, è stata smettere di affidarsi alla posizione o a show: false e impostare opacity: 0 — una finestra del tutto trasparente non lampeggia nemmeno quando il compositore la mostra brevemente. Il commit lo dice senza giri di parole: "show:false with off-screen positioning still flashes on macOS."

Il limite di lunghezza dell'URL. La prima versione incorporava tutto l'HTML — screenshot compresi — come una URL data:. Funziona finché una riunione non ha qualche screenshot codificato in base64, punto in cui l'URL vola oltre il limite di data-URL di ~2 MB di Chromium e il caricamento fallisce di netto con ERR_INVALID_URL. Ecco perché il passo 2 qui sopra scrive un file temporaneo e fa loadFile() invece di caricare una data URL: un percorso di file non ha limite di lunghezza. (Quel ~2 MB è l'unico numero di tutta questa storia, ed è una stima inline in un commento di codice, non qualcosa che qualcuno abbia misurato — trattalo come «abbastanza grande da sbatterci contro», non una soglia precisa.)

Il testo non-escaped. Il testo di trascrizione è contenuto utente arbitrario, e veniva buttato dritto nella stringa HTML. Qualsiasi trascrizione che per caso contenesse qualcosa come <div> o <script> avrebbe rotto la pagina o peggio. Così l'handler ha guadagnato una passata di escapeHtml() su ogni stringa che interpola. Nell'istante in cui il tuo PDF è una pagina web, l'injection di HTML è anche un problema tuo.

I riquadri vuoti che non erano vuoti

Ora il bug di punta, perché è il migliore. Gli utenti segnalavano PDF esportati con riquadri vuoti — voci che mostravano un timestamp e un badge ma nessuno screenshot, solo spazio bianco dove l'immagine dovrebbe stare. La lettura ovvia è «l'export sta perdendo i dati dell'immagine», e la correzione ovvia è «filtrare le voci vuote». Il team ha effettivamente aggiunto quel filtro — tra un attimo di più — ma non era la vera causa.

La vera causa era la paginazione. Ogni voce è un contenitore .item che tiene un timestamp, un badge e un'immagine. Chromium, stampando in PDF, era ben felice di spezzare una pagina nel mezzo di un item — rendendo il timestamp e il badge in fondo a una pagina e spingendo l'immagine su quella dopo. Ciò che sembrava un riquadro vuoto era la metà superiore di un item la cui immagine era traboccata sulla pagina seguente. I dati erano tutti lì; il layout li aveva ghigliottinati. Il commit è franco sulla diagnosi errata: "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 correzione è una riga di CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid dice al motore di layout di tenere un item intero — se non entra nello spazio rimanente di una pagina, spostare l'intero item su quella dopo invece di spezzarlo. Questo è il ritorno dell'architettura a browser-nascosto espresso in miniatura: il bug più brutto e più segnalato della funzionalità è stato corretto non con aritmetica di altezza di pagina e chiamate manuali a addPage(), ma con una singola proprietà CSS che il browser sapeva già onorare. Non hai scritto un paginatore; hai chiesto a quello che hai ereditato di comportarsi bene.

Due strati di filtraggio del davvero-vuoto

C'erano anche voci genuinamente vuote — solo non quelle che causavano i riquadri. Duplicati bianchi da un doppio Cmd+Enter rapido, screenshot senza anteprima, voci di trascrizione che erano solo spazi bianchi. Queste sono state filtrate, e curiosamente sono state filtrate due volte, a due strati:

  • Nell'handler, buildReportHtml() protegge ogni item — if (!item.content?.trim()) return '' per il testo, e per le immagini const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — poi .filter(Boolean) scarta i vuoti. Quell'ultimo controllo fa doppio servizio: è un cancello di validità e un piccolo controllo di sicurezza che solo dati d'immagine reali raggiungano la pagina.
  • Alla sorgente della timeline, nel renderer, lo stesso vuoto viene filtrato prima ancora che un item sia aggiunto — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

Il bel dettaglio nella storia è il metodo. Il primo commit ha aggiunto un ciclo logger.debug('PDF timeline item', …) esplicitamente «per fare debug delle voci vuote». Una volta che quel logging ha rivelato da dove venivano davvero i bianchi — incluso un bug separato di deduplicazione degli screenshot in cui catture rapide producevano duplicati bianchi — il commit successivo ha corretto la causa radice a monte e ha rimosso il logging di debug. Aggiungi strumentazione per trovare la causa, correggi la causa, cancella la strumentazione. Il punto del changelog "filtered empty entries" contiene silenziosamente tutto quell'arco di aggiungi-poi-rimuovi.

v1.8.12: «excludes AI responses», leggi il diff

Tre release dopo, un altro cambiamento — ed è una buona lezione sul leggere il codice invece del changelog. La nota della v1.8.12 dice che l'export PDF ora «excludes AI responses for cleaner meeting records». Vero, ma incompleto.

La timeline di export etichetta ogni item con un type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Una riga di riunione parlata è un transcript; una domanda che hai digitato nel pannello dell'assistente è un chat_user; la risposta dell'IA è un chat_assistant. Il cambiamento della v1.8.12 è una modifica di nove righe a un file del renderer, e la riga operativa è un filtro piazzato appena prima che il payload di export sia costruito:

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

Quella è una allowlist, non una blocklist. Non rimuove gli item chat_assistant; tiene solo transcript e screenshot e scarta tutto il resto — il che significa che toglie le domande chat_user dell'utente stesso insieme alle risposte dell'IA. Il corpo del commit è più preciso del changelog: "PDF export keeps only transcripts and screenshots, no chat messages." Il record esportato ora è puramente la riunione — ciò che è stato detto e ciò che era sullo schermo — con l'intera conversazione del pannello laterale dell'assistente rimossa da esso.

Ed è la scelta giusta, lato prodotto. La chat di GeekBye è un assistente privato che consulti durante una chiamata — chiedigli di uno screenshot, ottieni una spiegazione veloce. Quegli scambi sono il tuo blocco di appunti, non parte della riunione. Un record di riunione che condivideresti con qualcuno dovrebbe contenere la riunione, non le tue conversazioni parallele con un'IA a riguardo. La allowlist rende semplicemente «la riunione» la definizione letterale di ciò che viene esportato.

Tre cose che questa release ci ha insegnato

  1. Un browser è un renderer PDF che hai già. Se il tuo documento è testo strutturato e immagini, printToPDF su un BrowserWindow nascosto ti dà paginazione vera, rendering di font vero, e layout CSS gratis — nessuna API di disegno PDF. Scambi una dipendenza da libreria con casi limite di browser, che è spesso lo scambio migliore.
  2. Il bug che vedi e il bug che esiste non sono sempre lo stesso. «Riquadri vuoti» gridava dati mancanti; la causa era un contenitore spezzato. Strumenta prima di assumere — il logging di debug del team è ciò che ha trasformato una correzione plausibile-ma-sbagliata (filtrare i vuoti) in quella vera (break-inside: avoid).
  3. Il changelog è un riassunto; il filtro è la verità. «Excludes AI responses» è una allowlist che scarta anche le tue domande di chat. Quando una nota di rilascio descrive un cambiamento di comportamento, il diff ti dirà i suoi bordi esatti — e qui il bordo esatto è «solo la riunione sopravvive».

Per il capitolo precedente della storia v1, da OCR ai pixel senza perdere il fallback (v1.8.6–v1.8.7); e per tutto l'arco, l'anatomia del pubblicare software fino alla perfezione.

Articoli Correlati

I due modi di fallimento di un overlay click-through
Steven
Steven9 min di lettura

I due modi di fallimento di un overlay click-through

La finestra di GeekBye galleggia sopra ogni cosa e lascia che i tuoi clic la attraversino — tranne dove ha dei pulsanti. È un contratto a due facce, e la v1.8.5 e la v1.8.14 sono ciò che si vede quando ciascuna faccia si rompe: una release dove l'overlay ha inghiottito una finestra di dialogo di sistema, un'altra dove ti ha rubato le battute sulla tastiera. La correzione vincente per la seconda è stata cancellare codice.

Ingegneria
Electron
Desktop
Nato e corretto in dodici minuti
Steven
Steven8 min di lettura

Nato e corretto in dodici minuti

Il changelog di GeekBye v1.8.10 dice che ha corretto un crash durante la modifica delle scorciatoie da tastiera. L'ha fatto — ma il crash è stato introdotto e corretto nella stessa pull request, a dodici minuti di distanza, e non ha mai raggiunto un solo utente. La vera storia è la cascata di affidabilità che l'ha prodotto: un piccolo cambiamento corretto su quando le scorciatoie vengono sospese, e il bug di teardown di React caduto da una singola riga pensata per rendere l'editor più sicuro.

Ingegneria
React
Desktop
Da OCR ai pixel senza perdere il fallback
Steven
Steven8 min di lettura

Da OCR ai pixel senza perdere il fallback

GeekBye ha smesso di leggere i tuoi screenshot con l'OCR e ha iniziato a inviare l'immagine reale ai modelli di visione — che è la parte facile. La parte istruttiva è l'ottimizzazione che ha saltato l'OCR del tutto per gli utenti con visione, ha rotto senza rumore il fallback verso i modelli senza visione, ed è dovuta essere disfatta fino a una versione dove la rete di sicurezza gira in parallelo e non costa nulla.

Ingegneria
AI
Desktop