pdf-lib 导出高保真 PDF 实战:字体度量、子集化与行级 BiDi,让多语言排版“可控且一致”
pdf-lib 导出高保真 PDF 实战:字体度量、子集化与行级 BiDi,让多语言排版“可控且一致”
你把网页里的幻灯片导出成 PDF,最常见的翻车点不是“画不出来”,而是“差一点点”:标题多换了一行、阿拉伯语括号方向反了、数字跑到句首、某些字符变成空框(缺字)。 更可怕的是:这些问题一旦发生,靠肉眼回归几乎永远修不完。
这篇文章讲一个可复用的 PDF 导出方案:IR(中间层模型)→ layout(统一单位)→ 字体度量/回退/子集化 → 行级 BiDi 重排 → 渲染。 重点不在“调用库 API”,而在“把排版这件事工程化”,让多语言(尤其 RTL/混排)变成可控、可验证、可持续演进的能力。
TL;DR(30 秒讲清楚)
- 先做 IR:DOM/样式快照 → Presentation/Slide/Element;PDF 不直接依赖 DOM 渲染结果。
- 统一单位:slide 尺寸(px→pt)与元素位置(in→pt)分开处理,避免“坐标漂移”。
- 字体是核心:fontkit 字符级测量 + 缺字回退 + 收集 used chars;再做字体子集化(subsetting)控制体积。
- BiDi 必须行级:先按逻辑顺序断行,再对每行做 BiDi 重排 + 镜像标点,才能既正确又不破坏换行。
- 回归要自动化:像素 diff/缺字率/导出耗时 p95/文件体积 p95,才能把“一致性”变成工程闭环。
适用读者与前置知识
- 适合:你有 Web 端编辑/渲染的幻灯片(或海报、长图)系统,需要导出 PDF,并且在乎排版一致性与多语言支持。
- 不适合:你只想要“能下载一个 PDF”,完全不关心可搜索文本/多语言正确性(截图式导出更省事)。
- 前置:理解 DOM/CSS 基础;知道 px/pt/in;对 PDF 内部结构不要求精通,但要接受“布局是你算出来的”。
问题定义(Problem Statement)
在浏览器端把已渲染的幻灯片导出为高保真 PDF:文本换行/对齐可控、缺字可回退、RTL/BiDi 混排正确、图片裁剪一致;并用自动化回归与指标把一致性变成可维护的工程能力。
目标与非目标(Goals / Non-goals)
目标
- 正确性:不多一行、不跑版;RTL/混排不乱序;列表符号/编号位置正确。
- 稳定性:单元素失败不拖垮整份文档;字体/图片缺失有兜底。
- 体积可控:字体子集化后文件体积稳定(不随字体全量嵌入膨胀)。
非目标
- 不追求 100% 像素级完全一致(不同 PDF 阅读器的字体栅格化会有差异)。
- 不在本文解决“所有 CSS 效果都可矢量还原”(复杂 SVG/filter/mix-blend 往往需要降级)。
真实链路(从点击 Export 到拿到 PDF bytes)
- 固定导出渲染页:用专用页面渲染目标内容,固定 viewport(例如固定宽度),进入“导出态”。
- DOM → IR:遍历每个 slide,抽取 background / shape / image / svg / text;位置尺寸先统一到 inches。
- 构建 PDF layout:把 slide 的 px 尺寸映射到 pt,创建 PDF page;把 element 的 inches 映射到 pt,得到渲染坐标。
- 初始化字体:收集所有 text segments,fontkit 测量每个字符宽度;若缺字则按 Unicode 范围回退字体;记录 used chars。
- 字体子集化并嵌入:对每种字体把 used chars 子集化成更小的 TTF,再 embed 到 PDF;建立 font cache。
- 渲染每页:先画背景,再画图片/形状,最后画文本(先断行再 BiDi 重排,逐字符 drawText + 装饰)。
- 设置阅读方向(可选):RTL 文档设置阅读方向为 R2L,提高阅读器体验。
- 导出与回归:保存 bytes/base64;记录耗时与体积;抽样做像素 diff 与缺字率统计。
关键伪代码(读者可复现)
1) 单位体系:slide 用 px→pt,元素用 in→pt
一个实用的工程经验是:不要把所有东西都硬塞进同一个比例里。 PDF 页面的尺寸通常来自“设计宽度合同”(例如某个固定 viewport 宽度对应 10 inches),而元素的坐标更适合在 IR 里用 inches 表达(更贴近 PPT/PDF 的物理布局)。
伪代码:pageSize 与 elementPosition 的两套换算
const POINTS_PER_INCH = 72;
const DESIGN_WIDTH_IN = 10; // 业务约定
const designWidthPx = slideWidthPx; // 从第一张 slide 读到的真实像素宽度
// slide: px → pt(用于创建 PDF page 尺寸)
function pxToPt(px) {
const ratio = (DESIGN_WIDTH_IN / designWidthPx) * POINTS_PER_INCH;
return px * ratio;
}
// element: in → pt(IR 里 x/y/w/h 用 inches)
function inToPt(inches) {
return inches * POINTS_PER_INCH;
}
2) 字体管道:字符级测量 + 缺字回退 + 子集化嵌入
PDF 文本一致性最“本质”的问题,是字体:你必须知道每个字符在特定字体/字号下到底多宽,才能做正确的换行与对齐。 同时你还必须处理缺字(notdef),否则多语言一上线就会出现空框。
伪代码:measure + fallback + usedChars(概念版)
async function measureSegments(segments, cdnBaseUrl) {
const usedCharsByFont = new Map(); // fontFamily → Set(char)
for (const seg of segments) {
// 复杂脚本预处理:例,阿拉伯语 shaping(先让字符形态正确)
const shapedText = shapeArabicIfNeeded(seg.text);
// bidi 分析:这里只标记每个字符方向,不做重排(重排留给行级)
const levels = bidiGetEmbeddingLevels(shapedText, detectBaseDir(shapedText));
seg.chars = [];
for (let i = 0; i < shapedText.length; i++) {
const ch = shapedText[i];
// 1) 尝试用主字体测量 glyph
let result = measureGlyphWithFontkit(seg.fontFace, seg.fontSize, ch);
// 2) 缺字:按 Unicode 范围选择 fallback 字体重测
if (result.isNotdef) {
const fallbackFont = pickFallbackFontByUnicodeRange(ch, cdnBaseUrl);
result = measureGlyphWithFontkit(fallbackFont, seg.fontSize, ch);
result.fontFamily = fallbackFont;
}
// 3) 记录 used chars(用于后续子集化)
usedCharsByFont.get(result.fontFamily)?.add(ch) ??
usedCharsByFont.set(result.fontFamily, new Set([ch]));
seg.chars.push({ ...result, isRtl: (levels[i] & 1) === 1 });
}
}
return usedCharsByFont;
}
async function embedSubsetFonts(pdfDoc, usedCharsByFont) {
const pdfFontCache = new Map();
for (const [fontFamily, chars] of usedCharsByFont.entries()) {
const fontBuffer = await downloadFontFile(fontFamily);
const subsetBuffer = subsetTtfByCodepoints(fontBuffer, [...chars]);
pdfFontCache.set(fontFamily, await pdfDoc.embedFont(subsetBuffer));
}
return pdfFontCache;
}
3) 文本管道:先断行,再做行级 BiDi 重排(并镜像标点)
最常见的 BiDi 错误是:把整段文字先重排,再去断行。结果是换行点落在“视觉顺序”里,读起来像乱码。 正确姿势是:在逻辑顺序上断行,再对每一行做 BiDi 重排与镜像字符。
伪代码:tokenize + breakLines + reorderPerLine(概念版)
function layoutBidiLines({ chars, maxWidthPt, baseDir }) {
// 1) token 化:word/whitespace/complex/cjk/punct/newline...
const tokens = tokenize(chars);
// 2) 逻辑顺序断行:token fits 则累加;超长 token 用 grapheme 单元拆(Intl.Segmenter)
const lineRanges = breakIntoLineRanges(tokens, maxWidthPt);
// 3) BiDi:对每一行用 embedding levels + reordered indices 获取视觉顺序
const levels = bidiGetEmbeddingLevels(charsToText(chars), baseDir);
return lineRanges.map((range) => {
const visualIndices = bidiGetReorderedIndices(range.start, range.end, levels);
const lineChars = visualIndices.map((i) => ({
...chars[i],
char: isRtl(levels[i]) ? mirrorPunct(chars[i].char) : chars[i].char,
}));
return { chars: lineChars, widthPt: sum(lineChars.map((c) => c.widthPt)) };
});
}
4) 列表 bullet:不要把命运交给“某个字体刚好支持”
列表符号是多语言里很容易忽略的细节:某些字体没有圆点/方块 glyph,你用 drawText 画 bullet 就会变空框。 一个稳妥策略是:常见 bullet 用几何图形画(圆/方/线/三角),避免字体依赖;有序列表在 RTL 下还要注意标点位置(例如 .1 vs 1.)。
5) 图片裁剪:保留原图(clip)还是预裁剪(canvas)
对图片来说,PDF 渲染要解决两件事:cover/contain 的数学一致与体积/可编辑性的取舍。 一个常用开关是 preserveOriginalImage:
- true:保留原图,用 PDF clipping path 裁剪(文件更大,但编辑/重新裁剪空间更大)。
- false:先用 canvas 预裁剪成目标尺寸 PNG 再 embed(文件更小,但裁剪结果不可逆)。
指标与验证(Metrics & Validation)
建议至少建立三组指标,把“高保真”落到可量化:
- 正确性:像素 diff 超阈值的页数比例;缺字率(notdef 数 / glyph 总数);RTL 样本通过率。
- 性能:导出耗时 p95(按页数分桶)、峰值内存(浏览器端尤重要)、失败率与重试次数。
- 体积:PDF 文件体积 p95;字体子集化前后体积差;位图元素占比(影响可编辑/可搜索)。
通过标准(建议):
- 正确性:样本库像素 diff 不回退;RTL/混排样本不跑版、不乱序。
- 缺字:notdef 缺字率接近 0;fallback 命中可追踪可定位。
- 性能/体积:导出耗时 p95 与文件体积 p95 不回退(或在可接受阈值内)。
最小可复现验证(示例):
- 准备 10–50 份固定样本:长段落、混排(英文+数字+RTL)、列表、图片 cover/contain、复杂 SVG(可降级)。
- 把 PDF 渲染成位图(逐页),与浏览器导出态截图做像素 diff(允许 1–2px 容差)。
- 每次改动都跑:一旦回归失败,优先定位是“单位换算/字体回退/断行/BiDi/裁剪”哪一层。
预期与判定(建议):
- 像素回归:样本库像素 diff 不回退;允许轻微容差(例如 1–2px),但“不多一行/不跑版”应是硬底线。
- 缺字:notdef 缺字率接近 0;任何缺字都应该能定位到“哪个字符 + 哪个字体 + 是否触发 fallback”。
- 性能/体积:导出耗时 p95 与文件体积 p95 不出现明显回退;字体子集化后体积变化应可解释。
不通过先查:单位换算(px/in/pt)是否统一;断行是否发生在 BiDi 之前;fallback 字体映射是否覆盖该脚本范围; 以及图片裁剪策略(cover/contain)是否与预览一致。
常见坑与规避(Pitfalls)
- 先重排后断行:BiDi 重排必须在行级做,否则换行点会错。
- 不做子集化:字体全量嵌入会让 PDF 体积不可控,尤其多语言场景。
- 把 bullet 当文本画:字体不支持就会 notdef;常见 bullet 用几何图形画更稳。
- 忽略 grapheme:超长 token(例如 Emoji 组合/某些复杂脚本)按字符硬拆会破坏显示;优先按 grapheme 分割。
- 图片跨域:浏览器端 canvas 裁剪需要正确的跨域策略,否则会 tainted canvas 导致导出失败。
FAQ(常见问题)
Q1:为什么不直接用“HTML → PDF”类工具?
HTML→PDF 的优势是省事,但它把排版的“可控性”交给黑盒(浏览器/渲染引擎/分页算法)。当你需要稳定一致的导出(尤其多语言、可编辑文本、可控裁剪)时, 建 IR + 自己做布局会更可靠。
Q2:缺字(notdef)怎么定位?
工程上建议在“字符级测量”阶段就把 notdef 标出来:哪个字符、哪个字体、是否触发 fallback。这样你可以把缺字当成一个指标(缺字率),也能给产品提供可解释的报错。
Q3:为什么 PDF 文件会突然变得很大?
最常见原因是字体全量嵌入或图片没有裁剪/压缩。字体子集化是最有效的体积杠杆:只嵌入这份文档真正用到的字符集合。
Q4:RTL 里数字和标点为什么经常跑位?
因为这不是“纯 RTL”,而是 BiDi 混排。正确策略是:先按逻辑顺序断行,再行级 BiDi 重排,并对括号等符号做镜像处理。 另外,有序列表编号在 RTL 下“点号在左还是右”,也需要明确约定(例如 .1)。
Q5:为什么要逐字符 drawText?会不会很慢?
逐字符绘制的好处是:能在 BiDi 重排后仍然保留每个字符的样式(字体回退、underline/strike/highlight),并且不会被“字体不支持某段 run”拖累。 代价是性能与文件大小更敏感,所以需要做缓存(字体缓存、图片缓存)并关注导出耗时 p95。
Q6:preserveOriginalImage 该选 true 还是 false?
如果你的用户希望在 PDF 编辑器里重新裁剪/调整图片,选 true 更合适(保留原图,通过 clip 裁剪)。 如果你的目标是“分发与体积”,选 false 更合适(预裁剪后 embed 更小的 PNG)。工程上最好让它可配置,并用体积 p95 做验收。
Q7:同一份 PDF 在不同阅读器里为什么会“看起来不一样”?
这是常态:不同阅读器的字体栅格化、字形 hint、抗锯齿策略都可能不一致。更可执行的目标是:关键版式稳定(不多一行、不乱序、不缺字), 并用样本库把回归控制在“可接受误差”内,而不是追求所有阅读器 100% 像素一致。
Q8:字体子集化会不会引入新坑?
会,尤其是你做错了“收集 used chars”的时机:例如在 fallback 之前收集、或按 code point 而不是按实际 glyph/run 收集, 都可能导致子集缺字。工程上建议:在最终断行 + BiDi + fallback 决策之后,再收集 used chars;并把 notdef 缺字率作为强制门禁。