Steven
Steven8 хв читання

Друк зустрічі у PDF без бібліотеки PDF

GeekBye експортує зустріч як PDF, і ніде в коді немає бібліотеки PDF. Він рендерить HTML у невидимому вікні браузера і друкує його. Цей вибір — уся історія: він зробив функцію легкою у збірці й наділив її кожним збоєм, який має справжній браузер, — білим спалахом, лімітом довжини URL і розривом сторінки, що розрізав скриншоти навпіл. Ліком від найпотворнішого бага став один рядок CSS.

Інженерія
Electron
Десктоп
Релізи GeekBye
Друк зустрічі у PDF без бібліотеки PDF

У кнопці 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', обробник:

  1. Викликає buildReportHtml(), щоб зібрати повний рядок HTML із таймлайну зустрічі — один блок .item на кожен запис транскрипту або скриншот.
  2. Пише цей HTML у тимчасовий файл у os.tmpdir().
  3. Завантажує файл у приховане вікно — new BrowserWindow({ show: false, opacity: 0, focusable: false, skipTaskbar: true }).
  4. Чекає, поки зображення завантажаться, потім викликає win.webContents.printToPDF({ printBackground: true, margins: { … } }).
  5. Зберігає результат у 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 — це приватний асистент, з яким ти радишся під час дзвінка: спитай його про скриншот, дістань швидке пояснення. Ці обміни — твій чернетковий блокнот, не частина зустрічі. Запис зустрічі, яким ти б з кимось поділився, має містити зустріч, а не твої побічні розмови з ШІ про неї. Білий список просто робить «зустріч» буквальним визначенням того, що експортується.

Три речі, яких нас навчив цей реліз

  1. Браузер — це рендерер PDF, що в тебе вже є. Якщо твій документ — структурований текст і зображення, printToPDF над прихованим BrowserWindow дає тобі справжню розбивку на сторінки, справжній рендеринг шрифтів і верстку CSS безкоштовно — без API для малювання PDF. Ти міняєш залежність від бібліотеки на крайові випадки браузера, що часто вигідніше.
  2. Баг, що ти бачиш, і баг, що існує, не завжди те саме. «Порожні рамки» кричали втрата даних; причиною був розбитий контейнер. Інструментуй, перш ніж припускати, — налагоджувальне логування команди і перетворило правдоподібний-але-хибний лік (фільтруй порожні) на справжній (break-inside: avoid).
  3. Changelog — це зведення; фільтр — це правда. «Виключає відповіді ШІ» — це білий список, що відкидає й твої власні питання з чату. Коли примітка до випуску описує зміну поведінки, дифф скаже тобі її точні краї — а тут точний край це «виживає тільки зустріч».

Про попередній розділ історії v1 — від OCR до пікселів, не втрачаючи запасний шлях (v1.8.6–v1.8.7); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.

Схожі статті

Два режими відмови накладки, що пропускає кліки
Steven
Steven8 хв читання

Два режими відмови накладки, що пропускає кліки

Вікно GeekBye плаває поверх усього й пропускає твої кліки наскрізь — окрім місць, де в нього є кнопки. Це двосторонній контракт, а v1.8.5 і v1.8.14 показують, як виглядає поломка кожної зі сторін: один реліз, де накладка проковтнула системний діалог, і інший, де вона вкрала твої натискання клавіш. Переможним ліком для другого стало видалення коду.

Інженерія
Electron
Десктоп
Народжений і виправлений за дванадцять хвилин
Steven
Steven7 хв читання

Народжений і виправлений за дванадцять хвилин

Changelog GeekBye v1.8.10 каже, що виправив збій під час редагування клавіатурних скорочень. Так і є — але збій був внесений і виправлений в одному й тому самому pull request, з різницею у дванадцять хвилин, і не дістався жодного користувача. Справжня історія — це каскад надійності, що його породив: маленька, правильна зміна того, коли скорочення призупиняються, і баг розбирання React, що випав з одного рядка, покликаного зробити редактор безпечнішим.

Інженерія
React
Десктоп
Від OCR до пікселів, не втрачаючи запасний шлях
Steven
Steven7 хв читання

Від OCR до пікселів, не втрачаючи запасний шлях

GeekBye перестав читати твої скриншоти через OCR і почав надсилати саме зображення зрячим моделям — і це легка частина. Повчальна частина — оптимізація, яка пропускала OCR цілком для користувачів зі зрячою моделлю, тихо ламала запасний шлях до моделей без зору й її довелося відкотити до версії, де страхувальна сітка біжить паралельно і не коштує нічого.

Інженерія
AI
Десктоп