pdf-lib 实测:不截图、不转 HTML,用代码一行行写出 PDF——中文嵌入字体与 5 个实操坑
pdf-lib 实测:不截图、不转 HTML,用代码一行行写出 PDF——中文嵌入字体与 5 个实操坑
前端要"把页面转成 PDF",大多数人第一反应是 html2canvas 截图加 jsPDF 拼接:所见即所得,但出来的是一张大图,文字选不了、搜索不了,放大还发虚。另一条路是程序化生成:每个字、每条线都由代码放置,pdf-lib(Hopding/pdf-lib,约 8.7 千星,MIT)就是这条路上最常用的纯 JavaScript 库。
我在 Node 22 上把它完整跑了一遍:英文、中文、改文件、合文件都试了,输出全部真实截取,坑位集中在第九节。
一、它是什么,分工是什么
pdf-lib 是一个读写 PDF 的纯 JS 库:创建文档、画文字和图形、嵌入字体、给已有 PDF 盖章追加页、把多份 PDF 合并成一份。它不渲染 HTML,也不做页面布局引擎。和截图路线的分工很清楚:要"网页长这样"用截图,要"数据生成的规范文档"用它。发票、合同、报表、证书这类版式固定的输出,是它的主战场。
二、安装
npm install pdf-lib @pdf-lib/fontkit
实测版本 pdf-lib 1.17.1,Node 22.22.2,无原生依赖,Windows 直接装。中文字体要额外嵌入,@pdf-lib/fontkit 是官方搭档,负责解析字体文件。
三、标准字体画英文
PDF 内置 14 种标准字体,不嵌任何东西就能用,出来的文件极小:
const { PDFDocument, StandardFonts, rgb } = require('pdf-lib');
const doc = await PDFDocument.create();
const font = await doc.embedFont(StandardFonts.Helvetica);
const page = doc.addPage([595, 842]); // A4,单位是 pt
page.drawText('Hello from pdf-lib!', { x: 50, y: 780, size: 24, font });
page.drawText('Origin is bottom-left, y grows UP.', { x: 50, y: 740, size: 12, font });
fs.writeFileSync('demo1.pdf', await doc.save());
产物 938 字节(PDF 的 /ID 字段带生成时间戳,重跑字节数有几个字节的浮动,下文各字节数均为首次实跑值)。几行代码出文件,没有任何外部依赖。
四、Helvetica 画中文:响亮地失败
顺手拿标准字体写中文,直接抛错:
Error: WinAnsi cannot encode "中" (0x4e2d)
换成另一个字,报错连码位一起给:WinAnsi cannot encode "思" (0x601d)。原因是标准 14 字体只带西文编码(WinAnsi),中文不在其列。这个设计其实友好:它当场报错,不会像某些库那样静默画出一片空白。中文必须走嵌入字体这条路。
五、嵌入中文字体
Windows 自带的中文字体(微软雅黑、宋体)是 .ttc 集合格式,fontkit 不认;本机演示下载了 Noto Sans SC(SIL OFL 1.1 授权,16.4 MB)当嵌入字体:
const fontkit = require('@pdf-lib/fontkit');
const doc = await PDFDocument.create();
doc.registerFontkit(fontkit);
const noto = await doc.embedFont(fs.readFileSync('NotoSansSC.otf'), { subset: true });
const page = doc.addPage([595, 842]);
page.drawText('中文宋体字样:pdf-lib 嵌入字体', { x: 50, y: 780, size: 20, font: noto, color: rgb(0.1, 0.1, 0.4) });
page.drawRectangle({ x: 50, y: 740, width: 300, height: 30, color: rgb(0.9, 0.95, 1) });
page.drawText('带底纹的一行', { x: 60, y: 748, size: 14, font: noto });
产物 60,279 字节,字体名记录为 NotoSansCJKsc-Regular。注意 registerFontkit 这一步不能省,不注册就直接 embedFont 会报找不到字体解析器。
六、给已有的 PDF 盖章、加页
修改走 PDFDocument.load,读完想怎么画怎么画:
const doc = await PDFDocument.load(fs.readFileSync('demo3.pdf'));
const page = doc.getPages()[0];
page.drawText('STAMP 2026-10-17', { x: 380, y: 60, size: 10, font: stamp, color: rgb(0.8, 0.1, 0.1) });
const newPage = doc.addPage([300, 200]);
newPage.drawText('appended page', { x: 20, y: 160, size: 12, font: stamp });
fs.writeFileSync('demo4.pdf', await doc.save());
原文档 1 页,盖章并追加一页后 2 页,60,897 字节。已有内容原样保留,这是它和"重新生成一整份"的关键区别。
七、合并两份 PDF
合并的标准动作是 copyPages:从源文档复制页面,再 addPage 到目标文档。
const merged = await PDFDocument.create();
const pagesA = await merged.copyPages(docA, docA.getPageIndices());
const pagesB = await merged.copyPages(docB, docB.getPageIndices());
[...pagesA, ...pagesB].forEach(p => merged.addPage(p));
fs.writeFileSync('merged.pdf', await merged.save());
一份英文页加一份中文页合并后 2 页,60,586 字节,两边的文字和字体都正常带过去。
八、页码居中和坐标体系
页码要居中,得先量文字宽度。标准字体有 widthOfTextAtSize:
const label = `${i} / ${total}`;
const w = font.widthOfTextAtSize(label, 10);
page.drawText(label, { x: (595 - w) / 2, y: 30, size: 10, font });
三页文档加完页码 1,520 字节。坐标这块单独说,因为它和网页思维相反:pdf-lib 的原点在页面左下角,y 轴向上长。我用另一个渲染引擎(PyMuPDF)做了对拍验证,把 pdf-lib 写的 PDF 渲染出来提取文字位置:
| pdf-lib 里写的 y | PyMuPDF 实测距顶 y_top |
|---|---|
| 800 | 29.1 |
| 421 | 408.1 |
| 50 | 779.1 |
按 HTML/CSS 的"距顶距离"去写,内容会整片飞到页面外。页面高度 842 的 A4,中间线是 421,从底部往上数。
九、坑位记录(5 个,全部实测)
- 标准字体画中文直接抛错。
WinAnsi cannot encode "中" (0x4e2d),报错带码位。中文只有嵌入字体一条路。 subset: true不写,整个字体会塞进 PDF。实测:嵌入 16.4 MB 的 Noto Sans SC,只画了 5 个汉字,产物 13.9 MB。加上subset: true后同一份内容只要 60 KB。子集化剪掉没用到的字形,这是默认最该记住的一个参数。- .ttc 集合字体在 pdf-lib 里走不通。实测(wqy-microhei.ttc,5,177,387 字节):
fontkit.create不报错但返回空壳(familyName 是 undefined);embedFont带subset: true抛TypeError: _this.font.createSubset is not a function;不带子集虽然嵌得进去,一drawText就抛TypeError: this.font.layout is not a function。Windows 自带的微软雅黑、宋体正是 .ttc 集合格式,直接用必踩坑。换单文件 .ttf/.otf(演示用的 Noto Sans SC 就是),或者先把 .ttc 拆开。 - 坐标原点在左下,y 向上。表格已对拍验证。画表格行、画页脚时最容易搞反。
- 合并用 copyPages,不是把字节拼起来。两份 PDF 的二进制直接拼接出来的文件阅读器不认;copyPages 会把页面连同资源(字体、图片)一起搬进新文档。
十、适合谁,边界在哪
适合:用数据批量生成发票、合同、报表、证书;给已有 PDF 盖章、加页、合并;Node 服务端或浏览器里产出可打印、可检索文字的规范文档。
不适合:把网页原样转 PDF,该用无头浏览器截图路线;读取和提取已有 PDF 的文字内容,它是写入方不是解析方;复杂版面(多栏混排、自动分页)没有现成排版引擎,得自己算坐标。
一句话选型:页面是数据拼出来的,用 pdf-lib;页面是设计出来的,用截图。
参考与署名:本文实测项目为 Hopding/pdf-lib(MIT License,实测版本 1.17.1);文中全部代码与输出均为 Node 22 本机实跑记录,未使用项目宣传图;演示用中文字体 Noto Sans SC(SIL OFL 1.1)仅用于本机演示、未随文分发。
- 点赞
- 收藏
- 关注作者
评论(0)