
Drukowanie spotkania do PDF bez biblioteki PDF
GeekBye eksportuje spotkanie jako PDF, a nigdzie w kodzie nie ma biblioteki PDF. Renderuje HTML w niewidzialnym oknie przeglądarki i drukuje je. Ten wybór to cała historia: uczynił funkcję łatwą do zbudowania i dał jej każdą awarię, jaką ma prawdziwa przeglądarka — białe mignięcie, limit długości URL i podział strony, który przecinał zrzuty ekranu na pół. Lekarstwem na najbrzydszy błąd była jedna linijka CSS.
W przycisku GeekBye „wyeksportuj to spotkanie jako PDF” kryje się satysfakcjonująca sztuczka: nie stoi za nim żadna biblioteka PDF. Żadnego jsPDF, żadnego pdfmake, żadnego pdfkit, żadnego headless puppeteer. Cała funkcja to ukryta przeglądarka. Aplikacja buduje stronę HTML opisującą spotkanie — wpisy transkrypcji, zrzuty ekranu, znaczniki czasu — ładuje ją do niewidzialnego okna Electrona i prosi to okno, by wydrukowało się do PDF. webContents.printToPDF robi resztę. Ten pojedynczy wybór architektoniczny to cała historia tego wydania, bo uczynił funkcję tanią do zbudowania i wręczył jej każdy błąd, jaki ma prawdziwa przeglądarka.
„Przedtem” to to samo wydanie
Zwykle te wpisy otwiera „tak to działało wcześniej”. Tym razem nie ma żadnego przedtem. Changelog dla v1.8.9 czyta się jak konserwacja — „naprawiono podziały stron przecinające zrzuty ekranu i odfiltrowano puste wpisy” — jakby eksport PDF był istniejącą funkcją, którą się łata. Nie był. Pierwszy commit, który go wprowadza, feat: add PDF export via Electron printToPDF, ląduje trzy dni w ten sam cykl wydawniczy, a każda „poprawka” w changelogu to iteracja z tego samego tygodnia na kodzie, który miał kilka godzin. Funkcja narodziła się, zepsuła i została zahartowana w całości wewnątrz jednego tagu. Warto to powiedzieć na głos, bo to uczciwy kształt wielu wdrożeń: dopracowany punkt w notatkach do wydania i chaotyczne narodziny tej rzeczy to często to samo wydanie.
Po co w ogóle ukryta przeglądarka
Mechanizm warto zrozumieć przed błędami, bo wszystkie błędy z niego wynikają. Wszystko żyje w jednym pliku procesu głównego, electron/ipcHandlers/pdfExportHandler.ts. Gdy renderer wystrzeliwuje IPC 'pdf:export-report', handler:
- Wywołuje
buildReportHtml(), by złożyć pełny ciąg HTML z osi czasu spotkania — jeden blok.itemna każdy wpis transkrypcji lub zrzut ekranu. - Zapisuje ten HTML do pliku tymczasowego w
os.tmpdir(). - Ładuje plik do ukrytego okna —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Czeka, aż obrazy się załadują, potem wywołuje
win.webContents.printToPDF({ printBackground: true, margins: { … } }). - Zapisuje wynik do
app.getPath('downloads')jakoMeeting-Report-<timestamp>.pdf.
Atrakcyjność jest oczywista. Biblioteka PDF każe ci pozycjonować wszystko ręcznie — narysuj tekst w (x, y), zmierz go, przesuń kursor, sam zarządzaj podziałami stron. Przeglądarka już to wszystko robi: piszesz <div>-y i CSS, a silnik układu Chromium paginuje za ciebie. Nie uczysz się API do rysowania; piszesz stronę internetową. Dla dokumentu, który jest w większości ostylowanym tekstem i osadzonymi obrazami, to ogromny skrót.
Haczyk w tym, że odziedziczyłeś też przeglądarkę, z wszystkimi jej przypadkami brzegowymi. Trzy z nich pojawiły się w ciągu pierwszych czterech dni.
Białe mignięcie. Ukryte okno na macOS nie jest niezawodnie ukryte. Pierwsze próby umieszczały je poza ekranem (x: screenWidth + 1000), a i tak migało białym prostokątem na ekranie przez jedną klatkę przed drukiem. Lekarstwem, po trzech próbach, było przestać polegać na pozycji czy show: false i ustawić opacity: 0 — w pełni przezroczyste okno nie miga, nawet gdy kompozytor na chwilę je pokaże. Commit mówi to wprost: „show:false with off-screen positioning still flashes on macOS.”
Limit długości URL. Pierwsza wersja wstawiała cały HTML — wraz ze zrzutami ekranu — jako URL data:. To działa, dopóki spotkanie nie ma w sobie kilku zrzutów ekranu zakodowanych w base64, w którym to momencie URL wypływa poza limit data-URL Chromium ~2 MB i ładowanie zawodzi wprost z ERR_INVALID_URL. Dlatego krok 2 powyżej zapisuje tymczasowy plik i robi loadFile() zamiast ładować URL data: ścieżka pliku nie ma limitu długości. (Te ~2 MB to jedyna liczba w całej tej historii i jest to szacunek wpisany w komentarz w kodzie, nie coś, co ktokolwiek zbenchmarkował — traktuj to jak „wystarczająco duży, by go trafić”, a nie precyzyjny próg.)
Nieuciekniony tekst. Tekst transkrypcji to dowolna treść użytkownika, a był wrzucany prosto do ciągu HTML. Każda transkrypcja, która akurat zawierała coś w rodzaju <div> czy <script>, zepsułaby stronę albo gorzej. Więc handler dorobił się przebiegu escapeHtml() nad każdym ciągiem, który interpoluje. W momencie, gdy twój PDF jest stroną internetową, wstrzyknięcie HTML też jest twoim problemem.
Puste ramki, które nie były puste
Teraz sztandarowy błąd, bo jest najlepszy. Użytkownicy zgłaszali wyeksportowane PDF-y z pustymi ramkami — wpisami, które pokazywały znacznik czasu i plakietkę, ale żadnego zrzutu ekranu, tylko puste miejsce tam, gdzie powinien być obraz. Oczywisty odczyt to „eksport gubi dane obrazu”, a oczywiste lekarstwo to „odfiltruj puste wpisy”. Zespół faktycznie dodał ten filtr — o tym za chwilę — ale to nie była prawdziwa przyczyna.
Prawdziwą przyczyną była paginacja. Każdy wpis to kontener .item zawierający znacznik czasu, plakietkę i obraz. Chromium, drukując do PDF, z ochotą łamał stronę w środku elementu — renderując znacznik czasu i plakietkę na dole jednej strony i wypychając obraz na następną. To, co wyglądało jak pusta ramka, było górną połową elementu, którego obraz przelał się na kolejną stronę. Dane były w komplecie; to układ je zgilotynował. Commit jest szczery co do błędnej diagnozy: „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.”
Lekarstwo to jedna linijka CSS:
.item {
break-inside: avoid;
}
break-inside: avoid mówi silnikowi układu, by trzymał element w całości — jeśli nie zmieści się w pozostałym miejscu na stronie, przenieś cały element na następną stronę zamiast go dzielić. To opłacalność architektury ukrytej przeglądarki wyrażona w miniaturze: najbrzydszy, najczęściej zgłaszany błąd tej funkcji naprawiono nie arytmetyką wysokości strony i ręcznymi wywołaniami addPage(), lecz pojedynczą właściwością CSS, którą przeglądarka już umiała uszanować. Nie napisałeś paginatora; poprosiłeś ten, który odziedziczyłeś, żeby się zachowywał.
Dwie warstwy filtrowania naprawdę pustych
Były też naprawdę puste wpisy — tyle że nie te, które powodowały ramki. Puste duplikaty z szybkiego podwójnego Cmd+Enter, zrzuty ekranu bez podglądu, wpisy transkrypcji, które były samą spacją. Te zostały odfiltrowane, i co ciekawe, zostały odfiltrowane dwukrotnie, na dwóch warstwach:
- W handlerze
buildReportHtml()pilnuje każdego elementu —if (!item.content?.trim()) return ''dla tekstu, a dla obrazówconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— a potem.filter(Boolean)odrzuca puste. To ostatnie sprawdzenie pełni podwójną rolę: to bramka poprawności oraz małe zabezpieczenie, że na stronę trafiają wyłącznie prawdziwe dane obrazu. - U źródła osi czasu, w rendererze, ta sama pustka jest filtrowana, zanim element w ogóle zostanie dodany —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Miłym szczegółem w tej historii jest metoda. Pierwszy commit dodał pętlę logger.debug('PDF timeline item', …) wprost „do debugowania pustych wpisów”. Gdy to logowanie ujawniło, skąd właściwie brały się puste miejsca — w tym osobny błąd deduplikacji zrzutów ekranu, gdzie szybkie przechwycenia produkowały puste duplikaty — kolejny commit naprawił źródłową przyczynę u góry strumienia i usunął debugowe logowanie. Dodaj instrumentację, by znaleźć przyczynę, napraw przyczynę, usuń instrumentację. Punkt changelogu „odfiltrowano puste wpisy” po cichu zawiera cały ten łuk dodaj-a-potem-usuń.
v1.8.12: „wyklucza odpowiedzi AI”, przeczytaj diff
Trzy wydania później jeszcze jedna zmiana — i to dobra lekcja czytania kodu zamiast changelogu. Notatka v1.8.12 mówi, że eksport PDF teraz „wyklucza odpowiedzi AI dla czystszych zapisów spotkań”. Prawda, ale niepełna.
Oś czasu eksportu oznacza każdy element polem type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Wypowiedziana linia spotkania to transcript; pytanie wpisane w panel asystenta to chat_user; odpowiedź AI to chat_assistant. Zmiana z v1.8.12 to dziewięciolinijkowa edycja jednego pliku renderera, a kluczowa linia to filtr umieszczony tuż przed zbudowaniem ładunku eksportu:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
To lista dozwolonych, nie lista blokowanych. Nie usuwa elementów chat_assistant; zachowuje tylko transcript i screenshot, a wszystko inne odrzuca — co znaczy, że usuwa własne pytania użytkownika chat_user razem z odpowiedziami AI. Treść commita jest dokładniejsza niż changelog: „PDF export keeps only transcripts and screenshots, no chat messages.” Wyeksportowany zapis to teraz czysto spotkanie — co powiedziano i co było na ekranie — z usuniętą z niego całą rozmową z bocznego panelu asystenta.
I to słuszna decyzja produktowo. Czat GeekBye to prywatny asystent, z którym radzisz się w trakcie rozmowy — spytaj go o zrzut ekranu, dostań szybkie wyjaśnienie. Te wymiany to twój brudnopis, nie część spotkania. Zapis spotkania, którym byś się z kimś podzielił, powinien zawierać spotkanie, nie twoje poboczne rozmowy z AI na jego temat. Lista dozwolonych po prostu czyni „spotkanie” dosłowną definicją tego, co zostaje wyeksportowane.
Trzy rzeczy, których nauczyło nas to wydanie
- Przeglądarka to silnik renderujący PDF, który już masz. Jeśli twój dokument to ustrukturyzowany tekst i obrazy,
printToPDFnad ukrytymBrowserWindowdaje ci prawdziwą paginację, prawdziwe renderowanie czcionek i układ CSS za darmo — bez API do rysowania PDF. Wymieniasz zależność od biblioteki na przypadki brzegowe przeglądarki, co często jest lepszą wymianą. - Błąd, który widzisz, i błąd, który istnieje, nie zawsze są tym samym. „Puste ramki” krzyczały brakujące dane; przyczyną był podzielony kontener. Instrumentuj, zanim założysz — to debugowe logowanie zespołu zamieniło prawdopodobne-ale-błędne lekarstwo (filtruj puste) w to prawdziwe (
break-inside: avoid). - Changelog to streszczenie; filtr to prawda. „Wyklucza odpowiedzi AI” to lista dozwolonych, która odrzuca też twoje własne pytania z czatu. Gdy notatka do wydania opisuje zmianę zachowania, diff powie ci jej dokładne krawędzie — a tutaj dokładna krawędź to „przetrwa tylko spotkanie”.
Po poprzedni rozdział historii v1 — od OCR do pikseli bez utraty ścieżki awaryjnej (v1.8.6–v1.8.7); a po cały łuk — anatomia dostarczania oprogramowania do perfekcji.