news 2026/9/26 1:12:04

服务端HTML转PDF方案:无头浏览器解决中文与表格分页

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
服务端HTML转PDF方案:无头浏览器解决中文与表格分页

简介:这是一份面向前端开发者的网页转 PDF 完整源码方案,基于 jsPDF 与 html2canvas 实现,无需安装任何浏览器插件,即可将任意网页对象以所见即所得的矢量方式输出为 PDF,并完整支持中文、图片与表格。资源共 10 个文件,包含 5 个 js 脚本、2 个 html 示例页、1 个 css 样式、1 个 png 图标以及 1 个 ttf 中文字体,压缩包约 1.76MB,其中已内置转换好的中文字体与字体转换工具,省去自行处理字体嵌入的麻烦。核心调用仅需约 6 行代码,适合需要导出报表、合同、发票或页面快照的中初级开发者直接集成。目前已有 526 人学习下载,可作为快速落地 HTML 转 PDF 需求的参考实现。

1. 从「打印成 PDF」到「服务端生成 PDF」:为什么大多数 HTML 转 PDF 方案都翻车了

做过导出功能的人大概都经历过这个场景:前端window.print()一按,用户拿到手的 PDF 要么中文变方块,要么表格被拦腰截断,要么图片直接消失。更麻烦的是,这套流程完全依赖用户本地浏览器和打印机驱动,你根本不知道对方机器上会发生什么。所以真正能上生产的 HTML 转 PDF 文件下载方案,核心诉求只有四个字:服务端可控。

标题里说的「最合理的方法」,落到工程上就是:用无头浏览器在服务端渲染 HTML,再导出成 PDF 流回传给前端下载。它不需要用户装任何插件,中文靠字体文件解决,图片和表格靠标准 HTML/CSS 渲染,源码可以完整跑起来。这套方案适合谁?适合要做订单导出、报表下载、发票生成、合同归档的后端和全栈工程师。接下来我按「选型 → 环境 → 渲染 → 下载 → 避坑」的顺序,把这条链路拆开讲透。

2. 选型先立住:无头浏览器、wkhtmltopdf 和纯前端打印的边界在哪

2.1 三种主流路线的真实差异

在动手之前,先把可选路线摆清楚,不然很容易选错工具白干两天。

方案中文支持图片/表格是否需要插件服务端可控典型问题
浏览器window.print()依赖系统字体支持但分页差否否用户环境不可控
wkhtmltopdf需手动装字体表格易错位否是内核老,CSS 支持差
无头浏览器(Puppeteer/Playwright)装字体即可完整支持否是内存占用偏高

window.print()的问题在于它把渲染权交给了用户机器,你无法保证字体、纸张、边距一致。wkhtmltopdf 基于很老的 WebKit 内核,flex、grid这些现代布局基本残废,表格跨页经常错位,中文还得手动配置字体路径,踩坑成本高。无头浏览器走的是完整 Chromium 渲染管线,你写的 HTML/CSS 是什么样,导出来就是什么样,中文只要把字体文件塞进系统或通过 CSS 引入就能解决。

2.2 为什么最终选无头浏览器

我一般会选 Puppeteer 或 Playwright,原因有三个。第一,渲染一致性最好,Chrome 能渲染的它都能渲染,表格、图片、@page分页规则全都认。第二,中文问题本质是字体问题,只要在 HTML 里用@font-face引入中文字体,或者系统装了中文字体,就不会出现方块。第三,它天然支持把页面导出成 Buffer,直接对接 HTTP 响应做文件下载,不需要落盘中转。

提示:如果你的服务器是精简版 Linux 镜像,默认没有中文字体,这是中文变方块的头号原因,后面避坑章节会专门讲。

选型确定后,剩下的就是把它跑起来。下面进入环境搭建。

3. 环境搭建:Node + Puppeteer 在 Linux 上跑通中文渲染

3.1 安装依赖与中文字体

无头 Chromium 在 Linux 上需要一批系统库,缺一个就启动失败。先装依赖,再装字体,这一步不能省。

# 安装 Chromium 运行所需的系统库(Debian/Ubuntu 系) apt-get update && apt-get install -y \ ca-certificates fonts-liberation libappindicator3-1 \ libasound2 libatk-bridge2.0-0 libatk1.0-0 libcups2 \ libdbus-1-3 libgdk-pixbuf2.0-0 libnspr4 libnss3 \ libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 \ xdg-utils libgbm1 # 安装中文字体,解决中文变方块的核心一步 apt-get install -y fonts-noto-cjk fonts-wqy-zenhei # 刷新字体缓存,让新装的字体立即生效 fc-cache -fv # 验证中文字体是否被系统识别 fc-list :lang=zh

这段命令做了三件事:补齐 Chromium 运行库、安装思源黑体和文泉驿正黑两款中文字体、刷新字体缓存。fc-list :lang=zh是验证命令,如果输出里有字体路径,说明中文渲染的地基打好了。如果这条命令没有任何输出,后面导出的 PDF 里中文一定是方块,别急着往下走。

3.2 初始化项目并安装 Puppeteer

# 初始化 Node 项目 npm init -y # 安装 puppeteer,它会自动下载匹配版本的 Chromium npm install puppeteer # 如果服务器下载 Chromium 慢,可以指定国内镜像 # PUPPETEER_DOWNLOAD_BASE_URL=https://cdn.npmmirror.com/binaries/chrome-for-testing npm install puppeteer

Puppeteer 安装时会自动拉取一个和它版本匹配的 Chromium,这个 Chromium 是独立于系统浏览器的,所以不用担心服务器没装 Chrome。安装完成后,node_modules里会有完整的浏览器二进制。参数上,如果你在 CI 环境或磁盘紧张,可以用PUPPETEER_SKIP_DOWNLOAD=1跳过下载,改用系统 Chromium,但那样要自己保证版本兼容,新手不建议。

环境就绪后,进入核心的渲染环节。

4. 核心实现:把 HTML 渲染成支持中文、图片、表格的 PDF

4.1 最小可运行的服务端渲染脚本

先给一个能直接跑的最小版本,把 HTML 字符串渲染成 PDF 文件。

const puppeteer = require('puppeteer'); const fs = require('fs'); async function htmlToPdf(html, outputPath) { // 启动无头浏览器,--no-sandbox 用于容器环境 const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--font-render-hinting=none'] }); const page = await browser.newPage(); // 用 setContent 直接喂 HTML,waitUntil 保证图片等资源加载完 await page.setContent(html, { waitUntil: 'networkidle0' }); // 导出 PDF,format 和 margin 控制纸张与边距 await page.pdf({ path: outputPath, format: 'A4', printBackground: true, // 关键:不加这行背景色和背景图会丢 margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' } }); await browser.close(); } // 一段包含中文、图片、表格的测试 HTML const testHtml = ` <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <style> body { font-family: "Noto Sans CJK SC", "WenQuanYi Zen Hei", sans-serif; } table { width: 100%; border-collapse: collapse; } th, td { border: 1px solid #333; padding: 8px; text-align: left; } th { background: #f0f0f0; } img { max-width: 200px; } </style> </head> <body> <h1>订单导出示例</h1> <p>这是一段中文测试文本,用于验证字体渲染是否正常。</p> <table> <thead><tr><th>商品</th><th>数量</th><th>单价</th></tr></thead> <tbody> <tr><td>无线键盘</td><td>2</td><td>199.00</td></tr> <tr><td>显示器支架</td><td>1</td><td>89.00</td></tr> </tbody> </table> <img src="https://example.com/logo.png" alt="logo"> </body> </html> `; htmlToPdf(testHtml, './output.pdf').then(() => console.log('PDF 生成完成'));

逻辑上分四步:启动浏览器、新建页面并注入 HTML、等待资源加载、导出 PDF。参数里最容易被忽略的是printBackground: true,不加它,表格表头的灰色背景、页面的背景色全部消失,很多人以为是渲染 bug,其实是这个开关没开。waitUntil: 'networkidle0'表示网络空闲才继续,保证图片加载完,否则图片可能来不及渲染就导出了。--font-render-hinting=none是让字体渲染更平滑,避免某些环境下中文发虚。

4.2 用模板文件替代字符串拼接

实际项目里 HTML 不会写在代码里,而是用模板文件。常见做法是用fs.readFileSync读模板,再用简单替换或模板引擎填充数据。

const fs = require('fs'); const path = require('path'); function renderTemplate(templatePath, data) { let html = fs.readFileSync(path.resolve(templatePath), 'utf-8'); // 简单占位符替换,复杂场景建议用 handlebars 或 ejs Object.keys(data).forEach(key => { html = html.replace(new RegExp(`{{${key}}}`, 'g'), data[key]); }); return html; } // 使用示例 const html = renderTemplate('./templates/order.html', { orderNo: 'SO20240101001', customer: '张三', amount: '487.00' });

模板文件里同样要写@font-face或依赖系统字体。如果你的模板要引用本地图片,用file://协议或把图片转成 base64 内联,否则无头浏览器加载不到相对路径的图片。参数上,{{key}}这种占位符替换只适合简单场景,一旦数据里有特殊字符或需要循环表格行,就该上 ejs 或 handlebars,别硬用正则。

4.3 把 PDF 流回传给前端下载

生成 PDF 后,最合理的方式是不落盘,直接以流的形式返回给浏览器触发下载。

const express = require('express'); const puppeteer = require('puppeteer'); const app = express(); app.get('/export/order/:id', async (req, res) => { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setContent(buildOrderHtml(req.params.id), { waitUntil: 'networkidle0' }); // 导出为 Buffer,不写文件 const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true }); await browser.close(); // 设置响应头,触发浏览器下载 res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'attachment; filename="order-' + req.params.id + '.pdf"'); res.setHeader('Content-Length', pdfBuffer.length); res.end(pdfBuffer); }); app.listen(3000);

关键在响应头三件套:Content-Type告诉浏览器这是 PDF,Content-Disposition的attachment触发下载而不是在线预览,Content-Length让浏览器知道文件大小、显示下载进度。page.pdf()返回的是 Buffer,直接res.end发出去,省掉了写临时文件和清理的麻烦。如果并发量大,每次请求都launch一个浏览器开销很高,后面进阶章节会讲连接复用。

5. 避坑排查:中文方块、图片丢失、表格截断的 5 个血泪经验

5.1 中文全部变成方块

现象:导出的 PDF 里中文全是方框,英文正常。原因:服务器没有中文字体,Chromium 找不到字形就画方块。解决:apt-get install fonts-noto-cjk装字体,然后fc-cache -fv刷新缓存,再在 CSS 里显式声明font-family: "Noto Sans CJK SC", sans-serif。装完一定要用fc-list :lang=zh确认,别装完不验证。

5.2 图片在 PDF 里消失

现象:HTML 里图片能显示,导出 PDF 后图片位置空白。原因:waitUntil设成了load或domcontentloaded,图片还没加载完就导出了;或者图片是相对路径,无头浏览器解析不到。解决:把waitUntil改成networkidle0,相对路径改成绝对路径或 base64 内联。如果是外链图片,还要确认服务器能访问那个域名。

5.3 表格跨页被拦腰截断

现象:长表格翻页时,某一行被从中间切开,上下两半分别落在两页。原因:默认分页策略不保护表格行。解决:给tr加page-break-inside: avoid,给thead加display: table-header-group让表头每页重复。CSS 里写tr { page-break-inside: avoid; }和thead { display: table-header-group; },这两个属性在 Chromium 里是生效的。

5.4 背景色和背景图不显示

现象:表格表头背景、卡片背景色在 PDF 里全白。原因:page.pdf()默认printBackground: false。解决:显式传printBackground: true。这个坑几乎每个人都踩过一次,因为浏览器预览时背景是有的,导出就没了,很容易误判成 CSS 问题。

5.5 容器里启动浏览器报沙箱错误

现象:Docker 或 CI 环境里puppeteer.launch报No usable sandbox或直接崩溃。原因:容器默认没有沙箱权限。解决:启动参数加--no-sandbox --disable-setuid-sandbox,同时加--disable-dev-shm-usage避免/dev/shm太小导致崩溃。生产环境如果在意安全,可以用--cap-add=SYS_ADMIN给容器加权限,而不是长期关沙箱。

6. 进阶技巧:浏览器实例复用与导出质量验证

前面每次请求都launch一个浏览器,QPS 一上来机器就扛不住。我一般会把浏览器实例做成单例,页面按需创建和关闭。

let browserPromise = null; function getBrowser() { if (!browserPromise) { browserPromise = puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] }); } return browserPromise; } async function exportPdf(html) { const browser = await getBrowser(); const page = await browser.newPage(); try { await page.setContent(html, { waitUntil: 'networkidle0' }); return await page.pdf({ format: 'A4', printBackground: true }); } finally { await page.close(); // 只关页面,不关浏览器 } }

这里browserPromise缓存了浏览器实例,page每次新建、用完在finally里关闭。这样既复用了昂贵的浏览器进程,又避免页面泄漏。注意别把browser.close()写进请求流程,否则第一个请求结束后浏览器就没了,后续请求全部失败。

导出质量怎么验证?我习惯用pdf-parse把生成的 PDF 文本抽出来,断言关键中文和数字是否存在,再配合人工抽查一版带表格和图片的样本。

验证项方法通过标准
中文渲染pdf-parse 抽取文本中文关键词完整出现
图片存在检查 PDF 内嵌对象数图片数量与 HTML 一致
表格完整人工抽查跨页样本无截断、表头重复
分页正确检查页数与预期页数符合内容量
const pdfParse = require('pdf-parse'); const fs = require('fs'); async function verifyPdf(path) { const data = await pdfParse(fs.readFileSync(path)); const ok = data.text.includes('订单导出示例') && data.text.includes('无线键盘'); console.log('中文与内容校验:', ok ? '通过' : '失败'); console.log('总页数:', data.numpages); }

这套校验能挡住大部分回归问题,尤其是换字体、改模板之后。我踩过最深的一次坑是换了基础镜像,忘了装中文字体,测试环境因为缓存没暴露,上线后用户导出的全是方块,被投诉了一轮。从那以后,字体检查和 PDF 文本断言就成了我导出功能的固定动作。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 1:10:58

基于xxxwww的电商采集管道:会话保持与反爬实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:10:46

洗碗机水泵EMC整改:高集成方案如何从源头解决辐射超标

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:10:33

Proteus 8.17 SP2安装故障深度解析与系统级调优指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:10:07

多重假设检验校正:FDR、q值与Bonferroni原理及Python/R实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:09:17

STM32 SBUS解码:DMA循环接收+IDLE中断,稳定不丢帧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 1:08:47

智慧高速如何实现无感通行与车路协同预警

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华