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

前言
src/core/core_utils.js 不是一个处理阶段,而是一只公共工具箱。
前面几篇讲的是链路:XRef 怎么定位对象,Lexer 与 Parser 怎么把字节切成对象,PDFDocument 怎么把字典组织成页面,PartialEvaluator 怎么把内容流变成 operatorList。这些模块彼此有严格的上下游关系,而 core_utils.js 横在它们中间:语法层要用它的异常类型和空白判断,文档层要用它的继承属性遍历,注释层要用它的矩阵与日期,写出层要用它的名称转义。
文件一共 791 行,导出 37 个符号。里面只有四个类,而且全是异常;其余都是函数与常量,没有实例状态,没有缓存对象,也没有跨调用保存的可变数据。这种「无状态」不是风格偏好,而是它能被所有模块安全引用的前提。
它的依赖方向也值得先说明:
| 依赖 | 用途 |
|---|---|
../shared/util.js | BaseException、assert、Util、warn |
./primitives.js | Dict、Name、Ref、RefSet |
./base_stream.js | BaseStream(判断动作脚本是不是流) |
../shared/css_utils.js | CONTROL_CHAR_REGEXP |
./string_utils.js | stringToPDFString |
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、escapeString | XFA 路径解析、写出时的名称与字符串转义 |
| 动作 | collectActions(内部 _collectJS) | 收集 Additional Actions 与 A 里的 JavaScript |
| XML | encodeToXmlString、codePointIter(内部 XMLEntities) | XFA 回写时的实体编码 |
| 字体与 CSS | validateFontName、normalizeCSSFontFamily、validateCSSFont | 把来自 PDF 的字体名安全地放进 CSS |
| 数值与元数据 | recoverJsURL、numberToString、getNewAnnotationsMap、getModificationDate、getRotationMatrix、getSizeInBytes | URL 还原、数字序列化、注释分组、日期、旋转矩阵、字节宽度 |
它在链路里的位置不是一个箭头,而是被每一层引用: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 传递的普通对象还原成异常类型。
四种异常对应四种完全不同的失败语义:
| 异常 | 携带字段 | 触发位置 | 上层动作 |
|---|---|---|---|
MissingDataException | begin、end | ChunkedStream.ensureByte、ensureRange;网络文档读到尚未下载的区间 | 记下区间,请求该范围,然后从头重试 |
ParserEOFException | message | Parser.getObj 在数组或字典内部遇到 EOF,且不在恢复模式 | 认为该对象被截断,跳过它,不覆盖已有 xref 条目 |
XRefEntryException | message | XRef.fetchUncompressed / fetchCompressed 发现 entry 缺失或序号越界 | 转成 XRefParseException,或直接触发全文恢复 |
XRefParseException | message | XRef.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;
}三个分支各自有意义:
- 零块返回长度为 0 的
Uint8Array,而不是null。调用方可以无条件地取.length、做subarray,不需要额外判空。 - 单块直接
new Uint8Array(buffer)。这是视图,不是拷贝:新数组与原缓冲共享同一段内存。单元测试专门断言bytes.buffer === buffer。渐进式加载最常见的情况就是只有一块,省掉一次整份拷贝很划算。 - 多块先累加
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;
}它和读值函数的差别几乎每一项都相反:
| 维度 | getInheritableProperty | getParentToUpdate |
|---|---|---|
| 目标 | 找到某个键的值 | 找到应该被修改的字段字典 |
| 起点 | 任意字典 | 注释字典 + 它自己的 Ref |
| 终止条件 | 键出现(或整条链走完) | 遇到含 T 的字典(或父引用不是 Ref) |
| 循环保护 | 记录已访问字典的 objId | 记录已访问的 Ref |
| 取值方式 | get / getArray,自动解引用 | getRaw("Parent"),只接受 Ref |
| 返回值 | 值、值的数组或 undefined | dict 与 ref 均为 null,或命中字段的二者 |
| 典型调用 | Page.resources、字段类型、AA | WidgetAnnotation.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;
}递归顺序是有讲究的:
- 先比引用。同一个对象直接返回
true,避免对已经确认相等的子树做无谓递归。数字、字符串、null也都是在这一步通过===判等的。 Ref按值比较。isRefsEqual只看num与gen,不会去xref.fetch。这一点很重要:比较动作不应该触发磁盘读取,否则一个纯内存判断可能抛出MissingDataException,把上层拖进重试逻辑。Name按字符串比较。Name.get有缓存,正常情况下同一名字就是同一实例,第一步就返回了;但缓存清理过、或对象来自不同文档时,需要退回字符串比较。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 | 空 | 空 | 空 | I | I |
| 9 | 空 | 空 | 空 | IX | IX |
| 40 | 空 | 空 | XL | 空 | XL |
| 400 | 空 | CD | 空 | 空 | CD |
| 2019 | MM | 空 | X | IX | MMXIX |
它只在页码标签(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 |
|---|---|---|---|
lookupMatrix | 6 | 否 | IDENTITY_MATRIX、null |
lookupRect | 4 | 否 | 页面宽高组成的矩形、[0, 0, 0, 0]、null |
lookupNormalRect | 4 | 是 | 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('');
}条件分三类:
- 控制字符与空白:字符码小于
0x21,包括 NUL、TAB、LF、CR、空格。 - 非 ASCII:字符码大于
0x7e。 - 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].rab | foo 位置 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);
}
}
}
}三个设计点:
AA本身是可继承的。Widget 没写AA,字段或页面可能写了。所以用getInheritableProperty且stopWhenFound: false,拿到「由近到远」的字典数组。- 倒序遍历。数组第 0 项是最近的字典,最后一项是最远祖先。从末尾往前走,先写入祖先的动作,再让近的字典覆盖它。
Map.set对同一个键是替换而不是合并,所以最终结果符合「最近的声明生效」。 - 事件名映射。
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: '<',
/* > */ 0x3e: '>',
/* & */ 0x26: '&',
/* " */ 0x22: '"',
/* ' */ 0x27: ''',
};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 只产生 𝐀,而不是两个孤立的代理实体。
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 下标遍历,一个辅助平面字符会被算成两个未知字形。
举几个输出例子:双引号变成 ",与号变成 &,小于号变成 <,希腊字母 Η 变成 Η,码点 U+1D400 变成 𝐀。为什么非 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 3 | Wingdings3 |
MS Gothic 2 Bold 7 | MS Gothic2 Bold7 |
Times New Roman | Times New Roman |
| 首尾与中间有多余空格 | 每段空格压成一个 |
Wingdings3 | Wingdings3 |
为什么要把空格去掉?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" |
italicAngle | 2.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);
}| 输入 | 所需位数 | 输出 |
|---|---|---|
0 | 0 | 0 |
1 | 1 | 1 |
255 | 8 | 1 |
256 | 9 | 2 |
65536 | 17 | 3 |
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_32 | 2147483647 | 图像重采样前判断 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、NetworkPdfManager | arrayBuffersToBytes、MissingDataException |
| 语法解析 | Lexer、Parser | isWhiteSpace、ParserEOFException |
| 定位取回 | XRef | MissingDataException、ParserEOFException、XRefEntryException、XRefParseException |
| 文档建模 | PDFDocument、Catalog | getInheritableProperty、lookupNormalRect、collectActions、recoverJsURL、toRomanNumerals、PDF_VERSION_REGEXP、RESOURCES_KEYS_*、validateCSSFont |
| 内容求值 | PartialEvaluator | isNumberArray、lookupMatrix、lookupRect、getLookupTableFactory、IDENTITY_MATRIX、fetchBinaryData、codePointIter |
| 注释编辑 | Annotation、PDFEditor | getParentToUpdate、getNewAnnotationsMap、getRotationMatrix、getModificationDate、deepCompare、numberToString |
| 写出保存 | Writer | escapePDFName、escapeString、getSizeInBytes、parseXFAPath |
| XFA/XML | XMLParser、XFA 模块 | encodeToXmlString、validateFontName、normalizeCSSFontFamily |
用一句话串起来:语法层把字节变成对象,文档层把对象变成页面,而 core_utils 提供两者都要遵守的「对象长什么样、错误怎么分、写出去怎么编码」。
五条设计原则
- 失败按语义分类,不按来源分类。
MissingDataException是「等数据」,ParserEOFException是「对象截断」,XRefEntryException是「索引指错」,XRefParseException是「整份索引不可信」。四类对应四种恢复策略,上层只需要instanceof判断一次,不必解析错误消息。也正因为它们是控制流,wrapReason的白名单刻意不包含它们。 - 工具箱保持叶子依赖。
core_utils只依赖shared/util、primitives、base_stream、css_utils、string_utils,这些模块都不反向依赖它。任何阶段都能引用,不会形成环;新增工具时也应遵守这条边界,不要把XRef或PDFDocument拉进来。 - 读与写分成两套 API。读继承用
getInheritableProperty,写继承用getParentToUpdate;写名称用escapePDFName,读名称由Lexer负责。两套 API 各自只回答一个问题,调用方不会因为「顺手复用」而得到语义错误的默认值。 - 默认值与校验分层。
lookupMatrix、lookupRect、lookupNormalRect只检查形状,默认值由调用方给;validateCSSFont只做安全校验与缺省补齐,拒绝时返回false让调用方跳过。工具函数不替业务决定「坏了以后怎么办」。 - 热路径只做必要工作。
arrayBuffersToBytes的单块快速路径不拷贝,escapePDFName与encodeToXmlString在无需替换时返回原串,getInheritableProperty只在出现第二个值时才分配数组,查表工厂把构表推迟到首次查询。这些优化单看都很小,但它们位于每个文档、每一页、每一个名称上,累积起来就是启动时间与内存占用的差别。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- 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 源码解读: 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 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日