
简介这是一份面向前端开发者的网页转 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 :langzh这段命令做了三件事补齐 Chromium 运行库、安装思源黑体和文泉驿正黑两款中文字体、刷新字体缓存。fc-list :langzh是验证命令如果输出里有字体路径说明中文渲染的地基打好了。如果这条命令没有任何输出后面导出的 PDF 里中文一定是方块别急着往下走。3.2 初始化项目并安装 Puppeteer# 初始化 Node 项目 npm init -y # 安装 puppeteer它会自动下载匹配版本的 Chromium npm install puppeteer # 如果服务器下载 Chromium 慢可以指定国内镜像 # PUPPETEER_DOWNLOAD_BASE_URLhttps://cdn.npmmirror.com/binaries/chrome-for-testing npm install puppeteerPuppeteer 安装时会自动拉取一个和它版本匹配的 Chromium这个 Chromium 是独立于系统浏览器的所以不用担心服务器没装 Chrome。安装完成后node_modules里会有完整的浏览器二进制。参数上如果你在 CI 环境或磁盘紧张可以用PUPPETEER_SKIP_DOWNLOAD1跳过下载改用系统 Chromium但那样要自己保证版本兼容新手不建议。环境就绪后进入核心的渲染环节。4. 核心实现把 HTML 渲染成支持中文、图片、表格的 PDF4.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-hintingnone] }); const page await browser.newPage(); // 用 setContent 直接喂 HTMLwaitUntil 保证图片等资源加载完 await page.setContent(html, { waitUntil: networkidle0 }); // 导出 PDFformat 和 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 langzh-CN head meta charsetutf-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 theadtrth商品/thth数量/thth单价/th/tr/thead tbody trtd无线键盘/tdtd2/tdtd199.00/td/tr trtd显示器支架/tdtd1/tdtd89.00/td/tr /tbody /table img srchttps://example.com/logo.png altlogo /body /html ; htmlToPdf(testHtml, ./output.pdf).then(() console.log(PDF 生成完成));逻辑上分四步启动浏览器、新建页面并注入 HTML、等待资源加载、导出 PDF。参数里最容易被忽略的是printBackground: true不加它表格表头的灰色背景、页面的背景色全部消失很多人以为是渲染 bug其实是这个开关没开。waitUntil: networkidle0表示网络空闲才继续保证图片加载完否则图片可能来不及渲染就导出了。--font-render-hintingnone是让字体渲染更平滑避免某些环境下中文发虚。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; filenameorder- req.params.id .pdf); res.setHeader(Content-Length, pdfBuffer.length); res.end(pdfBuffer); }); app.listen(3000);关键在响应头三件套Content-Type告诉浏览器这是 PDFContent-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 :langzh确认别装完不验证。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-addSYS_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 文本断言就成了我导出功能的固定动作。希望帮到你。本文还有配套的精品资源点击获取