StreamPDFStreamPDF
返回博客

PDF.js 源码解读: default_appearance

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: default_appearance

前言

这是 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 返回的普通对象
DefaultAppearanceFactorycreateDefaultAppearance 加 FakeUnicodeFont
setFontName / setFontSize调用方直接读写对象的 fontName、fontSize
setColorgetPdfColor 与 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。

五条设计原则

  1. 复用内容流求值器,而不是另写语法。两个求值器都继承 EvaluatorPreprocessor,参数计数、未知算子、q / Q 状态与 EOF 处理全部共用。默认外观只是「关心更少算子的内容流」。
  2. 解析结果保持为普通数据。返回值只有 fontName、fontSize、fontColor,没有方法、没有原型链上的状态。它可以被结构化克隆,能在 Worker 与主线程之间直接传递。
  3. 字号 0 是「自动」而不是「无效」。解析时拒绝 0,调用方看到 0 就知道要按矩形计算;计算完再生成新的 /DA。这个约定让「缺省值」与「真实字号」不会被混为一谈。
  4. 颜色统一收敛到 RGB 三字节。灰度、RGB、CMYK 都通过 getRgbItem 写进同一个 Uint8ClampedArray,生成时再折叠成灰度或 RGB。上层因此只需要处理一种颜色表示。
  5. 兜底路径独立成类。FakeUnicodeFont 不修改解析器,也不依赖真实字体文件;它用 OffscreenCanvas 测量、用最小 Type0 结构承载、用 UTF-16 十六进制写文本。没有它,无法编码的字段就只能留白。

至此,注释与表单字段的「默认长什么样」这一环补齐了。它看起来只是一段字符串,背后却连着颜色空间、字体字典、文本矩阵与外观流。下一篇会回到更底层的对象组织,看 PDF.js 如何把大量字典与引用整理成页面模型。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章