StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 10: XRef

2026年10月26日 · StreamPDF Team

PDF.js 深入浅出 10: XRef

前言

这是 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 号对象在哪里。答案有两种:

  1. 在文件某个字节偏移处,直接解析;
  2. 在某个对象流(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
#cacheMapMap对象缓存,键是对象号,值是已解析对象
#pendingRefsRefSet正在解析中的引用,用于检测循环引用
#xrefSectionOffsetsSet已成功解析的 xref 段偏移
#xrefStmsSet已加入队列的 xref 流偏移,防止重复解析
#xrefSectionsCompleteboolean是否所有 xref 段都完整解析
#parsedWithRecoveryboolean是否走了 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 里的用途
streamBaseStream所有字节读取;makeSubStream 从任意偏移开始解析
pdfManagerBasePdfManager读取 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;
  }
}

三个细节:

  1. 从后往前、按 1024 字节步进。startxref 一定在文件尾部附近,但可能被垃圾字节、注释或多次增量更新推得比较远。反向扫描比从头扫快得多,find(..., true) 的最后一个参数就是「反向查找」。
  2. 只读数字字符。跳过 startxref 后跳过空白,然后只接受 0x20 到 0x39 的字符。读到 %%EOF 的 % 就停。
  3. 解析失败就置 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 分支只做三件事:

  1. MissingDataException 原样上抛。它不是「文件坏了」,而是「这段字节还没加载」,必须让上层补数据后重试。
  2. 其它异常只打一条 info 日志,并把 #xrefSectionsComplete 置为 false。
  3. 继续处理队列里的下一段。

这意味着单个损坏的 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;
}

逐段解释:

  1. 恢复位置。stream.pos、parser.buf1、parser.buf2 三个状态一起恢复。只恢复流位置不够——parser 是流式 lexer,可能已经预读了下一条记录的两个 token。
  2. 外层按 subsection 循环。每个 subsection 头部是「起始对象号 + 数量」,例如 23 2 表示从 23 号开始有 2 条记录。firstEntryNum 与 entryCount 只在 subsection 开始时读取;中断重入时若这两个字段还在,就直接跳过读取,避免把已经消耗的头部再读一次。
  3. 内层按记录循环。每条记录固定三段:偏移、generation、f 或 n。entryNum 记录当前处理到第几条,重入时从它继续。
  4. 类型校验。偏移与 generation 必须是整数,并且 f、n 至少命中一个;否则整张表判为损坏。
  5. 首对象修正。规范要求 0 号对象是空闲的,但有些生成器把第一条写成 1 号起始。当 i === 0、entry 为 free、first === 1 时,源码把 first 改成 0,修正下标。这是针对 issue 3248 与 7229 的兼容。
  6. ??= 写入。新段先解析,旧段后解析,??= 保证新段赢。
  7. 读完一整段后清空 subsection 头字段,下一轮循环继续读下一个 subsection,直到读到 trailer 命令才 break。
  8. 末尾校验。如果 #entries[0] 存在且不是 free,说明文件结构不符合规范,抛 FormatError。

传统表只能产生 free 与 uncompressed 两种 entry,无法表达压缩对象。压缩对象只能靠 xref 流或对象流索引(见下一节与 fetchCompressed)。

传统表与 xref 流对照

维度传统 xref 表xref 流
引入版本PDF 1.0PDF 1.5
形态文本关键字 + 十进制记录二进制流对象
入口startxref 指向 xref 关键字startxref 指向 N 0 obj
记录字段offset、generation、f / ntype、field2、field3
压缩对象不支持type 2 指向 ObjStm
末尾字典trailer 后的字典流字典本身
混合文件角色主索引,trailer 可带 /XRefStm被传统表引用,补充压缩 entry
读取方法readXRefTablereadXRefStream

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);
    }
  }

几个容易忽略的点:

  1. 宽度可以为 0。type 字段宽度为 0 时,整行没有 type 字节,规范规定默认值为 1(普通对象)。这是为了兼容只记录 offset 与 generation 的老式写入器。
  2. 偏移可以超过 4 字节。循环按宽度累积,(offset << 8) | byte 最多能拼出 8 字节的安全整数,不会把大文件截断。
  3. 读到文件尾会抛错。stream.getByte() 返回 -1 时分别抛出 'type'、'offset'、'generation' 三种错误,方便定位是哪个字段不完整。
  4. type 2 不设置任何布尔值。free 与 uncompressed 都保持 false,于是 entry 自然落入「压缩对象」分支,fetch 会调用 fetchCompressed。
  5. ??= 仍然是新段优先。与传统表完全一致。

/W、/Index 与字段含义

字段宽度来源含义缺省行为
typeW[0]0 空闲、1 普通、2 压缩宽度为 0 时按 1 处理
field2W[1]type 0:下一个空闲对象号;type 1:文件偏移;type 2:ObjStm 对象号全 0
field3W[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,并移除标记
不存在 absentgetEntry(num) 为 null返回 null(空闲或未记录)
可定位entry 有 offset 且非 free进入 fetchUncompressed 或 fetchCompressed

几点说明:

  1. 缓存优先于 entry。对象一旦解析,后续同一对象号的引用都命中 #cacheMap,不再查 entry、不再读字节。增量更新虽然会让 entry 更新,但旧对象缓存仍然保留;这是 PDF.js 在「文档不变化」前提下做的优化,编辑器改写 PDF 时会显式重置相关缓存。
  2. 空闲引用返回 null,不是错误。PDF 里引用一个空闲对象是合法的,表示「没有这个对象」;上层拿到 null 后按缺省值处理。
  3. CIRCULAR_REF 是符号哨兵。损坏文件里可能构造出 1 0 R 指向自身、或两个字典互指的环。#pendingRefs 在进入时登记、退出时移除,再次遇到同一个 ref 就说明正在绕圈,返回 Symbol("CIRCULAR_REF") 让解析继续。注释特别强调这套机制依赖「解析是同步的」——如果 fetch 中间 await,这个栈内标记就不成立。
  4. objId 补写。从对象流里解析出来的字典在解析时不知道自己对应哪个引用,缓存命中时补上 objId;fetch 结束后也会为 Dict 或「流的字典」写 objId。调试与循环检测都依赖这个字段。
  5. 流不进缓存。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;
  }

逐条拆解:

  1. generation 必须一致。PDF 的引用带 generation,xref entry 也带 generation,二者不一致说明引用过期。默认抛 XRefEntryException;只有在恢复模式点亮 _generationFallback、且 entry 的 generation 更旧时,才用 entry 的 generation 递归重试。这是对「引用写的是新 generation,但文件里只有旧 generation」这类损坏文件的兜底。
  2. 子流从偏移开始。makeSubStream(offset + stream.start) 不复制字节,只是给同一份底层数据换一个读取起点。这也是 PDF.js 能在网络分块流上工作的原因:子流读到未加载的 chunk 时照样抛 MissingDataException。
  3. 校验 5 0 obj 头。连续读三个 token,必须依次等于对象号、generation、obj 命令。如果不匹配,说明 xref 偏移指错了位置,直接抛 XRefEntryException。
  4. obj1234 兼容。有些生成器把命令写成 obj1234(后面直接跟对象号),parser 会把它 lex 成一个 Cmd。源码截掉 obj 前缀并返回数字——这实际上返回的是对象号而不是对象,属于历史兼容分支。
  5. 解密。Encrypt 存在且未要求抑制解密时,给 parser 传入 CipherTransform,字符串与流内容会按对象号与 generation 派生密钥。suppressEncryption 用于 trailer 里的 Encrypt 字典本身,避免「解密加密字典又需要加密字典」的死循环(源码注释提到 issue7665)。
  6. 只缓存非流对象。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;
  }

关键设计:

  1. 递归取对象流。this.fetch(Ref.get(tableOffset, 0)) 取的是 ObjStm 本身。按规范,ObjStm 必须是普通对象(type 1),所以这层递归最多一层,不会出现「ObjStm 里再套 ObjStm」。
  2. 一次性解包全部对象。虽然当前只需要 xrefEntry.gen 指定的那一个,源码仍然把 N 个对象全部解析并缓存。对象流的意义就是批量压缩,通常同一页的字体、注释、资源都挤在同一个流里,一次解包能顺带命中后续大量引用。
  3. 修正错误的 gen。xref 流里 type 2 的 field3 本应是对象在 ObjStm 内的序号。有些文件写错,源码用头部表顺序 i 去修正 entry(bug 1978317)。修正后再用 entry.gen === i 判断是否缓存,避免把修正前的错误映射写进缓存。
  4. suppressEncryption 在这里没有用上。ObjStm 的加密是在取流本身时整体完成的,流内对象不会单独再走 CipherTransform;参数保留是为了与 fetchUncompressed 保持同一签名。这是读源码时容易误会的一个点。
  5. 越界即错误。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 的区别:

字段赋值时机含义
topDictreadXRef 第一个成功解析的段最新修订的 trailer 字典
trailerparse 返回后经过 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.parsestartxref、xref 段#entries、trailer
对象取回XRef.fetchRefDict / 数组 / BaseStream
文档建模PDFDocument / Catalog根字典、页面树Catalog、Page
内容求值PartialEvaluator页面内容流operatorList

用一句话串起来:PDFManager 提供字节,XRef 把字节变成可按编号访问的对象,Document 与 Evaluator 再把对象组织成页面与绘制指令。

五条设计原则

  1. 索引与缓存分离。#entries 是磁盘布局的权威,#cacheMap 是解析结果的记忆。二者生命周期不同,职责不同;恢复模式可以只重建索引,缓存命中也不需要回查 entry。
  2. 新段优先,旧段补缺。readXRef 从最新 startxref 出发,沿 /Prev 向旧走;#entries[first + i] ??= 保证新段不被旧段覆盖。混合文件的 /XRefStm 只补压缩对象,不抢传统表已有的记录。
  3. 同步取回,异步补字节。fetch 是同步可重入的,环检测靠 #pendingRefs;只有 MissingDataException 会被 fetchAsync 接住,调用 requestRange 后从头重试。解析逻辑与数据加载彻底解耦。
  4. 容错分层。单个 xref 段坏掉只标记 #xrefSectionsComplete = false;整体不可用时切 indexObjects 全文扫描;generation 不一致时在恢复模式下回退旧 generation;循环引用返回 CIRCULAR_REF 而不是卡死。每一层只处理自己能处理的错误。
  5. 二进制记录按宽度直读。xref 流不走通用对象解析,而是按 /W 移位拼接;predictor 与 Flate 解码由 PredictorStream 在上游完成。几万对象的文档因此少掉大量 token 化开销。

至此,「对象怎么被定位和取回」这一环补齐了。不过这一篇刻意跳过了最底层:12 0 obj 这样的字节到底怎么被切成 token、又怎么被组装成 Dict。下一篇回到 src/core/parser.js,看 Lexer 与 Parser 如何完成「字节 → PDF 对象」;再往后才轮到 Catalog 把一堆字典组织成页面树、命名树与资源表。

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

相关文章