字体子集化优化在线动态渲染PDF

最近做了一个在线生成PDF,适时预览并可在线打印的系统。使用了pdf-lib.js(https://pdf-lib.js.org),作为前端创建PDF的框架,虽然很方便,但遇到一个棘手的问题:如何解决中文显示。

PDF文件不同于web文件(HTML+CSS),它的字体是嵌入文件的,而由于安全性和PDF文件本身机制的问题,并不能读取本地字体文件。而国际PDF阅读器规范中的 “Standard 14 Fonts”(14 种标准字体)中并没有中文字体,这就导致了在不嵌入中文字体的情况下,出现中文就会无法渲染。

Standard 14 Fonts

内置的 14 种标准字体 这 14 种字体全部属于 拉丁字母(英文) 系列,分为四大类:
1.无衬线 (Sans Serif)字体: Helvetica, Helvetica-Bold, Helvetica-Oblique, Helvetica-BoldOblique
2.衬线 (Serif/Roman) 字体:Times-Roman, Times-Bold, Times-Italic, Times-BoldItalic
3.等宽 (Monospace)字体: Courier, Courier-Bold, Courier-Oblique, Courier-BoldOblique
4.符号 (Symbol) :Symbol, ZapfDingbats

PDF 的设计初衷是 “所见即所得” (WYSIWYG)。它的目标是:不管你把这个文件发给谁,在手机上、打印机上还是 20 年后的电脑上,看到的排版必须一模一样。

封闭性:PDF 不信任阅读者的系统。它认为:“万一对方电脑里没有微软雅黑,我的排版不就乱了吗?”

机制:为了保证绝对一致,pdf-lib 必须执行 “字体嵌入 (Embedding)”。它需要把字体文件的每一个二进制数据“缝进” PDF 文件里。

面临的难点

这个系统需要兼容正体中文(繁体)和简体中文,而这种字体体量大得惊人。兼容性最好的Adobe全字符体——Super OTC 体积高达100M+,师出同门的Google Noto也差不多这个体量,如放到Web项目,简直是灾难,浏览器瞬间会被卡死。

于是只能放弃使用这种大字符集的字体,选择性地支持正体或简体。

但是单独的简体字体,体积也很大,思源Source Han Serif,单独一种字重的字体,也高达20M+,Noto也要10M-20M。如果要加载这么大的字体,配置一般的电脑尤其是内存小的电脑,会频繁出现假死的情况。

于是,只能想办法减少字体的体积。

通过万能的Gemini,终于有了解决办法:字体子集化。

说白了,就是从原字体中提取一部分字符,只要能覆盖自己常用的字符就行了。

方案:

1. 用集成工具做子集化。这是最常用的方式,工具例如,Fontmin(https://ecomfe.github.io/fontmin/)。

这种方式就是要准备“字体白名单”,也就是你要把那些你需要的字符一一列出来,让它把你需要的字符从庞大字库中“抠”出来。

可以到GitHub上下载常用中文字符3500字等整理好的字符集,导入Fontmin中。也可以自己整理用到的文字再加进去。

2.用 Python 做字体子集化。

#安装fonttools库
pip install fonttools brotli

#命令执行子集化
pyftsubset original.ttf --text-file=chars.txt --output-file=sub.woff2 --flavor=woff2
'''
其中
original.ttf:原始字体,如思源宋体
--text-file:字符白名单文件
--output-file输出字体
'''

#不指定字符白名单,只按照某个标准来子集化(如变成GB2312标准字符集)
pyftsubset NotoSerifSC-Medium.ttf --unicodes="U+0020-007E,U+00A0-00FF,U+2000-206F,U+3000-303F,U+4E00-9FA5" --output-file=label_gb2312.ttf --layout-features='*'

3.在线动态字体子集化

① Node.js 生态方案:Fontmin,基于 fonteditor-core,支持 TTF、EOT、WOFF、WOFF2 之间的转换。它支持插件机制,可以方便地动态剪裁。示例代码:

const Fontmin = require('fontmin');

const fontmin = new Fontmin()
    .src('fonts/SourceHanSansCN-Regular.ttf') // 原始 15MB 字体
    .use(Fontmin.glyph({
        text: '张三的PDF发票金额: ¥100.00' // 前端传过来的动态文本
    }))
    .use(Fontmin.ttf2woff2()) // 转换为更小的 woff2 格式
    .dest('dist/fonts');

fontmin.run((err, files) => {
    // 这里的 files[0].contents 就是剪裁后只有几 KB 的字体二进制流
});

font-compress-service框架:

下载地址:https://github.com/xituimao/font-compress-service

可以通过URL参数直接传入远程字体地址和需保留文字及字符集。

② Python / 命令行生态(性能与压缩率天花板)

FontTools (pyftsubset),Google、Adobe 等大厂都在使用的世界级行业标准开源字体处理库。

其自带的命令行工具 pyftsubset 性能极其强悍,对 OpenType 特性(如连字、变体)保留最完整,且生成的 woff2 压缩率最高。

动态调用方式:在后端(如 Go 或 Java)通过进程调用(Exec)其命令行:

pyftsubset original.ttf --text="需要保留的文字" --flavor=woff2 --output-file=subset.woff2

③ WebAssembly 方案(全前端免后端黑科技)

如果绝对不想部署后端,但又想实现动态子集化,目前可以通过 WebAssembly(Wasm)将底层的 C/C++ 字体剪裁库搬到前端浏览器运行。

 harfbuzzjs / hb-subset

简介:将大名鼎鼎的开源字形排版引擎 HarfBuzz 编译为 Wasm 的版本。

特点:可以让浏览器直接具备在本地“秒级”剪裁 20MB 字体的能力。前端收集到文本后,直接在浏览器内存里通过 Wasm 剪裁原始字体,再喂给 pdf-lib

缺点:首次加载页面时,用户需要下载 Wasm 模块和完整的原始大字体文件(缓存在浏览器中),初次加载较慢,适合离线工况或管理后台。

④云厂商现成方案

Google Fonts:Google 率先实现了自动子集化。当网页请求字体时,它的智能网络会自动分析你的 HTML 文本,或通过 &text=… 参数动态返回切片字体。

<!-- 必须对传递的字符进行 URL 编码UrlEncode-->
<link href="https://fonts.googleapis.com/css2?display=swap&family=Noto+Sans+SC:wght@400&text=%E6%9C%A0%E6%96%B0%E5%8F%91%E7%A5%A8" rel="stylesheet">

function applyDynamicGoogleFont(elementId, fontName) {
    const element = document.getElementById(elementId);
    if (!element) return;

    // 1. 获取元素内的文本,去重并排序
    const textContent = element.innerText;
    const uniqueChars = Array.from(new Set(textContent)).sort().join('');
    
    // 2. 对字符串进行 URL 编码
    const encodedText = encodeURIComponent(uniqueChars);
    
    // 3. 拼接 Google Fonts API 链接
    // 注意:建议加上 &display=swap 防止字体未加载出来时页面白屏
    const fontUrl = `https://fonts.googleapis.com/css2?display=swap&family=${fontName.replace(/ /g, '+')}&text=${encodedText}&display=swap`;
    
    // 4. 检查是否已有该字体的 link 标签,没有则创建,有则更新
    let link = document.getElementById(`font-link-${elementId}`);
    if (!link) {
        link = document.createElement('link');
        link.id = `font-link-${elementId}`;
        link.rel = 'stylesheet';
        document.head.appendChild(link);
    }
    
    link.href = fontUrl;
    
    // 5. 在 CSS 中应用该字体
    element.style.fontFamily = `"${fontName}", sans-serif`;
}

// 【使用场景】:当你的业务组件渲染完成后调用
// 假设 <div id="my-title">欢迎来到 AI 生产力工具库</div>
applyDynamicGoogleFont('my-title', 'Noto Sans SC');

 

PDF相关开发小知识:

Chrome的PDF渲染核心核心是PDFium(Google 和福昕合作开发的C / C++开源项目),Firefox使用的则是自家的PDF.js(纯 JavaScript 实现的 PDF 渲染器),开发环境下它们有一些使用上的差异:

在 PDF 官方规范中,FitH 是针对“宽度自适应”的,但在 Firefox 的 pdf.js 源代码里,他们对参数的映射是这样的:

view=FitH: 对应的内部指令是 FitH垂直滚动模式下对齐宽度)。

zoom=page-fit: 对应的内部指令是 page-fit强制将整个页面缩放到当前容器可视窗口内)。

Firefox中可以通过“about:config”——“pdfjs.defaultZoomValue”,将值设置为page-fit/page-width,可实现自适应窗口展示,不过这种方式只适用于用浏览器直接打开pdf文件的情况下适用(这种情况是用 Firefox 原生 PDF.js 打开的)。