
Отпечатване на среща в PDF без PDF библиотека
GeekBye експортира среща като PDF, а никъде в кода няма PDF библиотека. Той рендира HTML в невидим browser прозорец и го отпечатва. Този избор е цялата история: направи функцията лесна за изграждане и ѝ даде всеки провал, който има истински browser — бял проблясък, ограничение за дължината на URL и прекъсване на страница, което разрязваше скрийншотите наполовина. Fix-ът за най-грозния бъг беше един ред CSS.
Съществува приятен трик, скрит в бутона „експортирай тази среща като PDF" на GeekBye: зад него няма PDF библиотека. Няма jsPDF, няма pdfmake, няма pdfkit, няма headless puppeteer. Цялата функция е скрит browser. Приложението строи HTML страница, описваща срещата — записи от транскрипта, скрийншоти, времеви печати — зарежда я в невидим Electron прозорец и моли този прозорец да се отпечата сам в PDF. webContents.printToPDF върши останалото. Този единствен архитектурен избор е цялата история на това издание, защото направи функцията евтина за изграждане и ѝ връчи всеки бъг, който има истински browser.
Моментът „преди" е същото издание
Обикновено тези публикации започват с „ето как работеше преди." Този път няма преди. Changelog-ът за v1.8.9 се чете като поддръжка — "fixed page breaks splitting screenshots and filtered empty entries" — сякаш PDF експортът е съществуваща функция, която получава кръпка. Не беше. Първият commit, който я въвежда, feat: add PDF export via Electron printToPDF, каца три дни в същия цикъл на издаване, а всеки „fix" в changelog-а е итерация от същата седмица върху код, който беше на часове. Функцията се роди, счупи и втвърди, всичко в рамките на един таг. Струва си да се каже на глас, защото това е честната форма на много от доставянето: излъсканото каре в бележките към изданието и разхвърляното раждане на нещото често са едно и също издание.
Защо изобщо скрит browser
Механизмът си струва да се разбере преди бъговете, защото всички бъгове произлизат от него. Всичко живее в един файл на главния процес, electron/ipcHandlers/pdfExportHandler.ts. Когато renderer-ът задейства 'pdf:export-report' IPC, handler-ът:
- Извиква
buildReportHtml(), за да сглоби пълен HTML низ от timeline-а на срещата — по един.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), измерваш го, придвижваш курсора, управляваш прекъсванията на страниците сам. Browser вече прави всичко това: пишеш <div>-ове и CSS, а layout engine-ът на Chromium странициира вместо теб. Не учиш drawing API; пишеш уеб страница. За документ, който е предимно стилизиран текст и вградени изображения, това е огромна пряка пътека.
Уловката е, че също така наследи browser, с всичките му гранични случаи. Три от тях се появиха в първите четири дни.
Белият проблясък. Скрит прозорец на macOS не е надеждно скрит. Първите опити го позиционираха извън екрана (x: screenWidth + 1000), и той пак проблясваше бял правоъгълник на екрана за един кадър преди отпечатването. Поправката, след три опита, беше да спре да разчита на позиция или на show: false и да зададе opacity: 0 — напълно прозрачен прозорец не проблясва дори когато композиторът го покаже за кратко. Commit-ът го казва направо: "show:false with off-screen positioning still flashes on macOS."
Ограничението за дължината на URL. Първата версия вграждаше целия HTML — със скрийншоти и всичко — като data: URL. Това работи, докато срещата има няколко base64-кодирани скрийншота в себе си, в който момент URL-ът префучава покрай ограничението на Chromium за data-URL от ~2 MB и зареждането се проваля направо с ERR_INVALID_URL. Затова стъпка 2 по-горе записва временен файл и прави loadFile(), вместо да зарежда data URL: пътят на файл няма ограничение за дължина. (Онези ~2 MB са единственото число в цялата тази история, и е inline оценка в коментар в кода, а не нещо, което някой е бенчмаркнал — приемай го като „достатъчно голямо, за да го удариш," а не като точен праг.)
Неескейпнатият текст. Транскрипт текстът е произволно потребителско съдържание и се пускаше директно в HTML низа. Всеки транскрипт, който случайно съдържаше нещо като <div> или <script>, би счупил страницата или по-лошо. Затова handler-ът си отгледа escapeHtml() минаване върху всеки низ, който интерполира. В момента, в който твоят PDF е уеб страница, HTML инжекцията също е твой проблем.
Празните кутии, които не бяха празни
Сега основният бъг, защото е най-добрият. Потребителите съобщаваха за експортирани PDF-и с празни кутии — записи, които показваха времеви печат и бадж, но никакъв скрийншот, само празно пространство там, където трябваше да е изображението. Очевидното четене е „експортът изхвърля данните на изображението," а очевидната поправка е „филтрирай празните записи." Екипът наистина добави този филтър — повече за него след секунда — но той не беше истинската причина.
Истинската причина беше странициирането. Всеки запис е .item контейнер, съдържащ времеви печат, бадж и изображение. Chromium, отпечатвайки в PDF, с радост прекъсваше страница по средата на item — рендирайки времевия печат и баджа в долната част на една страница и избутвайки изображението на следващата. Това, което изглеждаше като празна кутия, беше горната половина на item, чието изображение беше преляло на следващата страница. Данните бяха всичките там; оформлението ги беше гилотинирало. Commit-ът е откровен за грешната диагноза: "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 казва на layout engine-а да запази item цял — ако не се побира в оставащото пространство на страница, премести целия item на следващата страница, вместо да го разделя. Това е изплатата на архитектурата със скрит browser, изразена в миниатюра: най-грозният, най-докладваният бъг във функцията беше поправен не с аритметика на височината на страницата и ръчни addPage() извиквания, а с едно-единствено CSS свойство, което browser-ът вече знаеше как да зачете. Не написа странициращ механизъм; помоли този, който наследи, да се държи прилично.
Два слоя филтриране на наистина празните
Имаше и наистина празни записи — само че не тези, които причиняваха кутиите. Празни дубликати от бърз двоен Cmd+Enter, скрийншоти без preview, записи от транскрипта, които бяха само празни интервали. Тези бяха филтрирани, и интересното е, че бяха филтрирани два пъти, на два слоя:
- В handler-а
buildReportHtml()пази всеки item —if (!item.content?.trim()) return ''за текст, а за изображенияconst safeSrc = item.preview?.startsWith('data:image/') ? item.preview : ''; if (!safeSrc) return ''— после.filter(Boolean)изхвърля празните. Онази последна проверка върши двойна работа: тя е порта за валидност и малка проверка за безопасност, че само реални данни на изображение изобщо достигат страницата. - При източника на timeline-а, в renderer-а, същата празнота се филтрира, преди item изобщо да бъде добавен —
if (!entry.text?.trim()) return,if (!screenshot.preview) return.
Приятният детайл в историята е методът. Първият commit добави цикъл logger.debug('PDF timeline item', …) изрично „за дебъгване на празните записи." Щом онова логване разкри откъде всъщност идваха празнотите — включително отделен бъг с дедупликация на скрийншоти, при който бързите заснемания произвеждаха празни дубликати — последващият commit поправи коренната причина нагоре по веригата и премахна debug логването. Добави инструментиране, за да намериш причината, поправи причината, изтрий инструментирането. Карето в changelog-а "filtered empty entries" тихо съдържа цялата тази дъга добави-после-премахни.
v1.8.12: "excludes AI responses," чети diff-а
Три издания по-късно, още една промяна — и е добър урок по четене на кода вместо на changelog-а. Бележката за v1.8.12 казва, че PDF експортът вече "excludes AI responses for cleaner meeting records." Вярно, но непълно.
Timeline-ът на експорта тагва всеки item с type: 'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'. Изговорен ред от срещата е transcript; въпрос, който си въвел в панела на асистента, е chat_user; отговорът на AI е chat_assistant. Промяната в v1.8.12 е редакция от девет реда в един renderer файл, а действащият ред е филтър, поставен точно преди да се построи export payload-ът:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
Това е allowlist, не blocklist. Не премахва chat_assistant елементите; пази само transcript и screenshot и изхвърля всичко останало — което означава, че съблича собствените chat_user въпроси на потребителя заедно с отговорите на AI. Тялото на commit-а е по-точно от changelog-а: "PDF export keeps only transcripts and screenshots, no chat messages." Експортираният запис вече е чисто срещата — какво е казано и какво е било на екрана — с целия разговор от страничния панел на асистента премахнат от него.
И това е правилното решение, от продуктова гледна точка. Chat-ът на GeekBye е частен асистент, който консултираш по време на разговор — питаш го за скрийншот, получаваш бързо обяснение. Тези размени са твоят чернови бележник, а не част от срещата. Запис от среща, който би споделил с някого, трябва да съдържа срещата, а не страничните ти разговори с AI за нея. Allowlist-ът просто прави „срещата" буквалната дефиниция на това, което се експортира.
Три неща, на които ни научи това издание
- Browser е PDF рендер, който вече имаш. Ако документът ти е структуриран текст и изображения,
printToPDFвърху скритBrowserWindowти дава истинско странициране, истинско рендиране на шрифтове и CSS оформление безплатно — никакъв PDF drawing API. Разменяш зависимост от библиотека за гранични случаи на browser, което често е по-добрата размяна. - Бъгът, който виждаш, и бъгът, който съществува, не винаги са един и същ. „Празните кутии" крещяха липсващи данни; причината беше разделен контейнер. Инструментирай, преди да предположиш — debug логването на екипа е това, което превърна правдоподобна-но-грешна поправка (филтрирай празните) в истинската (
break-inside: avoid). - Changelog-ът е резюме; филтърът е истината. "Excludes AI responses" е allowlist, който изхвърля и собствените ти chat въпроси. Когато бележка към издание описва промяна в поведението, diff-ът ще ти каже точните ѝ ръбове — а тук точният ръб е „оцелява само срещата."
За предишната глава от историята на v1 — от OCR към пиксели, без да загубим fallback-а (v1.8.6–v1.8.7); а за цялата дъга — анатомията на доставянето на софтуер до съвършенство.