流萤简历是我做的 AI 简历与求职资料工作台。这是专栏第 7 篇,从正式投递的 PDF 出发,聊 DOM 快照、字体、打印布局与浏览器渲染。
产品特写:认真调整的排版,要能带走
在流萤编辑器里,你可以选择模板、调整颜色、字体和间距,再从导出弹窗选择 PDF、Word、JSON 或 Markdown。不同格式对应不同下一步:投递需要排版稳定,继续修改需要可编辑结构,备份需要完整数据。

这张图里的"保留完整排版"是一个很具体的预期。用户花时间把项目描述压进一页,不希望导出后字体变宽、头像跑位,或者底部多出一张空白纸。导出成功不仅意味着下载了一个扩展名正确的文件。
当前 PDF 链路让服务端 Playwright 驱动 Chromium,按照打印介质把准备好的 HTML 输出成 PDF。Playwright 是控制浏览器的自动化工具,在这里承担可重复渲染的工作。难点主要在"准备好的 HTML"到底包含什么,以及什么时候能认为它已经准备好。
网页状态与打印文档,不是同一套坐标
编辑器里有工具栏、阴影、分页参考线和预览缩放,它们帮助用户工作,却不该全部进入投递文件。网页 viewport 是屏幕坐标,PDF 则受纸张物理尺寸和分页规则约束。
流萤服务端明确设置 A4:宽 210mm,最小高 297mm,@page 页边距为 0,再由 #pdf-page 统一承担内边距。浏览器的 page.pdf() 也使用零页边距并优先 CSS 页尺寸,避免两层页边距叠加。
服务端最终调用的核心参数如下,摘自 renderResumePdf():
ts
const pdf = await page.pdf({
format: "A4",
printBackground: true,
preferCSSPageSize: true,
margin: {
top: "0mm", right: "0mm",
bottom: "0mm", left: "0mm",
},
});
printBackground 保留模板底色,preferCSSPageSize 让打印 CSS 参与纸张定义;零边距则与页面容器内边距共同形成一套留白规则。参数必须结合前面构造的 HTML 阅读,单独复制它们不能保证任意网页都正确分页。
这类约定必须成对看。若 CSS 里已经给纸张留白,PDF 参数又加一圈边距,可用宽度会进一步缩小;文字重排后,原来一页的内容可能被挤成两页。只盯着最后一行 format: "A4",找不到真正原因。

这里的克隆很重要:导出过程会改样式、替换图片地址和隐藏辅助元素,应该作用于副本,不能把用户正在编辑的页面改成打印态。否则一次导出就可能影响光标、缩放或后续预览。
快照也意味着一个时间边界。导出开始后继续编辑,已经克隆的 DOM 不会自动跟上后来的变化。产品若要承诺"导出一定包含最后一次输入",需要在发起快照前定义编辑刷新与保存的关系,不能只依赖用户感觉点击时已经看到了新内容。
同样写着缩放,含义可能完全不同
预览画布缩放让用户在小屏上看整张纸,通常不应改变真实字号与分页。内容自身为了适配一页而产生的缩放,则可能需要进入导出布局。两个缩放如果混用,会出现"预览 70%,PDF 也缩成 70%"的错误。
当前客户端检查被克隆根元素上的缩放。如果找到小于 1 的 transform: scale(...),会移除 transform 与 transform-origin,规范宽度,再转为 zoom。这个分支针对该节点实际携带的内容缩放,不能把它概括成任何画布缩放都会写入 PDF。
原因在于 CSS transform 通常改变视觉呈现,不按同样方式改变布局占位;分页算法看的是布局空间。一个看起来已经变小的内容块,可能仍然占着原来的高度。zoom 则会影响布局计算,使视觉尺寸与分页占位更接近目标。

这不是一个所有浏览器、所有节点都通用的公式。它依赖被导出 DOM 的结构与缩放放置位置。因此验证要同时看普通简历、一页适配、不同模板和长内容,不能只截图证明某个纸张"看起来小了"。
样式收集,目标是重建这份文档
客户端从 document.styleSheets 读取规则、去重,剔除字体导入、Google 字体、动画、过渡和悬停等不适合打印的规则,再注入选定字体的 @font-face 与字体声明。分页参考线也会在克隆中隐藏。
这一步解决的是依赖问题。简历 DOM 的 class 名本身没有外观,真正的排版来自 CSS。服务端拿到一个 <div class="...">,如果没有对应规则,就只剩浏览器默认样式。
跨域 stylesheet 的规则可能因为浏览器限制无法读取,当前实现捕获这类读取失败并跳过。所以"页面已经显示正确"不等于"导出收集到了所有规则"。若某个模板依赖跨域样式,截图和 PDF 的差异应该沿样式来源排查。
按字符串过滤规则也有局限。它是当前实现的取舍,不能替代完整的 CSS 语义分析;一个规则同时包含字体与其他重要样式时,粗粒度过滤会把整条一起拿掉。文章描述链路时,应当让读者看到这些规则是怎样被选择,而不是只说"提取全部 CSS"。
长期维护可以考虑专门的打印样式入口,让模板对打印效果承担明确责任。但那是演进方向,当前代码走的是从页面收集再补充打印约定的路径,不能把理想方案写成已经实现。
字体不是装饰,它会改变分页
同一句中文,字体的字宽和行高不同,段落可能多换一行。几段正文累计下来,就会把末尾挤到下一页。字体问题还可能只出现在服务器,因为开发机有字体、容器没有。
当前服务端在设置 HTML 后等待 document.fonts.ready,再留 500ms 渲染稳定时间。客户端也为选定字体准备字体声明,避免完全依赖服务端操作系统的字体集合。
fonts.ready 表示当前字体加载与布局过程达到相应就绪状态,却不保证某个外部字体一定成功下载;字体加载失败也可能回退到替代字体。验证时应该检查实际使用的字体与字形,不能把 Promise resolve 当成"排版必然相同"的证明。
同样,500ms 是经验性稳定等待,不能证明所有异步资源都已完成。图片很慢、字体异常或布局依赖额外资源时,固定等待仍可能不足。要提高确定性,应该针对需要的资源观察完成状态,而不是无条件把等待时间继续加长。
图片内联为什么能减少差异
客户端 optimizeImages() 尝试 fetch 非 data URL 的图片,转成数据 URL 写进克隆。这样服务端拿到快照以后,不必再次依赖用户浏览器里的登录态或那张图片当时的远端状态。
但 fetch 可能受跨域策略阻止,也可能网络失败。当前失败会记录并继续,保留原地址,不能写成"所有图片已经强制内联成功"。某张头像在网页可见、PDF 不见,首先要看它是否成功变成 data URL,以及原地址是否能从服务端访问。
相对资源由打印 HTML 的 base 地址指向本机服务端端口。这有利于加载同站资源,也说明导出不是一个完全无依赖的纯字符串转换。容器里的服务端口、静态资源路径与字体部署都会影响结果。
文中谈的是素材加载链路,不把图片截图替代成 PDF 整页位图。Chromium 的 PDF 输出仍可保留文字与浏览器打印结构,用户需要的搜索和复制体验也应纳入验收。
服务端每个阶段都可能失败
renderResumePdf() 维护 stage 字段:从规范输入、动态导入 Playwright、定位 Chromium、启动浏览器,到设置页面、等待字体和创建 PDF。失败时记录阶段与环境诊断,帮助区分代码错误和部署问题。
假设本机导出正常,容器报错。单看"Failed to generate PDF"很难知道是没有浏览器可执行文件、字体没装好,还是渲染过程超时。阶段信息加上平台、Node 版本和可执行路径是否存在,能让排查更直接。
请求的内容和样式按 UTF-8 字节统计容量,边距也限制到明确范围。这里是外部输入边界,确实需要处理空内容、过大载荷和不合法数值;不是每个内部函数都重新做一轮相同检查。
浏览器与 page 在 finally 关闭。即使 page.pdf() 失败,创建过的资源也应释放,否则一次导出故障可能变成后续请求不断累积进程。当前实现每个请求启动浏览器,并没有在这里展示复用浏览器池。
每请求启动结构简单、隔离清楚,但有冷启动和资源成本。是否引入池,需要先测并发量、内存和生命周期,再设计上下文隔离与故障回收;不能只因为池听起来更快就省略这些责任。当前代码也没有证明它已经有一套导出任务队列。
渲染安全,哪些事实能从代码确认
服务端页面关闭 JavaScript,并拦截脚本、媒体、WebSocket 和 EventSource 请求,另外阻止 Google 字体网络依赖。这些是明确的资源策略。
但其他请求在当前路由里继续放行。因此,不能把这条链路描述成"完全离线渲染"或"所有外部网络都被禁止"。如果产品未来允许更广泛的用户 HTML,资源地址白名单、出站网络限制和隔离运行环境都需要在真正的渲染边界评估。
HTML 资料预览的 iframe 沙箱是另一条机制,也不能拿它为 PDF 服务作保证。两个功能都处理 HTML,不代表它们共享同一个执行环境和安全策略。技术分析要沿真实调用链讲事实。

图中的"DOM 已就绪"特意没有写成"所有资源就绪"。这两个状态的差别,往往就是导出偶发缺图的来源。
文件选择器为什么要在最前面打开
支持 showSaveFilePicker 的浏览器,通常要求调用发生在用户激活的上下文中。若先等图片、字体与网络渲染完成,几十秒后再弹选择器,原点击带来的激活可能早已失效。
当前 exportToPdf() 在第一段异步准备之前获取保存句柄,随后完成渲染,再把 Blob 写进句柄。用户取消时直接结束;浏览器不支持或拒绝选择器时,按现有策略回到普通下载。
这个细节很容易被"整理代码顺序"破坏。把所有下载操作收进最后一个 helper,看上去更统一,却可能改变浏览器手势生命周期。重构时除了函数输入输出,还要保留操作发生的时机。
文件保存完成后,下载统计通过独立异步入口发送,统计失败不会把已经成功的文件改成失败。统计副作用与核心交付需要分清,否则用户已经拿到 PDF,却收到"导出失败",会产生另一种不可信体验。
怎样验证 PDF,而不是只验证接口返回 200
验收要看最终文件。至少覆盖长项目经历、中文字体、头像、两栏模板和一页适配,检查页数、文字是否截断、边距是否一致、末页是否空白。预览与 PDF 要比较相同输入快照,避免拿编辑后的网页对比导出前的文档。
网络条件也要有代表性:图片不可达、跨域样式不可读、字体加载失败时,结果和提示是否符合预期。生产容器还要确认可执行文件、资源路径和字体,而不是只在开发机跑一次。
客户端交互则检查用户取消、选择器不支持、下载回退和重复点击。若要声称性能更好,应记录导出耗时分布、内存与并发结果;这些测量比单次导出完成提示更有判断价值。
你可以在流萤简历选一款模板,写一段足够长的占位项目经历,再看看导出文件是否仍然符合自己的投递预期。文件交付值得单独设计,因为它是用户真正带走的成果。下一篇回到 AI 连接器,讨论机器获得写入能力后,授权和审核怎样持续有效。