
在 Node.js 生态里折腾 PDF几乎每个做后端的同学都会撞上这堵墙。今天把我这段时间用 Node.js 处理 PDF 的完整经验整理出来覆盖从选库、生成、解析到排查问题的全过程。不管你是要做电子发票、合同生成、报表导出还是批量读取 PDF 里的关键信息这篇文章都能给你一套可以直接上手的方案。先说清楚这套东西能解决什么问题。Node.js 本身没有内置 PDF 处理能力所谓“pdf 包”其实是一堆第三方库的统称它们在底层调用 V8 引擎加原生 C/C 模块或者纯 JavaScript 实现 PDF 的读写。你需要从中选出适合自己场景的那一个然后围绕它搭一套可维护的工具链。适合谁看正在用 Node.js 做后端接口、需要生成或解析 PDF 的开发者以及被中文字体、PDF 乱码、打印尺寸这些细节折磨过的同学。1. 内容整体设计与选型思路1.1 不要把“PDF 处理”看成单一需求很多人一上来就搜“nodejs pdf 包”其实“处理 PDF”这个需求至少可以拆成四个完全不同的方向生成 PDF从零创建一份 PDF或者把 HTML/数据渲染成 PDF解析 PDF读取已有 PDF 的文本、图片、表格、元数据操作 PDF合并、拆分、加页码、加密、加水印打印转换把网页内容按指定纸张、边距、分页转成 PDF把这四个方向搞清楚你才能知道该选哪个库。我见过太多人拿着 pdfkit 去解析 PDF结果发现根本读不出文本最后又要重新选型。所以在项目开始前一定要先明确需求是哪一类。1.2 主流 PDF 库全景对比与选型逻辑我把社区里高频出现的几个库拉出来做一个横向对比下面是我实测和个人项目里的体验汇总不是官方文档的复读库名核心能力底层实现中文字体支持典型场景备注pdfkit生成 PDFJavaScript 字体嵌入需手动注册字体发票、简单文本报表API 偏底层不支持解析pdf-lib生成 修改 PDF纯 JS需嵌入字体支持子集化合并、拆分、填表体积小无原生依赖pdf-parse解析提取文本基于 pdf.js 抽取取决于 PDF 编码文本抽取、索引使用简单但复杂版式容易乱pdfjs-dist完整解析渲染Mozilla pdf.js内置中文解析能力复杂解析、前端预览功能最强包体积也最大puppeteerHTML 转 PDFHeadless Chrome完美支持系统字体网页打印、报表本质是浏览器打印重但省心选型逻辑其实很简单生成优先看 pdfkit 或 pdf-libHTML 渲染优先 puppeteer解析优先 pdf-parse复杂到需要精确坐标的解析就上 pdfjs-dist。纯前端操作 PDF 的话 pdf-lib 是首选因为它在浏览器和 Node 都能跑API 也统一。1.3 我自己踩过之后确定的组合方案最后我自己的项目里用了组合方案pdf-lib 负责所有生成和编辑类操作pdf-parse 负责文本抽取puppeteer 负责需要带样式和布局的网页转 PDF。这三个库各管一摊互不干扰比硬找一个“全家桶”靠谱得多。为什么不用 pdfkitpdfkit 的文字排版能力确实强但字体处理比较麻烦尤其遇到中文。pdf-lib 虽然也更底层但 API 设计更现代而且对字体子集化的支持让我在生成大量合同文件时省了不少空间。后面我会演示一个用 pdf-lib 写 Hello World 的完整例子。2. 核心细节解析与实操要点2.1 中文字体问题——90% 的坑都从这来先说这个最磨人的问题因为不管生成还是解析中文都是绕不开的坎。PDF 文件里的字体分为两类内嵌字体和引用系统字体。内嵌字体是把字体文件直接塞进 PDF 里这样任何设备打开都能正常显示引用系统字体则不嵌入只写一个字体名对方电脑里没有这个字体就会乱码。Node.js 生成 PDF 时默认只处理标准 PDF 字体Helvetica、Times-Roman 这种它们不支持中文。所以你需要做两件事准备一个支持中文的字体文件ttf/otf然后用库的能力把字体注册进文档。我用的是思源黑体开源免费而且字重齐全适合绝大多数业务场景。字体嵌入的核心代码如下以 pdf-lib 为例const { PDFDocument, StandardFonts, rgb } require(pdf-lib); const fs require(fs); const fontBytes fs.readFileSync(./fonts/source-han-sans-regular.ttf); async function createPdf() { const pdfDoc await PDFDocument.create(); const font await pdfDoc.embedFont(fontBytes); const page pdfDoc.addPage([595, 842]); // A4 page.drawText(中文测试-你好Node.js PDF, { x: 50, y: 800, size: 24, font: font, color: rgb(0, 0, 0), }); const pdfBytes await pdfDoc.save(); fs.writeFileSync(./test.pdf, pdfBytes); } createPdf();看到没有核心就两步把字体文件读进来然后embedFont。如果你用的是 pdfkit则需要registerFont再font()方法指定思路是一样的。2.2 页面尺寸与边距的设置细节另一个高频问题是页面尺寸和边距。PDF 页面单位是“磅”point1 英寸 72 磅A4 纸的尺寸是 595 x 842 磅。这个数字很多人记不住我都是直接用常量。pdf-lib 里除了手动传尺寸还提供了一个工具函数const { PDFDocument, PageSizes } require(pdf-lib); const page pdfDoc.addPage(PageSizes.A4); // 595.28 x 841.89PageSizes 里还有 A3、A5、LETTER、LEGAL 等常用尺寸不用自己手算。如果你需要自定义发票尺寸比如 76mm 热敏纸小票那就得自己换算76mm 约等于 215.4 磅正确写法是addPage([215.4, 812.6])小票高度一般 80mm。边距这块我建议在生成时统一封装一个坐标换算函数别在每一处绘制都硬编码坐标否则后期调边距会很痛苦。我一般会抽出一个MarginLayout类输入页面宽高和边距输出内容区的宽高和基准坐标。2.3 PDF 加密与权限控制如果生成的是合同或者发票一般都要设置只读或禁止打印之外的权限。pdf-lib 提供了encrypt方法const pdfBytes await pdfDoc.save({ useObjectStreams: true, addDefaultPage: false, }); await pdfDoc.encrypt({ userPassword: 123456, ownerPassword: admin888, permissions: { printing: lowResolution, modifying: false, copying: false, }, });这里有一个需要注意的点encrypt必须在save之前调用而且权限控制只对专业 PDF 阅读器严格生效有的浏览器内置阅读器并不完全遵守这些权限标记。所以不要指望靠这个做安全隔离它更多是一个“防君子”的手段。真正的安全还得靠服务端权限校验。2.4 打印与纸张尺寸的热点问题映射最近看到一个高频搜索词“Microsoft Print to PDF 如何添加新纸张尺寸”这虽然不完全属于 Node.js 范畴但也反映了一个普遍痛点大家做网页打印 PDF 时会发现浏览器里没有自己想要的纸张尺寸选项。在我用 puppeteer 做打印 PDF 时这个问题在代码里是很好解决的。puppeteer 的page.pdf()方法支持format和width/height两种指定方式const browser await puppeteer.launch(); const page await browser.newPage(); await page.goto(https://example.com, { waitUntil: networkidle0 }); await page.pdf({ path: ./webpage.pdf, format: A4, printBackground: true, margin: { top: 1cm, bottom: 1cm, left: 1cm, right: 1cm }, displayHeaderFooter: true, headerTemplate: div stylefont-size:8px;padding-left:20px;我的网站/div, footerTemplate: div stylefont-size:8px;text-align:center;width:100%;第 span classpageNumber/span 页 / 共 span classtotalPages/span 页/div, }); await browser.close();这里关键是format和margin的组合页面尺寸与边距在此一举搞定。如果你需要非标准尺寸比如长报表直接用width: 800px, height: 1200px就行。还有一个小坑displayHeaderFooter默认是 false你需要手动打开否则页眉页脚不会显示。3. 实操流程与关键接口实现3.1 环境准备安装 Node.js 与 npm 基础配置这一步很多人都卡住过网上大量“npm : 无法加载文件 npm.ps1因为在此系统上禁止运行脚本”的问题其实都和 PowerShell 的执行策略有关。我先说一下稳妥的安装路径再解释这个报错的解决方式。官方下载地址是 nodejs.org选择 LTS 版本不建议用 Current当前版本跑生产项目稳定性优先。Windows 下安装时安装向导默认会把 Node.js 和 npm 加到系统 PATH然后 npm 的全局缓存目录默认在C:\Users\你的用户名\AppData\Roaming\npm。如果你想改比如放到 D 盘需要手动配置。npm 报错的根源是 PowerShell 默认禁止运行未签名的脚本而 npm.ps1 就是一个脚本文件。解决办法有两个方向以管理员身份打开 PowerShell执行Set-ExecutionPolicy RemoteSigned然后选 Y。不用 PowerShell改用 CMD命令提示符来跑 npm 命令CMD 不检查执行策略可以绕过这个问题。比较推荐第一个方案因为改一次之后 PowerShell 和 npm 都能正常用。改完之后验证一下node -v npm -v两个都能输出版本号说明环境没问题。3.2 使用 pdf-lib 从零生成一份订单 PDF下面我用一个完整示例演示用 pdf-lib 生成订单 PDF串联起前面讲的知识点字体嵌入、页面布局、表格绘制、保存文件。const { PDFDocument, rgb } require(pdf-lib); const fs require(fs); async function generateOrderPdf(orderData) { const pdfDoc await PDFDocument.create(); const font await pdfDoc.embedFont( fs.readFileSync(./fonts/source-han-sans-regular.ttf) ); const page pdfDoc.addPage([595, 842]); // A4 const { width, height } page.getSize(); // 标题 page.drawText(XX 商城订单确认单, { x: width / 2 - 90, y: height - 100, size: 24, font, color: rgb(0.1, 0.1, 0.1), }); // 订单信息 let y height - 160; const drawLine (key, value) { page.drawText(key, { x: 80, y, size: 12, font }); page.drawText(value, { x: 220, y, size: 12, font }); y - 28; }; drawLine(订单号, orderData.orderNo); drawLine(下单时间, orderData.orderTime); drawLine(收件人, orderData.receiver); drawLine(联系电话, orderData.phone); drawLine(收货地址, orderData.address); // 商品清单头部 y - 20; const columns [商品名称, 单价, 数量, 小计]; const columnX [80, 320, 400, 480]; columns.forEach((col, idx) { page.drawText(col, { x: columnX[idx], y, size: 12, font }); }); y - 20; page.drawLine({ start: { x: 80, y }, end: { x: 520, y }, thickness: 1 }); // 商品行 y - 20; orderData.items.forEach((item) { page.drawText(item.name, { x: columnX[0], y, size: 11, font }); page.drawText(item.price.toFixed(2), { x: columnX[1], y, size: 11, font }); page.drawText(String(item.qty), { x: columnX[2], y, size: 11, font }); page.drawText((item.price * item.qty).toFixed(2), { x: columnX[3], y, size: 11, font }); y - 20; page.drawLine({ start: { x: 80, y }, end: { x: 520, y }, thickness: 0.5 }); y - 15; }); // 合计 page.drawText(合计¥ orderData.items.reduce((sum, it) sum it.price * it.qty, 0).toFixed(2), { x: 400, y: y - 20, size: 14, font, color: rgb(0.8, 0.2, 0.2), }); const pdfBytes await pdfDoc.save(); fs.writeFileSync(./order-${orderData.orderNo}.pdf, pdfBytes); } const mockOrder { orderNo: 20240601001, orderTime: 2024-06-01 10:30:00, receiver: 张三, phone: 13800000000, address: 北京市朝阳区某某街道 88 号, items: [ { name: 机械键盘, price: 399, qty: 1 }, { name: 双模鼠标, price: 199, qty: 2 }, ], }; generateOrderPdf(mockOrder).then(() console.log(PDF 生成完成));这个例子可以直接复制改一改用。几个值得注意的细节列坐标用数组管理方便后续调整商品行之间画了细线区分总金额用红色突出显示。实际业务中如果你有动态多页需求需要自己控制 y 坐标当 y 小于某个阈值时addPage()新开一页并把 y 重置为初始值。3.3 用 pdf-parse 批量读取 PDF 内容如果你的需求是把一批 PDF 文件里的文本提取出来做检索或入库pdf-parse 是效率最高的选择。安装很简单npm install pdf-parse基本用法如下const fs require(fs); const pdfParse require(pdf-parse); async function extractPdfText(filePath) { const dataBuffer fs.readFileSync(filePath); const data await pdfParse(dataBuffer); return data.text; } (async () { const text await extractPdfText(./manual.pdf); console.log(text.slice(0, 500)); })();pdf-parse 返回的对象包含text、numPages、info等字段numPages可以帮你判断 PDF 是否多页info里通常有作者、标题等元数据。但它有个短板对扫描版 PDF本质是图片无能为力这时候就需要 OCR常见方案是配合 tesseract.js 或调用云厂商的 OCR 服务。3.4 HTML 报表打印 PDF 的完整流程再有就是最近热词里特别火的“web 页面 pdf 打印”。我用 puppeteer 接到过好几个需求把后台管理系统里的报表页面一键打印成 PDF 发给客户。做法是先起一个无头浏览器打开页面然后调用page.pdf()。前端页面里需要配合media print样式来隐藏按钮、调整布局这部分容易被忽略。实际经验是在全局样式里加一段media print { .no-print { display: none !important; } .print-container { width: 100%; padding: 0; } }这样 puppeteer 打印时页面上那些“导出按钮”“操作列”就不会被带进 PDF 里。如果页面宽度需要严格匹配 A4可以在 puppeteer 启动时加--no-sandbox参数Linux 服务器上常见并在页面加载完成后等待自定义的渲染标记比如页面上放一个div idapp-loaded/div然后用page.waitForSelector(#app-loaded)确保动态数据渲染完成再打印。4. 常见问题排查与避坑实录4.1 npm.ps1 无法加载脚本的解决路径这个问题太经典了我单列一节。搜索记录里大量出现npm : 无法加载文件 c:\program files\nodejs\npm.ps1因为在此系统上禁止运行脚本这类报错其实解决方式就两种以管理员身份打开 PowerShell执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned或者直接用 CMD 运行 npm 命令不经过 PowerShell如果执行策略修改后依然报错检查一下 Node.js 的安装路径里是否有空格比如Program Files有些老版本工具处理带空格的路径会有问题。你可以把 npm 全局路径手动指到自定义目录来规避npm config set prefix D:\nodejs\npm-global npm config set cache D:\nodejs\npm-cache这样目录结构调整后路径里没有空格后续安装全局包比如 pnpm、yarn也会更顺。4.2 生成的 PDF 中文变豆腐块如果你生成的 PDF 中文全部显示成一堆方框基本可以断定是字体没嵌入成功。这里有一个隐藏很深的原因你传给embedFont的字体文件路径不正确或者文件损坏Node.js 不会报错而是静默失败最终生成不完整字体信息的 PDF。排查思路确认字体文件路径存在并且是有效 ttf/otf 文件用fs.stat验证文件大小正常字体文件不会小于 100KB单独写一个调试函数把嵌入后的字体信息打印出来确认字体对象包含内嵌数据另外还要注意思源宋体或思源黑体这类开源字体对子集化的支持较好但部分商业字体的授权协议不允许子集化比如一些特殊版权字库。团队项目里如果要商用记得检查字体授权。4.3 页面尺寸不对打印出来被裁切这个常见于 puppeteer 打印网页的场景。如果你在样式里写page { size: A4; margin: 0; }而 pdf 方法里又传了format: A4浏览器会优先采用 CSS 的page规则导致两个设置打架。经验是二选一页面样式里不要写size统一在page.pdf()里控制这样逻辑集中好排查。还有一种情况是网页内容宽度大于 A4 宽度打印时内容被横向截断。解决办法是给打印容器加上width: 100%并在样式里设置zoom如果页面按 1920 宽度设计打印时需要缩小适配。我一般会在打印媒体查询里加media print { body { width: 210mm; zoom: 0.75; } }这里的0.75是根据设计稿宽度和 A4 实际打印面积试出来的比例不同项目不同可以先从 0.75 试起再微调。4.4 PDF 解析出来乱码或空文本pdf-parse 对简单排版提取效果还行但遇到多栏布局、图文混排或者带特殊编码的 PDF吐出来的文本经常乱成一团。这在技术文档、论文扫描件里很常见。遇到这种情况升级到 pdfjs-dist 自己写解析逻辑会好很多。它的文本提取是基于页面渲染层做的保留了坐标和字体信息。不过代码复杂度指数级上升需要遍历 page 的 operators 来拼接文本。更省事的方案是把这类 PDF 先转成图片再交给 OCR 处理但准确率完全取决于清晰度。我的经验是如果整个项目对文本提取准确率要求高建议直接上云厂商的文档解析接口比如各种文档识别 API本地 node 方案在复杂版式面前肯定不够看。自己写的解析脚本更消耗时间但在敏感数据不出内网的前提下用小成本维护一个基础解析脚本还是值得的。4.5 其他高频报错排查速查表做一个快速对照表按我的实际排查顺序排的报错/现象出现环节根因方向首选处理Cannot find module pdf-lib首次运行依赖未安装npm install pdf-libfontkit.create is not a function字体嵌入pdf-lib 缺少 fontkit 依赖安装npm install pdf-lib/fontkitPUPPETEER 启动失败 / 无法找到 Chromepuppeteer 调用服务器缺少依赖库npm install puppeteer时加--ignore-scripts再单独npx puppeteer browsers install chrome或者用 puppeteer-core 配本机 Chrome内存溢出JS heap out of memory大批量生成 PDF文件一次性写入数组使用流式写入分批处理加大--max-old-space-size文件生成成功但无法打开生成过程中断保存前未调用end()pdfkit检查流水线方法是否以end()收尾4.6 关于大量文件操作的性能优化最后一个实操心得。如果业务里需要循环生成几百上千份 PDF要注意两件事第一是内存峰值我遇到过把 1000 份订单的PDFDocument都创建在循环内不释放结果进程直接 OOM。解决办法是每一份生成完立即save()并写入磁盘然后通过pdfDoc null或自然退出函数作用域让垃圾回收接管。第二是文件系统写入的并发控制。Node.js 的fs.writeFileSync是同步阻塞的循环里用它会把整个事件循环卡住。我习惯用fs.promises.writeFile配合一个简单的并发池比如 p-limit 包控制同时写盘的文件数在 5~10 个既不会把磁盘 IO 打满也不会因为并发太高导致临时文件句柄耗尽。const pLimit require(p-limit); const limit pLimit(5); const tasks orders.map((order) limit(() generateOrderPdf(order)) ); await Promise.all(tasks);这个手法在处理大批量 PDF 导出任务时非常管用配合--max-old-space-size4096一起用线上任务基本不会崩。我个人在实际操作中的体会是Node.js 生态处理 PDF 最大的问题不是缺库而是选库太随意。先想清楚自己的核心诉求是生成、解析还是转换再决定用哪个方案看起来简单实际能帮你在项目后期省掉至少一半的返工时间。另外中文字体这个环节真的建议在项目第一天就一次性配好放到一个公共模块里别每个文件写一遍血的教训。希望这篇东西能让你少走我走过的弯路。