StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 6: initDocument

2026年10月25日 · StreamPDF Team

PDF.js 深入浅出 6: initDocument

前言

initDocument 会开始解析 PDF 的一些数据, 所以本篇文章将触及到 PDF 的二进制流数据, 本文不仅仅涉及到 PDF.js, 还会涉及到了 PDF 的文件格式规范.

该方法的调用链请参考本系列的第二篇文章, 其在 https://streampdf.com/blog/pdfjs-deep-dive-2-get_document-get_page#:~:text=Entering%20Worker%3A%20on%20Ready%20%E2%86%92%20pdfManagerReady%20%E2%86%92%20loadDocument 章节调用到这里.

initDocument

async initDocument(recoveryMode) {
  await this.ensureDoc("checkHeader");
  await this.ensureDoc("parseStartXRef");
  await this.ensureDoc("parse", [recoveryMode]);
 
  // Check that at least the first page can be successfully loaded,
  // since otherwise the XRef table is definitely not valid.
  await this.ensureDoc("checkFirstPage", [recoveryMode]);
  // Check that the last page can be successfully loaded, to ensure that
  // `numPages` is correct, and fallback to walking the entire /Pages-tree.
  await this.ensureDoc("checkLastPage", [recoveryMode]);
}

checkHeader

进入 initDocument -> PDFDocument.checkHeader

// Find the header, get the PDF format version and setup the
// stream to start from the header.
checkHeader() {
  const stream = this.stream;
  stream.reset();
 
  if (!find(stream, PDF_HEADER_SIGNATURE)) {
    // May not be a PDF file, but don't throw an error and let
    // parsing continue.
    return;
  }
  stream.moveStart();
 
  // Skip over the "%PDF-" prefix, since it was found above.
  stream.skip(PDF_HEADER_SIGNATURE.length);
  // Read the PDF format version.
  let version = "",
    ch;
  while (
    (ch = stream.getByte()) > /* Space = */ 0x20 &&
    version.length < /* MAX_PDF_VERSION_LENGTH = */ 7
  ) {
    version += String.fromCharCode(ch);
  }
 
  if (PDF_VERSION_REGEXP.test(version)) {
    this.#version = version;
  } else {
    warn(`Invalid PDF header version: ${version}`);
  }
}

一个 PDF 文件会有一个 PDF 头, 用于标识 PDF 文件的开始, 并且会包含 PDF 文件的版本信息.

空口无凭, 我们手工构造一个最简单但完全合法的 PDF 文件. 它只有一页, 且这一页什么都不画, 全部 329 字节, 用文本编辑器就能写出来:

%PDF-1.4
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
186
%%EOF

把这份文件喂给 PDF.js, 它能正常解析: numPages 为 1, 页面尺寸 612 x 792 (单位是 pt, 72 pt = 1 英寸, 也就是 US Letter 纸张大小), 报告的版本为 1.4.

header

文件的前 9 个字节就是上文 checkHeader 要找的东西. 用十六进制查看:

25 50 44 46 2d 31 2e 34 0a    %PDF-1.4\n

find(stream, PDF_HEADER_SIGNATURE) 会在文件开头 (默认前 1024 字节) 内扫描这 5 个字节的签名 25 50 44 46 2d, 即 %PDF-. 找到签名后, 跳过 %PDF- 前缀, 继续读出 1.4 存入 this.#version. 版本号声明了该文件遵循哪个版本的规范, 阅读器据此决定可以启用哪些特性.

%PDF- 头部由 PDF 规范的文件结构章节定义: ISO 32000-1:2008 的 7.5.2 "File Header" (印刷页 39) 规定 "The first line of a PDF file shall be a header consisting of the 5 characters %PDF- followed by a version number of the form 1.N". 不过规范说的是 "第一行", 而 Adobe 的 PDF Reference 在附录 H 的实现注释 13 (印刷页 1102, 注解 3.4.1 "File Header" 一节) 里补充了实现层面的宽容: "Acrobat viewers require only that the header appear somewhere within the first 1024 bytes of the file" —— 因为一些工具会在文件最前面拼接自己的内容. find 的 1024 字节扫描限制正是沿袭了 Acrobat 的这一行为.

parseStartXRef

get startXRef() {
  const stream = this.stream;
  let startXRef = 0;
 
  if (this.linearization) {
    // Find the end of the first object.
    stream.reset();
    if (find(stream, ENDOBJ_SIGNATURE)) {
      stream.skip(6);
 
      let ch = stream.peekByte();
      while (isWhiteSpace(ch)) {
        stream.pos++;
        ch = stream.peekByte();
      }
      startXRef = stream.pos - stream.start;
    }
  } else {
    // 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;
      }
    }
  }
  return shadow(this, "startXRef", startXRef);
}

parseStartXRef 方法本身只有一行, 真正的逻辑都在上面的 startXRef getter 里 —— 它的任务是回答一个问题: 从哪个偏移开始解析 xref.

// Source: src/core/document.js
parseStartXRef() {
  this.xref.setStartXRef(this.startXRef);
}

为什么要从文件尾部找

PDF 支持增量更新: 每次更新都把新的 xref 和 trailer 追加到文件尾部, 因此最后一个 startxref 指向的才是最新状态. ISO 32000-1:2008 的 7.5.5 "File Trailer" (印刷页 42) 甚至明说了阅读器的正确姿势: "Conforming readers should read a PDF file from its end."

倒序窗口扫描

看非线化分支的扫描循环: step = 1024, STARTXREF_SIGNATURE 是 9 个字节的 startxref (73 74 61 72 74 78 72 65 66). 每轮 pos -= step - startXRefLength, 即窗口每次向左挪 1015 字节, 相邻窗口重叠 9 字节 —— 恰好是签名的长度. 这是为了防止 startxref 恰好横跨两个窗口时被漏掉: 无论签名卡在哪个位置, 必有某个完整窗口包含它. find 的第四个参数 backwards = true 表示从窗口右端往左找, 找到后把 stream.pos 定位到签名起始处.

随后 stream.skip(9) 跳过签名, 跳过空白符, 把后续字节收集进 str (条件 ch >= 0x20 && ch <= 0x39 覆盖空白和数字), 最后 parseInt 得到偏移. 若文件尾部没有合法的 startxref, 返回 0 —— 偏移 0 处是文件头而非 xref, 下一节 parse 必然失败, 从而触发系列第二篇讲过的 recoveryMode 全文件重建索引.

拿我们 329 字节的最小 PDF 对一遍账: 文件一轮扫描就命中, startxref 关键字位于偏移 309, 读出的整数是 186, 正是上一节手工排布的 xref 表偏移:

偏移:  186     275       309         319  323
       xref    trailer   startxref   186  %%EOF
       ^                             |
       └───────── 186 指回这里 ──────┘

线性化分支

this.linearization 由 Linearization.create 计算: 在文件头原位解析前几个 token, 若恰好构成 N G obj << /Linearized 1 /L 文件总长 ... >> 且 /L 等于文件实际长度, 则认定这是一个线性化 PDF (Linearized PDF, 俗称"快速 Web 视图"), 否则返回 null.

线性化是 PDF 规范为网络场景设计的文件布局 (ISO 32000-1:2008 Annex F): 第一页所需的数据被搬到文件最前面, 线性化参数字典就是文件里的第一个对象, 紧跟其后的就是 first-page xref. 所以线性化文件无需去尾部找起点 —— 找到第一个 endobj (ENDOBJ_SIGNATURE, 6 字节, 仍在前 1024 字节内), 跳过它和空白, 当前位置就是 first-page xref 的偏移. 阅读器只需下载文件头部就能渲染第一页, 这也是 StreamPDF 流式加载最理想面对的文件形态.

shadow: 只算一次

最后 return shadow(this, "startXRef", startXRef). shadow 是 util.js 里的工具函数, 用 Object.defineProperty 把计算结果固化为实例自身的同名属性, 后续访问直接命中属性而不再进入 getter. 扫描文件尾部是有成本的, 而偏移不会变, 缓存一次即可. PDF.js 的惰性解析几乎都靠这个函数, 前文的 linearization getter 也是同样写法.

parse

parse(recoveryMode) {
  this.xref.parse(recoveryMode);
  this.catalog = new Catalog(this.pdfManager, this.xref);
}

PDFDocument.parse 做两件事: 让 XRef 解析交叉引用表, 然后基于它构建 Catalog. 重头戏在 XRef.parse (src/core/xref.js):

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;
 
  // ... 检查 "Encrypt" → 构建 CipherTransformFactory ...
 
  // ... 检查 "Root" → 必须能读到 /Pages 才算通过 ...
  if (!recoveryMode) {
    throw new XRefParseException();
  }
  throw new XRefParseException("Invalid PDF structure");
}

readXRef: 沿 /Prev 链回溯

parseStartXRef 往 startXRefQueue 里放了一个偏移 (最后一个 startxref), readXRef 就是消费这个队列的循环 (src/core/xref.js#L732):

  • 跳到偏移处开始解析: 如果读到 xref 关键字, 按经典的 xref 表处理 (我们手工构造的例子就是这种); 如果读到整数, 按 PDF 1.5 引入的交叉引用流 (xref stream) 处理, 它本质上是个压缩的对象流;
  • 解析完当前 section 后, 把 trailer 字典里的 /Prev (上一代更新的 xref 偏移) 压回队列, 继续循环 —— 这样就从最新的 xref 一路回溯到文件最初的状态; 顺便还兼容了不合规的 /Prev NNN 0 R 写法, 以及混合引用文件的 /XRefStm;
  • 已解析过的偏移会记入缓存, 防止损坏文件的 /Prev 成环导致死循环;
  • 最终返回最顶层的 (最新的) trailer 字典.

收尾: Encrypt 与 Root

拿到 trailer 字典后, XRef.parse 做两个关键检查:

  1. /Encrypt: 存在则用文件 /ID 和用户密码构建 CipherTransformFactory, 后续所有字符串和流都要经它解密;
  2. /Root: 取出 Catalog 字典, 且必须能从中读到 /Pages —— 页面树是文档的命脉, 读不到就等于文档结构无效.

任一检查失败且当前不是 recoveryMode, 就抛出 XRefParseException. 这个异常会沿着调用链一路抛回 Worker 层的 loadDocument, 也就是系列第二篇讲过的: 请求缺失数据、以 recoveryMode = true 重试, indexObjects() 放弃 xref 索引、从头到尾扫描整个文件把每个 N G obj ... endobj 都编上号. 换句话说, xref 表只是加速结构, 丢了也能重建, 这正是 PDF.js 容错能力的基础.

Catalog

回到 PDFDocument.parse, xref 解析成功后 new Catalog(this.pdfManager, this.xref) 只是包住了 xref 引用, 并不立刻遍历页面树 —— /Pages 的展开是惰性的, 推迟到 getPage 时才发生. 而下一节的 checkFirstPage 会在初始化阶段就验证第一页真的能加载出来.

checkFirstPage

async checkFirstPage(recoveryMode = false) {
  if (recoveryMode) {
    return;
  }
  try {
    await this.getPage(0);
  } catch (reason) {
    if (reason instanceof XRefEntryException) {
      // Clear out the various caches to ensure that we haven't stored any
      // inconsistent and/or incorrect state, since that could easily break
      // subsequent `this.getPage` calls.
      this.#pagePromises.delete(0);
      await this.cleanup();
 
      throw new XRefParseException();
    }
  }
}

XRef.parse 的成功只代表 xref 表自洽, 不代表它正确. checkFirstPage 的存在来自一个朴素的洞察: 有些损坏的 PDF, xref 表看起来完全合法, parse 一路绿灯, 直到真正去取对象时才爆雷. 这个检查由 commit 60bcce184 (2018, 修复 issue 7496 和 10326) 引入, 当时的两种踩雷案例:

  • 编辑器修改了文件却没有同步更新 xref 表 (issue 7496);
  • 生成器按偏移而不是按对象号给 xref 表排序, 条目全错位 (issue 10326).

与其等用户渲染时崩溃, 不如加载阶段就拿第一页当试金石. 选第一页的理由 commit 里写得很直白: 查看器的默认场景下, 第一页加载不出来等于什么都干不了 (BaseViewer.setDocument 完全依赖第一页), 而盲目挑一个没有语义的对象来验证没有意义. 就算第一页通过了也不能保证整个 xref 有效, 但连第一页都过不了, 基本可以断定文档无效.

代码走读

async checkFirstPage(recoveryMode = false) {
  if (recoveryMode) {
    return; // 已经全文件重建过索引, 无需再验证
  }
  try {
    await this.getPage(0); // 注意: Worker 层用 0 起始的 pageIndex, API 层才是从 1 开始的 pageNumber
  } catch (reason) {
    if (reason instanceof XRefEntryException) {
      // Clear out the various caches to ensure that we haven't stored any
      // inconsistent and/or incorrect state, since that could easily break
      // subsequent `this.getPage` calls.
      this.#pagePromises.delete(0);
      await this.cleanup();
 
      throw new XRefParseException();
    }
  }
}

this.getPage(0) 与用户调用走完全相同的链路: 查 #pagePromises 缓存 → 线性化文件走 #getLinearizationPage, 否则 catalog.getPageDict(0) 沿页面树逐层查找 → new Page(...). 这条链路会按 xref 条目真实地 fetch 对象, 正是验证目的所在.

XRefEntryException 由 xref 的取对象阶段抛出, 典型场景 (src/core/xref.js):

  • 条目里的代数与请求的代数不一致 (L914, 之前还有一个 _generationFallback 机制会先尝试回退到旧代数);
  • seek 到条目偏移处, 读到的不是 num gen obj 三元组 (L928) —— 上述两个 issue 就是这种;
  • 压缩 xref 流里的条目损坏 (L1037).

为什么失败后要先清缓存

细节在 getPage 的实现里: promise 在进入回调前就已写入 #pagePromises (L1720), 失败后这个 rejected promise 会一直留在缓存里 —— 不删掉的话, recoveryMode 重建索引后再次调用 getPage(0) 会永远命中同一次失败. 所以先 #pagePromises.delete(0), 再 cleanup() 清掉 catalog 层及各全局缓存, 避免半解析状态污染后续调用. 最后升级成 XRefParseException 抛出, 接回系列第二篇讲过的 recoveryMode 重试链路.

另外注意 catch 只拦截 XRefEntryException, 其他异常 (例如流式加载时数据尚未就绪) 在这里被直接忽略 —— 这个方法只回答一个问题: xref 条目可信吗.

成本方面, commit 的测算结论是: 几乎没有额外解析 (最多遍历一部分页面树), 但会提前拉取更多数据, 略微推迟 getDocument 完成的时机; 而由于 getPage 的结果已缓存, 用户随后真正的第一次 getPage(0) 是免费的.

checkLastPage

async checkLastPage(recoveryMode = false) {
  const { catalog, pdfManager } = this;
 
  catalog.setActualNumPages(); // Ensure that it's always reset.
  let numPages;
 
  try {
    await Promise.all([
      pdfManager.ensureDoc("xfaFactory"),
      pdfManager.ensureDoc("linearization"),
      pdfManager.ensureCatalog("numPages"),
    ]);
 
    if (this.xfaFactory) {
      return; // The Page count is always calculated for XFA-documents.
    } else if (this.linearization) {
      numPages = this.linearization.numPages;
    } else {
      numPages = catalog.numPages;
    }
 
    if (!Number.isInteger(numPages)) {
      throw new FormatError("Page count is not an integer.");
    } else if (numPages <= 1) {
      return;
    }
    await this.getPage(numPages - 1);
  } catch (reason) {
    // Clear out the various caches to ensure that we haven't stored any
    // inconsistent and/or incorrect state, since that could easily break
    // subsequent `this.getPage` calls.
    this.#pagePromises.delete(numPages - 1);
    await this.cleanup();
 
    if (reason instanceof XRefEntryException && !recoveryMode) {
      throw new XRefParseException();
    }
    warn(`checkLastPage - invalid /Pages tree /Count: ${numPages}.`);
 
    let pagesTree;
    try {
      pagesTree = await catalog.getAllPageDicts(recoveryMode);
    } catch (reasonAll) {
      if (reasonAll instanceof XRefEntryException && !recoveryMode) {
        throw new XRefParseException();
      }
      catalog.setActualNumPages(1);
      return;
    }
 
    for (const [pageIndex, [pageDict, ref]] of pagesTree) {
      let promise;
      if (pageDict instanceof Error) {
        promise = Promise.reject(pageDict);
 
        // Prevent "uncaught exception: Object"-messages in the console.
        promise.catch(() => {});
      } else {
        promise = Promise.resolve(
          new Page({
            pdfManager,
            xref: this.xref,
            pageIndex,
            pageDict,
            ref,
            globalIdFactory: this._globalIdFactory,
            fontCache: catalog.fontCache,
            builtInCMapCache: catalog.builtInCMapCache,
            standardFontDataCache: catalog.standardFontDataCache,
            globalColorSpaceCache: this.globalColorSpaceCache,
            globalImageCache: catalog.globalImageCache,
            systemFontCache: catalog.systemFontCache,
            nonBlendModesSet: catalog.nonBlendModesSet,
            xfaFactory: null,
          })
        );
      }
 
      this.#pagePromises.set(pageIndex, promise);
    }
    catalog.setActualNumPages(pagesTree.size);
  }
}

checkLastPage 是 initDocument 的最后一步, 故事线与 checkFirstPage 一脉相承 —— 它正是 commit d0c4bbd82 (2021, 修复 issue 14303) 引入的, commit message 开头就自我介绍: "This patch basically extends the approach from PR 10392, by also checking the last page" (PR 10392 就是上一节的 checkFirstPage).

要验的东西不同: checkFirstPage 验的是 xref 条目, checkLastPage 验的是页面树根节点的 /Count. catalog.numPages 长期以来无条件信任它 (src/core/catalog.js#L753):

get numPages() {
  return this.#actualNumPages ?? this._pagesCount; // _pagesCount 就是 /Count
}

但 issue 14303 的样本证明 /Count 可以完全错误 —— 页面树里根本没有那么多页. 错误的 numPages 会让后续解析在 Worker 线程里崩坏甚至挂死浏览器. 而验证它的办法 commit 里也写得明白: "parse the entire /Pages-tree and essentially counting the pages" —— 没有捷径, 只能数.

happy path: 相信 /Count 的前提下

catalog.setActualNumPages(); // Ensure that it's always reset.

开头先重置 #actualNumPages —— 因为 recoveryMode 重试会再跑一次 checkLastPage, 上一次失败留下的真实页数必须先清掉.

随后三路并行拉取 xfaFactory、linearization、numPages (ensureDoc/ensureCatalog 负责处理 MissingDataException, 也就是系列第二篇的 range request 重试), 然后按优先级取页数:

  • XFA 文档: 页数由 XFA 表单机制计算, /Count 不作数, 直接 return;
  • 线性化文件: 线性化参数字典自带页数提示, 信它, 免得动页面树;
  • 其余: 用 catalog.numPages (即 /Count).

numPages <= 1 时直接 return —— 唯一的那一页已经在前一步被 checkFirstPage 真实验证过了. 页数大于 1, 就 await this.getPage(numPages - 1), 把最后一页真实加载一次: 页面树是逐层展开的, 树里任何一环损坏都会在这条链路上爆出来.

catch: /Count 撒谎之后

catch 里的逻辑分两层. 第一层和 checkFirstPage 相同: 清缓存 (#pagePromises.delete(numPages - 1) + cleanup()), 遇到 XRefEntryException 且非 recoveryMode 就升级成 XRefParseException —— 条目级损坏可能意味着 xref 表整体不可信.

第二层是真正的兜底: 若失败原因是 /Count 本身不可靠 (FormatError("Page count is not an integer.") 或按 /Count 走不到最后一页), warn 之后干脆放弃 /Count, 改为数页面树:

pagesTree = await catalog.getAllPageDicts(recoveryMode);

getAllPageDicts: 把整棵页面树数一遍

catalog.js#L1441 的 getAllPageDicts 用一个显式栈 (queue + posInKids) 深度遍历整棵 /Pages 树, visitedNodes 记录访问过的节点引用防环, 产出一个 Map: pageIndex → [pageDict, pageRef]. 两个细节值得注意:

  • 单页损坏不拖垮全树: addPageError 把出错的条目作为 [error, null] 存进 Map, 而不是中断遍历; 唯独 XRefEntryException 且非 recoveryMode 时才向上抛 (交给外层升级成 XRefParseException). recoveryMode + ignoreErrors 时第一页出错甚至会被替换成空字典, 保证 viewer 无论如何都能打开 (issue15590.pdf);
  • 顺带做索引: 填充 pageIndexCache (Ref → pageIndex), 后续 getPageIndex 这类反向查询可以直接命中.

如果连全树遍历都失败且非 recoveryMode, 同样升级为 XRefParseException; 否则 setActualNumPages(1) —— 极端损坏时摆烂, 就当文档只有一页.

预热: 从惰性到一次性展开

遍历成功的收尾是把结果预热进缓存:

for (const [pageIndex, [pageDict, ref]] of pagesTree) {
  let promise;
  if (pageDict instanceof Error) {
    promise = Promise.reject(pageDict);
    promise.catch(() => {}); // 防 unhandled rejection 警告
  } else {
    promise = Promise.resolve(
      new Page({
        /* ... */
      }),
    );
  }
  this.#pagePromises.set(pageIndex, promise);
}
catalog.setActualNumPages(pagesTree.size);

所有页面 (包括失败的, 以 rejected promise 的形式) 一次性填进 #pagePromises, 并以真实清点结果 pagesTree.size 覆盖页数. 回看上面的 numPages getter —— #actualNumPages ?? _pagesCount, 从此 numPages 返回的是数出来的真话, 而 document.numPages 会优先采用它. 有趣的对比: 正常 PDF 的页面树全程惰性 (目录树按需展开), 只有 /Count 撒谎的坏文件才会被一次性展开预热 —— 健康时懒惰, 生病时体检.

至此 initDocument 五步走完: 版本号就位、xref 起点锁定、交叉引用解析、第一页试金石、页数可信. 文档结构就绪, GetDocRequest 可以返回给主线程了.

总结

本篇我们逐一走完了 initDocument 的五个步骤(完整流程图如下):

initDocument 全流程图

  • checkHeader → 前 1024 字节内找到 %PDF- 签名,读出 1.N 版本号存入 #version(ISO 32000-1 §7.5.2,1024 字节的宽容来自 Adobe 实现注释 13)。
  • parseStartXRef → 线性化文件从第一个 endobj 之后取 first-page xref;普通文件从文件尾倒序扫描 startxref。增量更新决定了必须从尾部读(§7.5.5)。
  • parse → readXRef 沿 /Prev 链回溯各代 xref 得到 trailer,检查 /Encrypt 与 /Root → /Pages;失败则 recoveryMode 下 indexObjects() 全文件重建索引。
  • checkFirstPage → getPage(0) 当试金石,验证 xref 条目真实可用(commit 60bcce184)。
  • checkLastPage → 验证最后一页;/Count 撒谎就用 getAllPageDicts 数整棵树,以真实页数覆盖 numPages(commit d0c4bbd82)。

五步背后是三条一以贯之的设计原则:

  1. 从尾部读。增量更新把最新状态追加在文件尾,xref 起点、trailer、页数的权威来源都在尾部,所以整条初始化链路是「从尾向头」的。
  2. 信任但验证。xref 表自洽不代表正确,/Count 是整数不代表页数对。两道 check 都用同一个笨办法——真实取一次对象——来验证声明,宁可初始化多花一点时间,也不把坏状态留给渲染阶段。
  3. 永远有 Plan B。xref 表可以全文件扫描重建(indexObjects),页数可以整树数出来(getAllPageDicts),极端情况下甚至把文档当作只有一页。健康的文件走快路,损坏的文件走慢路,但总能打开。

回到 StreamPDF 关心的流式场景:线性化文件是理想形态——读完文件头部就能渲染第一页;而普通文件至少要等尾部字节到位才能锁定 xref 起点,这正是上一篇 range request 分段加载如此重要的原因。

下一篇,我们深入 parse 留下的细节:xref 表与 xref stream 的两种形态。

相关文章