Steven
Steven8 мин четене

Отпечатване на среща в PDF без PDF библиотека

GeekBye експортира среща като PDF, а никъде в кода няма PDF библиотека. Той рендира HTML в невидим browser прозорец и го отпечатва. Този избор е цялата история: направи функцията лесна за изграждане и ѝ даде всеки провал, който има истински browser — бял проблясък, ограничение за дължината на URL и прекъсване на страница, което разрязваше скрийншотите наполовина. Fix-ът за най-грозния бъг беше един ред CSS.

Инженерство
Electron
Desktop
Издания на GeekBye
Отпечатване на среща в PDF без PDF библиотека

Съществува приятен трик, скрит в бутона „експортирай тази среща като 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-ът:

  1. Извиква buildReportHtml(), за да сглоби пълен HTML низ от timeline-а на срещата — по един .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), измерваш го, придвижваш курсора, управляваш прекъсванията на страниците сам. 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-ът просто прави „срещата" буквалната дефиниция на това, което се експортира.

Три неща, на които ни научи това издание

  1. Browser е PDF рендер, който вече имаш. Ако документът ти е структуриран текст и изображения, printToPDF върху скрит BrowserWindow ти дава истинско странициране, истинско рендиране на шрифтове и CSS оформление безплатно — никакъв PDF drawing API. Разменяш зависимост от библиотека за гранични случаи на browser, което често е по-добрата размяна.
  2. Бъгът, който виждаш, и бъгът, който съществува, не винаги са един и същ. „Празните кутии" крещяха липсващи данни; причината беше разделен контейнер. Инструментирай, преди да предположиш — debug логването на екипа е това, което превърна правдоподобна-но-грешна поправка (филтрирай празните) в истинската (break-inside: avoid).
  3. Changelog-ът е резюме; филтърът е истината. "Excludes AI responses" е allowlist, който изхвърля и собствените ти chat въпроси. Когато бележка към издание описва промяна в поведението, diff-ът ще ти каже точните ѝ ръбове — а тук точният ръб е „оцелява само срещата."

За предишната глава от историята на v1 — от OCR към пиксели, без да загубим fallback-а (v1.8.6–v1.8.7); а за цялата дъга — анатомията на доставянето на софтуер до съвършенство.

Свързани статии

Двата режима на отказ на click-through overlay
Steven
Steven8 мин четене

Двата режима на отказ на click-through overlay

Прозорецът на GeekBye плава над всичко и оставя кликовете ти да минават през него — освен там, където има бутони. Това е двустранен договор, а v1.8.5 и v1.8.14 показват как изглежда, когато всяка от страните се счупи: едно издание, в което overlay-ът глътна системен диалог, и едно, в което открадна натисканията на клавишите ти. Печелившият fix за второто беше изтриване на код.

Инженерство
Electron
Desktop
Роден и поправен за дванадесет минути
Steven
Steven7 мин четене

Роден и поправен за дванадесет минути

Changelog-ът на GeekBye v1.8.10 казва, че е поправил срив при редактиране на клавишните комбинации. И наистина го поправи — но сривът беше въведен и поправен в един и същ pull request, на дванадесет минути разстояние, и никога не достигна до нито един потребител. Истинската история е каскадата от надеждност, която го произведе: малка, коректна промяна в това кога комбинациите се спират, и бъгът с React разграждането, който изпадна от един ред, предназначен да направи редактора по-безопасен.

Инженерство
React
Desktop
От OCR към пиксели, без да загубим fallback-а
Steven
Steven7 мин четене

От OCR към пиксели, без да загубим fallback-а

GeekBye спря да чете скрийншотовете ти с OCR и започна да изпраща самото изображение към vision моделите — което е лесната част. Поучителната част е оптимизацията, която прескочи OCR изцяло за vision потребителите, тихо счупи fallback-а към не-vision моделите и трябваше да бъде върната до версия, в която предпазната мрежа работи паралелно и не струва нищо.

Инженерство
AI
Desktop