StreamPDFStreamPDF
返回博客

PDF.js 源码解读: core_utils

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: core_utils

前言

src/core/core_utils.js 不是一个处理阶段,而是一只公共工具箱。

前面几篇讲的是链路:XRef 怎么定位对象,Lexer 与 Parser 怎么把字节切成对象,PDFDocument 怎么把字典组织成页面,PartialEvaluator 怎么把内容流变成 operatorList。这些模块彼此有严格的上下游关系,而 core_utils.js 横在它们中间:语法层要用它的异常类型和空白判断,文档层要用它的继承属性遍历,注释层要用它的矩阵与日期,写出层要用它的名称转义。

文件一共 791 行,导出 37 个符号。里面只有四个类,而且全是异常;其余都是函数与常量,没有实例状态,没有缓存对象,也没有跨调用保存的可变数据。这种「无状态」不是风格偏好,而是它能被所有模块安全引用的前提。

它的依赖方向也值得先说明:

依赖用途
../shared/util.jsBaseException、assert、Util、warn
./primitives.jsDict、Name、Ref、RefSet
./base_stream.jsBaseStream(判断动作脚本是不是流)
../shared/css_utils.jsCONTROL_CHAR_REGEXP
./string_utils.jsstringToPDFString

primitives.js、base_stream.js、string_utils.js 都不会反向引用 core_utils.js,所以它是一个依赖图上的叶子节点。任何模块都可以放心 import,不用担心循环依赖。反过来,如果把 getInheritableProperty 放进 document.js,xref.js 再想用就会形成 xref → document → xref 的环。

一句话概括本篇:这里放的是「多个阶段都要用,但不属于任何阶段」的东西——错误分类、字节搬运、继承链、转义、动作收集、编码与校验。

模块总览

按功能可以把 791 行切成十二块:

区块关键导出用途
常量IDENTITY_MATRIX、RESOURCES_KEYS_OPERATOR_LIST、RESOURCES_KEYS_TEXT_CONTENT、PDF_VERSION_REGEXP、MAX_INT_32单位矩阵、资源键清单、版本号规则、32 位上限
异常MissingDataException、ParserEOFException、XRefEntryException、XRefParseException四种可区分的失败,供上层决定补数据还是恢复
字节arrayBuffersToBytes、fetchBinaryData多块缓冲拼接、抓取 wasm 二进制
字典与比较getInheritableProperty、getParentToUpdate、deepCompare沿父链读值、定位可写字段、结构化相等
查表与编号getLookupTableFactory、toRomanNumerals惰性大表、页码罗马数字
字符与数值校验isWhiteSpace、isBooleanArray、isNumberArray字节级空白判断、数组形状检查
几何取值lookupMatrix、lookupRect、lookupNormalRect带长度校验与归一化的安全取值
转义与路径parseXFAPath、escapePDFName、escapeStringXFA 路径解析、写出时的名称与字符串转义
动作collectActions(内部 _collectJS)收集 Additional Actions 与 A 里的 JavaScript
XMLencodeToXmlString、codePointIter(内部 XMLEntities)XFA 回写时的实体编码
字体与 CSSvalidateFontName、normalizeCSSFontFamily、validateCSSFont把来自 PDF 的字体名安全地放进 CSS
数值与元数据recoverJsURL、numberToString、getNewAnnotationsMap、getModificationDate、getRotationMatrix、getSizeInBytesURL 还原、数字序列化、注释分组、日期、旋转矩阵、字节宽度

它在链路里的位置不是一个箭头,而是被每一层引用:Lexer 与 Parser 用空白判断和 EOF 异常,XRef 用四种异常区分恢复策略,Document 与 Catalog 用继承与矩形取值,PartialEvaluator 用数组校验与查表,Annotation 与编辑器用父链与矩阵,Writer 与 XFA 模块用转义与字节宽度。后面的总结会把这层关系整理成表。

版本说明:本篇严格按当前仓库这一版 core_utils.js 讲解。它没有 isValidUrl、createPromiseCapability 这类旧版本工具;getInheritableProperty 的参数是一个对象,而不是位置参数;deepCompare、getModificationDate、getSizeInBytes、getNewAnnotationsMap、recoverJsURL 都在本文件里,而不是散落在 util.js。读的时候以源码为准。

四种异常:失败也要分类

四个异常定义在文件最前面,共 25 行:

class MissingDataException extends BaseException {
  constructor(begin, end) {
    super(`Missing data [${begin}, ${end})`, 'MissingDataException');
    this.begin = begin;
    this.end = end;
  }
}
 
class ParserEOFException extends BaseException {
  constructor(msg) {
    super(msg, 'ParserEOFException');
  }
}
 
class XRefEntryException extends BaseException {
  constructor(msg) {
    super(msg, 'XRefEntryException');
  }
}
 
class XRefParseException extends BaseException {
  constructor(msg) {
    super(msg, 'XRefParseException');
  }
}

它们都继承 BaseException。BaseException 不在本文件,而在 shared/util.js 里,实现方式是给原型挂一个 Error 实例,然后直接写 message 与 name:

const BaseException = (function BaseExceptionClosure() {
  function BaseException(message, name) {
    // ...
    this.message = message;
    this.name = name;
  }
  BaseException.prototype = new Error();
  BaseException.constructor = BaseException;
 
  return BaseException;
})();

这套写法让异常仍然能通过 instanceof Error 判断,同时把 name 变成一个可读、可序列化的字符串。name 在这里不是装饰:wrapReason 就是靠它把跨 Worker 传递的普通对象还原成异常类型。

四种异常对应四种完全不同的失败语义:

异常携带字段触发位置上层动作
MissingDataExceptionbegin、endChunkedStream.ensureByte、ensureRange;网络文档读到尚未下载的区间记下区间,请求该范围,然后从头重试
ParserEOFExceptionmessageParser.getObj 在数组或字典内部遇到 EOF,且不在恢复模式认为该对象被截断,跳过它,不覆盖已有 xref 条目
XRefEntryExceptionmessageXRef.fetchUncompressed / fetchCompressed 发现 entry 缺失或序号越界转成 XRefParseException,或直接触发全文恢复
XRefParseExceptionmessageXRef.parse 校验 Root 与 Pages 失败切换 recoveryMode,重新加载整份文件

第一行最容易被误读成「错误」。MissingDataException 表达的是「还没下载」,不是「文件坏了」。NetworkPdfManager.ensure 把它当作正常的暂停信号:

  async ensure(obj, prop, args) {
    try {
      const value = obj[prop];
      if (typeof value === "function") {
        return await value.apply(obj, args);
      }
      return value;
    } catch (ex) {
      if (!(ex instanceof MissingDataException)) {
        throw ex;
      }
      await this.requestRange(ex.begin, ex.end);
      return this.ensure(obj, prop, args);
    }
  }

注意 return this.ensure(obj, prop, args):它不保留任何中间状态,直接重新执行整个属性访问。因此下游所有解析函数都必须写成「可重入」的形式——不能假定上一次执行到一半的结果还在。begin 与 end 构成一个半开区间,正是为了告诉 requestRange 只需要补哪一段。

ParserEOFException 则是真正的结构问题,但它只在「对象内部」抛出。parser.js 里两处抛点很明确:数组读完仍没等到 ],字典读完仍没等到 >>。到了 xref.indexObjects,它又被降级成一条警告:

            parser.getObj();
            updateEntries = true;
          } catch (ex) {
            if (ex instanceof ParserEOFException) {
              warn(`indexObjects -- checking object (${token}): "${ex}".`);
            } else {

意思是:这个偏移处确实有个对象头,但对象体不完整,所以不要用它覆盖已有的 entry。恢复模式宁可保留旧记录,也不要把截断的对象当成新对象。

XRefEntryException 是「索引指错了地方」:entry 为空、偏移为 0、对象流下标越界。PDFDocument.checkFirstPage 与 checkLastPage 捕获它之后,会先清掉页面缓存,再抛 XRefParseException:

try {
  await this.getPage(0);
} catch (reason) {
  if (reason instanceof XRefEntryException) {
    this.#pagePromises.delete(0);
    await this.cleanup();
 
    throw new XRefParseException();
  }
}

先 cleanup() 再抛,是因为坏 entry 可能已经让某些页面对象进入缓存;如果不清理,恢复模式重试时会读到不一致的状态。

XRefParseException 是最高级别的一类:整份 xref 不可信。Worker 对它的处理只有一条路——把整个文件下载完,再从零解析一遍:

loadDocument(false).then(onSuccess, function (reason) {
  ensureNotTerminated();
 
  // Try again with recoveryMode == true
  if (!(reason instanceof XRefParseException)) {
    onFailure(reason);
    return;
  }
  pdfManager.requestLoadedStream().then(function () {
    ensureNotTerminated();
 
    loadDocument(true).then(onSuccess, onFailure);
  }, onFailure);
});

设计要点:这四个异常都只在 Worker 内部流转。shared/message_handler.js 的 wrapReason 白名单里没有它们——它只认 AbortException、InvalidPDFException、PasswordException、ResponseException、UnknownErrorException。这是刻意的:MissingDataException 与 XRefParseException 是控制流,不是给调用方看的错误。它们必须在离开 Worker 之前被处理掉,否则主线程只会收到一个语义模糊的 UnknownErrorException。

多块缓冲的拼接

渐进式加载时,一份文件会以多个区间到达,每一段是一个独立 ArrayBuffer。解析器需要的却是一段连续字节。arrayBuffersToBytes 负责这件事:

function arrayBuffersToBytes(arr) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    for (const item of arr) {
      assert(item instanceof ArrayBuffer, 'arrayBuffersToBytes - expected an ArrayBuffer.');
    }
  }
  const length = arr.length;
  if (length === 0) {
    return new Uint8Array(0);
  }
  if (length === 1) {
    return new Uint8Array(arr[0]);
  }
  let dataLength = 0;
  for (let i = 0; i < length; i++) {
    dataLength += arr[i].byteLength;
  }
  const data = new Uint8Array(dataLength);
  let pos = 0;
  for (let i = 0; i < length; i++) {
    const item = new Uint8Array(arr[i]);
    data.set(item, pos);
    pos += item.byteLength;
  }
  return data;
}

三个分支各自有意义:

  1. 零块返回长度为 0 的 Uint8Array,而不是 null。调用方可以无条件地取 .length、做 subarray,不需要额外判空。
  2. 单块直接 new Uint8Array(buffer)。这是视图,不是拷贝:新数组与原缓冲共享同一段内存。单元测试专门断言 bytes.buffer === buffer。渐进式加载最常见的情况就是只有一块,省掉一次整份拷贝很划算。
  3. 多块先累加 byteLength 得到总长,只分配一次,再用 set(item, pos) 逐块写入。相比「反复 concat」或「先 push 再 join」,这里没有中间数组,也没有指数级扩容。

累加长度而不是依赖 arr[0].byteLength * arr.length,是因为块长可以不等;最后一块通常比前面的短。pos 手动推进而不是用 data.set 的返回值(它没有返回值),这也是这段代码唯一略显啰嗦的地方。

同一文件里还有一个会发网络请求的函数:

async function fetchBinaryData(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch file "${url}" with "${response.statusText}".`);
  }
  return response.bytes();
}

它是工具箱里唯一的「副作用函数」,只服务于 wasm 解码器(OpenJPEG 等)。返回 ArrayBuffer 而不是 Response,是为了让调用方拿到即可用;response.ok 检查则避免把 404 的 HTML 页面当成二进制喂给 wasm,得到一条难以定位的编译错误。

设计要点:arrayBuffersToBytes 的单块快速路径返回视图,意味着调用方不能原地修改结果,否则会污染底层缓冲。源码里所有调用方都只读。这类「零拷贝但共享」的约定没有类型系统兜底,只能靠命名与注释维持;读源码时看到返回 Uint8Array,不要默认它是独立副本。

父链:读值与定位写入点是两件事

getInheritableProperty 方法

PDF 的页面树与 AcroForm 字段树都允许属性继承。规范明确列出哪些键可继承:页面有 Resources、MediaBox、CropBox、Rotate,字段有 FT、Ff、DA、Q 等。子节点没写,就往 Parent 找。getInheritableProperty 实现这条规则:

function getInheritableProperty({ dict, key, getArray = false, stopWhenFound = true }) {
  let values;
  const visited = new RefSet();
 
  while (dict instanceof Dict && !(dict.objId && visited.has(dict.objId))) {
    if (dict.objId) {
      visited.put(dict.objId);
    }
    const value = getArray ? dict.getArray(key) : dict.get(key);
    if (value !== undefined) {
      if (stopWhenFound) {
        return value;
      }
      (values ||= []).push(value);
    }
    dict = dict.get('Parent');
  }
  return values;
}

几个细节决定了它的行为:

  • 取值自动解引用。dict.get(key) 遇到 Ref 会调 xref.fetch,dict.getArray(key) 还会把数组里的每个 Ref 都解引用。调用方拿到的就是最终值,不需要自己再判 Ref。
  • 循环保护按 objId。objId 是 Dict 被 XRef 取回时挂上的 Ref。用它而不是对象身份,是因为同一页字典在不同路径下可能被包装成不同实例,但对象号与 generation 不变;同时坏文件里的 Parent 可能直接指回祖先,用 RefSet 能立刻截断。
  • 没有 objId 的字典不进 visited。直接构造的 Dict(比如测试里手工拼的)没有对象号,循环条件只看「是不是 Dict」。真实文件里父链节点几乎都有 objId。
  • stopWhenFound 决定「最近优先」还是「全部收集」。默认 true,找到就返回——这符合继承语义:离自己最近的声明生效。传 false 时沿链一路向上,把每一层的值按「由近到远」压进数组。
  • values ||= [] 是延迟分配:只有真的出现第二个值时才建数组,常见路径(只找到一层)不产生额外对象。

为什么需要「全部收集」?因为 Resources 不是简单覆盖关系。页面自己的 Resources 里可能只有 Font,父节点的 Resources 里有 XObject;渲染时两者都要。Page.#getInheritableProperty 拿到数组后交给 Dict.merge 做浅合并,同一个键只保留第一个值,所以「近的」仍然优先,但不同键可以来自不同层级。这一层合并逻辑如果塞进 getInheritableProperty,那么这个函数就不再是通用工具,而会绑定页面资源的语义。

getParentToUpdate 方法

第二个函数解决的是另一个问题:要改值,应该改哪个字典。Widget 注释(比如一个复选框)通常挂在字段字典下面,字段字典持有 T(字段名),值 V 应该写在字段上而不是 Widget 上:

function getParentToUpdate(dict, ref, xref) {
  const visited = new RefSet();
  const firstDict = dict;
  const result = { dict: null, ref: null };
 
  while (dict instanceof Dict && !visited.has(ref)) {
    visited.put(ref);
    if (dict.has('T')) {
      break;
    }
    ref = dict.getRaw('Parent');
    if (!(ref instanceof Ref)) {
      return result;
    }
    dict = xref.fetch(ref);
  }
  if (dict instanceof Dict && dict !== firstDict) {
    result.dict = dict;
    result.ref = ref;
  }
  return result;
}

它和读值函数的差别几乎每一项都相反:

维度getInheritablePropertygetParentToUpdate
目标找到某个键的值找到应该被修改的字段字典
起点任意字典注释字典 + 它自己的 Ref
终止条件键出现(或整条链走完)遇到含 T 的字典(或父引用不是 Ref)
循环保护记录已访问字典的 objId记录已访问的 Ref
取值方式get / getArray,自动解引用getRaw("Parent"),只接受 Ref
返回值值、值的数组或 undefineddict 与 ref 均为 null,或命中字段的二者
典型调用Page.resources、字段类型、AAWidgetAnnotation.setValue

getRaw("Parent") 而不是 get("Parent") 是关键:这里需要判断父节点是不是间接引用。如果是直接内嵌的字典,就没有独立的 Ref 可写,函数直接放弃。dict !== firstDict 的判断则保证「注释自己就带 T」时返回空结果,让调用方把值写回注释本身,而不是误改一个祖先。

visited.has(ref) 在第一次循环时检查的是传入的 ref;visited.put(ref) 之后才比较。这样即使 Parent 指回自己,也会在下一轮被拦住。

设计要点:读继承与写继承不能共用一套遍历。读的时候「最近的值」就够用;写的时候必须知道值的归属对象,否则改的是 Widget 而不是字段,保存后 AcroForm 仍然读不到新值。getParentToUpdate 返回的是 dict 与 ref 这一对,而不是单个字典,就是因为写回时需要把 ref 作为变更表的键。

deepCompare:结构化相等,而不是引用相等

有些场景需要判断两份字典是不是「内容一样」。例如合并多个文档的 AcroForm 默认资源 DR,如果两份 DR 完全一致,就没必要复制两遍字体与图形状态。deepCompare 提供递归的结构比较:

function deepCompare(a, b) {
  if (a === b) {
    return true;
  }
  if (a instanceof Ref && b instanceof Ref) {
    return isRefsEqual(a, b);
  }
  if (a instanceof Name && b instanceof Name) {
    return a.name === b.name;
  }
 
  if (a instanceof Dict && b instanceof Dict) {
    if (a.size !== b.size) {
      return false;
    }
    for (const [key, value1] of a.getRawEntries()) {
      const value2 = b.getRaw(key);
      if (value2 === undefined || !deepCompare(value1, value2)) {
        return false;
      }
    }
    return true;
  }

递归顺序是有讲究的:

  1. 先比引用。同一个对象直接返回 true,避免对已经确认相等的子树做无谓递归。数字、字符串、null 也都是在这一步通过 === 判等的。
  2. Ref 按值比较。isRefsEqual 只看 num 与 gen,不会去 xref.fetch。这一点很重要:比较动作不应该触发磁盘读取,否则一个纯内存判断可能抛出 MissingDataException,把上层拖进重试逻辑。
  3. Name 按字符串比较。Name.get 有缓存,正常情况下同一名字就是同一实例,第一步就返回了;但缓存清理过、或对象来自不同文档时,需要退回字符串比较。
  4. Dict 先比大小,再逐键比原始值。用 getRawEntries() 与 getRaw(key),同样避免解引用。大小相等加上「a 的每个键都能在 b 中找到相等值」,就隐含了双向一一对应,不需要再反向遍历一次。 递归到数组时,同样先比长度,再逐元素递归;其余情况一律落到最后的 return false。它不做类型转换:1 与 "1" 不相等,true 与 1 也不相等。测试里有一个容易忽略的场景:清空 Ref 缓存后重新 Ref.get(10, 0),两个对象的身份不同,但 deepCompare 仍应返回 true。这证明比较的是对象号与 generation,而不是对象实例。

它也不处理循环结构。PDF 对象图如果通过 Ref 互指,递归在 Ref 那一层就停了,不会展开;只有两个直接内嵌、互相包含的字典才会无限递归,而解析器产出的结构不会出现这种形态。

设计要点:deepCompare 只读原始值,不触发取回。这让「相等」成为纯函数:同一对输入永远得到同一结果,不受加载进度影响。若把 get 换成解引用版本,比较一份尚未下载完的文档时,函数会时而抛异常、时而返回结果,调用方根本无法安排重试。

惰性查表与罗马数字

getLookupTableFactory 只有十一行,却是整个字体子系统启动性能的关键:

function getLookupTableFactory(initializer, useArray = false) {
  let lookup;
  return function () {
    if (initializer) {
      lookup = useArray ? [] : Object.create(null);
      initializer(lookup);
      initializer = null;
    }
    return lookup;
  };
}

它返回一个零参数函数。第一次调用时创建容器、执行初始化函数,然后把 initializer 置空;之后每次调用都直接返回同一个对象。效果是「用得到才建表,建一次就够」。

为什么需要它?metrics.js 里每种标准字体都有一张宽度表,glyphlist.js 有数千个字形名到 Unicode 的映射,unicode.js 还有各种特殊符号表。如果在模块顶层直接构建,那么只要 import 了字体子系统,哪怕当前页面全是图片、一个字形都不需要,也要付出全部构表成本。惰性工厂把成本推迟到第一次真正查询。

容器用 Object.create(null) 而不是对象字面量,有具体原因:表键来自 PDF 里的字形名与字体名,可能出现 constructor、toString、__proto__ 这类名字。普通对象会命中原型链,查表返回一个函数而不是 undefined,后续逻辑就会拿到错误类型。无原型对象把这条路径彻底切断。

useArray 分支用得很少,但确实存在。fonts.js 里的 getSymbolGlyphIdEncoding 就是这么建的:

const getSymbolGlyphIdEncoding = getLookupTableFactory(t => {
  let glyphId = 3;
  for (const [firstCharCode, lastCharCode] of [
    [0x20, 0x7e],
    [0xa1, 0xfe],
  ]) {
    for (let charCode = firstCharCode; charCode <= lastCharCode; charCode++) {
      t[glyphId++] = SymbolSetEncoding[charCode];
    }
  }
}, /* useArray = */ true);

这里的键是字形号,从 3 开始连续递增,不是字符串。数组的下标寻址比哈希查找快,而且天然按数字排序。0 到 2 是空洞,查询会得到 undefined,正好对应 .notdef、.null、nonmarkingreturn 这三个不参与映射的字形。换句话说,useArray 不是「对象也行,数组也行」的随意选项,而是「键是连续小整数」时的专用形态。

第二个「查表」是罗马数字:

// prettier-ignore
const ROMAN_NUMBER_MAP = [
  "", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM",
  "", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC",
  "", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"
];
 
function toRomanNumerals(number, lowerCase = false) {
  assert(Number.isInteger(number) && number > 0, 'The number should be a positive integer.');
 
  const roman =
    'M'.repeat((number / 1000) | 0) +
    ROMAN_NUMBER_MAP[((number % 1000) / 100) | 0] +
    ROMAN_NUMBER_MAP[10 + (((number % 100) / 10) | 0)] +
    ROMAN_NUMBER_MAP[20 + (number % 10)];
  return lowerCase ? roman.toLowerCase() : roman;
}

这张 30 项的表按「百位、十位、个位」切成三段,每段十项:

段下标范围覆盖的位内容示例
百位0 到 9百位数字""、C、CC、CCC、CD、D、CM
十位10 到 19十位数字""、X、XX、XXX、XL、L、XC
个位20 到 29个位数字""、I、II、III、IV、V、IX

所以取值时分别为 0 + 百位、10 + 十位、20 + 个位。千位没有表,直接重复 M。| 0 是截断取整,比 Math.floor 短,且在正数范围内等价。

举几个例子:

数字千位百位十位个位结果
1空空空II
9空空空IXIX
40空空XL空XL
400空CD空空CD
2019MM空XIXMMXIX

它只在页码标签(PageLabel 的 /S /R 或 /r)里使用,见 Catalog 的 pageLabels:

        case "R":
        case "r":
          currentLabel = toRomanNumerals(currentIndex, style === "r");
          break;

assert 拒绝 0 与负数,因为 PDF 页码从 1 开始;lowerCase 对应小写样式 r。超过 3999 时它仍然合法,只是会输出更长的 M 串,没有上划线或减法记号——PDF 页码标签本来也不需要那种古典写法。

设计要点:这两处「查表」解决的是不同问题。getLookupTableFactory 解决时间(把构表推迟到首次使用),ROMAN_NUMBER_MAP 解决分支(把十位与个位的规则压成一次数组索引)。共同点是都不在热路径上做字符串拼接或条件判断。

空白、数组与六个数字

isWhiteSpace 是给字节流用的,不是给字符串用的:

// Checks if ch is one of the following characters: SPACE, TAB, CR or LF.
function isWhiteSpace(ch) {
  return ch === 0x20 || ch === 0x09 || ch === 0x0d || ch === 0x0a;
}

参数是数字,调用方从流里取 getByte() 或从 Uint8Array 取下标。它只认四个值:空格 0x20、水平制表 0x09、回车 0x0d、换行 0x0a。垂直制表 0x0b 与换页 0x0c 不在其中,测试也明确断言 0x0b 返回 false。Lexer、ASCII85 解码、Type1 字体解析都依赖这份精确集合;如果为了省事写成 /\s/,就会把 0x0b 也当成空白,改变 token 边界。

接下来是两个数组形状检查:

function isBooleanArray(arr, len) {
  return (
    Array.isArray(arr) &&
    (len === null || arr.length === len) &&
    arr.every(x => typeof x === 'boolean')
  );
}
 
function isNumberArray(arr, len) {
  if (Array.isArray(arr)) {
    return (len === null || arr.length === len) && arr.every(x => typeof x === 'number');
  }
 
  // This check allows us to have typed arrays but not the
  // BigInt64Array/BigUint64Array types (their elements aren't "number").
  return (
    ArrayBuffer.isView(arr) &&
    !(arr instanceof BigInt64Array || arr instanceof BigUint64Array) &&
    (len === null || arr.length === len)
  );
}

isBooleanArray 只用于图案的 /Extend:规范要求两个布尔值。isNumberArray 更通用,它接受两种形态:

  • 普通数组:内容操作符的参数经常是 Parser 组装的数组。
  • 类型化数组:某些路径把参数整理成 Float32Array 之类,直接用 ArrayBuffer.isView 识别,避免逐元素复制成普通数组。

排除 BigInt64Array 与 BigUint64Array 的理由写在注释里:它们的元素类型是 bigint,与 number 混算会抛 TypeError。ArrayBuffer.isView 对 DataView 也返回 true,但本文件的两个调用点都传了具体长度(6 或 4),DataView 没有 length,会在长度判断处被排除。

len 传 null 表示「不检查长度」,只检查元素类型。evaluator.js 里大量 isNumberArray(args, null) 就是这种用法:操作符的参数个数已经在 switch 分支里确定,这里只关心它们是不是数字。

三个取值函数建立在这套检查之上:

function lookupMatrix(arr, fallback) {
  return isNumberArray(arr, 6) ? arr : fallback;
}
 
function lookupRect(arr, fallback) {
  return isNumberArray(arr, 4) ? arr : fallback;
}
 
function lookupNormalRect(arr, fallback) {
  return isNumberArray(arr, 4) ? Util.normalizeRect(arr) : fallback;
}
函数期望长度是否归一化典型 fallback
lookupMatrix6否IDENTITY_MATRIX、null
lookupRect4否页面宽高组成的矩形、[0, 0, 0, 0]、null
lookupNormalRect4是null

矩阵是 PDF 的六元组 a b c d e f,矩形是 x1 y1 x2 y2。长度不对、元素不是数字,都返回 fallback,而不是抛异常。这是渲染路径的常见取舍:一份坏注释不值得让整页失败,给一个安全默认值继续画。

lookupNormalRect 多一步 Util.normalizeRect。PDF 允许矩形的两个角点以任意顺序书写,[100, 100, 0, 0] 与 [0, 0, 100, 100] 表示同一块区域。下游计算宽高时默认 x1 不大于 x2、y1 不大于 y2,所以这里统一交换成「左上到右下」。normalizeRect 内部先 slice(0) 克隆,再按需交换,因此不会修改传入数组。

注意 lookupRect 不克隆:它直接返回原数组。调用方拿到的可能是 Dict.getArray 已经复制过的数组,也可能是解析器共享的数组;只读使用没有问题,若原地排序或改写就会污染字典。源码里的调用方都只读。

设计要点:这三个函数把「形状校验」与「业务默认值」分开。校验只回答「是不是 6 个数字 / 4 个数字」,默认值由调用方决定:外观流没有 /Matrix 时用单位矩阵,/BBox 缺失时用页面尺寸,结构树节点没有 /Rect 时用 null。如果把默认值写死进工具函数,不同调用点就只能各自再包一层。

名称、字符串与 XFA 路径

PDF 的名称对象(Name)有一套自己的字符规则:空白与分隔符不能直接出现,# 是转义引导符。写出 PDF 时必须把名称重新编码。escapePDFName 做这件事:

function escapePDFName(str) {
  const buffer = [];
  let start = 0;
  for (let i = 0, ii = str.length; i < ii; i++) {
    const char = str.charCodeAt(i);
    // Whitespace or delimiters aren't regular chars, so escape them.
    if (
      char < 0x21 ||
      char > 0x7e ||
      char === 0x23 /* # */ ||
      char === 0x28 /* ( */ ||
      char === 0x29 /* ) */ ||
      char === 0x3c /* < */ ||
      char === 0x3e /* > */ ||
      char === 0x5b /* [ */ ||
      char === 0x5d /* ] */ ||
      char === 0x7b /* { */ ||
      char === 0x7d /* } */ ||
      char === 0x2f /* / */ ||
      char === 0x25 /* % */
    ) {
      if (start < i) {
        buffer.push(str.substring(start, i));
      }
      buffer.push(`#${char.toString(16).padStart(2, '0')}`);
      start = i + 1;
    }
  }
 
  if (buffer.length === 0) {
    return str;
  }
 
  if (start < str.length) {
    buffer.push(str.substring(start));
  }
 
  return buffer.join('');
}

条件分三类:

  1. 控制字符与空白:字符码小于 0x21,包括 NUL、TAB、LF、CR、空格。
  2. 非 ASCII:字符码大于 0x7e。
  3. PDF 分隔符与转义符:#、(、)、<、>、[、]、{、}、/、%。

# 必须转义,因为它自己就是 #xx 的引导符;不转义的话,名称里的 # 会被读成后面两个十六进制数字的开始。反斜杠不在列表里,因为它在 PDF 名称中不是特殊字符。

输出格式是 # 加两位小写十六进制,不足两位用 padStart(2, "0") 补零。例如单个 # 变成 #23,/ 变成 #2f,制表符加 b 变成 #09b,字节 0xfe 开头的名称变成 #fe 加其余部分。实现上还有两个性能细节:start 记录「上一段未转义文本的起点」,只在命中特殊字符时切一刀并推入数组,最后一次性 join;如果整串都不需要转义,buffer.length === 0 就直接返回原字符串,连拼接都省掉。这正是绝大多数名称的路径。

字符串字面量是另一套规则。escapeString 处理括号、反斜杠与换行:

function escapeString(str) {
  return str.replaceAll(/([()\\\n\r])/g, match => {
    if (match === '\n') {
      return '\\n';
    } else if (match === '\r') {
      return '\\r';
    }
    return `\\${match}`;
  });
}
原字符输出原因
(\(否则提前结束字面量
反斜杠两个反斜杠否则被当成转义引导符
换行 \n\n 两字符避免字面量里出现真实换行,解析更稳定

右括号、回车同理。它不处理其他控制字符。生成的注释外观流里,文本来自表单值,通常是可打印字符;真正需要完整 PDF 字符串编码的场景在别处(比如加密与文档信息),不走这个函数。

XFA 的路径是第三种「字符串语法」:

function parseXFAPath(path) {
  // Anchoring prevents retrying the match at every character.
  const positionPattern = /^(.+)\[(\d+)\]$/;
  return path.split('.').map(component => {
    const m = component.match(positionPattern);
    if (m) {
      return { name: m[1], pos: parseInt(m[2], 10) };
    }
    return { name: component, pos: 0 };
  });
}

XFA 用点号分隔层级,用方括号表示重复元素的序号,例如 foo.bar[12].oof[3]。函数把整条路径拆成若干「名字 + 位置」:

输入输出
foo.bar[12].oof[3].rabfoo 位置 0、bar 位置 12、oof 位置 3、rab 位置 0
foo[].bar[1x]原样保留为名字,位置 0
foo[1][2]名字 foo[1],位置 2

正则两端的 ^ 与 $ 不能省。源码注释写得很直接:没有锚点时,正则会在每个字符位置尝试重试,而 .+ 在「不含方括号的长名字」上会退化成平方复杂度。测试专门构造了一个 20 万字符的组件,要求解析时间低于一秒;锚定之后只需要线性扫描一次。

方括号本身不合法或序号不是纯数字时,函数不会报错,而是把整段当作普通名字、位置归零。XFA 数据绑定宁可少一层索引,也不应该因为一个坏路径让整张表单解析失败。

它被两处使用:dataset_reader.js 用它把数据集的 ref 转成 searchNode 的路径;writer.js 用它把保存时的节点路径重新拼回去。

设计要点:三个转义/解析函数分别属于三种语法层——PDF 名称、PDF 字符串、XFA 路径。它们都不做「尽量修复」,而是严格按各自语法输出。写出一份能被自己重新读入的文件,靠的就是写出侧与读入侧的规则严格对称;任何「顺手兼容」的修补都会在往返一次之后变成另一种数据。

动作树:收集脚本,而不是执行脚本

_collectJS 方法

注释与页面都可以挂 Additional Actions(/AA),键是事件缩写,值是动作字典。动作字典可以是单个,也可以沿 /Next 串成链,还可以是数组。_collectJS 负责把这棵树里的 JavaScript 全部取出来:

function _collectJS(entry, xref, list, parents) {
  if (!entry) {
    return;
  }
 
  let parent = null;
  if (entry instanceof Ref) {
    if (parents.has(entry)) {
      // If we've already found entry then we've a cycle.
      return;
    }
    parent = entry;
    parents.put(parent);
    entry = xref.fetch(entry);
  }
  if (Array.isArray(entry)) {
    for (const element of entry) {
      _collectJS(element, xref, list, parents);
    }
  } else if (entry instanceof Dict) {
    if (isName(entry.get('S'), 'JavaScript')) {
      const js = entry.get('JS');
      let code;
      if (js instanceof BaseStream) {
        code = js.getString();
      } else if (typeof js === 'string') {
        code = js;
      }
      code &&= stringToPDFString(code, /* keepEscapeSequence = */ true).replaceAll('\x00', '');
      if (code) {
        list.push(code.trim());
      }
    }
    _collectJS(entry.getRaw('Next'), xref, list, parents);
  }
 
  if (parent) {
    parents.remove(parent);
  }
}
  • Ref:先查 parents,命中就返回——说明这条引用在当前路径上已经出现过,继续走会绕圈。取回对象后把 Ref 放进集合,函数结束时再移除。
  • 数组:逐个元素递归,动作链可以用数组表示多个动作。
  • 字典:只认 S 为 JavaScript 的动作。JS 可能是流(脚本较长时常见),也可能是字符串。两者都先经过 stringToPDFString,保留转义序列,再删掉 NUL 字符。
  • Next:用 getRaw 拿原始值再递归,因此下一环也可以是一个 Ref,同样受循环保护。

parents 的加入与移除发生在同一次调用内,所以它检测的是当前路径上的环,而不是全局去重。同一个动作字典如果被两个不同事件引用,应该分别出现在两个事件的脚本列表里;如果做全局去重,第二个事件就会丢脚本。这个区别很细,但直接影响行为。

删 NUL 与 trim 都是为了把「文件里能存、执行时不能用」的字节清理掉。Acrobat 对脚本里的 NUL 很宽容,浏览器里的 Function 构造器不一定;先清洗再交给上层,可以把兼容性问题挡在核心层。

collectActions 方法

外层 collectActions 决定「收集哪些事件」以及「谁覆盖谁」:

function collectActions(xref, dict, eventType) {
  const actions = new Map();
  const additionalActionsDicts = getInheritableProperty({
    dict,
    key: "AA",
    stopWhenFound: false,
  });
  if (additionalActionsDicts) {
    for (let i = additionalActionsDicts.length - 1; i >= 0; i--) {
      const additionalActions = additionalActionsDicts[i];
      if (!(additionalActions instanceof Dict)) {
        continue;
      }
      for (const [key, rawActionDict] of additionalActions.getRawEntries()) {
        const action = eventType[key];
        if (!action) {
          continue;
        }
        const parents = new RefSet();
        const list = [];
        _collectJS(rawActionDict, xref, list, parents);
        if (list.length > 0) {
          actions.set(action, list);
        }
      }
    }
  }

三个设计点:

  1. AA 本身是可继承的。Widget 没写 AA,字段或页面可能写了。所以用 getInheritableProperty 且 stopWhenFound: false,拿到「由近到远」的字典数组。
  2. 倒序遍历。数组第 0 项是最近的字典,最后一项是最远祖先。从末尾往前走,先写入祖先的动作,再让近的字典覆盖它。Map.set 对同一个键是替换而不是合并,所以最终结果符合「最近的声明生效」。
  3. 事件名映射。eventType 是一张缩写表,例如注释用 AnnotationActionEventType:E 映射为 Mouse Enter,X 映射为 Mouse Exit,K 映射为 Keystroke。页面用 PageActionEventType,只有 O 与 C。表里没有的键直接跳过,避免把未知事件塞进结果。

最后处理单个 A:如果字典自己有 A,就用同一套 _collectJS 再收一遍,键名固定为 Action。按钮的「点击打开链接」通常写在这里而不是 /AA,所以单独补一条;Action 与事件缩写不会冲突。函数结尾返回 actions.size ? actions : null,让调用方用一次真值判断就知道「没有动作」,这比检查 size === 0 更省事,也避免上层把空映射继续往下传。

来源键的形式是否继承覆盖规则
AA事件缩写是近的覆盖远的
A固定 Action否独立键,不参与事件覆盖
Next 链不产生键不适用同一次收集内按顺序追加到列表

设计要点:核心层只收集脚本字符串,不执行脚本。执行发生在 scripting_api 的沙箱里,那里有完整的 Acrobat 对象模型与超时控制。把「取脚本」与「跑脚本」分开,意味着即使运行环境不可用(比如 Node 端),注释里的 JavaScript 仍然可以被读到、被展示、被开发者工具检查。

XML 编码:XFA 回写时的实体表

XFA 在运行时会被改写成 XML 数据集。核心层没有 DOM,只能用字符串拼接,因此需要一个转义函数。实体表只有五项:

const XMLEntities = {
  /* < */ 0x3c: '&lt;',
  /* > */ 0x3e: '&gt;',
  /* & */ 0x26: '&amp;',
  /* " */ 0x22: '&quot;',
  /* ' */ 0x27: '&apos;',
};

encodeToXmlString 方法

对象键是数字字面量,等价于字符串键 "60"、"62" 等,查询时直接 XMLEntities[char]。XML 里必须转义的正好是这五个:两个尖括号、与号、两种引号。

function encodeToXmlString(str) {
  const buffer = [];
  let start = 0;
  for (let i = 0, ii = str.length; i < ii; i++) {
    const char = str.codePointAt(i);
    if (0x20 <= char && char <= 0x7e) {
      // ascii
      const entity = XMLEntities[char];
      if (entity) {
        if (start < i) {
          buffer.push(str.substring(start, i));
        }
        buffer.push(entity);
        start = i + 1;
      }
    } else {
      if (start < i) {
        buffer.push(str.substring(start, i));
      }
      buffer.push(`&#x${char.toString(16).toUpperCase()};`);
      if (char > 0xffff) {
        // char is represented by two u16
        i++;
      }
      start = i + 1;
    }
  }
 
  if (buffer.length === 0) {
    return str;
  }
  if (start < str.length) {
    buffer.push(str.substring(start));
  }
  return buffer.join('');
}

它按码点扫描,分两路:

  • 可打印 ASCII(0x20 到 0x7e):只有五类字符需要替换成命名实体;字母、数字、常见标点原样保留,所以纯 ASCII 文本走完循环后 buffer 仍为空,函数直接返回原串。
  • 其余字符:一律输出数字实体 &#xHH;,十六进制大写。包括换行、控制字符、非 ASCII 字母、以及辅助平面字符。

对辅助平面字符,codePointAt 返回大于 0xffff 的值,说明它在 UTF-16 里占两个码元;输出一个数字实体后把 i 多加一,跳过低位代理项。这样 U+1D400 只产生 &#x1D400;,而不是两个孤立的代理实体。

codePointIter 是同一套扫描逻辑的生成器版本:

function* codePointIter(str) {
  for (let i = 0, ii = str.length; i < ii; i++) {
    const char = str.codePointAt(i);
    if (char > 0xd7ff && (char < 0xe000 || char > 0xfffd)) {
      // char is represented by two u16
      i++;
    }
    yield char;
  }
}

条件看起来绕,实际是在识别「不是单个 BMP 字符」的码点:代理区 0xd800 到 0xdfff,以及所有大于 0xfffd 的值(辅助平面字符一定落在这里)。default_appearance.js 用它逐码点测量文本宽度,如果直接用 for 下标遍历,一个辅助平面字符会被算成两个未知字形。

举几个输出例子:双引号变成 &quot;,与号变成 &amp;,小于号变成 &lt;,希腊字母 Η 变成 &#x397;,码点 U+1D400 变成 &#x1D400;。为什么非 ASCII 不直接原样输出?XFA 数据集最终要交给 XML 解析器,输出端的编码(UTF-8、UTF-16、ASCII)不一定受这里控制。全部转成数字实体后,结果只含 ASCII,任何编码都能安全承载,也不会因为缺少编码声明而乱码。

设计要点:这里用的是「命名实体只覆盖必需项,其余全部数字化」的策略。命名实体可读性好但只有五个;数字实体冗长但通用。对于 XFA 这种动辄包含多语言标签的场景,通用性比可读性重要。同时保留纯 ASCII 快速路径,让绝大多数短文本不产生额外分配。

字体名与 CSS:把不可信输入挡在渲染层之外

validateFontName 方法

XFA 字体需要交给浏览器渲染,核心层会构造一份 CSS 字体描述:fontFamily、fontWeight、italicAngle。这些值来自 PDF 的 FontDescriptor,属于不可信输入。三个函数依次做「校验、规范化、补默认值」。

validateFontName 分两条路径:

function validateFontName(fontFamily, mustWarn = false) {
  const m = /^("|').*("|')$/.exec(fontFamily);
  if (m && m[1] === m[2]) {
    const re = new RegExp(`[^\\\\]${m[1]}`);
    if (re.test(fontFamily.slice(1, -1))) {
      if (mustWarn) {
        warn(`FontFamily contains unescaped ${m[1]}: ${fontFamily}.`);
      }
      return false;
    }
    if (CONTROL_CHAR_REGEXP.test(fontFamily)) {
      if (mustWarn) {
        warn(`FontFamily contains control characters: ${fontFamily}.`);
      }
      return false;
    }
  } else {
    for (const ident of fontFamily.split(/[ \t]+/)) {
      if (/^(?:\d|-[\d-])/.test(ident) || !/^[\w\\-]+$/.test(ident)) {
        if (mustWarn) {
          warn(`FontFamily contains invalid <custom-ident>: ${fontFamily}.`);
        }
        return false;
      }
    }
  }
  return true;
}
  • 带引号:必须以同一种引号开头结尾。去掉首尾后,内部不允许出现「前面不是反斜杠」的同种引号——那会提前结束 CSS 字符串。随后还要拒绝控制字符:CONTROL_CHAR_REGEXP 是 \p{Cc},包含换页、NUL、TAB、LF。CSS 把换页也当作换行,同样会终止字符串。
  • 不带引号:按空格或制表符拆成多个 custom-ident,逐个检查。不能以数字开头,不能以「连字符加数字或连字符」开头,且只能由单词字符、反斜杠、连字符组成。

校验失败时 mustWarn 决定是否打日志。读取 XFA 字体时传 true,因为这时还有机会告诉开发者「哪份 PDF 的字体名有问题」;内部复用时不希望刷屏。

normalizeCSSFontFamily 方法

normalizeCSSFontFamily 在校验之前做一次改写:

function normalizeCSSFontFamily(fontFamily) {
  return fontFamily.replaceAll(/( +)(\d)?/g, (_, spaces, digit) => digit ?? ' ');
}

规则是「连续空格,以及紧跟其后的一个数字」整体匹配:有数字就只保留数字,没有数字就压成一个空格。

输入输出
Wingdings 3Wingdings3
MS Gothic 2 Bold 7MS Gothic2 Bold7
Times New RomanTimes New Roman
首尾与中间有多余空格每段空格压成一个
Wingdings3Wingdings3

为什么要把空格去掉?CSS 的 custom-ident 不能以数字开头。Wingdings 3 不加引号时会被切成两个标识符,第二个是 3,整条 font-family 声明就无效。去掉空格后得到 Wingdings3,成为单个合法标识符,字体名也仍然可读。

正则把空格串与可选数字放在同一个匹配里,而不是写成「匹配空格再向后看一位」,是为了避免回溯。源码注释提到:如果允许在空格上反复回溯,替换会变成关于连续空格数的平方复杂度。测试用十万个连续空格验证耗时,正是防止这类退化。

validateCSSFont 方法

最后 validateCSSFont 补默认值并就地改写传入对象。权重白名单 CSS_FONT_WEIGHT_VALUES 是 100 到 1000 的整百值,加上 normal、bold、bolder、lighter 四个关键字;下面摘录省略了这份集合的声明:

function validateCSSFont(cssFontInfo) {
  const DEFAULT_CSS_FONT_OBLIQUE = '14';
  const DEFAULT_CSS_FONT_WEIGHT = '400';
  const { fontFamily, fontWeight, italicAngle } = cssFontInfo;
 
  if (!validateFontName(fontFamily, true)) {
    return false;
  }
 
  const weight = fontWeight ? fontWeight.toString() : '';
  cssFontInfo.fontWeight = CSS_FONT_WEIGHT_VALUES.has(weight) ? weight : DEFAULT_CSS_FONT_WEIGHT;
 
  const angle = parseFloat(italicAngle);
  cssFontInfo.italicAngle =
    isNaN(angle) || angle < -90 || angle > 90 ? DEFAULT_CSS_FONT_OBLIQUE : italicAngle.toString();
 
  return true;
}
字段输入处理输出
fontWeight数字 100转字符串后在集合内"100"
fontWeight数字 314不在集合内"400"
fontWeight"bold"关键字在集合内"bold"
italicAngle2.718在负 90 到 90 之间"2.718"
italicAngle-123越界"14"
italicAngle"91"越界"14"
italicAngle无法解析parseFloat 得到 NaN"14"

注意 italicAngle 通过时保留原字符串形式,而不是规范化后的数字。下游直接把它拼进 CSS,所以 2.718 会变成 oblique 2.718deg。默认值 "14" 是一个经验性的倾斜角:没有可用的 ItalicAngle 时,14deg 看起来接近常见斜体,又不至于过度倾斜。权重默认 "400" 就是常规体。

fontFamily 校验失败时整个函数返回 false,调用方 PDFDocument.loadXfaFonts 直接 continue 跳过这份字体。这里的取舍是「拒绝而不是修补」。名称里带引号或控制字符,说明上游数据已经不可信;强行替换字符可能得到一个看似正常、实际不存在的字体名,最后仍然回退到默认字体,还掩盖了问题。

设计要点:校验放在进入 CSS 之前,而不是渲染之后再兜底。font-family 会被拼进样式表,一个未转义的引号就可能改变后续声明;等到浏览器解析失败再补救,已经晚了。核心层在这里承担了「输出编码」的职责,这与 escapePDFName 在写出前转义是同一类思路。

数值、日期、矩阵与字节宽度

recoverJsURL 方法

旧版 PDF 里常见一种「伪链接」:动作类型是 JavaScript,脚本内容却只有一句打开网址的调用。为了在不执行脚本的情况下也能让链接可用,recoverJsURL 用白名单正则把它们还原成 URL:

function recoverJsURL(str) {
  const URL_OPEN_METHODS = ['app.launchURL', 'window.open', 'xfa.host.gotoURL'];
  const regex = new RegExp(
    '^\\s*(' +
      URL_OPEN_METHODS.join('|').replaceAll('.', '\\.') +
      ')\\((?:\'|")([^\'"]*)(?:\'|")(?:,\\s*(\\w+)\\)|\\))',
    'i',
  );
 
  const jsUrl = regex.exec(str);
  if (jsUrl?.[2]) {
    return {
      url: jsUrl[2],
      newWindow: jsUrl[1] === 'app.launchURL' && jsUrl[3] === 'true',
    };
  }
 
  return null;
}

三个捕获组分别是「方法名」「URL」「第二个参数」。第二个参数只有 app.launchURL(..., true) 会被解释为「新窗口打开」;window.open 与 xfa.host.gotoURL 即使传了 true,newWindow 也是 false。测试特意覆盖了这两种情况,也覆盖了 navigateToUrl(...) 这种不在白名单里的方法名,此时返回 null。

正则只接受单引号或双引号包裹、内部不含引号的 URL;方法名前允许空白,大小写不敏感。它不是通用 JavaScript 解析器,也不打算成为:白名单之外的一律返回 null,交给沙箱或直接忽略。Catalog.parseDestDictionary 在 S 为 JavaScript 时先尝试这条路,成功就退化成普通链接动作。

numberToString 方法

写内容流时,数字必须紧凑且不能出现指数形式。numberToString 的规则很简单:

function numberToString(value) {
  if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('TESTING')) {
    assert(typeof value === 'number', `numberToString - the value (${value}) should be a number.`);
  }
 
  if (Number.isInteger(value)) {
    return value.toString();
  }
 
  const roundedValue = Math.round(value * 100);
  if (roundedValue % 100 === 0) {
    return (roundedValue / 100).toString();
  }
 
  if (roundedValue % 10 === 0) {
    return value.toFixed(1);
  }
 
  return value.toFixed(2);
}

它先乘 100 再四舍五入,用「能否被 100 整除」判断要不要输出整数,用「能否被 10 整除」决定一位还是两位小数。这样既避免 0.30000000000000004 这类浮点噪声,也让内容流常见范围内的输出保持十进制小数。JS 的 Number.toString() 在很小或很大的值上会用科学记数法(例如 1e-7),而 PDF 内容流不接受指数形式,toFixed 恰好能覆盖非整数的小数路径。

getModificationDate 方法

PDF 的日期字符串格式是 D:YYYYMMDDHHmmSS,可选带时区偏移。getModificationDate 只生成前半段:

function getModificationDate(date = new Date()) {
  if (!(date instanceof Date)) {
    date = new Date(date);
  }
  const buffer = [
    date.getUTCFullYear().toString(),
    (date.getUTCMonth() + 1).toString().padStart(2, '0'),
    date.getUTCDate().toString().padStart(2, '0'),
    date.getUTCHours().toString().padStart(2, '0'),
    date.getUTCMinutes().toString().padStart(2, '0'),
    date.getUTCSeconds().toString().padStart(2, '0'),
  ];
 
  return buffer.join('');
}

年不补零,其余各段都补足两位。全部用 UTC,而不是本地时间,这样同一份文档在不同时区保存会得到同一个字符串。测试里的 3141-06-09 02:06:53 UTC 输出 31410609020653。

调用方会自己拼上 D: 前缀,例如注释保存时:

dict.set('M', `D:${getModificationDate()}`);

函数不返回前缀,是因为它也可以用于 CreationDate、ModDate 之外、需要纯时间戳的内部场景。参数接受字符串或其他可被 Date 构造器解析的值,方便从已有时间恢复。

getRotationMatrix 方法

注释旋转需要把外观流坐标系旋转到页面坐标系。getRotationMatrix 只处理三个合法角度:

function getRotationMatrix(rotation, width, height) {
  switch (rotation) {
    case 90:
      return [0, 1, -1, 0, width, 0];
    case 180:
      return [-1, 0, 0, -1, width, height];
    case 270:
      return [0, -1, 1, 0, 0, height];
    default:
      throw new Error('Invalid rotation');
  }
}
角度矩阵x 方向y 方向
90[0, 1, -1, 0, width, 0]由 y 翻转而来由 x 直接而来
180[-1, 0, 0, -1, width, height]x 反向并平移y 反向并平移
270[0, -1, 1, 0, 0, height]由 y 直接而来由 x 翻转而来

矩阵按 PDF 约定解释:x' = a*x + c*y + e,y' = b*x + d*y + f。以 90 度为例,x' = width - y,y' = x,原本的右上角 (width, height) 映射到左上角 (0, height),内容不会跑到负坐标去。180 度只做双轴翻转,所以需要同时平移 width 与 height;270 度只需平移 height。

不支持的角度的抛错,而不是返回单位矩阵。调用方传进来的旋转值来自注释字典或用户操作,只可能是 0、90、180、270;其他值说明上游逻辑已经错了,静默回退会画出一份方向错误的外观流,比直接失败更难排查。

与常量 IDENTITY_MATRIX 的配合值得单独看。Annotation.getRotationMatrix 在角度为 0 时直接返回这个共享常量,保存外观流时就可以用引用比较判断「需不需要写 /Matrix」:rotationMatrix !== IDENTITY_MATRIX 为真时才把矩阵写进字典。如果每次返回新数组,这里就必须逐元素比较。共享常量把「是否为单位矩阵」变成一次指针比较,同时也少一次分配。代价是调用方不能修改返回值——目前所有调用方都只读。

getSizeInBytes 方法

xref 流需要为偏移量与 generation 选择字段宽度。getSizeInBytes 回答「表示这个正整数需要几个字节」:

function getSizeInBytes(x) {
  // n bits are required for numbers up to 2^n - 1.
  // So for a number x, we need ceil(log2(1 + x)) bits.
  return Math.ceil(Math.ceil(Math.log2(1 + x)) / 8);
}
输入所需位数输出
000
111
25581
25692
65536173

0 返回 0 是刻意的:字段宽度为 0 表示这一列不占字节,读端会按规范取对应的默认值,所以某列恒为零时就可以整列省略。1 + x 而不是 x,是因为 x 本身也要能表示:255 需要 8 位,log2(255) 约 7.99 向上取整也是 8;但 256 用 log2(256) = 8 会错算成 1 字节,加一后 log2(257) 约 8.006,向上取整 9,正确。

writer.js 用它决定 /W 数组:第一列固定为 1 字节的 type,偏移量列与 generation 列分别按实际最大值收窄。一个几万对象的文档,偏移量通常 3 字节就够,避免整份 xref 流固定用 4 字节,省下的是整张表的四分之一。

getNewAnnotationsMap 方法

编辑器新建的注释存在 AnnotationStorage 里,键带固定前缀。渲染某一页时,需要快速找出「属于这一页的新注释」:

function getNewAnnotationsMap(annotationStorage) {
  if (!annotationStorage) {
    return null;
  }
  const newAnnotationsByPage = new Map();
  // The concept of page in a XFA is very different, so
  // editing is just not implemented.
  for (const [key, value] of annotationStorage) {
    if (!key.startsWith(AnnotationEditorPrefix)) {
      continue;
    }
    newAnnotationsByPage.getOrInsertComputed(value.pageIndex, makeArr).push(value);
  }
  return newAnnotationsByPage.size > 0 ? newAnnotationsByPage : null;
}

AnnotationEditorPrefix 是 pdfjs_internal_editor_。存储里同时有两类键:已有注释的 id(用户修改了它的值)与编辑器新对象的内部 id。只有后者需要在生成 operatorList 时被「合成」成注释,前者走已有的注释解析路径。前缀过滤把两类分开。

getOrInsertComputed(value.pageIndex, makeArr) 是「取数组,没有就建一个」的原子写法。makeArr 就是 () => [],单独提出来是为了避免每次传入新的闭包。按页分组之后,Page.getOperatorList 只需要 map.get(this.pageIndex),不必扫描整份存储。

返回 null 而不是空 Map,与 collectActions 的约定一致:调用方用真值判断即可,newAnnotsByPage?.get(...) 也能安全短路。XFA 文档不调用它,因为 XFA 的「页」概念与 AcroForm 页面完全不同,注释编辑本来就没有实现。

设计要点:这六个函数看起来互不相关,其实都在做「把内部表示转换成外部协议」。recoverJsURL 把脚本文本转成链接,numberToString 把浮点数转成 PDF 数字,getModificationDate 把时间转成 PDF 日期,getRotationMatrix 把角度转成 PDF 矩阵,getSizeInBytes 把整数转成字段宽度,getNewAnnotationsMap 把扁平存储转成按页索引。它们的输出都必须严格符合目标格式,不能有「大概能用」的中间态。

三个常量与两张资源表

文件顶部的常量看似零散,实际分成两组。第一组是数学与规模上限:

const PDF_VERSION_REGEXP = /^[1-9]\.\d$/;
const MAX_INT_32 = 2 ** 31 - 1;
 
const IDENTITY_MATRIX = [1, 0, 0, 1, 0, 0];
常量值使用位置
IDENTITY_MATRIX[1, 0, 0, 1, 0, 0]注释外观矩阵的默认值与「是否旋转」的引用比较
PDF_VERSION_REGEXP一位主版本、一位次版本文件头版本与 Catalog 的 /Version 校验
MAX_INT_322147483647图像重采样前判断 width * height * 4 是否超出安全范围

PDF_VERSION_REGEXP 只接受 1.0 到 9.9 这种一位小数形式,1.10 会被拒绝。文件头解析和 Catalog 版本读取都用同一个正则,保证「头部声明的版本」与「目录覆盖的版本」遵循同一套规则。版本不合法时只警告,不阻止打开文档:

if (PDF_VERSION_REGEXP.test(version)) {
  this.#version = version;
} else {
  warn(`Invalid PDF header version: ${version}`);
}

MAX_INT_32 用在 image_resizer.js:width * height * 4 超过它时,说明 RGBA 缓冲超出安全范围,先降采样再交给 ImageDecoder。这个上限不是 JavaScript 数组的上限,而是为了避免一次性分配数 GB 的内存。

第二组是资源键清单:

const RESOURCES_KEYS_OPERATOR_LIST = [
  'ColorSpace',
  'ExtGState',
  'Font',
  'Pattern',
  'Properties',
  'Shading',
  'XObject',
];
 
const RESOURCES_KEYS_TEXT_CONTENT = ['ExtGState', 'Font', 'Properties', 'XObject'];

两张表相差三项:ColorSpace、Pattern、Shading 只有渲染需要,文本提取不需要。渲染要画颜色、图案与渐变,所以七项全要;纯文本提取只关心字形映射与表单 XObject,颜色空间、图案、渐变对提取结果没有贡献。Page.loadResources 在两种意图下分别预取:渲染路径传 RESOURCES_KEYS_OPERATOR_LIST,文本路径传 RESOURCES_KEYS_TEXT_CONTENT。预取的意义是把「解析资源字典」这一步集中做一次,后续 PartialEvaluator 遇到引用时大多已经在缓存里。它只是提示,不是硬约束:求值器仍然会按实际 operator 懒加载缺失的资源,所以即使某份文件把字体藏在未列出的键里,也不会渲染失败,只是少了一次预取优化。

设计要点:资源键被提成常量,而不是在 document.js 里就地写数组,是为了让「渲染需要什么」与「文本需要什么」成为一份可对照的声明。两个数组的差集就是文本提取可以跳过的部分,读代码时不需要去求值器里反推。

总结

core_utils.js 没有自己的执行阶段。它的价值在于把「所有阶段都会碰到、但又不该由某个阶段拥有」的逻辑集中起来,并且保持无状态。

它在整条链路中的位置

阶段模块用到的导出
字节供给ChunkedStream、NetworkPdfManagerarrayBuffersToBytes、MissingDataException
语法解析Lexer、ParserisWhiteSpace、ParserEOFException
定位取回XRefMissingDataException、ParserEOFException、XRefEntryException、XRefParseException
文档建模PDFDocument、CataloggetInheritableProperty、lookupNormalRect、collectActions、recoverJsURL、toRomanNumerals、PDF_VERSION_REGEXP、RESOURCES_KEYS_*、validateCSSFont
内容求值PartialEvaluatorisNumberArray、lookupMatrix、lookupRect、getLookupTableFactory、IDENTITY_MATRIX、fetchBinaryData、codePointIter
注释编辑Annotation、PDFEditorgetParentToUpdate、getNewAnnotationsMap、getRotationMatrix、getModificationDate、deepCompare、numberToString
写出保存WriterescapePDFName、escapeString、getSizeInBytes、parseXFAPath
XFA/XMLXMLParser、XFA 模块encodeToXmlString、validateFontName、normalizeCSSFontFamily

用一句话串起来:语法层把字节变成对象,文档层把对象变成页面,而 core_utils 提供两者都要遵守的「对象长什么样、错误怎么分、写出去怎么编码」。

五条设计原则

  1. 失败按语义分类,不按来源分类。MissingDataException 是「等数据」,ParserEOFException 是「对象截断」,XRefEntryException 是「索引指错」,XRefParseException 是「整份索引不可信」。四类对应四种恢复策略,上层只需要 instanceof 判断一次,不必解析错误消息。也正因为它们是控制流,wrapReason 的白名单刻意不包含它们。
  2. 工具箱保持叶子依赖。core_utils 只依赖 shared/util、primitives、base_stream、css_utils、string_utils,这些模块都不反向依赖它。任何阶段都能引用,不会形成环;新增工具时也应遵守这条边界,不要把 XRef 或 PDFDocument 拉进来。
  3. 读与写分成两套 API。读继承用 getInheritableProperty,写继承用 getParentToUpdate;写名称用 escapePDFName,读名称由 Lexer 负责。两套 API 各自只回答一个问题,调用方不会因为「顺手复用」而得到语义错误的默认值。
  4. 默认值与校验分层。lookupMatrix、lookupRect、lookupNormalRect 只检查形状,默认值由调用方给;validateCSSFont 只做安全校验与缺省补齐,拒绝时返回 false 让调用方跳过。工具函数不替业务决定「坏了以后怎么办」。
  5. 热路径只做必要工作。arrayBuffersToBytes 的单块快速路径不拷贝,escapePDFName 与 encodeToXmlString 在无需替换时返回原串,getInheritableProperty 只在出现第二个值时才分配数组,查表工厂把构表推迟到首次查询。这些优化单看都很小,但它们位于每个文档、每一页、每一个名称上,累积起来就是启动时间与内存占用的差别。

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

相关文章