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
Cele trei verbe care mențin Web Audio în viață
Steven
Steven9 min citire

Cele trei verbe care mențin Web Audio în viață

Două lansări punctuale GeekBye, la două luni distanță și în două fișiere diferite, au învățat codul nostru audio aceeași lecție din capete opuse: nu mai trata AudioContext-ul browserului ca pe ceva de unică folosință. O lansare a învățat să apeleze resume() pe un context pe care macOS îl suspendase în tăcere în mijlocul înregistrării; cealaltă a învățat să apeleze suspend() în loc de close(), ca sesiunile consecutive să nu mai izbească plafonul Chromium de aproximativ șase contexte. Resume, suspend, close — asta e toată intriga.

Inginerie
Audio
Desktop
A deosebi un apel de o aplicație deschisă
Steven
Steven9 min citire

A deosebi un apel de o aplicație deschisă

GeekBye poate observa că te-ai alăturat unei ședințe video și îți poate oferi s-o înregistreze. Detecția se dovedește a fi jumătatea ușoară — un binar Swift care citește titlurile ferestrelor la fiecare zece secunde. Jumătatea grea e precizia: să nu se declanșeze când Zoom e doar deschis, să nu întrebe pentru o ședință pe care deja o înregistrezi și să nu silențieze microfonul din apelul în care chiar te afli. Trei lansări, iar fiecare e o gardă care a trebuit să învețe să nu se învingă singură.

Inginerie
macOS
Desktop