
不用 PDF 库把一场会议打印成 PDF
GeekBye 把一场会议导出成 PDF,而代码里任何地方都没有一个 PDF 库。它在一个看不见的浏览器窗口里渲染 HTML,再把它打印出来。那个选择就是整个故事:它让这个功能容易做,也把真正的浏览器所有的毛病一并给了它——一次白闪、一个 URL 长度上限,以及一个把截图拦腰切断的分页。最丑那个 bug 的修复,是一行 CSS。
GeekBye 那个「把这场会议导出为 PDF」的按钮里,藏着一个令人满意的把戏:它背后没有一个 PDF 库。没有 jsPDF,没有 pdfmake,没有 pdfkit,也没有无头的 puppeteer。整个功能就是一个隐藏的浏览器。应用拼出一个描述这场会议的 HTML 页面——转录条目、截图、时间戳——把它加载进一个看不见的 Electron 窗口,再请那个窗口把自己打印成 PDF。剩下的交给 webContents.printToPDF。那一个架构选择就是这次发布的整个故事,因为它让这个功能做起来便宜,同时把真正的浏览器所有的 bug 都塞给了它。
那个「从前」就是同一个发布
通常这类文章开头是「以前它是这样工作的」。这一次没有以前。v1.8.9 的 changelog 读起来像维护——"fixed page breaks splitting screenshots and filtered empty entries"——仿佛 PDF 导出是一个正在打补丁的现有功能。它不是。引入它的第一个 commit,feat: add PDF export via Electron printToPDF,落在同一个发布周期的第三天,而 changelog 里每一处「修复」都是对只有几个小时大的代码做的同一周迭代。这个功能诞生、崩坏、又被打磨结实,全都在一个 tag 之内。这值得说出来,因为它是很多次交付诚实的样子:发布说明里那颗打磨过的要点,和这东西乱糟糟的诞生,常常是同一个发布。
到底为什么要用一个隐藏的浏览器
这套机制值得在 bug 之前先弄懂,因为 bug 全都由它派生。一切都住在一个主进程文件里,electron/ipcHandlers/pdfExportHandler.ts。当渲染进程触发 'pdf:export-report' IPC 时,处理器:
- 调用
buildReportHtml(),从会议的时间线拼出一整个 HTML 字符串——每个转录条目或截图对应一个.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) 画文本、量它、推进光标、自己管理分页。一个浏览器早就把这些全做了:你写 <div> 和 CSS,Chromium 的布局引擎替你分页。你不是在学一套绘图 API;你是在写一个网页。对于一份大体上就是带样式的文本加内嵌图片的文档,那是一条巨大的捷径。
代价是你也继承了一个浏览器,连同它所有的边角情形。其中三个在头四天里就冒了出来。
那次白闪。 在 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 的 ~2 MB data-URL 上限,加载直接以 ERR_INVALID_URL 失败。这就是为什么上面第 2 步写的是一个临时_文件_并做 loadFile(),而不是加载一个 data URL:一个文件路径没有长度上限。(那个 ~2 MB 是整个故事里唯一的数字,而且它是一条代码注释里的随手估计,不是谁跑过基准——把它当作「大到会撞上」,而不是一个精确的阈值。)
那段未转义的文本。 转录文本是任意的用户内容,而它被直接丢进了 HTML 字符串。任何碰巧含有类似 <div> 或 <script> 的转录,都会弄坏页面,甚至更糟。于是处理器长出了一遍 escapeHtml(),罩住它插值的每一个字符串。你的 PDF 是一个网页的那一刻,HTML 注入也成了你的问题。
那些其实不空的空盒子
现在来讲头号 bug,因为它是最好的一个。用户报告导出的 PDF 里有空盒子——那些条目显示了时间戳和一个徽章,却没有截图,图片该在的地方只有一片空白。显而易见的读法是「导出把图像数据丢了」,显而易见的修复是「把空条目过滤掉」。团队确实加了那个过滤器——稍后再说它——但那不是真正的成因。
真正的成因是分页。每个条目是一个 .item 容器,装着一个时间戳、一个徽章和一张图片。Chromium 在打印成 PDF 时,乐得_在一个条目的中间_分页——把时间戳和徽章渲染在一页的底部,把图片推到下一页。看起来像空盒子的东西,是一个条目的上半截,它的图片溢到了后一页。数据全都在;是布局把它上了断头台。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 告诉布局引擎把一个条目整个留住——如果它放不进一页剩下的空间,就把整个条目挪到下一页,而不是把它切开。这是隐藏浏览器这套架构的回报的一个缩影:这个功能里最丑、被报告最多的 bug,不是靠页高算术和手动的 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。
历史里那个漂亮的细节是方法。第一个 commit 加了一个 logger.debug('PDF timeline item', …) 循环,明确是 "for debugging empty entries"。一旦那些日志揭示出空白究竟从哪来——包括一个单独的截图去重 bug,飞快的捕捉在那里产生了空白重复——后续的 commit 就在上游修好了根因,并_删掉了那些调试日志_。加仪表去找成因,修成因,删仪表。changelog 那颗要点 "filtered empty entries" 悄悄装着这整段先加后删的弧线。
v1.8.12:"excludes AI responses",去读 diff
三个发布之后,还有一处改动——而它是一堂读代码而非读 changelog 的好课。v1.8.12 的说明说 PDF 导出现在 "excludes AI responses for cleaner meeting records"。没错,但不完整。
导出时间线给每个条目打上一个 type:'transcript' | 'chat_user' | 'chat_assistant' | 'screenshot'。一句说出来的会议话是一个 transcript;你敲进助手面板的一个提问是一个 chat_user;AI 的回答是一个 chat_assistant。v1.8.12 的改动是对一个渲染进程文件的九行编辑,而那句起作用的行是一个过滤器,就摆在导出负载被拼出来之前:
timeline.filter((item) => item.type === 'transcript' || item.type === 'screenshot')
那是一份白名单,不是黑名单。它不是移除 chat_assistant 条目;它只保留 transcript 和 screenshot,把其余一切都丢掉——也就是说,它把用户自己的 chat_user 提问,连同 AI 的回答,一起剥掉了。commit 正文比 changelog 更准确:"PDF export keeps only transcripts and screenshots, no chat messages." 导出的记录现在纯粹就是会议——什么被说了、屏幕上有什么——整个助手侧边栏的对话都从里面被移除了。
而从产品角度看,这是对的抉择。GeekBye 的聊天是一个你在通话_期间_去咨询的私人助手——就一张截图问它、拿一个快速的解释。那些往来是你的草稿纸,不是会议的一部分。一份你会分享给别人的会议记录,应该装的是会议,而不是你跟一个 AI 就它进行的旁支对话。这份白名单只不过把「会议」变成了「被导出的东西」这句话字面上的定义。
这次发布教给我们的三件事
- 一个浏览器就是你早已拥有的一个 PDF 渲染器。 如果你的文档是结构化的文本和图片,隔着一个隐藏
BrowserWindow的printToPDF免费给你真正的分页、真正的字体渲染,和 CSS 布局——不用 PDF 绘图 API。你拿一个库依赖去换一堆浏览器边角情形,那常常是更划算的交换。 - 你能看见的 bug 和真正存在的 bug 并不总是同一个。 「空盒子」尖叫着_数据丢失_;成因却是一个_被切开的容器_。在假设之前先上仪表——正是团队的调试日志,把一个说得通却错了的修复(过滤空的)变成了真正的那个(
break-inside: avoid)。 - changelog 是一份摘要;过滤器才是真相。 "Excludes AI responses" 是一份连你自己的聊天提问也一起丢掉的白名单。当一条发布说明描述一个行为改动时,diff 会告诉你它确切的边界——而这里确切的边界是「只有会议活了下来」。
关于 v1 故事的上一章,见从 OCR 到像素,而不丢掉那道退路(v1.8.6–v1.8.7);整段弧线则见把软件打磨到完美地发布出去的解剖学。