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
Три глагола, които поддържат Web Audio жив
Steven
Steven9 мин четене

Три глагола, които поддържат Web Audio жив

Две точкови издания на GeekBye, на два месеца разстояние и в два различни файла, научиха нашия аудио код на един и същ урок от противоположни краища: спри да третираш AudioContext на браузъра като за еднократна употреба. Едно издание се научи да прави resume() на контекст, който macOS тихо беше суспендирал по средата на записа; другото се научи да прави suspend() вместо close(), за да спрат последователните сесии да се блъскат в тавана на Chromium от приблизително шест контекста. Resume, suspend, close — това е целият сюжет.

Инженерство
Audio
Desktop
Да различиш обаждане от отворено приложение
Steven
Steven8 мин четене

Да различиш обаждане от отворено приложение

GeekBye може да забележи, че си се присъединил към видео среща, и да ти предложи да я запише. Разпознаването се оказва лесната половина — Swift binary, който чете заглавия на прозорци на всеки десет секунди. Трудната половина е прецизността: да не се задейства, когато Zoom е просто отворен, да не пита за среща, която вече записваш, и да не заглушава микрофона в обаждането, в което всъщност си. Три издания, всяко от които е предпазител, който трябваше да се научи да не побеждава сам себе си.

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