Steven
Steven9 min citire

Tipărirea unei ședințe în PDF fără o bibliotecă PDF

GeekBye exportă o ședință ca PDF, și nu există nicăieri în cod vreo bibliotecă PDF. Randează HTML într-o fereastră de browser invizibilă și o tipărește. Alegerea aceea e toată povestea: a făcut funcționalitatea ușor de construit și i-a dat fiecare eșec pe care-l are un browser real — un flash alb, o limită de lungime a URL-ului și o întrerupere de pagină care tăia capturile de ecran în două. Fix-ul pentru cel mai urât bug a fost o singură linie de CSS.

Inginerie
Electron
Desktop
Lansări GeekBye
Tipărirea unei ședințe în PDF fără o bibliotecă PDF

Există un truc satisfăcător ascuns în butonul „exportă această ședință ca PDF" al GeekBye: nu există nicio bibliotecă PDF în spatele lui. Fără jsPDF, fără pdfmake, fără pdfkit, fără puppeteer headless. Întreaga funcționalitate e un browser ascuns. Aplicația construiește o pagină HTML care descrie ședința — intrări de transcript, capturi de ecran, timestamp-uri — o încarcă într-o fereastră Electron invizibilă și cere acelei ferestre să se tipărească singură în PDF. webContents.printToPDF face restul. Acea unică alegere de arhitectură e toată povestea acestei lansări, pentru că a făcut funcționalitatea ieftin de construit și i-a înmânat fiecare bug pe care-l are un browser real.

Perioada de dinainte e aceeași lansare

De obicei aceste articole încep cu „iată cum funcționa înainte." De data asta nu există niciun înainte. Changelog-ul pentru v1.8.9 se citește ca mentenanță — "fixed page breaks splitting screenshots and filtered empty entries" — de parcă exportul PDF ar fi o funcționalitate existentă care primește un patch. Nu era. Primul commit care o introduce, feat: add PDF export via Electron printToPDF, aterizează la trei zile în același ciclu de lansare, iar fiecare „fix" din changelog e iterație din aceeași săptămână pe cod care avea ore vechime. Funcționalitatea s-a născut, s-a rupt și a fost consolidată, totul în interiorul unui singur tag. Merită spus cu voce tare pentru că e forma onestă a multor livrări: bullet-ul lustruit din notele de lansare și nașterea dezordonată a lucrului sunt frecvent aceeași lansare.

De ce un browser ascuns, la urma urmei

Merită înțeles mecanismul înaintea bug-urilor, pentru că toate bug-urile descind din el. Totul trăiește într-un singur fișier de proces principal, electron/ipcHandlers/pdfExportHandler.ts. Când renderer-ul declanșează IPC-ul 'pdf:export-report', handler-ul:

  1. Apelează buildReportHtml() pentru a asambla un șir HTML complet din timeline-ul ședinței — un bloc .item per intrare de transcript sau captură de ecran.
  2. Scrie acel HTML într-un fișier temporar în os.tmpdir().
  3. Încarcă fișierul într-o fereastră ascunsă — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Așteaptă încărcarea imaginilor, apoi apelează win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Salvează rezultatul în app.getPath('downloads') ca Meeting-Report-<timestamp>.pdf.

Atracția e evidentă. O bibliotecă PDF te pune să poziționezi totul de mână — desenezi text la (x, y), îl măsori, avansezi cursorul, gestionezi singur întreruperile de pagină. Un browser face deja tot asta: scrii <div>-uri și CSS, iar motorul de layout al Chromium paginează pentru tine. Nu înveți un API de desenare; scrii o pagină web. Pentru un document care e în mare parte text stilizat și imagini încorporate, asta e o scurtătură uriașă.

Cârligul e că ai moștenit și un browser, cu toate cazurile lui limită. Trei dintre ele au apărut în primele patru zile.

Flash-ul alb. O fereastră ascunsă pe macOS nu e ascunsă în mod fiabil. Primele încercări au poziționat-o în afara ecranului (x: screenWidth + 1000), și tot arunca un dreptunghi alb pe ecran pentru un cadru înainte de tipărire. Fix-ul, după trei încercări, a fost să nu se mai bazeze pe poziție sau pe show: false și să seteze opacity: 0 — o fereastră complet transparentă nu dă flash nici măcar când compozitorul o arată pentru scurt timp. Commit-ul o spune limpede: "show:false with off-screen positioning still flashes on macOS."

Limita de lungime a URL-ului. Prima versiune băga tot HTML-ul inline — cu tot cu capturi de ecran — ca un URL data:. Asta merge până când o ședință are câteva capturi de ecran codate base64 în ea, moment în care URL-ul depășește limita de data-URL de ~2 MB a Chromium, iar încărcarea eșuează de-a dreptul cu ERR_INVALID_URL. De aceea pasul 2 de mai sus scrie un fișier temporar și face loadFile() în loc să încarce un data URL: o cale de fișier nu are limită de lungime. (Acel ~2 MB e singura cifră din toată povestea asta, și e o estimare inline dintr-un comentariu de cod, nu ceva ce a măsurat cineva — tratează-l ca „suficient de mare cât să-l atingi," nu ca un prag precis.)

Textul neescapat. Textul de transcript e conținut arbitrar de utilizator, și era aruncat direct în șirul HTML. Orice transcript care se întâmpla să conțină ceva de genul <div> sau <script> ar fi rupt pagina sau mai rău. Așa că handler-ul a căpătat o trecere escapeHtml() peste fiecare șir pe care-l interpolează. În momentul în care PDF-ul tău e o pagină web, injecția HTML e și ea problema ta.

Cutiile goale care nu erau goale

Acum bug-ul principal, pentru că e cel mai bun. Utilizatorii raportau PDF-uri exportate cu cutii goale — intrări care arătau un timestamp și un badge, dar nicio captură de ecran, doar spațiu gol unde ar fi trebuit să fie imaginea. Citirea evidentă e „exportul aruncă datele imaginii," iar fix-ul evident e „filtrează intrările goale." Echipa chiar a adăugat acel filtru — mai multe despre el într-o secundă — dar nu era cauza reală.

Cauza reală era paginarea. Fiecare intrare e un container .item care ține un timestamp, un badge și o imagine. Chromium, tipărind în PDF, era bucuros să întrerupă o pagină la mijlocul unui item — randând timestamp-ul și badge-ul în josul unei pagini și împingând imaginea pe următoarea. Ceea ce arăta ca o cutie goală era jumătatea de sus a unui item a cărui imagine dăduse pe dinafară pe pagina următoare. Datele erau toate acolo; layout-ul le ghilotinase. Commit-ul e sincer despre diagnosticul greșit: "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."

Fix-ul e o singură linie de CSS:

.item {
  break-inside: avoid;
}

break-inside: avoid îi spune motorului de layout să păstreze un item întreg — dacă nu încape în spațiul rămas pe o pagină, mută întregul item pe pagina următoare în loc să-l împartă. Asta e răsplata arhitecturii cu browser ascuns, spusă în miniatură: cel mai urât și cel mai raportat bug al funcționalității a fost reparat nu cu aritmetică de înălțime a paginii și apeluri manuale addPage(), ci cu o singură proprietate CSS pe care browser-ul știa deja s-o onoreze. N-ai scris un paginator; l-ai rugat pe cel moștenit să se poarte frumos.

Două straturi de filtrare a celor cu adevărat goale

Existau și intrări cu adevărat goale — doar că nu cele care cauzau cutiile. Duplicate goale de la un dublu Cmd+Enter rapid, capturi de ecran fără preview, intrări de transcript care erau doar spații albe. Acestea au fost filtrate, și interesant e că au fost filtrate de două ori, la două straturi:

  • În handler, buildReportHtml() protejează fiecare item — if (!item.content?.trim()) return '' pentru text, iar pentru imagini const safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return '' — apoi .filter(Boolean) renunță la cele goale. Ultima verificare face muncă dublă: e o poartă de validitate și o mică verificare de siguranță că doar date reale de imagine ajung vreodată pe pagină.
  • La sursa timeline-ului, în renderer, aceeași goliciune e filtrată înainte ca un item să fie măcar adăugat — if (!entry.text?.trim()) return, if (!screenshot.preview) return.

Detaliul frumos din istoric e metoda. Primul commit a adăugat o buclă logger.debug('PDF timeline item', …) explicit „pentru depanarea intrărilor goale." Odată ce acel logging a dezvăluit de unde veneau de fapt spațiile goale — inclusiv un bug separat de deduplicare a capturilor de ecran unde capturile rapide produceau duplicate goale — commit-ul următor a reparat cauza-rădăcină în amonte și a eliminat logging-ul de debug. Adaugă instrumentare ca să găsești cauza, repară cauza, șterge instrumentarea. Bullet-ul din changelog "filtered empty entries" conține discret întregul arc adaugă-apoi-elimină.

v1.8.12: "excludes AI responses," citește diff-ul

Trei lansări mai târziu, încă o schimbare — și e o lecție bună despre a citi codul în loc de changelog. Nota v1.8.12 spune că exportul PDF acum "excludes AI responses for cleaner meeting records." Adevărat, dar incomplet.

Timeline-ul de export etichetează fiecare item cu un type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. O linie de ședință vorbită e un transcript; o întrebare pe care ai tastat-o în panoul asistentului e un chat_user; răspunsul AI e un chat_assistant. Schimbarea v1.8.12 e o editare de nouă linii într-un singur fișier de renderer, iar linia operativă e un filtru plasat chiar înainte ca payload-ul de export să fie construit:

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

Ăsta e un allowlist, nu un blocklist. Nu elimină item-urile chat_assistant; păstrează doar transcript și screenshot și renunță la tot restul — ceea ce înseamnă că elimină și propriile întrebări chat_user ale utilizatorului odată cu răspunsurile AI. Corpul commit-ului e mai exact decât changelog-ul: "PDF export keeps only transcripts and screenshots, no chat messages." Înregistrarea exportată e acum pur ședința — ce s-a spus și ce era pe ecran — cu întreaga conversație din panoul lateral al asistentului eliminată din ea.

Și e alegerea corectă, din punct de vedere de produs. Chat-ul GeekBye e un asistent privat pe care-l consulți în timpul unui apel — întreabă-l despre o captură de ecran, primești o explicație rapidă. Acele schimburi sunt ciorna ta, nu parte din ședință. O înregistrare de ședință pe care ai împărtăși-o cu cineva ar trebui să conțină ședința, nu conversațiile tale laterale cu un AI despre ea. Allowlist-ul pur și simplu face din „ședință" definiția literală a ceea ce se exportă.

Trei lucruri pe care ni le-a predat această lansare

  1. Un browser e un randor PDF pe care deja îl ai. Dacă documentul tău e text structurat și imagini, printToPDF peste un BrowserWindow ascuns îți oferă paginare reală, randare de fonturi reală și layout CSS pe gratis — niciun API de desenare PDF. Schimbi o dependență de bibliotecă pentru cazuri limită de browser, ceea ce e adesea schimbul mai bun.
  2. Bug-ul pe care-l vezi și bug-ul care există nu sunt mereu același. „Cutiile goale" strigau date lipsă; cauza era un container împărțit. Instrumentează înainte să presupui — logging-ul de debug al echipei e ceea ce a transformat un fix plauzibil-dar-greșit (filtrează golurile) în cel real (break-inside: avoid).
  3. Changelog-ul e un rezumat; filtrul e adevărul. "Excludes AI responses" e un allowlist care renunță și la propriile tale întrebări de chat. Când o notă de lansare descrie o schimbare de comportament, diff-ul îți va spune marginile ei exacte — iar aici marginea exactă e „doar ședința supraviețuiește."

Pentru capitolul anterior al poveștii v1, de la OCR la pixeli fără a pierde fallback-ul (v1.8.6–v1.8.7); iar pentru întregul arc, anatomia livrării de software până la perfecțiune.

Articole Similare

Cele două moduri de eșec ale unui overlay click-through
Steven
Steven9 min citire

Cele două moduri de eșec ale unui overlay click-through

Fereastra GeekBye plutește peste tot și lasă click-urile tale să treacă prin ea — cu excepția locurilor unde are butoane. Ăsta e un contract cu două fețe, iar v1.8.5 și v1.8.14 arată cum e când se rupe fiecare față: o lansare în care overlay-ul a înghițit un dialog de sistem, una în care ți-a furat tastele. Fix-ul câștigător pentru a doua a fost ștergerea de cod.

Inginerie
Electron
Desktop
Născut și reparat în douăsprezece minute
Steven
Steven8 min citire

Născut și reparat în douăsprezece minute

Changelog-ul GeekBye v1.8.10 spune că a reparat un crash în timpul editării scurtăturilor de tastatură. Chiar a făcut-o — dar crash-ul a fost introdus și reparat în același pull request, la douăsprezece minute distanță, și nu a ajuns niciodată la vreun utilizator. Povestea reală e cascada de fiabilitate care l-a produs: o schimbare mică și corectă a momentului când scurtăturile sunt suspendate, și bug-ul de demontare React care a căzut dintr-o singură linie menită să facă editorul mai sigur.

Inginerie
React
Desktop
De la OCR la pixeli fără a pierde fallback-ul
Steven
Steven8 min citire

De la OCR la pixeli fără a pierde fallback-ul

GeekBye a încetat să-ți citească capturile de ecran cu OCR și a început să trimită imaginea propriu-zisă către modelele cu viziune — iar asta e partea ușoară. Partea instructivă e optimizarea care a sărit complet peste OCR pentru utilizatorii cu viziune, a rupt pe tăcute fallback-ul către modelele fără viziune și a trebuit readusă la o versiune în care plasa de siguranță rulează în paralel și nu costă nimic.

Inginerie
AI
Desktop