StreamPDFStreamPDF
返回博客

PDF.js 源码解读: pdf_manager

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: pdf_manager

前言

src/core/pdf_manager.js 只有 244 行,但它决定了 Worker 里「字节从哪里来」这件事。PDFDocument、XRef、Parser、Catalog 全都只认一个东西:一个可以同步读的 Stream。文件可能来自内存里的 ArrayBuffer,也可能来自按范围请求的网络响应;manager 的职责就是把这两种来源抹平成同一个 Stream,再给上层提供统一的异步访问接口。

这层抽象解决的是一个结构性矛盾:

层期望的读取方式现实
XRef / Parser同步、随机访问网络分块可能还没到
网络传输异步、按需一次只能取一段,有延迟
PDFDocument不关心来源需要同时支持内存与流式两种场景

BasePdfManager 把「缺字节」包装成 MissingDataException,把「补字节」包装成 requestRange,再用 ensure 把两者串起来。上层看到的仍然是一次普通的属性读取或方法调用;重试、等待、分块全部发生在 manager 内部。

本篇按当前版本的真实实现讲,包括一个与旧版接口不同的地方:这个文件已经不再转发 numPages、pdfInfo、getData 这类成员,抽象接口被收缩到了真正需要多态的几个方法上。

模块总览

文件由三部分组成:一个 URL 辅助函数、一个基类、两个子类。

组成类别职责
parseDocBaseUrl模块函数校验并规范化文档基准 URL
BasePdfManager抽象基类保存选项、提供通用委托、定义必须实现的方法
LocalPdfManager子类直接持有一个内存 Stream
NetworkPdfManager子类持有 ChunkedStreamManager 与 ChunkedStream

基类的成员可以按「谁来实现」分成四组:

分组成员说明
只读属性docId、password、docBaseUrl文档身份与安全信息
通用委托ensureDoc、ensureCatalog、initDocument、getPage、fontFallback、cleanup直接转发给 PDFDocument 或 Catalog
具体实现updatePassword更新密码并同步给加密器
抽象方法ensure、requestRange、requestLoadedStream、sendProgressiveData、terminate子类必须覆盖,否则触发 unreachable

两个子类的差别集中在一张表里:

维度LocalPdfManagerNetworkPdfManager
数据来源完整字节数组范围请求或渐进式分块
底层流StreamChunkedStream
ensure 缺字节不会发生捕获异常,请求区间后重试
requestRange抽象未实现委托 ChunkedStreamManager
requestLoadedStream返回已完成的 PromiserequestAllChunks
sendProgressiveData抽象未实现onReceiveData
terminate空实现中止分块管理器

选择哪一个由 Worker 在 getPdfManager 里根据传输能力决定:

条件选择流类型
调用方直接给了完整 dataLocalPdfManagerStream
响应头表明支持范围请求NetworkPdfManagerChunkedStream
不支持范围请求,边下边读先缓存,读完后 LocalPdfManagerStream
支持范围请求且正在流式下载NetworkPdfManager + sendProgressiveDataChunkedStream

parseDocBaseUrl 方法

docBaseUrl 是解析文档内相对 URI 的基准。它来自调用方传入的选项,但在进入文档对象之前必须先过一次校验。

function parseDocBaseUrl(url) {
  if (url) {
    const absoluteUrl = createValidAbsoluteUrl(url);
    if (absoluteUrl) {
      return absoluteUrl.href;
    }
    warn(`Invalid absolute docBaseUrl: "${url}".`);
  }
  return null;
}

函数的行为可以归纳成四种输入:

输入处理结果
空值或空字符串直接返回null
合法的绝对 URLcreateValidAbsoluteUrl 解析并校验协议规范化后的 href
相对路径没有 base 可依,解析失败警告并返回 null
协议不被允许协议校验失败警告并返回 null

它只接受绝对 URL,不会把相对路径拼到当前页面地址上。这是刻意的:Worker 运行在独立线程里,没有 document.baseURI 可用;相对路径的解析责任留在主线程,由调用方在发起加载前自己补全。

非法值不会抛异常,只打印警告并退化成 null。文档仍然能打开,只是后续遇到相对 URI 时无法解析。对一个纯展示型选项来说,这种降级比中断加载更合理。

这个值最终会出现在 Catalog.baseUrl 上:

优先级来源
1目录字典 /URI 里的 /Base,且必须是合法绝对 URL
2pdfManager.docBaseUrl
3null

Catalog 用这个基准去解析动作字典、附件与注释里的相对链接;注释层拿到的 baseUrl 也是同一个值。因此 parseDocBaseUrl 虽然只有十行,却是整条 URL 解析链的起点。

设计要点:外部输入在进入核心层之前先归一化。docBaseUrl 只在 manager 构造时解析一次,之后所有读取方拿到的都是已校验的 href 或 null,不需要在每次使用前重复判断合法性。

构造:一个基类,两套实现

constructor 方法

基类构造函数做了三件事:保存通用选项、做一次特性检测、把配置分发给模块级的选项容器。

  constructor({
    // source,
    // disableAutoFetch,
    docBaseUrl,
    docId,
    enableXfa,
    evaluatorOptions,
    handler,
    // length,
    password,
    // rangeChunkSize,
  }) {
    if (
      (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
      this.constructor === BasePdfManager
    ) {
      unreachable("Cannot initialize BasePdfManager.");
    }
    this._docBaseUrl = parseDocBaseUrl(docBaseUrl);
    this._docId = docId;
    this._password = password;
    this.enableXfa = enableXfa;

参数列表里被注释掉的 source、disableAutoFetch、length、rangeChunkSize 只属于网络实现,基类刻意不接收。这种「基类只声明自己用得到的字段」的写法,让两个子类的构造参数可以共用同一个对象,而不需要基类了解传输细节。

实例化保护只在测试构建里生效。生产构建中 PDFJSDev 会被替换成常量,这段判断会被静态求值掉;测试里如果误用 new BasePdfManager(...),会立刻得到「不能初始化基类」的错误。

docId 会参与全局 ID 的生成。PDFDocument 里有一个全局 ID 工厂,用 g_${docId} 作为文档前缀,再拼上字体序号,得到跨页面共享的字体标识。这个标识会一路传到主线程,用来复用已经创建的 FontFace。

password 是初始密码,之后还可能被 updatePassword 替换。它不参与流的选择,只影响 XRef 解密。

// Check `OffscreenCanvas` and `ImageDecoder` support once,
// rather than repeatedly throughout the worker-thread code.
evaluatorOptions.isOffscreenCanvasSupported &&= FeatureTest.isOffscreenCanvasSupported;
evaluatorOptions.isImageDecoderSupported &&= FeatureTest.isImageDecoderSupported;
 
this.evaluatorOptions = Object.freeze(evaluatorOptions);

这两个 &&= 是「调用方意愿与运行时能力」的与运算:

选项与什么做与运算效果
isOffscreenCanvasSupportedFeatureTest.isOffscreenCanvasSupported两者都真才启用
isImageDecoderSupportedFeatureTest.isImageDecoderSupported两者都真才启用

这段代码会直接修改传入对象。调用方传进来的 evaluatorOptions 在进入 manager 之前是可变配置,进入之后被就地补齐并立即 Object.freeze。冻结是浅层的:顶层属性不能再改,但嵌套对象仍然可变。真正的约束来自约定:后续代码只读不写。

选项随后被分发给八个模块级容器:

模块收到的选项用途
ImageResizerevaluatorOptions图片缩放阈值
JpegStreamevaluatorOptionsJPEG 解码参数
OperatorListevaluatorOptions操作符列表缓存策略
IccColorSpace选项加 handler读取 wasmUrl、useWasm、useWorkerFetch
CmykICCBasedCS选项加 handler读取 iccUrl
PDFFunctionFactory选项加 handler读取 useWasm
Pattern选项加 handler读取 hasGPU
WasmImage选项加 handlerWASM 图片解码

后五个收到的是带 handler 的选项对象,但真正使用它的只有 WasmImage:它在 Worker 内加载 WASM 模块时需要跨线程通信能力。其余几个只读取各自的 URL 与开关字段,多传一个 handler 不会造成影响。

这些 setOptions 都是模块级静态方法,在同一个 Worker 里只应被调用一次。PDF.js 的默认部署是「一个文档一个 Worker」,因此不存在多个文档互相覆盖选项的问题;如果复用一个 Worker 打开多个文档,后一个文档的选项会覆盖前一个,这是这套静态容器已知的边界。

设计要点:特性检测只做一次,选项只注入一次,然后把对象冻结。PDF.js 在 Worker 里刻意避免「每次解析都查一遍能力」的成本,把这类全局判断压缩到文档构造阶段。

委托接口:ensureDoc 与 ensureCatalog

基类不直接暴露 pdfDocument 的细节,而是提供两个语义明确的入口:一个读文档属性,一个读目录属性。

ensureDoc 方法

  ensureDoc(prop, args) {
    return this.ensure(this.pdfDocument, prop, args);
  }
 
  ensureCatalog(prop, args) {
    return this.ensure(this.pdfDocument.catalog, prop, args);
  }

两个方法都是一行转发,差别只在目标对象。ensureDoc("numPages") 读的是 PDFDocument 上的 getter;ensureCatalog("numPages") 读的是 Catalog 上的 getter。同一个名字可能出现在两个对象上,语义不同:文档层会考虑线性化与 XFA,目录层只负责页面树。

ensureCatalog 里的 this.pdfDocument.catalog 是一次普通属性访问。Catalog 由 parse 创建,而 initDocument 保证 parse 先完成,因此到这里目录对象一定存在;真正可能缺字节的是后续的方法调用,它们发生在 ensure 的 try 块内部。

传参形式也统一:如果属性值是函数,args 会作为参数数组传进去;否则 args 被忽略,直接返回属性值。

initDocument 方法

文档初始化的五个步骤全部通过 ensureDoc 执行:

  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校验文件头与版本不是合法 PDF
parseStartXRef定位交叉引用表起点尾部结构损坏
parse构建 XRef,处理对象流与加密交叉引用不可用
checkFirstPage至少能取到第一页页面树根节点不可用
checkLastPage校验页数,必要时回退遍历整棵页面树页数声明错误

每一步都可能因为缺字节而失败,而每一步都可能改变内部状态。ensure 的重试发生在单步内部,不会让整个初始化从头再来;只有当某一步抛出非缺字节异常时,才会中断。

recoveryMode 会一路传到 parse、checkFirstPage、checkLastPage。Worker 的处理方式是:先用 false 初始化,如果拿到 XRefParseException,就等 requestLoadedStream 完成,再用 true 重来一遍。第二遍拿到的是完整字节,可以做更激进的修复,比如逐个对象重建交叉引用。

抽象方法一览

基类里五个方法直接调用 unreachable,它们是子类必须覆盖的契约:

方法为什么必须由子类实现LocalPdfManagerNetworkPdfManager
ensure决定缺字节时是否等待与重试直接读属性捕获异常后请求区间
requestRange决定区间如何变成网络请求不会调用委托分块管理器
requestLoadedStream决定如何拿到完整字节已完成的 Promise等待全部分块
sendProgressiveData决定如何处理渐进式到达的块不会调用写入分块流
terminate决定如何中止正在进行的传输空实现中止分块管理器

unreachable 在测试构建里会抛出明确的错误,在生产构建里被优化成不可达标记。它比「返回 undefined」更安全:一旦某个子类忘了实现,调用点会立刻失败,而不是带着空值继续往下走。

updatePassword 是唯一在基类里给出具体实现的「动作型」方法:

  updatePassword(password) {
    this._password = password;
    this.pdfDocument.xref.encrypt?.setPassword(password);
  }

它同时更新两处:manager 自己的 _password 供后续读取,以及 XRef 上的加密器。用可选链是因为未加密文档没有 encrypt,这时只需要更新字段。

cleanup、getPage、fontFallback 也都是纯转发,它们不涉及字节来源,因此不需要在子类里重复实现。

设计要点:基类只抽象「与数据来源有关」的方法。页面获取、字体回退、缓存清理这些与传输无关的行为,统一以委托方式实现,避免两个子类各写一份完全相同的代码。

LocalPdfManager:全部在内存里

ensure 方法

class LocalPdfManager extends BasePdfManager {
  constructor(args) {
    super(args);
 
    const stream = new Stream(args.source);
    this.pdfDocument = new PDFDocument(this, stream);
    this._loadedStreamPromise = Promise.resolve(stream);
  }
 
  async ensure(obj, prop, args) {
    const value = obj[prop];
    if (typeof value === 'function') {
      return value.apply(obj, args);
    }
    return value;
  }
 
  requestLoadedStream(noFetch = false) {
    return this._loadedStreamPromise;
  }
 
  terminate(reason) {}
}

这个实现短得几乎不像一个「管理器」,因为内存场景下没有异步可言:

  1. args.source 是一个完整的字节数组,new Stream 直接把它包成可随机访问的流。
  2. PDFDocument 构造时拿到 manager 与流,后续所有解析都在这块内存上同步进行。
  3. _loadedStreamPromise 立即完成,任何等待完整字节的调用方都不会阻塞。

ensure 没有 try/catch,因为 Stream 不会抛 MissingDataException。越界读取会抛范围错误,那是真正的文档损坏,应该让上层看到,而不是重试。

这里有一个容易忽略的细节:async 函数会把返回值自动展平。如果属性值本身是一个 Promise(例如 XFA 文档的 numPages 可能是 Promise),return value 会让调用方拿到的仍然是 Promise 的最终值。这就是为什么 ensure 声明为 async,即使内存实现里没有任何 await。

terminate 是空实现。没有网络请求需要中止,也没有分块管理器需要关闭;实例被丢弃后,内存随垃圾回收释放。

特征内存实现的表现
首次读取延迟无
缺字节异常不会出现
完整字节获取立即完成
中止成本无
适用文档规模受内存限制

NetworkPdfManager:按需补块

ensure 方法

class NetworkPdfManager extends BasePdfManager {
  constructor(args) {
    super(args);
 
    this.streamManager = new ChunkedStreamManager(args.source, {
      msgHandler: args.handler,
      length: args.length,
      disableAutoFetch: args.disableAutoFetch,
      rangeChunkSize: args.rangeChunkSize,
    });
    this.pdfDocument = new PDFDocument(this, this.streamManager.getStream());
  }
 
  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);
    }
  }

构造函数把四个网络参数交给 ChunkedStreamManager:消息处理器、文件总长度、是否禁用自动预取、分块大小。getStream() 返回的 ChunkedStream 是一个定长数组,已加载的块被写进去,未加载的块保持空白。

ensure 是整个文件里最关键的一段。它只捕获一种异常:MissingDataException。

异常类型处理方式原因
MissingDataException请求缺失区间,递归重试数据只是还没到,可以等待
其它异常原样抛出文档损坏、解密失败、语法错误

重试路径是「请求区间,然后重新执行同一次属性读取或方法调用」。这意味着被 ensure 包裹的 getter 必须能在重入时利用已有缓存。PDF.js 大量使用 shadow 缓存:第一次成功计算后把结果写回实例属性,第二次读取直接命中,不会重复解析。如果某个方法每次调用都从头开始,重试就会反复做无用功。

递归调用没有次数上限。安全性来自另一侧:requestRange 返回的 Promise 只有在对应块真正到达后才完成;如果管理器被中止,它会 reject,递归自然终止。只要请求的区间确实能覆盖缺失范围,每一轮都会取得进展。

把它和 ObjectLoader 放在一起看,分工很清楚:

场景谁负责请求粒度
文档初始化、单属性读取NetworkPdfManager.ensure单个缺失区间
页面资源批量预取ObjectLoader多区间合并

ensure 是兜底机制,保证任何一次同步访问最终都能拿到字节;ObjectLoader 是优化机制,把大量小缺口合并成少数几次请求,避免兜底机制被频繁触发。

requestRange 与 requestLoadedStream 方法

  requestRange(begin, end) {
    return this.streamManager.requestRange(begin, end);
  }
 
  requestLoadedStream(noFetch = false) {
    return this.streamManager.requestAllChunks(noFetch);
  }

requestRange 只做转发。区间如何换算成块号、如何合并、如何通过消息处理器向主线程发 GetRangeReader,全部由 ChunkedStreamManager 决定。manager 不关心这些细节,它只需要一个「等这个区间到齐」的 Promise。

requestLoadedStream 的 noFetch 参数值得单独说明:

noFetch行为使用场景
false请求所有缺失块,等全部到齐GetData、SaveDocument
true不主动发请求,只等待已经安排的传输完成DataLoaded 事件、恢复模式

Worker 里 GetData 的实现就是等这个 Promise 完成后返回 stream.bytes;SaveDocument 也先等它,因为写回需要完整的原始字节。noFetch 为真时用于「确认当前下载已经完成」的场景,避免为了一个状态通知再触发一轮请求。

sendProgressiveData 与 terminate 方法

  sendProgressiveData(chunk) {
    this.streamManager.onReceiveData({ chunk });
  }
 
  terminate(reason) {
    this.streamManager.abort(reason);
  }

渐进式数据是「边下边读」模式的入口。Worker 在 getPdfManager 里一边读网络流,一边把每块数据交给 sendProgressiveData;如果 manager 还没建好,块会先进入临时数组,建好后再补发。onReceiveData 接收的对象只带 chunk,没有 begin,因为渐进式下载总是从当前写指针继续,不需要显式偏移。

terminate 把所有挂起的区间请求置为失败。Worker 终止时还会调用单独的取消函数中止底层 XHR,但 manager 这一层只需要保证「等待中的 Promise 不会永远悬着」。

两个实现放在一起对比,可以看清抽象的实际边界:

行为内存实现网络实现
读取属性同步取值同步取值,缺字节时等待
读取方法直接调用直接调用,缺字节时重试
等待完整字节已就绪请求缺失块
接收渐进数据不适用写入分块流
中止无操作中止分块管理器并拒绝挂起请求

设计要点:抽象基类只规定「能做什么」,不规定「怎么做」。ensure 在两个子类里的实现差异很大,但对调用方完全透明;这正是 PDFDocument 可以只写一套解析代码,却同时支持内存与流式两种来源的原因。

文档、XRef 与 manager

manager 本身不解析任何字节,它只负责把流交出去。PDFDocument 在构造时同时保存 manager 与流,并立刻创建 XRef:

this.pdfManager = pdfManager;
this.stream = stream;
this.xref = new XRef(stream, pdfManager);

XRef 的构造函数同样保存这两样东西:

  constructor(stream, pdfManager) {
    this.stream = stream;
    this.pdfManager = pdfManager;

于是三者形成一条固定的引用链:PDFDocument 持有 xref 与 stream,XRef 也持有 stream 与 pdfManager。任何一层需要补字节,都能顺着 pdfManager 回到 ensure 或 requestRange,不需要额外的全局状态。

不同读取者走的是不同入口:

读取者实际读取缺字节时的路径
PDFDocument.checkHeaderstream.getBytesensureDoc 捕获并重试
PDFDocument.startXRefstream.getBytesensureDoc 捕获并重试
XRef.parsestream 与 ParserensureDoc("parse")
XRef.fetchstream.getBytes由外层 ensure 捕获
ObjectLoaderxref.stream.manager.requestRanges自己批量请求,不经过 ensure
PDFDocument.#getByteRangestream.getByteRange直接调用 requestRange
Catalog 的各类属性间接读 XRefensureCatalog 捕获并重试

在内存实现里,这些路径全部退化成普通数组读取。在网络实现里,一次典型的读取会经过六步:

  1. 上层调用 ensureDoc("parse"),进入 NetworkPdfManager.ensure 的 try 块。
  2. XRef.parse 读流,ChunkedStream 发现目标块未加载,抛出带 begin 与 end 的异常。
  3. ensure 捕获异常,调用 requestRange(begin, end) 并等待。
  4. ChunkedStreamManager 把区间换算成块,通过消息处理器向主线程发范围请求。
  5. 数据返回后写入分块流,对应 Promise 完成。
  6. ensure 递归重跑 ensureDoc("parse"),这次读取命中已加载的块;单个 getter 的结果会被 shadow 缓存,后续访问不再走这条路。

PDFDocument.#getByteRange 是一个例外。它用于签名校验,只读一小段连续字节;与其把异常抛到 ensureDoc,不如在本地捕获后直接调用 requestRange,少一层重入:

  async #getByteRange(begin, end) {
    try {
      return this.stream.getByteRange(begin, end);
    } catch (ex) {
      if (!(ex instanceof MissingDataException)) {
        throw ex;
      }
      await this.pdfManager.requestRange(begin, end);
      return this.#getByteRange(begin, end);
    }
  }

这段代码和 NetworkPdfManager.ensure 的形状几乎一样,说明「捕获缺字节、请求区间、重试」是这一层的通用模式。区别只在重试对象:一个重跑属性读取,一个重跑字节切片。

ensure 没有重试计数,也不需要。它依赖一个更强的保证:请求的区间覆盖异常声明的缺口,区间到齐后同一个读取必然能前进。如果底层传输被中止,Promise 会 reject,递归不会继续。把终止条件交给数据层,比在重试层猜一个上限更可靠。

设计要点:字节读取的重试逻辑只允许出现在两个地方:ensure 与极少数需要精确切片的本地方法。调用点越多,重复请求与状态不一致的风险越大;把入口收敛之后,XRef 与 Catalog 可以保持纯同步的写法。

版本说明:接口边界

不同版本的 PDF.js 里,BasePdfManager 的成员并不完全相同。本版把一部分转发型 getter 删掉了,让 manager 更专注于「数据供给」这一件事。如果你看到的资料里提到下面这些名字,它们在本版中的位置如下:

名称本版是否存在实际位置
pdfDocument是子类构造时赋值的实例属性
docId是基类 getter
numPages否PDFDocument.numPages,通过 ensureDoc 访问
numPagesWithOffset否本版没有该成员
pdfInfo否GetDoc 消息的载荷,内容为 numPages、fingerprints、htmlForXfa
ensure是基类抽象方法
ensureDoc是基类委托方法
ensureCatalog是基类委托方法
requestRange是基类抽象方法,网络实现委托分块管理器
getData否requestLoadedStream,Worker 的 GetData 取 stream.bytes
requestRanges否ChunkedStreamManager.requestRanges,由 ObjectLoader 调用
cleanup是基类委托给 PDFDocument.cleanup
terminate是基类抽象方法
updateMetadata否本版没有该成员
updateDocument否保存流程走 SaveDocument 与写出器

这些差异说明一件事:抽象接口会随实现演进而收缩。早期版本把 numPages、pdfInfo 这类 getter 也放在 manager 上,调用方少写一层 ensureDoc,但 manager 因此知道了太多文档内部结构;现在这些读取统一走 ensureDoc,manager 只保留与传输强相关的多态点。

读源码时以当前文件为准,比记住某个版本的成员列表更可靠。判断一个成员是否属于抽象接口,可以看它是否在多处被 unreachable 标记,或者是否在两个子类里都有不同实现。

总结

pdf_manager.js 是 PDF.js 的数据供给层:向上给 PDFDocument 与 XRef 提供统一的流与访问接口,向下屏蔽内存字节、范围请求与渐进式下载的差异。

它在整条链路中的位置

阶段模块输入输出
发起加载display/api.js文件、URL、范围参数GetDocRequest
选择实现worker.js 的 getPdfManager完整数据或范围能力LocalPdfManager 或 NetworkPdfManager
建立流manager 构造函数字节数组或网络流Stream 或 ChunkedStream
建立文档PDFDocumentmanager 与流pdfDocument
初始化initDocument恢复模式已解析的 XRef 与 Catalog
属性访问ensureDoc / ensureCatalog属性名与参数值,或一次重试
补齐字节requestRange / requestLoadedStream区间或全量数据到达
生命周期cleanup / terminate触发原因缓存释放与请求中止

用一句话串起来:manager 把「数据可能不在」这件事关在门内,让 PDFDocument 与 XRef 可以假设字节随时可用。

五条设计原则

  1. 依赖倒置。PDFDocument 只依赖 manager 的抽象接口,不依赖数据来自内存还是网络;传输方式的差异被限制在两个子类里。
  2. 把缺字节当控制流。MissingDataException 不是错误,而是「请补数据」的信号;只有它会被 ensure 捕获并重试。
  3. 选项一次性注入。特性检测、选项冻结、模块级 setOptions 都发生在文档构造阶段,热路径上不再重复判断。
  4. 两个实现共享同一语义。无论内存还是网络,ensure 都承诺「返回属性值或抛出真实错误」;内存实现只是把等待退化成零成本。
  5. 生命周期显式化。完整字节通过 requestLoadedStream 获取,缓存通过 cleanup 释放,传输通过 terminate 中止;每个动作都有明确入口,不依赖隐式回收。

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

相关文章