☰
HTML转PDF实战:无头浏览器、中文字体与打印CSS全攻略
2026/10/14 4:49:20 网站建设 项目流程

前阵子接了个需求:把一份带复杂表格、渐变配色、还夹着指标卡片的 HTML 周报自动导出成 PDF,要求排版和浏览器里“肉眼一致”,而且整份都是中文。我第一反应是,这事浏览器自己就能干,Ctrl+P 另存为 PDF 嘛。可真要把这个动作搬到服务端、做到批量、保证不丢样式、不出现乱码,问题立刻变多了:字体缺失、分页截断、背景色消失、异步样式没加载完就开始转换……每一条都是实战里真实踩过的坑。这篇文章就把我折腾完后的完整方案和排错思路写出来,给正在做报表导出、合同生成、网页存证、电子发票这类功能的开发者一个参考。

1. 为什么“网页另存为 PDF”听起来简单,做起来总翻车

1.1 CSS 解析的“标准鸿沟”才是样式丢失的根源

HTML 转 PDF 在服务端的实现路子,归根结底分两类:一类是直接复用浏览器渲染内核,让网页自己把版面算出来;另一类是独立的 HTML/CSS 排版引擎,它从零解析标签和样式,自己完成文本布局。两类方案对 CSS 的支持程度差异非常大。CSS 到今天已经是几千条特性的大集合,没有任何一个引擎敢说自己全部实现。

实际开发中最常见的翻车点集中在 flexbox 和 grid。老一代轻量级转换工具基于 WebKit 早期内核,当年连position: sticky都支持得别扭,遇到 flex 布局时经常把子元素拍扁、对齐方式丢失,甚至直接忽略display: flex,把所有块元素从上到下排一遍。你精心写的两栏布局、居中盒子,到 PDF 里全变成了竖着的裸文本。这不是代码 bug,是排版引擎的能力边界。

另外,“样式没加载完就开始转换”这个时序问题也很坑。很多页面用外链 CSS,转换工具抓完 HTML 立刻渲染输出,但样式表还没回来,出来的 PDF 就是纯文本加默认样式。排错时第一件事不是翻 CSS,是先确认样式真实加载完成。

1.2 中文字体不是“装了就能用”:字体覆盖才是关键

中文在 PDF 里出问题的表象有两种:乱码(字符错位、变成问号)和豆腐块(一个个空心方框)。乱码大多是编码处理问题,比如 HTML 声明了 UTF-8,但服务器读取时用了错误的解码方式。这种问题在现代开发里已经不太常见了。豆腐块则完全不同,它说明文本的字节是对的,编码也是对的,但 PDF 渲染时找不到能画出这些字形的字体。

PDF 里的文字严格来说是“字形引用”,一段中文文本存储的是对应字体的字形索引。如果当前字体里根本没有某个汉字,渲染器就只能画一个占位方块。换句话说,中文字体不是“装了就能自动生效”,它需要在字体栈里被正确匹配到,并且覆盖你要用的字符集。很多 Linux 服务端镜像默认不带任何 CJK 字体,HTML 里写着微软雅黑、苹方这些中文字体名,系统里根本没有,fallback 又没接住,于是满屏豆腐块。

1.3 屏幕渲染和打印渲染本来就是两套逻辑

很多人忽略了一个基础事实:CSS 里screen和print是两种媒体类型,浏览器“另存为 PDF”本质上是在print媒体下重新渲染页面。这意味着你在屏幕上看到的布局、颜色、交互效果,到了打印语境里完全可能不一样。

典型的例子包括:背景色默认不打印(这是浏览器省墨设计),fixed定位元素在打印时可能重复出现在每一页,超宽表格被直接裁掉而不是缩放。所以一份“能看”的网页,不等于一份“能印”的网页。我们要做的是在交付 PDF 之前,先抓好两件事:一是选择还原度足够高的渲染方案,二是针对打印媒体写一层专门的 CSS。这两件事我后面会逐个展开。

2. 技术路线选型:三条路的擅长领域和潜在代价

2.1 路线A:浏览器内核方案,还原度第一

所谓浏览器内核方案,就是用一个“无头”的浏览器进程去加载页面,然后调用它的打印输出能力直接生成 PDF。因为是真实渲染内核在干活,网页上见到的 flexbox、grid、变量、滤镜、Canvas 都能保留,还原度几乎可以做到像素级一致。这也是我在做复杂报表导出时的首选。

这个方案的代价主要有三个:内存占用高,单个页面转换可能吃掉几百 MB 内存;需要配套维护浏览器运行时和系统字体;批量高并发时要自己做进程池或复用策略。相比还原度带来的收益,这些代价在服务端完全可以接受。

2.2 路线B:独立排版引擎,更适合文档出版

独立排版引擎不依赖浏览器,它自己解析 HTML 和 CSS 再排版输出。这类引擎的设计目标更偏“文档”而不是“网页”,对分页、页边距、页眉页脚、脚注、目录这些出版特性支持得很细腻,生成的 PDF 体积通常也更小。

但它一个明显短板是跟不上 Web 生态:flexbox 和 grid 的支持往往不完整,CSS 变量的支持也分版本。如果你的 HTML 是手写的、结构简单的文档型内容,比如说明书、论文、书籍章节,这条路很合适;如果是项目组里从后台管理系统里导出的复杂模板,用了大量现代布局特性,就很容易翻车。

2.3 路线C:在线转换服务,快但要想清楚数据代价

在线服务就是把 HTML 内容 POST 到远程接口,服务端渲染完了把 PDF 文件返回给你。优势非常直接:不用自己装字体、不用维护浏览器、中文支持开箱即用,某些服务甚至内置了网页截图和打印方案的完整能力。

但代价同样直接:HTML 内容要出内网,涉及敏感数据时基本一票否决;按次计费,高频批量导出成本可观;单次请求有大小限制,超大文档要切块。它更适合内部小工具、低频临时转换,或者团队从零起步想快速验证效果的时候。

2.4 我的默认选择:先看还原度需求,再看数据边界

给普通项目做选型,我一般按这个逻辑判断:

判断维度优先选浏览器内核优先选独立排版引擎优先选在线服务
布局复杂度flex/grid/动效多纯文档式流式排版灵活但受上传限制
中文要求需自己配字体通常内置或易配置开箱即用
数据敏感度本地最稳本地最稳会出网,需评估
部署资源内存要求高轻量无硬件负担
批量规模可做池化并发吞吐高受套餐限制

如果需求方只说“要 PDF、要不变样”,我默认走浏览器内核。如果对方明确是书籍类、文档类排版,且 HTML 结构非常规范,我再考虑独立引擎。纯内部临时用,在线接口也够。大多数时候,浏览器内核方案是错误率最低的路线。

3. 落地实现:无头浏览器方案从零跑通

3.1 环境准备:渲染内核和 CJK 字体缺一不可

先明确一点:服务端环境要装两样东西,一个是能跑无头模式的浏览器内核,一个是覆盖中文的字体包。缺了前者,方案跑不起来;缺了后者,中文全变豆腐块。

以常见的 Linux 服务端环境为例,安装的思路大致如下(不同发行版的包管理器和包名会有差异):

# 浏览器内核本体 apt-get install -y chromium # CJK 中文字体,至少装一套 apt-get install -y fonts-noto-cjk fonts-dejavu

装完字体最好验证一下,别等 PDF 出了问题再回头查环境:

fc-list :lang=zh

这条命令会列出系统里所有能覆盖中文的字体。如果输出为空,说明中文字体根本没装上,后面做再多都是白搭。有些镜像特别精简,连 fontconfig 都没有,那得先把基础字体管理装回来。

3.2 核心调用:加载、等待、导出三步走

技术方案确定后,完整流程其实就三步:让无头浏览器打开页面、等它把样式和字体渲染完、再把渲染结果输出成 PDF。

这里给一个接近实际结构的伪代码参考,用的是 Node 生态里常见的自动化调用模式,其他语言也有对应的绑定库,逻辑完全一致:

const browser = await launchBrowser({ headless: true }); try { const page = await browser.newPage(); // 方式一:直接喂 HTML 字符串 await page.setContent(htmlContent, { waitUntil: 'networkidle' }); // 方式二:打开线上 URL,适合页面依赖接口数据 // await page.goto(targetUrl, { waitUntil: 'networkidle' }); // 关键:等字体加载完成,避免字形缺失 await page.evaluateHandle(async () => { await document.fonts.ready; }); const pdf = await page.pdf({ format: 'A4', margin: { top: '15mm', bottom: '15mm', left: '12mm', right: '12mm' }, printBackground: true, preferCSSPageSize: false, }); fs.writeFileSync('output.pdf', pdf); } finally { await browser.close(); }

两个细节值得单独说。第一,waitUntil: 'networkidle'的意思是“等网络空闲了再继续”,但如果页面里有定时轮询接口,这个状态可能永远等不到,任务直接超时。这种情况下我习惯改成等某个关键选择器出现,或者干脆设一个固定的最长等待时间。第二,document.fonts.ready这个等待很重要,尤其是用了 @font-face 动态加载字体时,不等字体就绪就导出,必出豆腐块。

3.3 参数调优:纸张、边距、背景色和 CSS 优先级

无头浏览器的导出参数里,最常调的是这几个:

  • format: 'A4':定义纸张大小,常见还有 Letter、A3。但注意,如果页面 CSS 里写了@page { size: ... },就需要配合preferCSSPageSize来决定听谁的。
  • margin:默认会有浏览器的默认页边距,PDF 排版要还原网页时通常要显式指定。
  • printBackground: true:默认不打印背景色和背景图,这是最容易漏的一项。后面排错章节还会提到。
  • scale:缩放整个页面内容,适合内容稍宽过界时微调。

我自己定了一个原则:凡是能用 CSS 控制的,优先写进 CSS,参数里只留最基础的纸张和边距。原因很简单,CSS 的@page规则能跟着模板走,一份页面放到不同项目里,只要样式没丢,PDF 表现就一致。而参数是调用侧的东西,换一个调用方就容易漏掉。

3.4 批量与并发:别为每个文件重新启一个浏览器

批量导出最常见的性能误区,是循环里每转一个文件就 launch 一次浏览器。无头浏览器启动是重操作,几十次下来光是启动开销就占掉大半时间,内存还不断震荡。

正确做法是维护一个浏览器实例池,一次启动、多次复用。每个页面任务用完就关掉 page,而不是关整个浏览器。并发数一般控制在 4 到 8 比较稳,太多了容易把服务器内存打满,触发 OOM。另外要给每个任务加超时控制,一个页面卡死了不能拖垮整个队列,超时之后直接把这个任务标记失败,扔进重试队列。

4. 中文支持全攻略:从满屏豆腐块到印刷级排版

4.1 先理解豆腐块:不是编码问题,是字体覆盖问题

前面说过,PDF 里的文字要引用字体的字形,字体里没这个字形,渲出来就是方框。中文出方框,99% 是字体覆盖问题:要么系统里没有 CJK 字体,要么字体栈里中文字体名没匹配上,要么 @font-face 的字体文件没有真正加载成功。

有一个很容易误导人的现象:同一份 HTML 在本地 Windows 上转 PDF 一切正常,部署到服务器就全变方框。因为本地有微软雅黑、宋体这类商用字体,而 Linux 服务器上一套都没有。HTML 里写的 font-family 是“微软雅黑”,服务器找不到,fallback 又没有回落中文字体,于是全部画方块。这跟代码没半毛钱关系,纯粹是运行环境的字体差异。

4.2 字体栈设计:西文字体在前,中文字体兜底

推荐的中英文字体栈写法是这样的:

body { font-family: "Helvetica Neue", "Noto Sans CJK SC", "Source Han Sans SC", "Microsoft YaHei", sans-serif; }

顺序有讲究。西文字体放最前面,让拉丁字母、数字优先用西文字体渲染,字形更精致、行高也更准。中文字体放在后面做兜底,当文本里出现 CJK 字符时,浏览器会沿着字体栈往下找第一个包含该字符的字体。最后一个sans-serif是通配兜底,保证任何环境下都有字可用。

如果你做的是偏正式的合同、公文类 PDF,衬线体更合适,字体栈可以换成:

font-family: Georgia, "Noto Serif CJK SC", "Source Han Serif SC", "SimSun", serif;

中文字体命名在不同系统上差异很大,跨环境部署时,我建议统一用开源字体(比如思源黑体/Noto Sans CJK 系),并且在所有环境里保持同样的字体名和安装版本,从根上消除命名不一致的问题。

4.3 字体嵌入策略:体积和兼容性的权衡

PDF 有两种处理字体的方式:

  • 引用系统字体:PDF 里只记录字体名,不携带字体文件。阅读方电脑上必须装了同名字体才能正常显示,否则会触发替代字体,排版可能变化。这种方案生成快、文件小,但观赏性不可控。
  • 嵌入字体:PDF 把用到的字形子集打包进文件里,任何设备打开都是同一副模样。这种方式才真正满足“保持原始样式”的需求。

嵌入字体又分两种做法。一是通过 @font-face 把字体文件指给渲染引擎:

@font-face { font-family: "MyCJK"; src: url("./fonts/NotoSansCJKsc-Regular.otf") format("opentype"); }

二是干脆把字体转成 base64 写进 HTML 的 @font-face 里,好处是模板文件只有一个,坏处是 HTML 体积暴涨。如果你的模板里只用几百个常用汉字,建议先做一个“按字符集裁剪”的子集字体,再嵌入。中文字体全量往往十几 MB,子集化之后常常几百 KB 就能覆盖模板里所有汉字。实践里我会在构建阶段用字体子集化工具处理,按页面实际用字生成精简字体文件,PDF 体积能小一个数量级。

4.4 服务端字体排查:三分钟定位缺字问题

遇到中文变方框,我按下面顺序排查:

  1. 先跑fc-list :lang=zh,确认系统里有没有中文字体。没有就直接装字体包。
  2. 确认 HTML 里 font-family 的字体名和系统字体名对得上。Linux 里思源黑体的名字往往是Noto Sans CJK SC,不是其他别名,写错就 fallback 失败。
  3. 再看 @font-face 的 src 路径。服务端转换时经常因为路径解析问题加载不到字体文件。我习惯把字体和模板放在同一个资源根下,用相对路径引用。
  4. 最后检查是不是忘了等字体加载完成。用了异步字体加载,不等待document.fonts.ready,即使字体没问题也会因为时机问题输出残缺字形。

5. 打印专用 CSS:让网页为 PDF 而生的写法

5.1 @page 规则:把纸张定义留给模板

无头浏览器导出 PDF 时可以用参数定义纸张,但更推荐的做法是在模板 CSS 里直接用@page声明:

@page { size: A4; margin: 15mm 12mm; } @page :first { margin-top: 0; /* 封面页特殊处理 */ }

这样纸张和边距信息就跟着模板走了,调用方不用记参数。配合前述的preferCSSPageSize,可以让 CSS 里的定义优先于导出参数。注意不同引擎对@page的支持程度不一样,如果用旧版工具链,@page可能直接不生效,那就只能靠导出参数兜底。

5.2 分页控制:别再让表格和标题“劈叉”

PDF 是分页的,网页是滚动的,这个差异会在导出时集中爆发。常见需求是:卡片不要从中间断开、标题不要孤零零留在页尾、表格表头每页都要重复。

核心 CSS 写法如下:

.card, table, tr, img { page-break-inside: avoid; } h1, h2, h3 { page-break-after: avoid; } thead { display: table-header-group; } .page-break { page-break-before: always; }

page-break-inside: avoid告诉渲染器:这个元素尽量整体放到同一页,不要从中间切断。page-break-after: avoid保证标题后面紧跟正文,不会出现标题在页尾、正文在下一页的尴尬局面。长表格的thead设置成display: table-header-group后,表头会在每一页重复出现,这是打印表格的基本素养。如果想让某个章节强制另起一页,就在前面加一个page-break-before: always的元素。

这里提醒一句:排版引擎对分页规则是“尽量满足”而不是“绝对满足”。如果一个卡片本身比整页还高,再怎么 avoid 也会被切断。设计模板时就要控制单个卡片的自然高度。

5.3 背景色与图片:两个开关必须同时开

网页里常见的高亮背景、状态标签颜色,经常在 PDF 里消失。原因有两个:一是导出参数没开printBackground,默认不打印背景;二是 CSS 没声明print-color-adjust,浏览器出于省墨设计把背景“节能”掉了。

推荐在打印样式里统一声明:

@media print { * { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }

同时把导出参数的printBackground设为 true。两个开关缺一个,背景色都出不来。如果你发现背景色时有时无,先看这两个开关,别上来就怀疑代码。

5.4 页眉页脚、页码和固定标题

页码是文档类 PDF 的刚需。无头浏览器方案自带页眉页脚模板能力,可以用 HTML 片段给每一页加页码、日期和标题。但模板里只能写简单的行内样式,字体也受限,别想塞多复杂的排版。另一种路子是用 CSS 的固定定位元素,某些渲染引擎在打印时会把position: fixed的元素重复绘制到每一页上,配合计数器可以实现更自由的页眉页脚。但这个行为跨引擎不稳定,需要先做小样验证再全量用。

我的一般倾向:简单页码用自带模板,复杂页眉页脚用 CSS 固定元素,但要先确认目标渲染引擎支持。如果两者都不成熟,退回一步:把页眉页脚信息直接写进页面内容的顶层,只放在第一页,至少保证信息存在,虽然牺牲了重复展示的效果。

6. 高频翻车现场:按排查顺序捋一遍

6.1 翻车一:样式全部丢失,输出像普通文本

这种问题绝大多数是“CSS 没生效”,分两种情况。第一,外链 CSS 是相对路径,服务端拿到 HTML 字符串时没有基准 URL,相对路径解析失败。我的做法是在转换前把外链样式全部内联进 HTML,或者给页面设置一个正确的 base URL。第二,转换动作来得太早,样式表还在网络加载中。前面提过,waitUntil: 'networkidle'是个好默认值,但它怕轮询请求,改用“等特定元素出现 + 固定超时”的组合更稳。

6.2 翻车二:中文全是方框,但网页显示正常

网页正常说明数据没坏,纯粹是 PDF 渲染环境缺字体。按 4.4 的排查顺序走一遍,大多数问题在第二步就找到了:字体名不匹配。我踩过最典型的坑是 Linux 服务器装了思源字体,但 HTML 写的是“微软雅黑”,本地没问题,服务器上直接全方块。统一字体命名之后问题消失。另一个隐蔽问题是字体装在了用户目录,而服务进程用另一个系统账户跑,fontconfig 扫描不到,自然也就用不上。

6.3 翻车三:背景色、背景图和表格斑马纹消失

还是那两条,先看导出参数printBackground,再看 CSS 里的print-color-adjust。另外,如果你用了大背景图或者渐变,并且被裁切,可以检查一下视觉视口和页面宽度的匹配。某些引擎打印时的页面宽度不等于网页视口宽度,超出部分直接切掉。这时候需要通过scale参数或 CSS 控制内容宽度来解决,而不是单纯设置更宽的边距。

6.4 翻车四:表格跨页被切断,表头不重复

长表格导出后,中间某页的单元格被横向劈开,下面半截跑到下一页,这是最烦人的。先给tr和td加page-break-inside: avoid,把拆分行的问题解决;再给thead加display: table-header-group,让表头每页重复。如果表格实在太长,不得已要拆分时,我更愿意在方案层面做分块:把一张大表按数据逻辑拆成多张表,配合页码控制,而不是硬扛分页。

6.5 翻车五:任务超时、内存暴涨、PDF 体积异常

这类问题往往是批量导出时的通病。定时轮询导致 networkidle 永不触发,那就别依赖它;单页任务没设超时,卡死拖垮队列,那就每个任务加独立超时和失败重试;内存不够,那就限制并发数,并且让浏览器实例常驻复用,而不是每次新建。PDF 体积异常大,先怀疑全量嵌入了中文字体,用子集字体替换能显著改善;再检查页面里的图片是不是原图未压缩,这一步往往比字体更占体积。

做 HTML 转 PDF 这个功能,我在不同项目里反复折腾过很多轮,最大的体会是:不要把它当成一个转换函数,而要当成“渲染引擎、字体环境、打印样式”三件套的协同问题。先把选型定明白,再统一字体环境,再给页面补一层打印专用 CSS,最后用排错清单兜底,基本能覆盖九成以上的需求。

最后分享一个小技巧:交付前我习惯把生成的 PDF 放到手机和另一台电脑上各打开一次,确认字体嵌入正常、没触发替代字体。这一步成本很低,但能拦住不少“在我电脑上是好的”这类尴尬问题。希望这篇梳理对正在做导出功能的你有帮助,少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询