PDF.js 深入浅出 10: XRef
2026年10月26日 · StreamPDF Team

前言
这是 PDF.js 系列文章的第 10 篇,介绍 Worker 端 src/core/xref.js 中的 XRef 类。
前面几篇已经把「文档怎么起来」讲完了:
- 第 6 篇讲
PDFManager.initDocument:从文件尾部找到startxref,建立XRef,解析 trailer 与页面树。 - 第 7 篇讲
PartialEvaluator:把页面内容流解释成 operatorList。 - 第 8 篇讲
PDFDocument:页面模型、Resources、注释与渲染意图。
这几篇里反复出现同一个动作——xref.fetch(ref)。字体字典、图片流、内容流、页面树节点,全都通过它取回。但「一个 12 0 R 到底怎么变成内存里的对象」这一步一直没有展开。本篇补上这一环:对象怎么被定位,又怎么被取回。
一句话概括 XRef 的职责:它是整份 PDF 的目录加取书员。目录记录「第几号对象在第几字节」,取书员负责按编号把对象读出来;如果对象被塞进对象流(ObjStm),取书员还要先解压再拆包。
模块总览
xref.js 约 1063 行,可以切成六块:
| 区块 | 关键方法 | 职责 |
|---|---|---|
| 入口与遍历 | setStartXRef、parse、readXRef | 从 startxref 出发,沿 /Prev 收集所有 xref 段 |
| 传统表 | processXRefTable、readXRefTable | 解析文本形式的 xref 表与 trailer |
| xref 流 | processXRefStream、readXRefStream | 解析二进制形式的 /W、/Index 记录 |
| 对象取回 | getEntry、fetch、fetchUncompressed、fetchCompressed | 按对象号返回字典、数组或流 |
| 恢复 | indexObjects | 全文件扫描,重建损坏的 xref |
| 辅助 | getNewPersistentRef、getNewTemporaryRef、countUpdatesAfter、getCatalogObj | 引用号分配、增量更新计数、取回根字典 |
它在整条链路里的位置:
PDF 字节
→ PDFDocument.startXRef (从文件尾部读 startxref)
→ XRef.setStartXRef / parse
→ readXRef (传统表 / xref 流 / Prev 链)
→ #entries (对象号 → entry 的稀疏数组)
→ fetch(ref) (读偏移,或解压 ObjStm)
→ Dict / Array / BaseStream
→ Catalog / PDFDocument / PartialEvaluator
版本说明:本篇严格按当前仓库这一版
xref.js讲解。它的构造函数是constructor(stream, pdfManager),没有catalog、dispatcher、filesize、docId参数;也没有cleanup()、statistics、getPage()方法。旧版本里常见的entries/_entries在本版是私有字段#entries,fetchAsyncIfRef在本版叫fetchIfRefAsync。目录(Catalog)与页面(Page)分别是PDFDocument和Catalog的职责,XRef只保留getCatalogObj()返回根字典。读源码时不要套用记忆里的其它版本 API。
数据结构:entry 与缓存
PDF 的间接引用写作 12 0 R:对象号 12、generation 0。XRef 要回答的问题只有一个——12 号对象在哪里。答案有两种:
- 在文件某个字节偏移处,直接解析;
- 在某个对象流(ObjStm)里,先解压对象流,再按序号取出。
源码里没有名为 XRefEntryType 的枚举,三种类型由 entry 上的两个布尔字段编码:
| 源码表示 | 语义 | offset 的含义 | gen 的含义 | 来源 |
|---|---|---|---|---|
free: true、uncompressed: false | 空闲对象 | 下一个空闲对象号 | 将来复用时的 generation | 传统表的 f,xref 流的 type 0 |
free: false、uncompressed: true | 普通对象 | 文件内字节偏移 | generation | 传统表的 n,xref 流的 type 1 |
free: false、uncompressed: false | 压缩对象 | 所属 ObjStm 的对象号 | 在 ObjStm 内的序号 | 只有 xref 流的 type 2 |
第三行是最容易读错的地方:压缩 entry 的 gen 不是 generation,而是对象在 ObjStm 里的下标。fetchCompressed 最后就是拿 entries[xrefEntry.gen] 取结果。
XRef 的私有字段全部在类顶部初始化:
class XRef {
#cacheMap = new Map();
#entries = [];
#newPersistentRefNum = null;
#newTemporaryRefNum = null;
#parsedWithRecovery = false;
#pendingRefs = new RefSet();
#persistentRefsCache = null;
#xrefSectionOffsets = new Set();
#xrefSectionsComplete = true;
#xrefStms = new Set();| 字段 | 结构 | 作用 |
|---|---|---|
#entries | 稀疏数组 | 位置索引,下标是对象号,值是 entry |
#cacheMap | Map | 对象缓存,键是对象号,值是已解析对象 |
#pendingRefs | RefSet | 正在解析中的引用,用于检测循环引用 |
#xrefSectionOffsets | Set | 已成功解析的 xref 段偏移 |
#xrefStms | Set | 已加入队列的 xref 流偏移,防止重复解析 |
#xrefSectionsComplete | boolean | 是否所有 xref 段都完整解析 |
#parsedWithRecovery | boolean | 是否走了 indexObjects 恢复路径 |
#entries 之所以用稀疏数组而不是 Map,是因为对象号本身就是从 0 开始的整数,数组下标可以直接寻址;损坏文件里出现的巨大对象号只会留下空洞,不会撑大有效数据。遍历时源码也特意用 for (const num in this.#entries) 而不是 for..of,注释写得很直白:不要迭代空槽。
对外查询 entry 只经过一个很小的门:
getEntry(i) {
const entry = this.#entries[i];
return entry && !entry.free && entry.offset ? entry : null;
}它把三种情况统一折叠成 null:没有记录、明确空闲、偏移为 0。调用方因此只需要判断「有没有 entry」,不用关心 xref 里到底写了 f 还是根本没写。
设计要点:
#entries与#cacheMap是两层完全不同的东西。前者是磁盘布局,回答「对象在哪」;后者是内存对象,回答「对象是否已经解析过」。二者解耦之后,xref 段可以在不触碰对象缓存的情况下重建(indexObjects会同时清空二者),而对象缓存也可以在 xref 不变的情况下反复命中。任何把两者混在一起的实现,都会在增量更新或恢复模式里出问题。
constructor 与引用号分配
构造函数只有两个参数:
constructor(stream, pdfManager) {
this.stream = stream;
this.pdfManager = pdfManager;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
// For testing purposes.
Object.defineProperty(this, "xrefSectionOffsetsAdd", {
value: offset => this.#xrefSectionOffsets.add(offset),
});
}
}依赖被压到了最小:
| 参数 | 类型 | 在 XRef 里的用途 |
|---|---|---|
stream | BaseStream | 所有字节读取;makeSubStream 从任意偏移开始解析 |
pdfManager | BasePdfManager | 读取 password;fetchAsync 里调用 requestRange 补数据 |
PDFDocument 在构造时把两者传进来,随后才调用 parse:
this.pdfManager = pdfManager;
this.stream = stream;
this.xref = new XRef(stream, pdfManager);这个顺序很重要:XRef 创建时文件可能只加载了一部分,parse 时也可能因为缺字节而抛出 MissingDataException,由 PDFManager 补完再重试。XRef 本身不持有任何「已完成」的假设。
除了定位对象,XRef 还负责分配新的对象号。保存或打印时,编辑器需要创建新对象,但既不能和已有对象号冲突,又要尽量复用。getNewPersistentRef 从 #entries.length || 1 开始递增,并把对象直接写进缓存;临时引用则要「节省号码」,会把持久引用暂时移出 #cacheMap,避免保存过程中新分配的号码与已有号码撞车,resetNewTemporaryRef 在保存结束后再把它们放回去。这是编辑器写回 PDF 时的号码空间管理,与读取链路关系不大,但它解释了为什么 #cacheMap 里会出现没有对应 xref entry 的对象。
startXRef:从文件尾部找到第一扇门
PDF 文件的末尾通常长这样:
startxref
123456
%%EOF
startxref 后面的十进制数,就是最新一个 xref 段的字节偏移(相对于 %PDF- 头)。XRef 侧只提供一个极简的入口:
setStartXRef(startXRef) {
// Store the starting positions of xref tables as we process them
// so we can recover from missing data errors
this.startXRefQueue = [startXRef];
}注意它存的是队列,不是单个值。因为解析一个 xref 段时,还会发现 /Prev 指向更早的段,混合文件里还会有 /XRefStm 指向同一修订里的 xref 流。这些偏移都会追加到这个队列里。
真正扫描 startxref 的代码不在 xref.js,而在 PDFDocument 的 getter 里:
// Find `startxref` by checking backwards from the end of the file.
const step = 1024;
const startXRefLength = STARTXREF_SIGNATURE.length;
let found = false,
pos = stream.end;
while (!found && pos > 0) {
pos -= step - startXRefLength;
if (pos < 0) {
pos = 0;
}
stream.pos = pos;
found = find(stream, STARTXREF_SIGNATURE, step, true);
}
if (found) {
stream.skip(9);
let ch;
do {
ch = stream.getByte();
} while (isWhiteSpace(ch));
let str = '';
while (ch >= /* Space = */ 0x20 && ch <= /* '9' = */ 0x39) {
str += String.fromCharCode(ch);
ch = stream.getByte();
}
startXRef = parseInt(str, 10);
if (isNaN(startXRef)) {
startXRef = 0;
}
}三个细节:
- 从后往前、按 1024 字节步进。
startxref一定在文件尾部附近,但可能被垃圾字节、注释或多次增量更新推得比较远。反向扫描比从头扫快得多,find(..., true)的最后一个参数就是「反向查找」。 - 只读数字字符。跳过
startxref后跳过空白,然后只接受0x20到0x39的字符。读到%%EOF的%就停。 - 解析失败就置 0。
NaN不抛出,而是把起点设成 0,让后面的readXRef报错,再由 Worker 切到恢复模式。这是一个「先别死,留给上一层兜底」的选择。
线性化(Linearized)PDF 走另一个分支:第一页的对象被放在文件开头,startxref 指向第一个对象的结尾,而不是最后一个 xref 段。PDFDocument 检测到线性化字典时直接取第一个对象的末尾位置,避免为了首屏再去读文件尾部。两条路径最终都汇入 setStartXRef。
startxref 里的偏移是相对 PDF 头的。如果文件前面有垃圾字节,checkHeader 会调用 moveStart 调整 stream.start,所以 readXRef 里统一写成:
stream.pos = startXRef + stream.start;readXRef:沿 /Prev 回溯
这是整份文件最核心的遍历逻辑:
readXRef(recoveryMode = false) {
const stream = this.stream;
// Keep track of already parsed XRef tables, to prevent an infinite loop
// when parsing corrupt PDF files where e.g. the /Prev entries create a
// circular dependency between tables (fixes bug1393476.pdf).
const startXRefParsedCache = new Set();
while (this.startXRefQueue.length) {
try {
const startXRef = this.startXRefQueue[0];
if (startXRefParsedCache.has(startXRef)) {
warn("readXRef - skipping XRef table since it was already parsed.");
this.startXRefQueue.shift();
continue;
}
startXRefParsedCache.add(startXRef);
stream.pos = startXRef + stream.start;
const parser = new Parser({
lexer: new Lexer(stream),
xref: this,
allowStreams: true,
});
let obj = parser.getObj();
let dict;
// Get dictionary
if (isCmd(obj, "xref")) {
// Parse end-of-file XRef
dict = this.processXRefTable(parser);
this.topDict ||= dict;
// Recursively get other XRefs 'XRefStm', if any
obj = dict.get("XRefStm");
if (Number.isInteger(obj) && !this.#xrefStms.has(obj)) {
// ignore previously loaded xref streams
// (possible infinite recursion)
this.#xrefStms.add(obj);
this.startXRefQueue.push(obj);
}
} else if (Number.isInteger(obj)) {
// Parse in-stream XRef
if (
!Number.isInteger(parser.getObj()) ||
!isCmd(parser.getObj(), "obj") ||
!((obj = parser.getObj()) instanceof BaseStream)
) {
throw new FormatError("Invalid XRef stream");
}
dict = this.processXRefStream(obj);
this.topDict ||= dict;
if (!dict) {
throw new FormatError("Failed to read XRef stream");
}
} else {
throw new FormatError("Invalid XRef stream header");
}
this.#xrefSectionOffsets.add(startXRef);
// Recursively get previous dictionary, if any
obj = dict.get("Prev");
if (Number.isInteger(obj)) {
this.startXRefQueue.push(obj);
} else if (obj instanceof Ref) {
// The spec says Prev must not be a reference, i.e. "/Prev NNN"
// This is a fallback for non-compliant PDFs, i.e. "/Prev NNN 0 R"
this.startXRefQueue.push(obj.num);
}
} catch (e) {
if (e instanceof MissingDataException) {
throw e;
}
this.#xrefSectionsComplete = false;
info("(while reading XRef): " + e);
}
this.startXRefQueue.shift();
}
if (this.topDict) {
return this.topDict;
}
if (recoveryMode) {
return undefined;
}
throw new XRefParseException();
}虽然注释里写着 recursively,实现其实是队列驱动的迭代。每轮取队首偏移,解析完把新发现的偏移追加到队尾,直到队列为空。这样做的好处是:恢复模式、混合文件、MissingDataException 重试都可以共用同一套状态,不需要维护递归栈。
/Prev 链与「新段优先」
读第一个 token 就能判断类型:xref 命令走传统表,整数走 N 0 obj 形式的 xref 流,其它情况直接报错。两条分支最后都产出一个字典——传统表是 trailer 后面的字典,xref 流是流本身的字典——因此后续逻辑可以统一处理。
/Prev 指向上一个修订的 xref 段,多次增量更新会形成一条向文件头回溯的链。readXRef 从最新开始,把旧段依次追加到队列。entry 写入时用的是 ??=:
this.#entries[first + i] ??= entry;这行代码决定了整个遍历顺序的正确性:先写入的最新 entry 不会被后解析的旧 entry 覆盖。因此队列必须保持「新的在前、旧的在后」;对象缓存同理,最新段解析出的对象进入 #cacheMap 后,旧段即使指向同一个对象号也不会再被访问。PDF 的增量更新语义就这样落在了数据结构的写入顺序上。
混合引用文件与 /XRefStm
PDF 1.5 引入 xref 流后,存在一类「混合引用文件」:文件用传统 xref 表作为主索引,但表里的 trailer 多了一个 /XRefStm,指向同一修订中的 xref 流。这样老阅读器只读传统表也能打开,新阅读器则能从 xref 流里拿到压缩对象的 entry。
处理逻辑只有四行:
obj = dict.get('XRefStm');
if (Number.isInteger(obj) && !this.#xrefStms.has(obj)) {
this.#xrefStms.add(obj);
this.startXRefQueue.push(obj);
}两个集合的分工要分清:
| 集合 | 生命周期 | 防的是什么 |
|---|---|---|
startXRefParsedCache | 单次 readXRef 调用 | 任意偏移的重复解析,包括 /Prev 形成的环 |
#xrefStms | 整个 XRef 实例 | xref 流被反复入队,尤其是 indexObjects 与 /XRefStm 交叉引用 |
因为 #XRefStm 的入队发生在 /Prev 入队之前,所以队列顺序是「当前段 → 同修订的 xref 流 → 上一修订」。传统表已经写入的 entry 优先;xref 流只补上传统表无法表达的 type 2(压缩对象)记录。这正是混合文件能工作的关键:两张索引叠在一起,各管一部分对象。
损坏文件的容错策略
readXRef 的 catch 分支只做三件事:
MissingDataException原样上抛。它不是「文件坏了」,而是「这段字节还没加载」,必须让上层补数据后重试。- 其它异常只打一条
info日志,并把#xrefSectionsComplete置为false。 - 继续处理队列里的下一段。
这意味着单个损坏的 xref 段不会终止整个文档:只要还有别的段能提供 Root,文档仍然可以打开。#xrefSectionsComplete 后来被 countUpdatesAfter 用来判断「能否可靠地统计增量更新次数」——文件不完整时它直接返回 null,而不是给出一个可能错误的数字。
如果队列跑完仍然没有 topDict,非恢复模式抛 XRefParseException,Worker 捕获后切到 indexObjects;恢复模式则返回 undefined,让调用方自己决定下一步。
设计要点:
readXRef把「遍历」和「解释」分开:循环只负责按顺序取偏移、入队、去重,具体的表/流解析交给processXRefTable与processXRefStream。这样MissingDataException的处理可以集中在一处,而两种格式的断点续读状态(_tableState与streamState)各自维护,互不干扰。容错也分层:坏一段只降级一段,只有「字节没到」才是必须上抛的硬错误。
传统 xref 表
传统表是纯文本,结构如下:
xref
0 3
0000000000 65535 f
0000000015 00000 n
0000000123 00000 n
trailer
<< /Size 3 /Root 1 0 R >>
startxref
1234
%%EOF
processXRefTable 负责把「表」和「trailer 字典」接起来:
processXRefTable(parser) {
this._tableState ??= {
entryNum: 0,
streamPos: parser.lexer.stream.pos,
parserBuf1: parser.buf1,
parserBuf2: parser.buf2,
};
const obj = this.readXRefTable(parser);
if (!isCmd(obj, "trailer")) {
throw new FormatError(
"Invalid XRef table: could not find trailer dictionary"
);
}
let dict = parser.getObj();
if (dict instanceof BaseStream) {
dict = dict.dict;
}
if (!(dict instanceof Dict)) {
throw new FormatError(
"Invalid XRef table: could not parse trailer dictionary"
);
}
delete this._tableState;
return dict;
}_tableState 是断点续读状态。网络分块加载时,读到表中间可能发现所需字节还没到,MissingDataException 会把整个调用栈掀掉;等数据补齐后 readXRef 重试,processXRefTable 看到 _tableState 已经存在就复用它,readXRefTable 再把流位置和 parser 的两个缓冲 token 恢复回去,从上次中断的 entry 继续。没有这套状态,每次缺字节都要从头再解析一遍表。
trailer 后面的字典由 parser.getObj() 读取。注释里的 << ... >> 是 PDF 字典语法,parser 提供的是按键取值接口。pdflib 生成的文件可能把 trailer 包成流,所以源码判断 BaseStream 并取 .dict。
readXRefTable
这里节选内层循环;前面的流位置恢复、subsection 头读取与末尾的 trailer 判断由同一方法完成:
for (let i = tableState.entryNum; i < count; i++) {
tableState.streamPos = stream.pos;
tableState.entryNum = i;
tableState.parserBuf1 = parser.buf1;
tableState.parserBuf2 = parser.buf2;
const entry = {
offset: parser.getObj(),
gen: parser.getObj(),
free: false,
uncompressed: false,
};
const type = parser.getObj();
if (type instanceof Cmd) {
switch (type.cmd) {
case 'f':
entry.free = true;
break;
case 'n':
entry.uncompressed = true;
break;
}
}
// Validate entry obj
if (
!Number.isInteger(entry.offset) ||
!Number.isInteger(entry.gen) ||
!(entry.free || entry.uncompressed)
) {
throw new FormatError(`Invalid entry in XRef subsection: ${first}, ${count}`);
}
// The first xref table entry, i.e. obj 0, should be free. Attempting
// to adjust an incorrect first obj # (fixes issue 3248 and 7229).
if (i === 0 && entry.free && first === 1) {
first = 0;
}
this.#entries[first + i] ??= entry;
}逐段解释:
- 恢复位置。
stream.pos、parser.buf1、parser.buf2三个状态一起恢复。只恢复流位置不够——parser 是流式 lexer,可能已经预读了下一条记录的两个 token。 - 外层按 subsection 循环。每个 subsection 头部是「起始对象号 + 数量」,例如
23 2表示从 23 号开始有 2 条记录。firstEntryNum与entryCount只在 subsection 开始时读取;中断重入时若这两个字段还在,就直接跳过读取,避免把已经消耗的头部再读一次。 - 内层按记录循环。每条记录固定三段:偏移、generation、
f或n。entryNum记录当前处理到第几条,重入时从它继续。 - 类型校验。偏移与 generation 必须是整数,并且
f、n至少命中一个;否则整张表判为损坏。 - 首对象修正。规范要求 0 号对象是空闲的,但有些生成器把第一条写成
1号起始。当i === 0、entry 为 free、first === 1时,源码把first改成 0,修正下标。这是针对 issue 3248 与 7229 的兼容。 ??=写入。新段先解析,旧段后解析,??=保证新段赢。- 读完一整段后清空 subsection 头字段,下一轮循环继续读下一个 subsection,直到读到
trailer命令才break。 - 末尾校验。如果
#entries[0]存在且不是 free,说明文件结构不符合规范,抛FormatError。
传统表只能产生 free 与 uncompressed 两种 entry,无法表达压缩对象。压缩对象只能靠 xref 流或对象流索引(见下一节与 fetchCompressed)。
传统表与 xref 流对照
| 维度 | 传统 xref 表 | xref 流 |
|---|---|---|
| 引入版本 | PDF 1.0 | PDF 1.5 |
| 形态 | 文本关键字 + 十进制记录 | 二进制流对象 |
| 入口 | startxref 指向 xref 关键字 | startxref 指向 N 0 obj |
| 记录字段 | offset、generation、f / n | type、field2、field3 |
| 压缩对象 | 不支持 | type 2 指向 ObjStm |
| 末尾字典 | trailer 后的字典 | 流字典本身 |
| 混合文件角色 | 主索引,trailer 可带 /XRefStm | 被传统表引用,补充压缩 entry |
| 读取方法 | readXRefTable | readXRefStream |
xref 流
xref 流把索引本身做成一个流对象。processXRefStream 负责建立断点续读状态:
processXRefStream(stream) {
if (!("streamState" in this)) {
// Stores state of the stream as we process it so we can resume
// from middle of stream in case of missing data error
const { dict, pos } = stream;
const byteWidths = dict.get("W");
const range = dict.get("Index") || [0, dict.get("Size")];
this.streamState = {
entryRanges: range,
byteWidths,
entryNum: 0,
streamPos: pos,
};
}
this.readXRefStream(stream);
delete this.streamState;
return stream.dict;
}三个要素:
/W:三个整数的数组,分别给出 type、field2、field3 的字节宽度。/Index:成对出现的区间列表,默认是[0, Size],表示从 0 号对象开始、共 Size 条。entryRanges是可变数组,每读完一个区间就splice(0, 2)吃掉一对,因此中断重入时剩下的区间还在。
readXRefStream 按宽度逐字节读取,手工拼出大端整数:
readXRefStream(stream) {
const streamState = this.streamState;
stream.pos = streamState.streamPos;
const [typeFieldWidth, offsetFieldWidth, generationFieldWidth] =
streamState.byteWidths;
const entryRanges = streamState.entryRanges;
while (entryRanges.length > 0) {
const [first, n] = entryRanges;
if (!Number.isInteger(first) || !Number.isInteger(n)) {
throw new FormatError(`Invalid XRef range fields: ${first}, ${n}`);
}
if (
!Number.isInteger(typeFieldWidth) ||
!Number.isInteger(offsetFieldWidth) ||
!Number.isInteger(generationFieldWidth)
) {
throw new FormatError(
`Invalid XRef entry fields length: ${first}, ${n}`
);
}
for (let i = streamState.entryNum; i < n; ++i) {
streamState.entryNum = i;
streamState.streamPos = stream.pos;
let type = 0,
offset = 0,
generation = 0;
for (let j = 0; j < typeFieldWidth; ++j) {
const typeByte = stream.getByte();
if (typeByte === -1) {
throw new FormatError("Invalid XRef byteWidths 'type'.");
}
type = (type << 8) | typeByte;
}
// if type field is absent, its default value is 1
if (typeFieldWidth === 0) {
type = 1;
}
for (let j = 0; j < offsetFieldWidth; ++j) {
const offsetByte = stream.getByte();
if (offsetByte === -1) {
throw new FormatError("Invalid XRef byteWidths 'offset'.");
}
offset = (offset << 8) | offsetByte;
}
for (let j = 0; j < generationFieldWidth; ++j) {
const generationByte = stream.getByte();
if (generationByte === -1) {
throw new FormatError("Invalid XRef byteWidths 'generation'.");
}
generation = (generation << 8) | generationByte;
}
const entry = {
offset,
gen: generation,
free: false,
uncompressed: false,
};
switch (type) {
case 0:
entry.free = true;
break;
case 1:
entry.uncompressed = true;
break;
case 2:
break;
default:
throw new FormatError(`Invalid XRef entry type: ${type}`);
}
this.#entries[first + i] ??= entry;
}
streamState.entryNum = 0;
streamState.streamPos = stream.pos;
entryRanges.splice(0, 2);
}
}几个容易忽略的点:
- 宽度可以为 0。type 字段宽度为 0 时,整行没有 type 字节,规范规定默认值为 1(普通对象)。这是为了兼容只记录 offset 与 generation 的老式写入器。
- 偏移可以超过 4 字节。循环按宽度累积,
(offset << 8) | byte最多能拼出 8 字节的安全整数,不会把大文件截断。 - 读到文件尾会抛错。
stream.getByte()返回-1时分别抛出'type'、'offset'、'generation'三种错误,方便定位是哪个字段不完整。 - type 2 不设置任何布尔值。
free与uncompressed都保持false,于是 entry 自然落入「压缩对象」分支,fetch会调用fetchCompressed。 ??=仍然是新段优先。与传统表完全一致。
/W、/Index 与字段含义
| 字段 | 宽度来源 | 含义 | 缺省行为 |
|---|---|---|---|
| type | W[0] | 0 空闲、1 普通、2 压缩 | 宽度为 0 时按 1 处理 |
| field2 | W[1] | type 0:下一个空闲对象号;type 1:文件偏移;type 2:ObjStm 对象号 | 全 0 |
| field3 | W[2] | type 0:复用时的 generation;type 1:generation;type 2:ObjStm 内序号 | 全 0 |
/Index 是成对的 [first, count, first, count, ...]。一份文件可以有多个不连续的对象号区间,例如正文对象一段、被删除对象一段。源码用 entryRanges.splice(0, 2) 逐对消费,entryNum 负责记录当前区间内处理到第几条——这两个状态合起来就是断点续读所需的全部信息。值得注意的是,xref 流字典里并没有 /Fields 之类的键,三个字段的名字与顺序完全由 /W 定义;所谓「field2」「field3」只是给同一位置在不同 type 下的不同含义起的说明性名称。
Predictor:xref 流在进入 XRef 之前已经解压
xref 流几乎总是 /Filter /FlateDecode,并带 /DecodeParms 的 PNG predictor:
<< /Type /XRef
/Size 100
/W [1 4 2]
/Index [0 100]
/Filter /FlateDecode
/DecodeParms << /Columns 7 /Predictor 12 >>
/Root 1 0 R
/Length 1234 >>
readXRefStream 里看不到任何解压或反预测代码,因为它拿到的 stream 已经是解码后的字节。readXRef 创建 Parser 时传了 allowStreams: true,parser 解析流对象时会调用 makeFilter 套上解码器:
case "Fl":
case "FlateDecode":
if (params) {
return new PredictorStream(
new FlateStream(stream, maybeLength),
maybeLength,
params
);
}
return new FlateStream(stream, maybeLength);PredictorStream 再根据 /Predictor 决定按行还原:
constructor(str, maybeLength, params) {
super(maybeLength);
if (!(params instanceof Dict)) {
return str; // no prediction
}
const predictor = (this.predictor = params.get("Predictor") || 1);
if (predictor <= 1) {
return str; // no prediction
}
if (predictor !== 2 && (predictor < 10 || predictor > 15)) {
throw new FormatError(`Unsupported predictor: ${predictor}`);
}
this.readBlock = predictor === 2 ? this.readBlockTiff : this.readBlockPng;这里有一个隐含约束:/Columns 必须等于 W[0] + W[1] + W[2](通常再乘 /Colors 与每样本字节数)。如果写入器写错,反预测就会按错误的行宽切行,xref 流读出来的 type、offset、generation 全部错位。readXRefStream 只能看到「字段宽度非法」或「entry 类型非法」,真正的根因在 predictor 参数。
设计要点:xref 流是定长二进制记录,不是 PDF 对象。PDF.js 没有为每条记录构造对象、也不使用通用的
getObj,而是直接按/W宽度移位拼接。这条路径绕开了 lexer 的 token 化开销,在几万对象的文档上差别明显;代价是必须自己处理宽度为 0、偏移超过 4 字节、流被截断这些边界。把「格式解析」和「字节解码」分层(PredictorStream在前,readXRefStream在后)让两边都能独立测试。
恢复模式:indexObjects
当 readXRef 连一个可用的 topDict 都拿不到时,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);
});恢复模式不再信任任何 xref,直接扫描全文:
// Clear out any existing entries, since they may be bogus.
this.#entries.length = 0;
this.#cacheMap.clear();
const stream = this.stream;
stream.pos = 0;
const buffer = stream.getBytes(),
bufferStr = bytesToString(buffer),
length = buffer.length;
let position = stream.start;
const trailers = [],
xrefStms = [];扫描器逐 token 前进,只关心四类东西:
| 遇到的内容 | 处理 |
|---|---|
空白与 % 注释 | 跳过 |
xref 关键字 | 跳到后面的 trailer,记录位置;再跳到 startxref |
N G obj | 记录成普通对象 entry,并计算对象内容长度,顺带识别 xref 流嫌疑 |
trailer 关键字 | 记录位置,内容长度截到下一个 startxref 或对象头 |
对象 entry 的偏移是相对 PDF 头的:
this.#entries[num] = {
offset: position - stream.start,
gen,
free: false,
uncompressed: true,
};这里用的是无条件赋值,不是 ??=。扫描是从文件头向文件尾走的,后出现的对象属于更晚的修订,理应覆盖先出现的同名对象。覆盖前还有一层保护:如果已有 entry 的 generation 相同,会先尝试用临时 parser 解析一下新位置,只有能解析出对象才覆盖(issue13783);否则保留旧 entry,避免把好索引换成坏偏移。
找出可用的 trailer
扫描结束后,trailers 里可能有一堆候选字典。源码先全部预解析,判断文件是否加密;然后按「候选 → 生成回退标记 → 候选」的顺序做两轮验证:
验证标准是:Root 必须是字典,Root 里的 Pages 必须是字典,Pages 里的 Count 是整数。满足这些条件、且(若文件加密)带有 Encrypt、并带 ID 的候选,直接胜出。没有带 ID 的,就保留最后一个可用的;再不行就用 topDict;再不行就遍历 #entries 找一个含 Root 的字典;最后抛 Invalid PDF structure.。
_generationFallback 就是在这里被点亮的。当第一轮候选全部报错时,第二轮会允许 fetchUncompressed 在 generation 不一致时回退到旧的 generation——这条线索解释了为什么 fetchUncompressed 里会有一个看起来很奇怪的重试分支。
扫描 xref 流
扫描阶段会发现「内容里带 /XRef 且后面不是字母」的对象,记进 xrefStms 数组;扫描结束后逐个入队并调用 readXRef(true)。这一步让恢复模式也能利用二进制 xref 流里的 type 2 记录。否则,压缩对象只能靠对象流内部的对象号表来间接发现,成本会高得多。
设计要点:恢复模式不是「另一套解析器」,而是重建索引后再走同一条取回路径。
indexObjects只负责把对象号、偏移、trailer 候选找出来,写回#entries;之后fetch、fetchUncompressed、fetchCompressed完全复用。这种设计把「坏文件兼容」限制在索引层,不会让容错逻辑渗透到每个对象读取点。
取回对象:fetch 的三个入口
XRef 对外暴露的不是一个取回方法,而是三个,分别对应不同的调用场景:
| 入口 | 接受 | 遇到 MissingDataException | 典型场景 |
|---|---|---|---|
fetch(ref, suppressEncryption) | 必须是 Ref,否则抛错 | 原样上抛,调用方负责补数据 | Worker 内部同步解析 |
fetchIfRef(obj, suppressEncryption) | 任意值 | 同上 | 字典字段可能直接是值,也可能是引用 |
fetchAsync(ref, suppressEncryption) / fetchIfRefAsync(obj, ...) | 任意值 / Ref | 捕获异常,requestRange 后重试 | 网络分块流、异步验证 |
前两个是同步的薄封装:
fetchIfRef(obj, suppressEncryption = false) {
return obj instanceof Ref ? this.fetch(obj, suppressEncryption) : obj;
}异步版本在 MissingDataException 上做重试:
async fetchIfRefAsync(obj, suppressEncryption) {
return obj instanceof Ref ? this.fetchAsync(obj, suppressEncryption) : obj;
}
async fetchAsync(ref, suppressEncryption) {
try {
return this.fetch(ref, suppressEncryption);
} catch (ex) {
if (!(ex instanceof MissingDataException)) {
throw ex;
}
await this.pdfManager.requestRange(ex.begin, ex.end);
return this.fetchAsync(ref, suppressEncryption);
}
}fetch 主流程
fetch(ref, suppressEncryption = false) {
if (!(ref instanceof Ref)) {
throw new Error("ref object is not a reference");
}
const num = ref.num;
// The XRef cache is populated with objects which are obtained through
// `Parser.getObj`, and indirectly via `Lexer.getObj`. Neither of these
// methods should ever return `undefined` (note the `assert` calls below).
const cacheEntry = this.#cacheMap.get(num);
if (cacheEntry !== undefined) {
// In documents with Object Streams, it's possible that cached `Dict`s
// have not been assigned an `objId` yet (see e.g. issue3115r.pdf).
if (cacheEntry instanceof Dict && !cacheEntry.objId) {
cacheEntry.objId = ref.toString();
}
return cacheEntry;
}
let xrefEntry = this.getEntry(num);
if (xrefEntry === null) {
// The referenced entry can be free.
return xrefEntry;
}
// Prevent circular references, in corrupt PDF documents, from hanging the
// worker-thread. This relies, implicitly, on the parsing being synchronous.
if (this.#pendingRefs.has(ref)) {
this.#pendingRefs.remove(ref);
warn(`Ignoring circular reference: ${ref}.`);
return CIRCULAR_REF;
}
this.#pendingRefs.put(ref);
try {
xrefEntry = xrefEntry.uncompressed
? this.fetchUncompressed(ref, xrefEntry, suppressEncryption)
: this.fetchCompressed(ref, xrefEntry, suppressEncryption);
this.#pendingRefs.remove(ref);
} catch (ex) {
this.#pendingRefs.remove(ref);
throw ex;
}
if (xrefEntry instanceof Dict) {
xrefEntry.objId = ref.toString();
} else if (xrefEntry instanceof BaseStream) {
xrefEntry.dict.objId = ref.toString();
}
return xrefEntry;
}按状态拆开看,一次查找会落在四种情况之一:
| 状态 | 判断 | 结果 |
|---|---|---|
| 已缓存 present | #cacheMap.get(num) 不是 undefined | 直接返回,必要时补 objId |
| 解析中 pending | #pendingRefs.has(ref) | 返回 CIRCULAR_REF,并移除标记 |
| 不存在 absent | getEntry(num) 为 null | 返回 null(空闲或未记录) |
| 可定位 | entry 有 offset 且非 free | 进入 fetchUncompressed 或 fetchCompressed |
几点说明:
- 缓存优先于 entry。对象一旦解析,后续同一对象号的引用都命中
#cacheMap,不再查 entry、不再读字节。增量更新虽然会让 entry 更新,但旧对象缓存仍然保留;这是 PDF.js 在「文档不变化」前提下做的优化,编辑器改写 PDF 时会显式重置相关缓存。 - 空闲引用返回
null,不是错误。PDF 里引用一个空闲对象是合法的,表示「没有这个对象」;上层拿到null后按缺省值处理。 CIRCULAR_REF是符号哨兵。损坏文件里可能构造出1 0 R指向自身、或两个字典互指的环。#pendingRefs在进入时登记、退出时移除,再次遇到同一个 ref 就说明正在绕圈,返回Symbol("CIRCULAR_REF")让解析继续。注释特别强调这套机制依赖「解析是同步的」——如果fetch中间await,这个栈内标记就不成立。objId补写。从对象流里解析出来的字典在解析时不知道自己对应哪个引用,缓存命中时补上objId;fetch结束后也会为Dict或「流的字典」写objId。调试与循环检测都依赖这个字段。- 流不进缓存。
fetchUncompressed只对非BaseStream写#cacheMap,fetchCompressed遇到流直接continue。流是有读取游标的状态对象,缓存后多个调用方会共享游标;而且解码后的流数据通常很大,长期驻留内存不划算。
设计要点:
fetch是同步的、可重入的、没有 Promise。环检测靠#pendingRefs,记忆化靠#cacheMap,缺字节靠最外层fetchAsync的requestRange重试。三者合起来,让任意深度的对象图都能在一次同步调用里走完;异步只出现在「字节还没到」这一种情况。把「解析逻辑」与「数据加载」切开,是 Worker 端同时支持本地文件与分块网络流的前提。
fetchUncompressed:按偏移读 5 0 obj
普通对象的取回:
fetchUncompressed(ref, xrefEntry, suppressEncryption = false) {
const gen = ref.gen;
let num = ref.num;
if (xrefEntry.gen !== gen) {
const msg = `Inconsistent generation in XRef: ${ref}`;
// Try falling back to a *previous* generation (fixes issue15577.pdf).
if (this._generationFallback && xrefEntry.gen < gen) {
warn(msg);
return this.fetchUncompressed(
Ref.get(num, xrefEntry.gen),
xrefEntry,
suppressEncryption
);
}
throw new XRefEntryException(msg);
}
const stream = this.stream.makeSubStream(
xrefEntry.offset + this.stream.start
);
const parser = new Parser({
lexer: new Lexer(stream),
xref: this,
allowStreams: true,
});
const obj1 = parser.getObj();
const obj2 = parser.getObj();
const obj3 = parser.getObj();
if (obj1 !== num || obj2 !== gen || !(obj3 instanceof Cmd)) {
throw new XRefEntryException(`Bad (uncompressed) XRef entry: ${ref}`);
}
if (obj3.cmd !== "obj") {
// some bad PDFs use "obj1234" and really mean 1234
if (obj3.cmd.startsWith("obj")) {
num = parseInt(obj3.cmd.substring(3), 10);
if (!Number.isNaN(num)) {
return num;
}
}
throw new XRefEntryException(`Bad (uncompressed) XRef entry: ${ref}`);
}
xrefEntry =
this.encrypt && !suppressEncryption
? parser.getObj(this.encrypt.createCipherTransform(num, gen))
: parser.getObj();
if (!(xrefEntry instanceof BaseStream)) {
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
assert(
xrefEntry !== undefined,
'fetchUncompressed: The "xrefEntry" cannot be undefined.'
);
}
this.#cacheMap.set(num, xrefEntry);
}
return xrefEntry;
}逐条拆解:
- generation 必须一致。PDF 的引用带 generation,xref entry 也带 generation,二者不一致说明引用过期。默认抛
XRefEntryException;只有在恢复模式点亮_generationFallback、且 entry 的 generation 更旧时,才用 entry 的 generation 递归重试。这是对「引用写的是新 generation,但文件里只有旧 generation」这类损坏文件的兜底。 - 子流从偏移开始。
makeSubStream(offset + stream.start)不复制字节,只是给同一份底层数据换一个读取起点。这也是 PDF.js 能在网络分块流上工作的原因:子流读到未加载的 chunk 时照样抛MissingDataException。 - 校验
5 0 obj头。连续读三个 token,必须依次等于对象号、generation、obj命令。如果不匹配,说明 xref 偏移指错了位置,直接抛XRefEntryException。 obj1234兼容。有些生成器把命令写成obj1234(后面直接跟对象号),parser 会把它 lex 成一个Cmd。源码截掉obj前缀并返回数字——这实际上返回的是对象号而不是对象,属于历史兼容分支。- 解密。
Encrypt存在且未要求抑制解密时,给 parser 传入CipherTransform,字符串与流内容会按对象号与 generation 派生密钥。suppressEncryption用于 trailer 里的Encrypt字典本身,避免「解密加密字典又需要加密字典」的死循环(源码注释提到 issue7665)。 - 只缓存非流对象。
Dict、数组、数字、字符串都能安全共享;流有游标,缓存会共享状态。
fetchCompressed:解压 ObjStm 再拆包
压缩对象的 entry 里,offset 是对象流对象号、gen 是对象在流内的序号:
fetchCompressed(ref, xrefEntry, suppressEncryption = false) {
const tableOffset = xrefEntry.offset;
const stream = this.fetch(Ref.get(tableOffset, 0));
if (!(stream instanceof BaseStream)) {
throw new FormatError("bad ObjStm stream");
}
const first = stream.dict.get("First");
const n = stream.dict.get("N");
if (!Number.isInteger(first) || !Number.isInteger(n)) {
throw new FormatError("invalid first and n parameters for ObjStm stream");
}
let parser = new Parser({
lexer: new Lexer(stream),
xref: this,
allowStreams: true,
});
const nums = new Array(n);
const offsets = new Array(n);
// read the object numbers to populate cache
for (let i = 0; i < n; ++i) {
const num = parser.getObj();
if (!Number.isInteger(num)) {
throw new FormatError(
`invalid object number in the ObjStm stream: ${num}`
);
}
const offset = parser.getObj();
if (!Number.isInteger(offset)) {
throw new FormatError(
`invalid object offset in the ObjStm stream: ${offset}`
);
}
nums[i] = num;
// The entry in the xref table is the object number followed by the index.
// So if index (gen number) is not the same as the index (i), we fix it
// (fixes bug 1978317).
const entry = this.getEntry(num);
if (entry?.offset === tableOffset && entry.gen !== i) {
entry.gen = i;
}
offsets[i] = offset;
}对象流的布局是「头部表 + 对象体」:
ObjStm 流(解压后)
┌──────────────────────────────┐
│ N=3 First=24 │ 流字典
│ 10 0 11 12 12 25 │ 头部:对象号 + 相对偏移
├──────────────────────────────┤ ← First 指向这里
│ <对象 10><对象 11><对象 12> │ 对象体
└──────────────────────────────┘
First 是第一个对象体相对于流起点的偏移,头部表本身占 First 字节。头部表有 N 对「对象号, 相对偏移」。每个对象的长度由下一个对象的偏移决定,最后一个对象一直读到流末尾:
对象体部分为每个对象建一个子流并解析,解析结果按 entry 校验后写入 #cacheMap;最后按下标取回:
xrefEntry = entries[xrefEntry.gen];
if (xrefEntry === undefined) {
throw new XRefEntryException(`Bad (compressed) XRef entry: ${ref}`);
}
return xrefEntry;
}关键设计:
- 递归取对象流。
this.fetch(Ref.get(tableOffset, 0))取的是 ObjStm 本身。按规范,ObjStm 必须是普通对象(type 1),所以这层递归最多一层,不会出现「ObjStm 里再套 ObjStm」。 - 一次性解包全部对象。虽然当前只需要
xrefEntry.gen指定的那一个,源码仍然把N个对象全部解析并缓存。对象流的意义就是批量压缩,通常同一页的字体、注释、资源都挤在同一个流里,一次解包能顺带命中后续大量引用。 - 修正错误的
gen。xref 流里 type 2 的 field3 本应是对象在 ObjStm 内的序号。有些文件写错,源码用头部表顺序i去修正 entry(bug 1978317)。修正后再用entry.gen === i判断是否缓存,避免把修正前的错误映射写进缓存。 suppressEncryption在这里没有用上。ObjStm 的加密是在取流本身时整体完成的,流内对象不会单独再走CipherTransform;参数保留是为了与fetchUncompressed保持同一签名。这是读源码时容易误会的一个点。- 越界即错误。
entries[xrefEntry.gen]为undefined说明 xref 指向了一个不存在的序号,抛XRefEntryException,由上层决定是否忽略。
trailer、topDict 与根字典
parse 负责把 xref 的产物变成文档可用的 trailer:
parse(recoveryMode = false) {
this.#parsedWithRecovery = recoveryMode;
let trailerDict;
if (!recoveryMode) {
trailerDict = this.readXRef();
} else {
warn("Indexing all PDF objects");
trailerDict = this.indexObjects();
}
trailerDict.assignXref(this);
this.trailer = trailerDict;assignXref(this) 是把 trailer 字典和 XRef 绑定起来。Dict.get 遇到 Ref 值时会直接调 xref.fetch:
value = this.#map.get(key2);
}
if (value instanceof Ref && this.xref) {
return isAsync
? this.xref.fetchAsync(value, this.suppressEncryption)
: this.xref.fetch(value, this.suppressEncryption);
}所以后面 trailerDict.get("Root") 拿到的不是 1 0 R 这个引用,而是真正的 Catalog 字典。同样,Root.get("Pages")、Pages.get("Kids") 一层层取下去,每一次都是懒加载——只有被访问到的节点才会真正解析。这就是「对象模型」能建立在「索引」之上的原因。
topDict 与 trailer 的区别:
| 字段 | 赋值时机 | 含义 |
|---|---|---|
topDict | readXRef 第一个成功解析的段 | 最新修订的 trailer 字典 |
trailer | parse 返回后 | 经过 assignXref 绑定的最终字典 |
topDict ||= dict 只在第一次赋值,后续旧段的 trailer 不会覆盖它。PDF 的约定是最新修订的 trailer 包含完整信息;旧段的 trailer 只用于回溯,不作为最终结果。如果某个生成器把 /Root 只写在旧段里,parse 的 Root 校验会失败,Worker 随即切到恢复模式,由 indexObjects 重新挑选候选。
加密也在这里处理。trailer.get("Encrypt") 可能因为缺字节抛 MissingDataException,此时原样上抛;其它错误只警告。取到 Encrypt 字典后:
if (encrypt instanceof Dict) {
const ids = trailerDict.get('ID');
const fileId = ids?.length ? ids[0] : '';
// The 'Encrypt' dictionary itself should not be encrypted, and by
// setting `suppressEncryption` we can prevent an infinite loop inside
// of `XRef_fetchUncompressed` if the dictionary contains indirect
// objects (fixes issue7665.pdf).
encrypt.suppressEncryption = true;
this.encrypt = new CipherTransformFactory(encrypt, fileId, this.pdfManager.password);
}ID[0] 作为文件标识参与密钥派生;pdfManager.password 是用户输入的口令。Encrypt 字典自身标记 suppressEncryption,防止取它内部的间接对象时又触发解密。
最后 getCatalogObj 只是把校验过的根字典返回:
getCatalogObj() {
return this.root;
}this.root 是在 parse 里通过 Root 与 Pages 双重校验后保存的。真正的 Catalog 类由 PDFDocument.parse 在 xref.parse 之后创建,页面查找(getPage)也在 Catalog / PDFDocument 里完成,XRef 不参与页面树遍历。
惰性、缓存与生命周期
XRef 的性能来自「按需」:解析阶段只建立索引,不解析对象;对象第一次被访问时才读字节。整个链路有三层缓存:
| 层 | 位置 | 缓存内容 |
|---|---|---|
| 字节层 | PDFManager / ChunkedStreamManager | 网络分块、已下载区间 |
| 对象层 | XRef.#cacheMap | 已解析的字典、数组、字符串 |
| 文档层 | Catalog 的各类 cache | 字体、图片、颜色空间、页面字典 |
对象层的缓存没有淘汰策略,随文档存活。这对于阅读场景是合理的:一页渲染完,字体与资源本来就要复用;真正的大对象(流)又没进缓存。用户触发清理时,PDFDocument.cleanup 会转给 Catalog.cleanup:
async cleanup(manuallyTriggered = false) {
return this.catalog
? this.catalog.cleanup(manuallyTriggered)
: clearGlobalCaches();
}Catalog.cleanup 清的是 fontCache、globalImageCache、pageDictCache 等文档级缓存,不触碰 XRef.#cacheMap。XRef 自己也没有 cleanup 或 statistics 方法;indexObjects 开头那两行(清空 #entries、清空 #cacheMap)是它唯一一次主动释放索引与对象缓存。流的生命周期则由 PDFManager 管理:本地文件是内存 Stream,网络文件是 ChunkedStream,terminate 时统一中止。
countUpdatesAfter
唯一带有「统计」意味的方法是 countUpdatesAfter:
countUpdatesAfter(offset) {
if (this.#parsedWithRecovery || !this.#xrefSectionsComplete) {
return null;
}
const relativeOffset = offset - this.stream.start;
let count = 0;
for (const sectionOffset of this.#xrefSectionOffsets) {
if (
sectionOffset >= relativeOffset &&
!this.#xrefStms.has(sectionOffset)
) {
count++;
}
}
return count;
}它统计「某个字节位置之后还有多少个 xref 段」,用于数字签名校验:如果签名覆盖范围之后还存在增量更新段,文档就被修改过。这里有两个保守判断:
- 走了恢复模式(
#parsedWithRecovery)时返回null,因为indexObjects重建的段偏移不代表真实修订结构。 - 有段解析失败(
#xrefSectionsComplete为false)时返回null,因为计数不完整。
#xrefStms 里的偏移被排除,因为混合文件里的 xref 流与主表属于同一个修订,不能算成两次更新。这个细节直接决定了签名校验会不会误报。
总结
XRef 是 PDF.js 的对象寻址层:向上给 Catalog、PDFDocument、PartialEvaluator 提供 fetch(ref),向下把 startxref、xref 表、xref 流、对象流串成一条可容错的读取链路。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字节供给 | PDFManager / Stream | 本地数据、网络分块 | BaseStream |
| 索引建立 | PDFDocument.startXRef → XRef.parse | startxref、xref 段 | #entries、trailer |
| 对象取回 | XRef.fetch | Ref | Dict / 数组 / BaseStream |
| 文档建模 | PDFDocument / Catalog | 根字典、页面树 | Catalog、Page |
| 内容求值 | PartialEvaluator | 页面内容流 | operatorList |
用一句话串起来:PDFManager 提供字节,XRef 把字节变成可按编号访问的对象,Document 与 Evaluator 再把对象组织成页面与绘制指令。
五条设计原则
- 索引与缓存分离。
#entries是磁盘布局的权威,#cacheMap是解析结果的记忆。二者生命周期不同,职责不同;恢复模式可以只重建索引,缓存命中也不需要回查 entry。 - 新段优先,旧段补缺。
readXRef从最新startxref出发,沿/Prev向旧走;#entries[first + i] ??=保证新段不被旧段覆盖。混合文件的/XRefStm只补压缩对象,不抢传统表已有的记录。 - 同步取回,异步补字节。
fetch是同步可重入的,环检测靠#pendingRefs;只有MissingDataException会被fetchAsync接住,调用requestRange后从头重试。解析逻辑与数据加载彻底解耦。 - 容错分层。单个 xref 段坏掉只标记
#xrefSectionsComplete = false;整体不可用时切indexObjects全文扫描;generation 不一致时在恢复模式下回退旧 generation;循环引用返回CIRCULAR_REF而不是卡死。每一层只处理自己能处理的错误。 - 二进制记录按宽度直读。xref 流不走通用对象解析,而是按
/W移位拼接;predictor 与 Flate 解码由PredictorStream在上游完成。几万对象的文档因此少掉大量 token 化开销。
至此,「对象怎么被定位和取回」这一环补齐了。不过这一篇刻意跳过了最底层:12 0 obj 这样的字节到底怎么被切成 token、又怎么被组装成 Dict。下一篇回到 src/core/parser.js,看 Lexer 与 Parser 如何完成「字节 → PDF 对象」;再往后才轮到 Catalog 把一堆字典组织成页面树、命名树与资源表。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- 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 深入浅出 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日