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 говорит, что исправлена авария при редактировании клавиатурных сочетаний. Так и есть — но авария была внесена и исправлена в одном и том же пул-реквесте, с разницей в двенадцать минут, и не добралась ни до одного пользователя. Настоящая история — это каскад надёжности, что её породил: маленькое, верное изменение того, когда сочетания приостанавливаются, и баг разборки React, что выпал из одной строки, призванной сделать редактор безопаснее.

Инженерия
React
Десктоп
От OCR к пикселям, не теряя запасной путь
Steven
Steven7 мин. чтения

От OCR к пикселям, не теряя запасной путь

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

Инженерия
AI
Десктоп