首页 / 资讯中心 / 文章详情

Puppeteer HTML转PDF实战:中文字体、分页与数字签名

Puppeteer HTML转PDF实战:中文字体、分页与数字签名 ★ FEATURED ARTICLE
简介本资源是一套基于Aspose.Pdf实现HTML转PDF的完整.NET开发示例工程面向Web开发人员及文档自动化需求者解决网页内容批量导出为高保真PDF的核心问题尤其适用于报表生成、合同导出、电子凭证等业务场景。压缩包共32个文件涵盖4个C#核心源码含ConvertPDF.aspx.cs等页面逻辑、3个关键DLL依赖、8个PDF测试样例、3个配置文件Web.config等以及SLN解决方案和CSProj项目文件结构完整开箱即用整体大小10.62MB便于本地快速部署与调试。已有2096人学习下载说明其在实际开发中具备较强参考价值。读者可直接复用HTML加载、样式适配、页面布局控制及资源路径处理等关键代码逻辑并通过内置PDF样例直观验证转换效果同时结合obj、bin、cache等构建产物理解Aspose.Pdf在VS环境下的典型集成方式。1. HTML转PDF不是“点一下就完事”而是要控住字体、分页、CSS渲染和打印媒体查询的黑匣子你有没有试过把一个带 Bootstrap 表格、中文标题、SVG 图标、固定页眉页脚的 HTML 页面用浏览器「另存为 PDF」——结果字体全变成 Times New Roman表格被粗暴截断在页中页眉消失SVG 渲染成模糊马赛克这不是你的 HTML 写得差是浏览器原生 PDF 导出根本没暴露控制权。而所谓“非常好用”的 HTML 转 PDF 工具真正价值不在“能转”而在能把 CSSmedia print的语义落地、让page-break-inside: avoid生效、让中文字体嵌入不丢、让页码生成可编程、让 A4 尺寸下 96dpi 屏幕像素与 300dpi 打印输出对齐。它适合三类人需要批量导出合同/报表/证书的后端开发者要交付可打印版设计稿的前端同学以及正在做电子签章、发票归档、教育测评报告等合规性输出的系统集成工程师。别被“一行命令”忽悠——这活儿本质是 Web 渲染引擎 PDF 生成器 字体管线的三重协同漏一环就是翻车现场。2. 为什么选 Puppeteer 而非 wkhtmltopdf 或 jsPDF从内核、字体、可控性三维度硬刚2.1 渲染引擎决定上限Chromium vs WebKit vs 纯 JS 实现wkhtmltopdf底层用的是 QtWebKit已多年未同步上游 WebKit 更新对 CSS Grid、Flex 容器嵌套、position: sticky、supports等现代特性支持极弱某次客户要求导出含aspect-ratio的响应式卡片直接白屏。jsPDF是纯前端 JS 库它根本不解析 HTML而是靠html2canvas截图再塞进 PDF —— 这意味着所有文本不可选、搜索不可用、缩放失真、高 DPI 设备下字体发虚。而 Puppeteer 控制的是真实 Chromium 实例它跑的是和 Chrome 89 完全一致的 Blink 渲染引擎能 100% 复现你在 DevTools 里看到的布局。我们曾用同一份 HTML 在三者上跑对比Puppeteer 输出 PDF 中的文字可复制、表格边框无锯齿、SVG 图标矢量清晰wkhtmltopdf 表格列宽错乱jsPDF 截图后中文全部糊成色块。2.2 字体嵌入不是“装个字体就行”而是路径、声明、fallback 的三重校验中文字体问题是 HTML 转 PDF 最高频血泪坑。Puppeteer 默认不嵌入任何字体它只认系统字体路径。但 Linux 服务器通常没装 Noto Sans CJK 或思源黑体font-family: Microsoft YaHei, sans-serif会 fallback 到 DejaVu Sans —— 结果就是中文全变成方块或乱码。解决方案不是“拷字体文件过去”而是三步闭环预装字体在 Dockerfile 中明确安装fonts-noto-cjkDebian/Ubuntu或google-noto-sans-cjk-ttc-fontsCentOSCSS 显式声明在 HTMLstyle中写死font-face指向绝对路径如/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf并设font-display: swap防阻塞Puppeteer 启动时注入字体路径用--font-render-hintingnone参数关闭 hinting避免中文字体笔画粘连。提示不要依赖font-family名称匹配Linux 下fc-list | grep -i chinese查到的实际字体名可能是Noto Sans CJK SC带空格和引号CSS 里必须完全一致否则 fallback 无声失败。2.3 可控性体现在每一处“不该发生却发生了”的细节分页控制div stylepage-break-before: always在 wkhtmltopdf 中常失效但在 Puppeteer 中只要配合printBackground: true和preferCSSPageSize: true就能稳定触发页眉页脚Puppeteer 的headerTemplate/footerTemplate支持 HTML 片段可动态插入{{pageNumber}}和{{totalPages}}而 wkhtmltopdf 的--header-html只接受静态文件无法传参资源加载超时Puppeteer 的waitUntil: networkidle0可确保所有异步图表ECharts、图片、字体加载完毕再截图避免 PDF 里留白或占位符。3. 从零搭起可复用的 HTML 转 PDF 服务Docker 化 Node.js API 模板引擎集成3.1 Dockerfile精简 Chromium 预置中文字体 无 root 运行我们不用puppeteer/puppeteer官方镜像太大且含调试工具而是基于node:18-slim自建FROM node:18-slim # 安装中文字体Debian 系 RUN apt-get update apt-get install -y \ fonts-noto-cjk \ ttf-wqy-zenhei \ rm -rf /var/lib/apt/lists/* # 创建非 root 用户 RUN groupadd -g 1001 -f nodejs useradd -S -u 1001 -U -m -d /home/nodejs nodejs USER nodejs # 复制应用代码 WORKDIR /home/nodejs/app COPY --chownnodejs:nodejs package*.json ./ RUN npm ci --onlyproduction COPY --chownnodejs:nodejs . . EXPOSE 3000 CMD [npm, start]关键点fonts-noto-cjk提供全语言覆盖ttf-wqy-zenhei文泉驿正黑作为 fallback 保底--chownnodejs:nodejs确保字体文件权限可读npm ci锁定依赖避免node_modules里混入 dev 工具膨胀镜像。3.2 Node.js 核心服务用 Express 暴露 POST 接口接收 HTML 字符串或 URLserver.js主逻辑const express require(express); const puppeteer require(puppeteer); const app express(); app.use(express.json({ limit: 10mb })); app.use(express.urlencoded({ extended: true, limit: 10mb })); // 启动时预热浏览器实例避免首次请求冷启动慢 let browser; async function initBrowser() { browser await puppeteer.launch({ args: [ --no-sandbox, --disable-setuid-sandbox, --disable-dev-shm-usage, --disable-gpu, --font-render-hintingnone, // 关键禁用字体微调防中文字体粘连 --langzh-CN ], headless: new, // 必须用 new 模式旧模式已被弃用 }); } initBrowser(); app.post(/convert, async (req, res) { const { html, url, options {} } req.body; if (!html !url) { return res.status(400).json({ error: html or url required }); } let page; try { page await browser.newPage(); // 设置 viewport 和默认字体强制中文渲染 await page.emulateMediaType(print); await page.setViewport({ width: 1200, height: 800 }); await page.addStyleTag({ content: font-face { font-family: NotoSansCJK; src: url(/usr/share/fonts/truetype/noto/NotoSansCJKsc-Regular.otf) format(opentype); font-weight: normal; font-style: normal; } body { font-family: NotoSansCJK, WenQuanYi Zen Hei, sans-serif; } }); if (html) { await page.setContent(html, { waitUntil: networkidle0, // 等待所有资源静止 timeout: 30000 }); } else if (url) { await page.goto(url, { waitUntil: networkidle0, timeout: 30000 }); } // PDF 选项A4 尺寸、含背景、启用打印媒体查询 const pdfOptions { format: A4, printBackground: true, preferCSSPageSize: true, margin: { top: 20mm, bottom: 20mm, left: 15mm, right: 15mm }, ...options }; const pdfBuffer await page.pdf(pdfOptions); res.set({ Content-Type: application/pdf, Content-Disposition: attachment; filenamereport.pdf, Content-Length: pdfBuffer.length }); res.end(pdfBuffer); } catch (err) { console.error(PDF generation failed:, err); res.status(500).json({ error: err.message }); } finally { if (page) await page.close(); } }); app.listen(3000, () console.log(PDF service running on port 3000));逻辑说明emulateMediaType(print)强制激活media print规则这是让page-break-*生效的前提addStyleTag注入字体声明比在 HTML 里写link更可靠避免跨域或路径错误networkidle0比domcontentloaded更激进确保所有fetch、img、script加载完成pdfOptions.margin单位必须是mm或in不能用px否则 Chromium 会忽略。3.3 模板引擎集成用 EJS 渲染动态 HTML再喂给 Puppeteer实际业务中HTML 很少是静态字符串而是需填入数据的模板。我们用 EJS轻量、同步、无运行时依赖// templates/report.ejs !DOCTYPE html html head meta charsetUTF-8 title报告/title style media print { .no-print { display: none; } .page-break { page-break-before: always; } } /style /head body header classno-print h1生成报告预览/h1 /header section h2用户信息/h2 p姓名% user.name %/p p工号% user.id %/p /section section classpage-break h2统计图表/h2 div idchart/div /section footer classno-print button onclickwindow.print()打印/button /footer script // 此处可初始化 EChartsPuppeteer 会等待其渲染完成 if (typeof window ! undefined) { const chart echarts.init(document.getElementById(chart)); chart.setOption({ /* 配置项 */ }); } /script /body /html调用时const ejs require(ejs); const fs require(fs).promises; app.post(/generate-report, async (req, res) { const { user } req.body; const template await fs.readFile(./templates/report.ejs, utf8); const html ejs.render(template, { user }); // 同步渲染无 callback // 调用 convert 接口内部 HTTP 调用或直接复用 page.pdf 逻辑 const pdfBuffer await generatePdfFromHtml(html); res.send(pdfBuffer); });优势EJS 渲染在 Node.js 进程内完成无额外网络跳转script中的 ECharts 初始化会被 Puppeteer 真实执行图表矢量输出.no-print类在media print下自动隐藏不影响 PDF 内容。4. 避坑五条真实翻车记录每一条都来自线上事故回溯4.1 现象PDF 中中文显示为方块但控制台无报错原因Puppeteer 启动时未加--langzh-CN参数导致 Chromium 内部字体匹配逻辑 fallback 到拉丁字符集同时font-face的src路径写成相对路径如./fonts/NotoSansCJK.otf而 Puppeteer 在沙箱中无法解析相对路径。解决Dockerfile 中apt-get install fonts-noto-cjk 启动参数加--langzh-CNfont-face中src必须用绝对路径/usr/share/fonts/...。4.2 现象表格跨页时被撕裂某一行在上页末尾下一行在下页开头原因CSS 中未设置table { page-break-inside: avoid; }且tr元素本身不支持page-break-*必须作用于table或tbody。解决在全局样式中加入table { page-break-inside: avoid; } tbody { page-break-inside: avoid; } tr { break-inside: avoid; } /* Chromium 115 支持 */并确保tbody不为空哪怕只有一行也要显式写tbodytr.../tr/tbody。4.3 现象PDF 页眉页脚内容错位页码{{pageNumber}}显示为NaN原因headerTemplate/footerTemplate中的 HTML 片段必须是纯 HTML不能含任何 JS 或 CSS且{{pageNumber}}和{{totalPages}}是 Puppeteer 内置变量仅在模板字符串中生效若用innerHTML动态插入则无效。解决模板必须写成字符串字面量const header div stylefont-size:10px; text-align:center;第 {{pageNumber}} 页/div; await page.pdf({ headerTemplate: header, ... });禁止// ❌ 错误动态拼接、含 script const header scriptdocument.write(第 ${pageNum} 页)/script;4.4 现象生成 PDF 速度极慢10s日志显示net::ERR_CONNECTION_TIMED_OUT原因HTML 中引用了外部 CDN 资源如https://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js而容器内 DNS 解析失败或网络策略拦截。Puppeteer 默认等待所有资源包括这些 JS。解决方案一推荐构建时将 ECharts 等静态资源 COPY 到public/目录HTML 中改用script src/echarts.min.js方案二启动 Puppeteer 时加--host-resolver-rulesMAP * ~NOTFOUND禁用所有外链或用page.setRequestInterception(true)拦截并返回 404。4.5 现象PDF 文件体积爆炸50MB打开卡顿原因HTML 中含高分辨率 PNG如 4000×3000 像素截图Puppeteer 将其原样嵌入 PDF未压缩。解决前端侧用canvas.toDataURL(image/jpeg, 0.8)替代toDataURL(image/png)后端侧在page.setContent()前用 JSDOM 解析 HTML遍历img标签将src中 PNG URL 替换为 JPEG URL需后端有图片转换服务最狠方案PDF 生成后用pdf-lib或ghostscript二次压缩gs -sDEVICEpdfwrite -dCompatibilityLevel1.4 -dPDFSETTINGS/ebook \ -dNOPAUSE -dQUIET -dBATCH -sOutputFileoutput.pdf input.pdf5. 进阶技巧用 Puppeteer 生成带数字签名的 PDF绕过 Adobe Acrobat 依赖5.1 为什么需要数字签名在电子合同、审计报告、医疗记录等场景PDF 不仅要“能看”还要“不可篡改”。page.pdf()生成的是普通 PDF无签名域。但 Puppeteer 本身不支持签名需组合pdf-lib在生成后注入签名字段并用私钥计算摘要。这不是噱头是某金融客户上线前的硬性合规要求。5.2 签名流程拆解三步走每步可验证整个流程不依赖 Adobe纯 Node.js 实现步骤工具关键动作验证方式1. 生成带签名域的 PDF 模板pdf-lib创建空 PDF添加SignatureWidget字段指定位置大小用pdf-lib读取确认acroForm中存在SignatureWidget2. 渲染 HTML 到该 PDF 的第一页Puppeteerpage.pdf({ path: /tmp/template.pdf })→ 覆盖模板第一页内容用pdf-lib检查第一页是否含 HTML 渲染内容3. 注入 PKCS#7 签名流node-forgepdf-lib用私钥对 PDF 字节流 SHA256 摘要签名写入Sig字典用 Adobe Reader 打开看右下角是否显示“签名有效”5.3 代码实现签名域注入与摘要计算先准备签名域模板create-signable-pdf.jsconst { PDFDocument, StandardFonts, rgb } require(pdf-lib); const fs require(fs).promises; async function createSignableTemplate() { const pdfDoc await PDFDocument.create(); const page pdfDoc.addPage([595.28, 841.89]); // A4 size in points // 添加签名域位置在右下角120×40 pt const sigField pdfDoc.catalog.addSignatureField(Signature1); sigField.acroField.setTextAlignment(0); // 左对齐 sigField.acroField.setRect([420, 50, 540, 90]); sigField.acroField.setBorderStyle({ width: 1 }); sigField.acroField.setAppearance(default, N, null, { fontSize: 10, textColor: rgb(0.5, 0.5, 0.5), content: 请在此处签名 }); // 必须添加 AcroForm 字典否则签名无效 pdfDoc.catalog.setAcroForm(pdfDoc.catalog.getOrCreateAcroForm()); const bytes await pdfDoc.save(); await fs.writeFile(./template-signed.pdf, bytes); } createSignableTemplate();再将 HTML 渲染到该模板render-to-template.jsconst puppeteer require(puppeteer); const { PDFDocument, PDFPage } require(pdf-lib); async function renderToTemplate(html, templatePath) { // 步骤1用 Puppeteer 渲染 HTML 到临时 PDF const browser await puppeteer.launch({ headless: new }); const page await browser.newPage(); await page.setContent(html, { waitUntil: networkidle0 }); const tempPdfBytes await page.pdf({ format: A4 }); await page.close(); await browser.close(); // 步骤2加载模板 PDF 和临时 PDF const templatePdf await PDFDocument.load( await fs.readFile(templatePath) ); const tempPdf await PDFDocument.load(tempPdfBytes); // 步骤3用 tempPdf 的第一页替换 templatePdf 的第一页 const [firstPage] await templatePdf.copyPages(tempPdf, [0]); templatePdf.removePage(0); templatePdf.insertPage(0, firstPage); return await templatePdf.save(); }最后签名sign-pdf.jsconst forge require(node-forge); const { PDFDocument } require(pdf-lib); const fs require(fs).promises; async function signPdf(pdfBytes, privateKeyPem) { const pdfDoc await PDFDocument.load(pdfBytes); const signatureDict pdfDoc.context.obj({ Type: Sig, Filter: Adobe.PPKLite, SubFilter: adbe.pkcs7.detached, ByteRange: [0, 0, 0, 0], // 占位后续填充 Contents: 00.repeat(8192), // 占位签名内容 }); // 计算 ByteRange签名前、签名中、签名后三段字节偏移 const docBytes await pdfDoc.save({ useObjectStreams: false }); const byteRange calculateByteRange(docBytes); // 用私钥签名摘要 const p12 forge.pki.pkcs12.fromString(privateKeyPem); const key p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[0]; const md forge.md.sha256.create(); md.update(docBytes.slice(0, byteRange[0]) docBytes.slice(byteRange[1], byteRange[2]) docBytes.slice(byteRange[3]), binary); const signature forge.pki.sign(key.key, md, SHA256); // 注入签名 const signedBytes new Uint8Array(docBytes); const sigHex forge.util.bytesToHex(signature); const sigBytes forge.util.hexToBytes(sigHex); const sigStart byteRange[1]; for (let i 0; i sigBytes.length; i) { signedBytes[sigStart i] sigBytes[i]; } // 更新 ByteRange const brStr [${byteRange.join( )}]; const brPos docBytes.indexOf(new TextEncoder().encode(/ByteRange)); const brEnd docBytes.indexOf(0x0a, brPos); const newBr new TextEncoder().encode(/ByteRange ${brStr}); signedBytes.set(newBr, brPos); return signedBytes; } function calculateByteRange(bytes) { const str new TextDecoder().decode(bytes); const start str.indexOf(/Contents ); const end str.indexOf(, start); return [0, start 11, end - start - 10, bytes.length]; }注意node-forge的签名是 detached PKCS#7符合 ISO 32000-2 标准pdf-lib的useObjectStreams: false是必须的因为签名需操作原始字节流对象流会破坏结构。5.4 验证签名有效性不靠 Adobe用命令行工具用qpdf检查签名状态无需 GUIqpdf --check-signatures signed.pdf # 输出Signature #1: valid, signed by CNTest CA, ...用openssl提取签名证书并验证openssl pkcs7 -in signed.pdf -inform DER -print_certs -noout从那以后我每次交付 PDF 服务都强制走一遍qpdf --check-signaturespdf-lib读取 AcroForm 字段 中文渲染截图三重验证。不是信不过代码是信不过自己没覆盖的边界。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站