
Друк зустрічі у PDF без бібліотеки PDF
GeekBye експортує зустріч як PDF, і ніде в коді немає бібліотеки PDF. Він рендерить HTML у невидимому вікні браузера і друкує його. Цей вибір — уся історія: він зробив функцію легкою у збірці й наділив її кожним збоєм, який має справжній браузер, — білим спалахом, лімітом довжини URL і розривом сторінки, що розрізав скриншоти навпіл. Ліком від найпотворнішого бага став один рядок CSS.
У кнопці GeekBye «експортувати цю зустріч як PDF» ховається приємний трюк: за нею немає жодної бібліотеки PDF. Ні jsPDF, ні pdfmake, ні pdfkit, ні headless puppeteer. Уся функція — це прихований браузер. Застосунок будує HTML-сторінку, що описує зустріч — записи транскрипту, скриншоти, мітки часу, — завантажує її у невидиме вікно Electron і просить це вікно надрукувати себе у PDF. webContents.printToPDF робить решту. Цей єдиний архітектурний вибір — уся історія цього релізу, бо він зробив функцію дешевою у збірці й вручив їй кожен баг, який має справжній браузер.
«До» — це той самий реліз
Зазвичай такі пости відкриваються словами «ось як це працювало раніше». Цього разу жодного раніше немає. Changelog для v1.8.9 читається як обслуговування — «виправлено розриви сторінок, що розрізали скриншоти, і відфільтровано порожні записи» — наче експорт PDF був наявною функцією, яку латають. Не був. Перший коміт, що його вводить, feat: add PDF export via Electron printToPDF, приземляється на третій день того самого релізного циклу, і кожне «виправлення» в changelog — це ітерація того самого тижня над кодом, якому було кілька годин. Функція народилася, зламалася і була загартована цілком усередині одного тега. Це варто сказати вголос, бо це чесна форма багатьох релізів: відшліфований пункт у примітках до випуску і безладне народження цієї штуки — часто той самий реліз.
Навіщо взагалі прихований браузер
Механізм варто зрозуміти до багів, бо всі баги походять із нього. Усе живе в одному файлі головного процесу, electron/ipcHandlers/pdfExportHandler.ts. Коли рендерер вистрілює IPC 'pdf:export-report', обробник:
- Викликає
buildReportHtml(), щоб зібрати повний рядок HTML із таймлайну зустрічі — один блок.itemна кожен запис транскрипту або скриншот. - Пише цей HTML у тимчасовий файл у
os.tmpdir(). - Завантажує файл у приховане вікно —
new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }). - Чекає, поки зображення завантажаться, потім викликає
win.webContents.printToPDF({ printBackground: true, margins: { … } }). - Зберігає результат у
app.getPath('downloads')якMeeting-Report-<timestamp>.pdf.
Принадність очевидна. Бібліотека PDF змушує тебе позиціонувати все вручну — намалюй текст у (x, y), зміряй його, посунь курсор, сам керуй розривами сторінок. Браузер уже робить усе це: ти пишеш <div>-и і CSS, а рушій верстки Chromium розбиває на сторінки за тебе. Ти не вчиш API для малювання; ти пишеш вебсторінку. Для документа, що переважно є стилізованим текстом і вбудованими зображеннями, це величезне скорочення шляху.
Заковика в тому, що ти також успадкував браузер з усіма його крайовими випадками. Три з них випливли в перші чотири дні.
Білий спалах. Приховане вікно на macOS сховане ненадійно. Перші спроби розміщали його за екраном (x: screenWidth + 1000), а воно все одно блимало білим прямокутником на екрані на один кадр перед друком. Ліком, після трьох спроб, було перестати покладатися на позицію чи show: false і виставити opacity: 0 — цілком прозоре вікно не блимає, навіть коли композитор на мить його показує. Коміт каже це прямо: «show:false with off-screen positioning still flashes on macOS.»
Ліміт довжини URL. Перша версія вбудовувала весь HTML — разом зі скриншотами — як URL data:. Це працює, доки у зустрічі не опиниться кілька скриншотів, закодованих у base64, і в цей момент URL випливає за ліміт data-URL Chromium ~2 MB, і завантаження провалюється напряму з ERR_INVALID_URL. Ось чому крок 2 вище пише тимчасовий файл і робить loadFile() замість завантаження URL data: у шляху до файлу немає ліміту довжини. (Ці ~2 MB — єдине число в усій цій історії, і це вбудована оцінка в коментарі коду, а не те, що хтось зміряв бенчмарком, — сприймай це як «достатньо великий, щоб упертися», а не точний поріг.)
Неекранований текст. Текст транскрипту — це довільний контент користувача, а він кидався прямо в рядок HTML. Будь-який транскрипт, що випадково містив щось на кшталт <div> чи <script>, зламав би сторінку або гірше. Тож обробник обріс проходом escapeHtml() над кожним рядком, що він інтерполює. Тієї миті, коли твій PDF — вебсторінка, впровадження HTML теж стає твоєю проблемою.
Порожні рамки, що не були порожніми
Тепер заголовний баг, бо він найкращий. Користувачі повідомляли про експортовані PDF з порожніми рамками — записами, що показували мітку часу і значок, але жодного скриншота, тільки порожнє місце там, де мало бути зображення. Очевидне прочитання — «експорт втрачає дані зображення», а очевидний лік — «відфільтруй порожні записи». Команда справді додала цей фільтр — про нього за секунду, — але не він був справжньою причиною.
Справжньою причиною була розбивка на сторінки. Кожен запис — це контейнер .item, що тримає мітку часу, значок і зображення. Chromium, друкуючи в PDF, з радістю ламав сторінку посеред елемента — рендерячи мітку часу і значок унизу однієї сторінки і виштовхуючи зображення на наступну. Те, що виглядало порожньою рамкою, було верхньою половиною елемента, чиє зображення перелилося на наступну сторінку. Дані були на місці; верстка їх гільйотинувала. Коміт відвертий щодо хибного діагнозу: «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.»
Виправлення — один рядок CSS:
.item {
break-inside: avoid;
}
break-inside: avoid велить рушію верстки тримати елемент цілим — якщо він не влазить у місце, що лишилося на сторінці, перенеси весь елемент на наступну сторінку, а не розбивай його. Це окупність архітектури прихованого браузера, виражена в мініатюрі: найпотворніший, найчастіше повідомлюваний баг цієї функції виправили не арифметикою висоти сторінки і ручними викликами addPage(), а єдиною властивістю CSS, що браузер уже вмів шанувати. Ти не написав пагінатор; ти попросив той, що успадкував, поводитися як слід.
Два шари фільтрації по-справжньому порожніх
По-справжньому порожні записи теж були — просто не ті, що спричиняли рамки. Порожні дублікати від швидкого подвійного Cmd+Enter, скриншоти без прев'ю, записи транскрипту, що були самими пробілами. Їх відфільтрували, і, що цікаво, відфільтрували двічі, на двох шарах:
- В обробнику
buildReportHtml()охороняє кожен елемент —if (!item.content?.trim()) return ''для тексту, а для зображеньconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— потім.filter(Boolean)відкидає порожні. Ця остання перевірка несе подвійну службу: це і вентиль валідності, і маленька перевірка безпеки, що на сторінку потрапляють лише справжні дані зображення. - Біля джерела таймлайну, у рендерері, та сама порожнеча фільтрується, перш ніж елемент узагалі буде додано —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Приємна деталь в історії — це метод. Перший коміт додав цикл logger.debug('PDF timeline item', …) явно «для налагодження порожніх записів». Щойно це логування виявило, звідки насправді бралися порожнечі — включно з окремим багом дедуплікації скриншотів, де швидкі захоплення породжували порожні дублікати, — наступний коміт виправив кореневу причину вище за течією і прибрав налагоджувальне логування. Додай інструментацію, щоб знайти причину, виправ причину, видали інструментацію. Пункт changelog «відфільтровано порожні записи» тихо містить усю цю дугу додай-а-потім-прибери.
v1.8.12: «виключає відповіді ШІ», читай дифф
Три релізи потому ще одна зміна — і це добрий урок читання коду замість changelog. Примітка v1.8.12 каже, що експорт PDF тепер «виключає відповіді ШІ задля чистіших записів зустрічей». Правда, але неповна.
Таймлайн експорту позначає кожен елемент полем type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Вимовлений рядок зустрічі — це transcript; питання, що ти набрав у панель асистента, — це chat_user; відповідь ШІ — це chat_assistant. Зміна v1.8.12 — це правка на дев'ять рядків в одному файлі рендерера, і ключовий рядок — це фільтр, поставлений прямо перед збіркою корисного навантаження експорту:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Це білий список, а не чорний. Він не видаляє елементи chat_assistant; він лишає тільки transcript і screenshot і відкидає все інше — а отже, зрізає власні питання користувача chat_user разом з відповідями ШІ. Тіло коміту точніше, ніж changelog: «PDF export keeps only transcripts and screenshots, no chat messages.» Експортований запис тепер суто зустріч — що було сказано і що було на екрані — з видаленою з нього цілою розмовою з бічної панелі асистента.
І це правильне рішення з погляду продукту. Чат GeekBye — це приватний асистент, з яким ти радишся під час дзвінка: спитай його про скриншот, дістань швидке пояснення. Ці обміни — твій чернетковий блокнот, не частина зустрічі. Запис зустрічі, яким ти б з кимось поділився, має містити зустріч, а не твої побічні розмови з ШІ про неї. Білий список просто робить «зустріч» буквальним визначенням того, що експортується.
Три речі, яких нас навчив цей реліз
- Браузер — це рендерер PDF, що в тебе вже є. Якщо твій документ — структурований текст і зображення,
printToPDFнад прихованимBrowserWindowдає тобі справжню розбивку на сторінки, справжній рендеринг шрифтів і верстку CSS безкоштовно — без API для малювання PDF. Ти міняєш залежність від бібліотеки на крайові випадки браузера, що часто вигідніше. - Баг, що ти бачиш, і баг, що існує, не завжди те саме. «Порожні рамки» кричали втрата даних; причиною був розбитий контейнер. Інструментуй, перш ніж припускати, — налагоджувальне логування команди і перетворило правдоподібний-але-хибний лік (фільтруй порожні) на справжній (
break-inside: avoid). - Changelog — це зведення; фільтр — це правда. «Виключає відповіді ШІ» — це білий список, що відкидає й твої власні питання з чату. Коли примітка до випуску описує зміну поведінки, дифф скаже тобі її точні краї — а тут точний край це «виживає тільки зустріч».
Про попередній розділ історії v1 — від OCR до пікселів, не втрачаючи запасний шлях (v1.8.6–v1.8.7); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.