PDF.js 源码解读: default_appearance
2026年10月27日 · StreamPDF Team

前言
这是 PDF.js 源码解读系列中关于「默认外观」的一篇,介绍 src/core/default_appearance.js(495 行)。
PDF 里的「外观」有两种写法。一种是外观流(Appearance Stream):一个 Form XObject,里面是完整的绘制指令,阅读器直接执行就能画出注释或表单字段。另一种是默认外观字符串(/DA):一段缩小的内容流,通常只有字体、字号和填充色,例如 /Helvetica 12 Tf 0 g。当文件没有为某个字段提供完整外观流时,阅读器就要靠 /DA 自己生成一个。
default_appearance.js 同时处理这两端:
- 把
/DA解析成字号、字体名、颜色,供表单与注释使用; - 把已有外观流求值成同样的三元组,供编辑器回填;
- 把三元组重新拼成
/DA字符串; - 在没有可用字体时,用
OffscreenCanvas现测字宽,造一个只存在于内存里的字体与外观流。
一句话概括:它是 PDF.js 的外观参数层,负责「字符串 → 参数 → 绘制指令」的往返。
模块总览
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 默认外观求值 | DefaultAppearanceEvaluator、parseDefaultAppearance | 解析 /DA 字符串里的 Tf、g、rg、k |
| 外观流求值 | AppearanceStreamEvaluator、parseAppearanceStream | 读取已有外观流,取第一段文字的字体与颜色 |
| 颜色与字符串生成 | getPdfColor、createDefaultAppearance | 把 RGB 颜色与参数拼回内容流语法 |
| 假字体与外观生成 | FakeUnicodeFont | 测量字宽、构造 Type0 字体、生成 Form XObject |
它在整条链路里的位置:
Annotation / WidgetAnnotation / FreeTextAnnotation
→ setDefaultAppearance
→ parseDefaultAppearance("/Helvetica 12 Tf 0 g")
→ { fontName, fontSize, fontColor }
→ _getFontData / _computeFontSize
→ createDefaultAppearance(...)
→ 新的 /DA 字符串
已有外观流
→ parseAppearanceStream(stream, xref, globalColorSpaceCache)
→ { fontName, fontSize, fontColor }
→ 编辑器回填
没有可用字体
→ new FakeUnicodeFont(xref, family)
→ createFontResources / createAppearance
→ Type0 字体字典 + Form XObject版本说明:本篇严格按当前仓库这一版
default_appearance.js讲解。本版没有DefaultAppearance数据类,也没有DefaultAppearanceFactory;parseDefaultAppearance直接返回普通对象,createDefaultAppearance是纯函数,FakeUnicodeFont负责合成字体与外观流。也没有#toNumber或getColor方法:字号通过typeof与正数判断过滤,颜色通过ColorSpaceUtils的三个设备颜色空间转换。标准字体的取回在PartialEvaluator.fetchStandardFontData,不在本文件。旧版本里常见的setFontName、setFontSize、setColor在这里没有对应方法,参数由调用方直接读写。
一段 DA 字符串是什么
PDF 规范 12.7.3.3 规定,表单字段与注释可以带一个 /DA 字符串,用来描述「没有专门外观时应该怎么画」。它的语法是内容流的一个子集,常见算子只有几类:
| 算子 | 参数 | 含义 |
|---|---|---|
Tf | 字体名、字号 | 设置字体与字号 |
g | 一个灰度值 | 设置填充色为灰度 |
rg | 红、绿、蓝三个值 | 设置填充色为 RGB |
k | 青、品红、黄、黑 | 设置填充色为 CMYK |
cs | 颜色空间名 | 设置填充颜色空间 |
sc | 若干分量 | 在当前颜色空间里设填充色 |
scn | 若干分量或图案名 | 同上,允许图案 |
一个典型值如下:
'/Helvetica 12 Tf 0 g';它表示:字体 Helvetica,字号 12,填充色为灰度 0(黑色)。字号 0 有特殊含义:表示自动缩放,具体大小由字段矩形决定。PDF.js 在解析时会保留「没有有效字号」这个事实,后面再由 _computeFontSize 计算。
/DA 里通常不写描边颜色,也不写路径、图像、文本显示算子。它只描述状态,不描述内容。源码的两个求值器都按这个前提设计:只关心字体与填充色,遇到其它算子就跳过。
DefaultAppearanceEvaluator
解析 /DA 的类只有一个构造函数和一个 parse 方法。它继承 EvaluatorPreprocessor,复用了内容流的词法与参数计数逻辑。
构造函数
class DefaultAppearanceEvaluator extends EvaluatorPreprocessor {
constructor(str) {
super(new StringStream(str));
}StringStream 把字符串包装成可读流,EvaluatorPreprocessor 的构造函数再建立 Parser、Lexer 与默认的 StateManager。因此 /DA 字符串不需要 XRef,也不需要页面资源;它是一段完全自包含的指令。
parse 方法
parse() {
const operation = {
fn: 0,
args: [],
};
const result = {
fontSize: 0,
fontName: "",
fontColor: /* black = */ new Uint8ClampedArray(3),
};
try {
while (true) {
operation.args.length = 0; // Ensure that `args` it's always reset.
if (!this.read(operation)) {
break;
}
if (this.savedStatesDepth !== 0) {
continue; // Don't get info in save/restore sections.
}
const { fn, args } = operation;
switch (fn | 0) {
case OPS.setFont:
const [fontName, fontSize] = args;
if (fontName instanceof Name) {
result.fontName = fontName.name;
}
if (typeof fontSize === "number" && fontSize > 0) {
result.fontSize = fontSize;
}
break;
case OPS.setFillRGBColor:
ColorSpaceUtils.rgb.getRgbItem(args, 0, result.fontColor, 0);
break;
case OPS.setFillGray:
ColorSpaceUtils.gray.getRgbItem(args, 0, result.fontColor, 0);
break;
case OPS.setFillCMYKColor:
ColorSpaceUtils.cmyk.getRgbItem(args, 0, result.fontColor, 0);
break;
}
}
} catch (reason) {
warn(`parseDefaultAppearance - ignoring errors: "${reason}".`);
}
return result;
}这段代码可以拆成四个部分。
第一,复用同一个 operation 对象。 每次循环只清空 args.length,不重新分配数组。read 的注释说明它支持两种模式:传入空数组表示「请把参数写进这个数组」,传入 null 表示「请按需新建」。这里选前者,避免在每个算子上产生一次数组分配。/DA 很短,这个优化不算关键,但和内容流求值器保持了一致。
第二,跳过保存/恢复区段。 savedStatesDepth 来自 StateManager.stateStack.length。read 在处理 q 与 Q 时会调用 stateManager.save() / restore(),所以深度会实时变化。源码的判断是:只要深度不为 0,就跳过当前算子。
这意味着 /DA 里如果出现 q ... Q,里面的 Tf 与颜色都不会被采纳。规范的 /DA 不应该有 q / Q;如果生成器写了,源码选择忽略嵌套状态,只认最外层。代价是:一个没有闭合的 q 会让后面所有算子都被跳过,最终得到默认的「空字体、字号 0、黑色」。
第三,只接受合法的字体参数。 fontName 必须是 Name 实例,fontSize 必须是大于 0 的数字。其它情况保持原值:
| 参数情况 | 结果 |
|---|---|
字体名是 Name | 取 name 字段 |
字体名不是 Name | 保持空字符串 |
| 字号是大于 0 的数字 | 更新字号 |
| 字号是 0、负数、字符串或缺失 | 保持 0,表示「自动」或「未设置」 |
字号 0 不被接受,是因为它不是一个可直接绘制的尺寸。调用方看到 0,就知道需要根据字段矩形自己算一个。这个「0 表示自动」的约定与 _computeFontSize 完全对应。
第四,颜色只处理填充色。 g、rg、k 分别映射到 setFillGray、setFillRGBColor、setFillCMYKColor。三个设备颜色空间的 getRgbItem 把参数写进同一个 Uint8ClampedArray(3),所以无论源颜色是几通道,结果都是 RGB 三字节。Uint8ClampedArray 会自动取整并夹到 0 到 255,颜色值越界不会产生异常。
描边颜色(G、RG、K)、颜色空间切换(cs)、通用颜色算子(sc、scn)都没有分支。也就是说,/DA 里的 sc 与 scn 在本求值器里被忽略。真实文件的 /DA 绝大多数用 g、rg、k,这个取舍足够覆盖常见情况。
整个循环包在 try 里。词法错误、参数数量错误、颜色空间转换错误都不会让调用方拿到异常,而是记录一条 warn,返回已经解析出的部分结果。对默认外观这种「没有也能画」的信息来说,部分结果比整体失败更有用。
parseDefaultAppearance
对外入口只有一层包装:
function parseDefaultAppearance(str) {
return new DefaultAppearanceEvaluator(str).parse();
}调用方不需要知道求值器的存在。返回值是一个普通对象,字段固定为 fontSize、fontName、fontColor。
设计要点:
/DA解析不建立自己的词法器,而是复用EvaluatorPreprocessor。这样参数计数、未知算子、q/Q状态、EOF 处理都与页面内容流共用一套实现。默认外观只是「只关心三个算子的内容流」,不是一个独立的语法。
颜色算子与 ColorSpaceUtils
DefaultAppearanceEvaluator 的颜色分支都走 ColorSpaceUtils 的三个设备颜色空间:g 对应 setFillGray 与 ColorSpaceUtils.gray,rg 对应 setFillRGBColor 与 ColorSpaceUtils.rgb,k 对应 setFillCMYKColor 与 ColorSpaceUtils.cmyk。三者都是惰性单例,用 shadow 缓存:
static get gray() {
return shadow(this, "gray", new DeviceGrayCS());
}
static get rgb() {
return shadow(this, "rgb", new DeviceRgbCS());
}
static get cmyk() {
if (CmykICCBasedCS.isUsable) {
try {
return shadow(this, "cmyk", new CmykICCBasedCS());
} catch {
warn("CMYK fallback: DeviceCMYK");
}
}
return shadow(this, "cmyk", new DeviceCmykCS());
}cmyk getter 会先判断当前环境是否支持 ICC,支持就尝试 CmykICCBasedCS,构造失败则记录一条警告并退回 DeviceCmykCS。对本文件来说,差异被 getRgbItem 的统一接口遮住了:调用方只提供四个分量和一个目标数组,具体怎么转换由颜色空间自己决定。
getRgbItem(args, 0, result.fontColor, 0) 的四个参数分别是:源数组、源起始下标、目标数组、目标起始下标。目标固定是 result.fontColor 的第 0 个位置,写三个字节。灰度写三次同一个值,RGB 直接复制,CMYK 做一次转换。
Uint8ClampedArray 的选择很实用:颜色转换可能产生小数或越界值,普通数组会保留这些脏值,后面拼字符串时还要再处理;Uint8ClampedArray 在赋值时就完成四舍五入与夹取,getPdfColor 拿到的永远是 0 到 255 的整数。
AppearanceStreamEvaluator 比这里多一层:它先处理 cs 切换颜色空间,再处理 sc / g / rg / k。scn 仍然没有分支,因为图案颜色需要图案资源,不能只靠三个字节表示。
AppearanceStreamEvaluator
当注释已经有自己的外观流时,PDF.js 仍然需要知道「这段外观用了什么字体、多大字号、什么颜色」。编辑器的文本框要据此回填,FreeText 注释也要据此保持视觉一致。AppearanceStreamEvaluator 就是为这个场景写的。
构造函数
class AppearanceStreamEvaluator extends EvaluatorPreprocessor {
constructor(stream, xref, globalColorSpaceCache) {
super(stream);
this.stream = stream;
this.xref = xref;
this.globalColorSpaceCache = globalColorSpaceCache;
this.resources = stream.dict?.get("Resources");
}与 /DA 求值器不同,这里需要三个外部依赖:stream 是外观流本身并同时提供 Resources,xref 用于解析颜色空间时取间接对象,globalColorSpaceCache 是跨页面共享的颜色空间缓存。
stream.dict?.get("Resources") 使用了可选链:外观流的字典可能没有 Resources,此时 resources 为 undefined。颜色空间里如果出现需要资源的名字(例如 DeviceN),解析会失败并进入外层 catch。
parse 方法
求值结果除了 fontSize、fontName、fontColor,还临时多两个字段:scaleFactor 初始为 1,fillColorSpace 初始为 ColorSpaceUtils.gray。前者累积文本矩阵的缩放,后者保存当前填充颜色空间。下面是状态与字体相关的分支:
switch (fn | 0) {
case OPS.save:
stack.push({
scaleFactor: result.scaleFactor,
fontSize: result.fontSize,
fontName: result.fontName,
fontColor: result.fontColor.slice(),
fillColorSpace: result.fillColorSpace,
});
break;
case OPS.restore:
result = stack.pop() || result;
break;
case OPS.setTextMatrix:
const tm = Util.transform(this.stateManager.state.ctm, args);
result.scaleFactor *= Math.hypot(tm[0], tm[1]);
break;
case OPS.setFont:
const [fontName, fontSize] = args;
if (fontName instanceof Name) {
result.fontName = fontName.name;
}
if (typeof fontSize === "number" && fontSize > 0) {
result.fontSize = fontSize;
}
break;颜色分支在 /DA 求值器的三种填充色之外,另加 setFillColorSpace 与 setFillColor:前者用 ColorSpaceUtils.parse 解析 cs 的参数,后者用解析出的颜色空间转换 sc 的分量。数组末尾的几个文本显示算子负责收尾:
case OPS.showText:
case OPS.showSpacedText:
case OPS.nextLineShowText:
case OPS.nextLineSetSpacingShowText:
// The font (Tf) and the text matrix (Tm) can be set in any order,
// so the scale factor is applied here, when text is actually shown
// and both are known to be in effect.
result.fontSize *= result.scaleFactor;
breakLoop = true;
break;
}
}
} catch (reason) {
warn(`parseAppearanceStream - ignoring errors: "${reason}".`);
}
this.stream.reset();
delete result.scaleFactor;
delete result.fillColorSpace;
return result;
}与 /DA 求值器相比,这里多了四件事:图形状态栈、文本矩阵缩放、颜色空间解析、遇到文本就停止。
q 与 Q 的状态快照
OPS.save 把当前五个字段复制进 stack。其中 scaleFactor、fontSize、fontName 是数值或字符串,复制本身就是快照;fontColor 必须 slice(),否则栈内外会共享同一个数组;fillColorSpace 直接复用引用,因为颜色空间对象不可变。
OPS.restore 用 stack.pop() || result。如果外观流里多写了一个 Q,栈空时 pop 返回 undefined,|| 让当前结果保持不变,不会把状态清空。这与容错的整体风格一致:错误的结构不升级为异常。
Tm 与缩放因子
OPS.setTextMatrix 的参数是一个 6 元素文本矩阵。源码先用当前 CTM 做一次变换:
const tm = Util.transform(this.stateManager.state.ctm, args);
result.scaleFactor *= Math.hypot(tm[0], tm[1]);Util.transform 把两个矩阵相乘,得到文本空间到设备空间的组合矩阵。Math.hypot(tm[0], tm[1]) 取第一列的长度,也就是水平方向的缩放。若矩阵只有平移,第一列是 [1, 0],长度为 1,因子不变;若矩阵放大了两倍,因子乘 2。
为什么要乘到 scaleFactor 上,而不是直接乘 fontSize?因为 Tf 与 Tm 的出现顺序不固定。外观流可能先写 Tm 再写 Tf,也可能反过来。源码把矩阵缩放累积到一个独立因子,等真正出现文本显示算子时再一次性乘到字号上。这样无论顺序如何,最终字号都等于「声明字号 × 文本矩阵缩放 × 当前 CTM 缩放」。
颜色空间与颜色
OPS.setFillColorSpace 是 /DA 求值器没有的分支。它调用 ColorSpaceUtils.parse,传入算子的第一个参数、构造函数保存的 xref、外观流字典的 Resources、惰性创建的 PDFFunctionFactory,以及全局与局部两个颜色空间缓存。
解析结果保存到 result.fillColorSpace。之后的 OPS.setFillColor(也就是 sc)直接用这个颜色空间转换参数:
const cs = result.fillColorSpace;
cs.getRgbItem(args, 0, result.fontColor, 0);这里的 cs 可能是 DeviceGray、DeviceRGB、DeviceCMYK、ICCBased、Indexed、Separation 等任意实现,但 getRgbItem 的调用方式一致。sc 与 scn 中的 scn 仍然没有分支;如果外观流用图案填充文字,这里会保持上一个颜色,直到遇到下一个可识别算子。
为什么遇到文本就停
四个文本显示算子 Tj、TJ、'、" 被合并成一组。处理方式都是先把 fontSize 乘上 scaleFactor,再设置 breakLoop = true。下一轮循环看到 breakLoop 就退出。
这与文件的注释一致:parseAppearanceStream 只返回第一个文本对象用到的字体属性。外观流可能包含多段文字、多种字体,编辑器只需要一个初始值来回填文本框;继续往后读既没有意义,也可能读到与第一段无关的状态。
收尾:重置与删除临时字段
收尾有三步:this.stream.reset() 把流的位置拨回开头,因为调用方拿到的 stream 还要用于后续渲染,求值器不能把它读空;delete result.scaleFactor 与 delete result.fillColorSpace 删掉实现细节,保证返回形状与 parseDefaultAppearance 一致:只有 fontSize、fontName、fontColor。
惰性依赖
_localColorSpaceCache 与 _pdfFunctionFactory 两个 getter 都用 shadow 做一次性初始化。只有外观流真的出现 cs 时才会创建它们;只包含 Tf 与 g 的外观流不会为颜色空间付出任何分配成本。
parseAppearanceStream
parseAppearanceStream(stream, xref, globalColorSpaceCache) 只是一行包装:新建求值器并调用 parse。入口与 parseDefaultAppearance 对称,调用方只看到「流进、参数出」。
设计要点:
/DA求值器假设「没有嵌套状态」,外观流求值器则完整处理q/Q与Tm。两者的差异不是重复实现,而是对输入复杂度的准确匹配:默认外观字符串是简化语法,外观流是完整内容流。共用基类保证算子识别一致,各自的分支只处理自己需要的状态。
getPdfColor 与 createDefaultAppearance
解析是「字符串到参数」,生成是「参数到字符串」。两个纯函数完成反方向。
function getPdfColor(color, isFill) {
if (color[0] === color[1] && color[1] === color[2]) {
const gray = color[0] / 255;
return `${numberToString(gray)} ${isFill ? 'g' : 'G'}`;
}
return Array.from(color, c => numberToString(c / 255)).join(' ') + ` ${isFill ? 'rg' : 'RG'}`;
}它先判断三个通道是否相等:相等时只输出一个灰度值加 g 或 G,不全相等时输出三个分量加 rg 或 RG。isFill 为真时用填充算子,为假时用描边算子。本文件里 createDefaultAppearance 与 FakeUnicodeFont.createAppearance 都传 true,所以实际生成的都是填充色;描边分支保留给可能的复用。
颜色分量除以 255 转成 0 到 1 的浮点数,再交给 numberToString。这个函数会:
- 整数直接转字符串;
- 否则四舍五入到两位小数;
- 能省掉末尾的 0 就省掉。
所以 128 变成 0.5,51 变成 0.2,0 变成 0。生成的内容流因此紧凑且稳定。
function createDefaultAppearance({ fontSize, fontName, fontColor }) {
return `/${escapePDFName(fontName)} ${fontSize} Tf ${getPdfColor(fontColor, /* isFill */ true)}`;
}拼接顺序是「字体名、字号、Tf、颜色」,与规范示例一致。字体名前面加 /,并经过 escapePDFName 转义。这个名字来自 PDF 名字语法,可能包含空格、斜杠、井号或非 ASCII 字符;escapePDFName 把每个需要转义的字节写成 # 加两位十六进制。
例如字体名 Helvetica 不需要转义,字号 12,颜色全黑,输出就是 "/Helvetica 12 Tf 0 g"。如果字体名是 A B,输出会变成 /A#20B ...,这样空格不会被当成名字的结束符。
fontSize 直接插入模板字符串。它来自调用方的数值计算,通常是整数或两位小数;源码没有再调用 numberToString,因为 _computeFontSize 已经做过取整。
设计要点:颜色生成只输出 RGB 或灰度,不输出 CMYK。参数层的
fontColor本来就是三字节 RGB,回到 PDF 语法时没有必要再转回四通道;灰度折叠只是为了让常见的黑白文本更短。转义交给escapePDFName,拼接逻辑里不再关心名字里有什么字符。
FakeUnicodeFont
有些表单字段的字体无法编码用户输入的文字。例如字段声明了 Helvetica,用户却输入了中文或 emoji。保存时 PDF.js 可以选择标记 NeedAppearances,让其它软件去处理;渲染时则不能什么都不画。FakeUnicodeFont 的思路是:不嵌入真实字体文件,直接用浏览器测量字宽,构造一个只存在于内存里的 Type0 字体,再把文字写成 UTF-16 十六进制串。
它生成的不是「正确的排版」,而是「能看见、能选中、大致对齐」的兜底外观。
构造函数
class FakeUnicodeFont {
static #fontNameId = 1;
constructor(xref, fontFamily) {
this.xref = xref;
this.widths = null;
this.firstChar = Infinity;
this.lastChar = -Infinity;
this.fontFamily = fontFamily;
const canvas = new OffscreenCanvas(1, 1);
this.ctxMeasure = canvas.getContext("2d", { willReadFrequently: true });
this.fontName = Name.get(
`InvalidPDFjsFont_${fontFamily}_${FakeUnicodeFont.#fontNameId++}`
);
}widths 初值是 null,之后成为字符码到 1000 单位宽度的 Map;firstChar 与 lastChar 分别是 Infinity 与 -Infinity,在测量时更新为最小与最大字符码;fontFamily 是调用方给的 sans-serif 或 monospace;ctxMeasure 是 2D 上下文;fontName 是新建的 Name。
字体名带自增编号,是为了让同一页里的多个假字体互不冲突。Name.get 会驻留同名对象,编号保证每次都是新的。前缀 InvalidPDFjsFont_ 也提醒调试者:这不是文件里真实存在的字体。
OffscreenCanvas 的尺寸是 1 乘 1,只用来拿一个 2D 上下文,不参与最终绘制。willReadFrequently: true 是给浏览器的提示:这块画布会频繁读取像素或测量文本,可能更适合软件实现。构造阶段不测量任何字符,widths 保持 null。
fontDescriptorRef
get fontDescriptorRef() {
if (!FakeUnicodeFont._fontDescriptorRef) {
const fontDescriptor = new Dict(this.xref);
fontDescriptor.setIfName("Type", "FontDescriptor");
fontDescriptor.set("FontName", this.fontName);
fontDescriptor.set("FontFamily", "MyriadPro Regular");
fontDescriptor.set("FontBBox", [0, 0, 0, 0]);
fontDescriptor.setIfName("FontStretch", "Normal");
fontDescriptor.set("FontWeight", 400);
fontDescriptor.set("ItalicAngle", 0);
FakeUnicodeFont._fontDescriptorRef =
this.xref.getNewPersistentRef(fontDescriptor);
}
return FakeUnicodeFont._fontDescriptorRef;
}描述符里的字段几乎都是固定值:类型、字体族名、零尺寸边界盒、正常拉伸、400 字重、零斜体角。唯一来自实例的是 FontName,而它只在第一次创建时写入。
FakeUnicodeFont._fontDescriptorRef 挂在类上,不是实例字段,也没有声明为 static。第一次调用时创建字典,并用 xref.getNewPersistentRef 分配一个持久引用,后续实例直接复用。这样同一批字段不会为每个假字体都造一份相同的描述符。
getNewPersistentRef 会把字典放进 XRef 的缓存,并返回一个 Ref。后面的 descendantFont 通过这个引用指向描述符,而不是内联字典。这让生成的字体结构与真实 PDF 字体一致,渲染器可以用统一的路径处理。
descendantFontRef
get descendantFontRef() {
const descendantFont = new Dict(this.xref);
descendantFont.set("BaseFont", this.fontName);
descendantFont.setIfName("Type", "Font");
descendantFont.setIfName("Subtype", "CIDFontType0");
descendantFont.setIfName("CIDToGIDMap", "Identity");
descendantFont.set("FirstChar", this.firstChar);
descendantFont.set("LastChar", this.lastChar);
descendantFont.set("FontDescriptor", this.fontDescriptorRef);
descendantFont.set("DW", 1000);
const widths = [];
const chars = [...this.widths].sort();
let currentChar = null;
let currentWidths = null;
for (const [char, width] of chars) {
if (!currentChar) {
currentChar = char;
currentWidths = [width];
continue;
}
if (char === currentChar + currentWidths.length) {
currentWidths.push(width);
} else {
widths.push(currentChar, currentWidths);
currentChar = char;
currentWidths = [width];
}
}
if (currentChar) {
widths.push(currentChar, currentWidths);
}
descendantFont.set("W", widths);后半段构造 CIDSystemInfo 并返回引用,这里省略。核心是 W 数组的压缩。
this.widths 是一个 Map,键是字符码,值是 1000 单位下的宽度。[...this.widths] 把它展开成「键值对数组」,再排序。排序后遍历,把连续编号的字符合并成一段:当前字符码等于上一段起点加段长度时,把宽度追加到当前段;不连续时结束当前段并写入「起点、宽度数组」;遍历结束后如果还有当前段,补写一次。
W 的格式是「起点、宽度数组、起点、宽度数组」交替。一个段只能覆盖连续字符码,所以压缩的效果取决于字符码是否密集。中文的码位通常分散,压缩率一般;ASCII 连续输入则能合成一段。
这里有一个实现细节:Array.prototype.sort() 不带比较函数时,会把每个键值对数组转成字符串再比较。因此排序结果是字典序,不是数值序。比如 65 会排在 9 前面。对于 W 数组来说,每段的起点与宽度仍然一一对应,不会把宽度配错;只是连续段可能被切得更碎,压缩率下降。
DW 设为 1000,表示没有列在 W 里的字符默认宽度是 1000。CIDToGIDMap 设为 Identity,Subtype 设为 CIDFontType0,这两项让渲染器按 CID 直接映射,不做额外的字形查找。
baseFontRef 与 resources
get baseFontRef() {
const baseFont = new Dict(this.xref);
baseFont.set("BaseFont", this.fontName);
baseFont.setIfName("Type", "Font");
baseFont.setIfName("Subtype", "Type0");
baseFont.setIfName("Encoding", "Identity-H");
baseFont.set("DescendantFonts", [this.descendantFontRef]);
baseFont.setIfName("ToUnicode", "Identity-H");
return this.xref.getNewPersistentRef(baseFont);
}
get resources() {
const resources = new Dict(this.xref);
const font = new Dict(this.xref);
font.set(this.fontName.name, this.baseFontRef);
resources.set("Font", font);
return resources;
}外层字体是 Type0,编码 Identity-H。这意味着字符串里的两个字节直接当作一个 CID,不做字符集映射。配合 CIDToGIDMap 的 Identity,CID 又被当作字形号。于是「字符码 → 字形」的路径被压成一条直线。
ToUnicode 被设成名字 Identity-H,而不是一个流。这是假字体的一部分:文本本来就是以 UTF-16 十六进制写进内容流的,字符码就是 Unicode 码位,不需要额外的映射表。源码用这个标记表达「按恒等关系理解」。
resources 返回一个只含 Font 的字典,字体名指向 baseFontRef。把它挂到外观流字典的 Resources 上,渲染器就能通过 Tf 里的名字找到这个字体。
createFontResources
createFontResources(text) 先调用 _createContext:把 widths 重置为空 Map,把上下文尺寸设为 1000px 加字体族。以 1000 像素为基准,是因为 PDF 的字体宽度单位就是「千分之一 em」:在 1000 像素下测出的像素宽度,正好可以直接当作 W 数组里的数值。
之后按行、再按 UTF-16 代码单元遍历文本。每个字符码只测一次;measureText 返回的宽度向上取整,写入 Map,同时更新最小与最大字符码。
firstChar 与 lastChar 会成为字体字典的范围。任何不在范围内的字符不会被渲染器查询;所以测量必须覆盖最终要写的全部文字。调用方在生成字段外观前,会先用完整文本调用这个方法。
getFirstPositionInfo
static getFirstPositionInfo(rect, rotation, fontSize) {
// Get the position of the first char in the rect.
const [x1, y1, x2, y2] = rect;
let w = x2 - x1;
let h = y2 - y1;
if (rotation % 180 !== 0) {
[w, h] = [h, w];
}
const lineHeight = LINE_FACTOR * fontSize;
const lineDescent = LINE_DESCENT_FACTOR * fontSize;
return {
coords: [0, h + lineDescent - lineHeight],
bbox: [0, 0, w, h],
matrix:
rotation !== 0 ? getRotationMatrix(rotation, h, lineHeight) : undefined,
};
}rect 是注释在页面坐标系里的矩形。对 90 度与 270 度旋转,宽高互换,让后续排版在「旋转后的局部坐标」里进行。
两个常量来自 shared/util.js:LINE_FACTOR 为 1.35,表示行高是字号的 1.35 倍;LINE_DESCENT_FACTOR 为 0.35,表示基线到行底的下降量。
第一行文字的基线位置是 h + 下降量 - 行高。h 是框高,加下降量把基线从底部抬起,再减行高把第一行放到顶部。返回的 bbox 是局部坐标里的框,matrix 负责把局部坐标旋转回页面坐标。
如果旋转为 0,matrix 是 undefined,调用方知道不需要额外变换;非 0 时用 getRotationMatrix 生成 90、180、270 度对应的矩阵。
createAppearance
createAppearance 负责把文字真正画成一个 Form XObject。前半段做测量与缩放,后半段拼内容流。
const hscale = maxWidth > w ? w / maxWidth : 1;
let vscale = 1;
const lineHeight = LINE_FACTOR * fontSize;
const lineDescent = LINE_DESCENT_FACTOR * fontSize;
const maxHeight = lineHeight * lines.length;
if (maxHeight > h) {
vscale = h / maxHeight;
}
const fscale = Math.min(hscale, vscale);
const newFontSize = fontSize * fscale;
const buffer = [
'q',
`0 0 ${numberToString(w)} ${numberToString(h)} re W n`,
`BT`,
`1 0 0 1 0 ${numberToString(h + lineDescent)} Tm 0 Tc ${getPdfColor(bgColor, /* isFill */ true)}`,
`/${this.fontName.name} ${numberToString(newFontSize)} Tf`,
];前面的测量部分先算 maxWidth。它用 ctx.measureText(line).width 测整行,而不是把每个字符的宽度相加;源码注释说明,阿拉伯文等语言有连字,逐字相加会偏大。随后把 maxWidth 乘以 fontSize / 1000,换算到目标字号。
缩放分两个方向:水平方向在文本宽度超过框宽时取 w / maxWidth,否则为 1;垂直方向在总行高超过框高时取 h / maxHeight,否则为 1。最终取两者较小值 fscale,乘到原字号上。这样文字既不会横向溢出,也不会纵向溢出;两个方向都放得下时,字号保持不变。
内容流的前几条指令依次是:q 保存图形状态,用矩形 re W n 裁剪防止文字画出框外,BT 开始文本对象,用 Tm 设置第一行基线并把字符间距 Tc 归零,写入颜色算子,最后用 Tf 设置字体。
透明度另行处理:strokeAlpha 先经过范围检查,不是数字、小于 0 或大于 1 时一律当作 1。只有真正需要半透明时才生成 ExtGState,同时设置 ca(填充 alpha)与 CA(描边 alpha),再用 /R0 gs 把这份状态挂到当前图形状态上。
逐行输出时,每一行先用 Td 向下移动一个行高,再用 Tj 显示 UTF-16BE 十六进制串。第一行也会移动一次,所以它的基线正好落在「框高加下降量减行高」的位置,与 getFirstPositionInfo 的计算一致。stringToUTF16HexString 把每个 JavaScript 字符写成四个十六进制数字,外层用尖括号包成十六进制字符串。
最后构造外观流字典:
const appearanceStreamDict = new Dict(this.xref);
appearanceStreamDict.setIfName('Subtype', 'Form');
appearanceStreamDict.setIfName('Type', 'XObject');
appearanceStreamDict.set('BBox', [0, 0, w, h]);
appearanceStreamDict.set('Length', appearance.length);
appearanceStreamDict.set('Resources', resources);
if (rotation) {
const matrix = getRotationMatrix(rotation, w, h);
appearanceStreamDict.set('Matrix', matrix);
}
return new StringStream(appearance, appearanceStreamDict);字典里的 BBox 是局部坐标的框;有旋转时再加 Matrix,把局部坐标映射回页面。Length 用字符串长度而不是字节长度,能成立是因为生成的内容流全是 ASCII:算子、数字、字体名(可能被转义成 #xx)与十六进制串都不含多字节字符。
StringStream 把这段字符串包装成流对象,字典一起带上。上层可以像普通外观流一样把它挂到注释的 /AP 里,渲染器不需要知道它是现场生成的。
设计要点:假字体不下载、不嵌入、不解析真实字体文件。它用
OffscreenCanvas在 1000 像素基准下测量字宽,构造最小的 Type0 字体结构,再把文本写成 Identity-H 编码的十六进制串。代价是排版质量取决于系统字体;收益是在没有字体数据、无法编码用户输入时,仍然能画出可读、可选中、可打印的内容。
真实字体与假字体的分工
本文件不负责取回标准字体数据。PartialEvaluator.fetchStandardFontData(name) 会先查 standardFontDataCache,命中就包装成 Stream 返回;开启 useSystemFonts 时,除 Symbol 与 ZapfDingbats 外直接返回 null,交给系统字体。需要数据时,按 standardFontDataUrl 拼出文件名:useWorkerFetch 为真就在 Worker 内取,否则通过 FetchBinaryData 消息让主线程取,取到后再写回缓存。
FakeUnicodeFont 走的是另一条路:它不请求任何字体文件,只用系统字体测量。两者的分界很清楚:能用真实字体数据就交给 PartialEvaluator;拿不到、或者字段本身就声明了一个无法编码输入的字体,才用假字体兜底。
与旧版 DefaultAppearanceFactory 的对照
旧版 PDF.js 里常见一组面向对象的接口:DefaultAppearance 保存字体名、字号、颜色,DefaultAppearanceFactory 提供 setFontName、setFontSize、setColor 之类的修改方法。当前这一版把这些状态管理删掉了,换成「普通对象 + 纯函数 + 一个假字体类」。
| 旧版概念 | 本版对应实现 |
|---|---|
DefaultAppearance 数据类 | parseDefaultAppearance 返回的普通对象 |
DefaultAppearanceFactory | createDefaultAppearance 加 FakeUnicodeFont |
setFontName / setFontSize | 调用方直接读写对象的 fontName、fontSize |
setColor | getPdfColor 与 ColorSpaceUtils |
| 工厂里的颜色转换 | parseDefaultAppearance 内的 getRgbItem |
| 标准字体数据取回 | 不在本文件,在 PartialEvaluator.fetchStandardFontData |
这个变化的收益是状态更少:解析结果是一个可以被结构化克隆的普通对象,能在 Worker 与主线程之间传递,不需要附带方法。生成方向也只依赖传入的三个字段,不维护内部可变状态。代价是调用方要自己保证字段完整;源码通过默认值和 ||= 兜底。
调用链:注释与表单字段
default_appearance.js 的导出被 annotation.js 大量使用。理解调用链,才能知道每个函数在什么时候被触发。
setDefaultAppearance 方法
注释基类在初始化时读取 /DA:
setDefaultAppearance(params) {
const { dict, annotationGlobals } = params;
const defaultAppearance =
getInheritableProperty({ dict, key: "DA" }) ||
annotationGlobals.acroForm.get("DA");
this._defaultAppearance =
typeof defaultAppearance === "string" ? defaultAppearance : "";
this.data.defaultAppearanceData = parseDefaultAppearance(
this._defaultAppearance
);
}查找顺序是:先沿字段的父链找可继承的 /DA,找不到再用 AcroForm 的 /DA。两者都没有时得到空字符串。parseDefaultAppearance("") 返回默认对象:空字体名、字号 0、黑色。
typeof 检查保证非字符串不会被传进求值器。PDF 里 /DA 必须是字符串,但损坏文件可能写成别的东西;这里选择静默降级为空字符串。
表单字段:字号 0 与自动缩放
Widget 注释在生成外观时,如果没有 /DA,会显式补一个默认值:
this.data.defaultAppearanceData = parseDefaultAppearance(
(this._defaultAppearance = '/Helvetica 0 Tf 0 g'),
);字号 0 表示自动。_computeFontSize 随后根据字段矩形计算:
| 条件 | 计算方式 |
|---|---|
| 单行文本 | 宽度与高度都约束,取能放下的最大字号 |
| 多行文本 | 按行数与行高反推字号,再检查是否超宽 |
| 组合框(comb) | 每个字符一个格子,按格宽计算 |
算出字号后,调用 createDefaultAppearance({ fontSize, fontName, fontColor }) 重新拼出 /DA,写回 this._defaultAppearance。这就是「解析 → 计算 → 再生成」的完整往返。
FreeText 注释:已有外观与兜底
FreeTextAnnotation 的构造过程展示了两个求值器的分流:
this.setDefaultAppearance(params);
this._hasAppearance = !!this.appearance;
if (this._hasAppearance) {
const { fontColor, fontSize } = parseAppearanceStream(
this.appearance,
xref,
annotationGlobals.globalColorSpaceCache
);
this.data.defaultAppearanceData.fontColor = fontColor;
this.data.defaultAppearanceData.fontSize = fontSize || 10;
} else {
this.data.defaultAppearanceData.fontSize ||= 10;| 情况 | 使用的方法 | 结果 |
|---|---|---|
| 自带外观流 | parseAppearanceStream | 从第一段文字取颜色与字号,字号 0 时用 10 |
| 没有外观流 | parseDefaultAppearance | 用 /DA 的参数,字号 0 时用 10 |
| 无法编码输入文字 | FakeUnicodeFont | 造字体与外观流 |
没有外观流且有内容时,FakeUnicodeFont 的 getFirstPositionInfo 先算出第一行位置,createAppearance 再生成 Form XObject 并放进 this._streams。有外观流时,parseAppearanceStream 只读第一段文字,不重建外观;原来的外观流继续用于渲染,解析结果只用于编辑器回填。
三者的边界
| 模块 | 输入 | 输出 | 是否读取资源 |
|---|---|---|---|
DefaultAppearanceEvaluator | /DA 字符串 | 字体、字号、颜色 | 否 |
AppearanceStreamEvaluator | 外观流、XRef、缓存 | 字体、字号、颜色 | 是,颜色空间需要 |
FakeUnicodeFont | 文本、矩形、旋转、字号 | 字体字典、外观流 | 否,使用系统字体测量 |
三者的输入输出形状被刻意对齐:两个求值器都返回 fontName、fontSize、fontColor,假字体则消费同样的参数。这样注释代码可以在「解析已有」「解析默认」「现场生成」之间切换,而不需要改变数据流。
设计要点:默认外观的解析与生成都只依赖三个字段,不依赖注释对象本身。注释负责决定「用哪一个来源」:有外观流就读外观流,有
/DA就读/DA,什么都没有就用固定默认值。模块边界因此很窄,测试也可以只针对字符串或流,不必构造完整注释。
总结
default_appearance.js 是 PDF.js 的外观参数层:把 /DA 与外观流还原成字体、字号、颜色,再把这三者变回内容流;在没有字体数据时,用画布测量造一个可用的替代品。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字段初始化 | Annotation.setDefaultAppearance | 字段或 AcroForm 的 /DA | /DA 字符串 |
| 默认解析 | parseDefaultAppearance | /DA 字符串 | fontName、fontSize、fontColor |
| 外观解析 | parseAppearanceStream | 外观流、XRef、缓存 | 同上 |
| 字号计算 | WidgetAnnotation._computeFontSize | 字段矩形、文本、字体 | 实际字号 |
| 字符串生成 | createDefaultAppearance | 三个字段 | 新的 /DA 字符串 |
| 兜底生成 | FakeUnicodeFont | 文本、矩形、旋转、字号 | Type0 字体与 Form XObject |
| 渲染 | PartialEvaluator | 外观流 | operatorList |
用一句话串起来:parseDefaultAppearance 把字符串拆成字段,_computeFontSize 决定最终字号,createDefaultAppearance 把字段拼回字符串;外观流走 parseAppearanceStream,没有字体时走 FakeUnicodeFont。
五条设计原则
- 复用内容流求值器,而不是另写语法。两个求值器都继承
EvaluatorPreprocessor,参数计数、未知算子、q/Q状态与 EOF 处理全部共用。默认外观只是「关心更少算子的内容流」。 - 解析结果保持为普通数据。返回值只有
fontName、fontSize、fontColor,没有方法、没有原型链上的状态。它可以被结构化克隆,能在 Worker 与主线程之间直接传递。 - 字号 0 是「自动」而不是「无效」。解析时拒绝 0,调用方看到 0 就知道要按矩形计算;计算完再生成新的
/DA。这个约定让「缺省值」与「真实字号」不会被混为一谈。 - 颜色统一收敛到 RGB 三字节。灰度、RGB、CMYK 都通过
getRgbItem写进同一个Uint8ClampedArray,生成时再折叠成灰度或 RGB。上层因此只需要处理一种颜色表示。 - 兜底路径独立成类。
FakeUnicodeFont不修改解析器,也不依赖真实字体文件;它用OffscreenCanvas测量、用最小 Type0 结构承载、用 UTF-16 十六进制写文本。没有它,无法编码的字段就只能留白。
至此,注释与表单字段的「默认长什么样」这一环补齐了。它看起来只是一段字符串,背后却连着颜色空间、字体字典、文本矩阵与外观流。下一篇会回到更底层的对象组织,看 PDF.js 如何把大量字典与引用整理成页面模型。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日