StreamPDFStreamPDF
返回博客

PDF.js 源码解读: brotli_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: brotli_stream

前言

这是「字节与流」层的又一篇,对象是 Worker 端 src/core/brotli_stream.js。

前面几篇已经把这条链路拆过一遍:decode_stream 提供输出缓冲与读取游标,flate_stream 把 DEFLATE 位流还原成字节,predictor_stream 再把按行预测的样本还原成原始行。本篇换了一个过滤器,但骨架没有变——BrotliStream 仍然是 DecodeStream 的子类,仍然只实现一个 readBlock,仍然由上层按需驱动。

不同的是资历。Flate、LZW、ASCII85 都是 PDF 1.x 时代的老面孔,Brotli 则是 PDF 2.0 才进入标准的过滤器。规范里写作 /BrotliDecode,数据本身是 RFC 7932 定义的 Brotli 字节流。真实文档中它出现得不多,因为绝大多数生成工具仍以兼容旧阅读器为先;一旦出现,往往意味着生成方愿意用较新的压缩算法换取更小的体积,比如体积敏感的字体子集或高分辨率扫描图。

对解析器来说,它的位置和 Flate 完全一致:解密之后,PDF 语义解析之前。字典里出现 /Filter /BrotliDecode,Parser.makeFilter 就把原始字节包成 BrotliStream;上层读到的已经是解压后的字节,不需要知道压缩算法是什么。

brotli_stream.js 只有 86 行,没有自己的 Huffman 表,也没有位缓冲。真正的解压被交给了两个外部实现:浏览器的 DecompressionStream,以及 external/brotli/decode.js 里的 BrotliDecode。这个文件的职责因此很窄——在两套解码器之间做选择、搬运字节、复用已经读出的数据,并把状态收敛回 DecodeStream 的契约。

本篇也不打算展开 Brotli 算法本身。静态字典、上下文建模、窗口大小、元块布局,这些都属于 RFC 7932 与 external/brotli 的范畴,和 PDF 的流模型没有直接关系。真正值得在 PDF.js 语境里讨论的问题是:一个没有增量接口的解码器,怎样塞进一个按需读取的流框架;一个依赖平台能力的快路径,怎样在失败时安全退回。这两点才是这 86 行代码的主题。

一句话概括:它是 Brotli 过滤器的适配层,先问浏览器能不能解,不能就退回自研解码器,两条路对上层给出同一份字节。

模块总览

全文可以切成五块:

区块关键方法职责
流身份constructor保存底层流与字典,初始化异步标志
原生解码asyncGetBytes调用浏览器 DecompressionStream,整块返回
同步解码readBlock一次性读完压缩字节,交给 BrotliDecode
图片入口getImageData异步取整段数据并按声明的长度截断
状态暴露get isAsync让上层判断是否还能走异步路径

它在整条链路里的位置:

PDF 字节
  → Parser.readStream              (读完 stream 字典与数据段)
  → DecryptStream                  (可选: 默认流加密 / /Crypt 过滤器)
  → Parser.makeFilter              (读到 /Filter /BrotliDecode)
  → BrotliStream(stream, length)   (本文件)
  → DecompressionStream("brotli")  (原生快路径)
  → BrotliDecode                   (失败后的同步回退)
  → 上层: 内容流 / 字体 / 图像数据

继承关系仍然是 BaseStream → DecodeStream → BrotliStream。基类提供 buffer、bufferLength、eof、pos、ensureBuffer、getByte、getBytes,并规定子类只需实现 readBlock:每次调用往输出缓冲里补一段数据,源耗尽时把 eof 置为真。BrotliStream 没有改写读取接口,也没有引入第二套缓冲;它只在这条契约之上加了一个「先异步、后同步」的分支。

设计要点:这个类的复杂度全部来自「有两条解码路径」。成功时它用返回值把整段数据交出去,失败时把自己降级成普通同步流。两条路共用同一个 dict、同一个 pos 和同一个 buffer 字段,所以上层始终只面对一个流对象,不需要关心本次解码走的是哪条路。

父类契约: readBlock 何时被调用

BrotliStream 自己没有读取循环,驱动权在父类手里。理解这一点,才能理解为什么 readBlock 可以「一次读完」而不破坏按需读取的语义:

  getBytes(length, decoderOptions = null) {
    const pos = this.pos;
    let end;
 
    if (length) {
      this.ensureBuffer(pos + length);
      end = pos + length;
 
      while (!this.eof && this.bufferLength < end) {
        this.readBlock(decoderOptions);
      }
      const bufEnd = this.bufferLength;
      if (end > bufEnd) {
        end = bufEnd;
      }
    } else {
      while (!this.eof) {
        this.readBlock(decoderOptions);
      }
      end = this.bufferLength;
    }
 
    this.pos = end;
    return this.buffer.subarray(pos, end);
  }

带长度调用时,父类先确保缓冲能容纳目标位置,然后反复调用 readBlock,直到缓冲够长或者流结束;不带长度调用时,它一直读到 eof,再返回从当前位置到末尾的视图。getByte 走的是同一个小循环,只是每次只要一个字节。

构造一个 BrotliStream 本身不触发任何解码。只有第一次有人要字节时,readBlock 才运行;而它一运行就把整段数据解完。这带来一个看似矛盾、实际合理的结果:流在语义上是惰性的,在粒度上是整块的。语义惰性保证没人读的流零成本;整块解码保证一次调用之后,后续任意长度的读取都只是移动 pos。

readBlock(decoderOptions) 的实参传进 BrotliStream 后并没有被使用。这也符合它的定位:decoderOptions 服务于 JPX 之类的图像解码器,而 Brotli 只做字节到字节的转换,没有可调参数。父类允许子类忽略参数,正是为了让这类纯过滤器不必伪装出并不存在的选项。

类结构与构造

constructor 方法

class BrotliStream extends DecodeStream {
  #isAsync = true;
 
  constructor(stream, maybeLength) {
    super(maybeLength);
 
    this.stream = stream;
    this.dict = stream.dict;
  }

构造函数只做三件事。

第一,把 maybeLength 交给父类。DecodeStream 用它计算 minBufferLength 的增长起点:从这个值开始不断翻倍,直到能容纳请求长度。注意它只是缓冲起点,不参与任何长度校验,也不会被当作解码结果的长度。PDF 字典里的 /Length 经常写错,甚至为 0,构造函数不相信它。

第二,保存底层流。this.stream 既可能是文件切片包成的 Stream,也可能是 ChunkedStream,甚至是另一个 DecodeStream。这一点很关键:滤镜是可以叠加的,如果 /Filter 数组里 Brotli 后面还跟着别的解码器,外层解码器的底层流就是 BrotliStream,它的 asyncGetBytes 会先向这里要字节。

第三,共享字典。this.dict = stream.dict 是引用赋值而不是复制。字典上挂着对象号、加密上下文以及后续解析需要的全部信息;复制一份不仅浪费,还会让两层的 dict 身份不一致。

私有字段 #isAsync 初值为 true,含义是「还可以尝试原生路径」。它不是全局能力缓存,而是每个实例自己的状态:同一个文档里,有的 Brotli 流走原生成功,有的失败降级,互不影响。

代码没有对 Brotli 数据做任何头部校验。Flate 有 zlib 的两字节头和 FCHECK,可以提前发现坏数据;Brotli 没有固定头,第一段字节就是压缩流本身,因此这里只能把校验推迟到真正解码的时候。

异步快路径: asyncGetBytes

复用的 DecompressionStream 工具

原生解码本身不在这个文件里,而是在父类的 asyncGetBytesFromDecompressionStream 中:

  async asyncGetBytesFromDecompressionStream(name) {
    this.stream.reset();
    const bytes = this.stream.isAsync
      ? await this.stream.asyncGetBytes()
      : this.stream.getBytes();
 
    try {
      const { readable, writable } = new DecompressionStream(name);

它先调用底层流的 reset(),把读取位置退回到起点;然后根据底层流是否支持异步,选择 await this.stream.asyncGetBytes() 或同步的 this.stream.getBytes()。前者让整条异步链可以递归:一个异步流叠在另一个异步流上时,外层会等内层先给出结果,再一次性喂给自己的解码器。后者是无参调用,意味着「读到结尾」,不留下残余字节。

拿到的 bytes 既是待解压的输入,也是失败时要回收的资产。函数返回的是 { decompressed, compressed } 两个字段:成功时前者是 Uint8Array,失败时前者为 null,后者始终保留已经读出的压缩字节。所有原生异常都在这个函数内部被吞掉,不会穿到调用方。

格式名传的是 "brotli"。它不属于 Compression Streams 最早支持的那批格式(deflate、deflate-raw、gzip),可用性随浏览器版本变化。不支持时,new DecompressionStream("brotli") 会直接抛错,正好落进上面的 catch,流程自然转入回退。所以这里不需要额外的能力探测:用一次真实尝试代替一次特征检测,成功就省下一次判断,失败也能拿到已经读出的字节。

asyncGetBytes 方法

  async asyncGetBytes() {
    const { decompressed, compressed } =
      await this.asyncGetBytesFromDecompressionStream("brotli");
    if (decompressed) {
      return decompressed;
    }
    // DecompressionStream failed (for example because there are some extra
    // bytes after the end of the compressed data), so we fallback to our
    // decoder.
    // We already get the bytes from the underlying stream, so we just reuse
    // them to avoid get them again.
 
    this.#isAsync = false;
    this.stream = new Stream(
      compressed,
      0,
      compressed.length,
      this.stream.dict
    );
    this.reset();
    return null;
  }

方法体可以拆成两段:成功则返回数据,失败则改造自己。

成功分支只判断 decompressed 是否为空。注意这里用的是真值判断而不是长度判断:解压结果即使是零长度,也是一个合法的 Uint8Array 对象,同样为真。零长度的合法结果与「解码失败」是两件事,用 null 表示后者,语义不会混淆。

失败分支做四步。

  1. #isAsync = false。这是一个单向开关:一旦原生路径被证明不可用,就不再尝试第二次。它同时告诉上层「这个流别再当异步流用了」。
  2. 新建 Stream(compressed, 0, compressed.length, this.stream.dict)。起点是 0,因为 Brotli 没有需要跳过的头部;作为对照,FlateStream 回退时起点写的是 2,正好跳过 zlib 头。字典继续从旧流继承,保持对象身份。
  3. this.reset()。DecodeStream.reset 只把 pos 归零,不清空缓冲。此时输出缓冲还没有写入任何内容,复位是为了让后续的 getBytes 从头驱动 readBlock。
  4. return null。这是一个哨兵值:不是「没有数据」,而是「异步路径失败,请改走同步路径」。

compressed 是刚刚从底层流整段读出的字节,直接拿来构造新 Stream,避免再向网络或文件请求一次。代价是这段压缩数据不能在回退完成前释放;考虑到 Brotli 流本身需要整块解码,这个代价与解码器的内存需求同阶,不构成额外负担。

设计要点:失败不是异常,而是返回值。异常一旦抛给上层,调用方就必须了解 Brotli 的实现细节才能正确处理;返回 null 则把「换条路」的决定留在流内部。#isAsync 同时承担两个语义——能力开关与降级记录,而且只降不升,不会因为某次偶然成功又切回异步。

同步回退: readBlock

readBlock 方法

  readBlock() {
    // TODO: add some telemetry to measure how often we fallback here.
    // Get all bytes from the input stream
    const bytes = this.stream.getBytes();
    const decodedData = BrotliDecode(
      new Int8Array(bytes.buffer, bytes.byteOffset, bytes.length)
    );
 
    this.buffer = new Uint8Array(
      decodedData.buffer,
      decodedData.byteOffset,
      decodedData.length
    );
    this.bufferLength = this.buffer.length;
    this.eof = true;
  }

第一行的注释交代了一个事实:作者认为回退是少见但有价值观测的路径。原生解码更快,回退意味着额外的 JavaScript 解码开销;如果某类文档频繁落到这里,说明原生实现与 PDF 生成器的数据之间存在系统性差异。

this.stream.getBytes() 没有参数,含义是「读到流结束」。这与多数解码器逐块消费底层流的方式不同:FlateStream 用位缓冲一点点取,RunLengthStream 按段取,而这里必须一次拿全。原因是 BrotliDecode 没有增量接口,Brotli 的窗口引用又可能跨越很远的距离,只有拿到完整压缩流才能安全解码。代价是回退路径的内存峰值等于「压缩数据 + 解压数据」;收益是实现简单、没有状态机。

new Int8Array(bytes.buffer, bytes.byteOffset, bytes.length) 只是换了一个视图,没有复制。三个参数缺一不可:如果只传 bytes.buffer,当 bytes 是某个大缓冲的子数组时,会把缓冲里无关的字节一起交给解码器,长度也会错。BrotliDecode 的签名要求 Int8Array,而底层流给出的是 Uint8Array,所以这里必须转换,但转换的只是类型标签。

解码结果同样以视图方式包装回 Uint8Array:decodedData.buffer 加上自己的偏移与长度。这样即使 BrotliDecode 返回的是某个更大缓冲的切片,也不会把多余字节暴露出去,而且没有二次拷贝。

最关键的一行是 this.buffer = ...。其他文本类解码器通常用 ensureBuffer 扩容再 set 追加,这里直接替换整个输出缓冲。原因有三点:输出长度在解码前未知,无法预先扩容;解压结果已经是完整独立的数组,追加只会多一次全量拷贝;替换之后 bufferLength 可以直接取结果长度,读取接口不需要再判断「已写入多少」。替换的副作用是旧的空缓冲被丢弃,但它在回退发生时本来就没有内容。

最后把 eof 置为真。Brotli 是一次性整块解码,不存在「还有下一块」的可能;DecodeStream.getBytes 的循环因此立刻停止,后续读取只是在固定缓冲上移动 pos。

图像入口: getImageData

getImageData 方法

  async getImageData(length, _decoderOptions) {
    const data = await this.asyncGetBytes();
    if (!data) {
      return this.getBytes(length);
    }
    if (data.length <= length) {
      return data;
    }
    return data.subarray(0, length);
  }

这是给图像路径准备的专用入口,逻辑与 FlateStream.getImageData 完全一致。

它先无条件尝试异步整块解码。失败时 asyncGetBytes 已经把流改造成同步模式并返回 null,于是落到 this.getBytes(length):由 DecodeStream 的循环驱动 readBlock,取够 length 字节就停。成功时拿到的是整段解压结果,还需要按 length 做一次裁剪。

length 来自图像字典自己声明的尺寸,调用方在 PDFImage.getImageBytes 里按 Width、Height 与位深算出来。它未必与流的真实长度一致:生成器可能把 /Length 写错,也可能在数据尾部留下填充。多出来的字节对像素解码没有意义,subarray(0, length) 直接切掉。若解压结果比 length 短,则原样返回,不做补零——补齐与否属于具体图像解码器的职责,这一层不替它做决定。

subarray 返回的是视图而非副本,与解码缓冲共享内存。这种共享在这里是安全的:PDFImage 对 DecodeStream 产出的字节有明确约定,认为它是自包含的,不会再被后续流读取修改;只有底层为普通 Stream 的图像数据才需要额外复制,以免返回的视图影响其他读取。

还有一个前置细节:PDFImage.getImageBytes 在调用前会执行 this.image.reset(),把流的读取位置退回起点。因为同一张图片可能被多次请求,而 getImageData 的语义是「从头取一份完整数据」,不是「接着上次读」。

只有当 Brotli 是滤镜链的最后一环(输出直接是像素数据)时,图像路径才会落在 BrotliStream 上。如果链上还有 DCT、JPX 这类图像编码,PDFImage 拿到的是更外层的图像流,Brotli 只是它底下的字节来源。_decoderOptions 参数在这里没有被使用,原因也在这里:Brotli 不负责图像格式,选项属于外层解码器。

状态: #isAsync

isAsync 方法(getter)

  get isAsync() {
    return this.#isAsync;
  }

BaseStream.isAsync 默认返回 false,DecodeStream 没有覆盖它,只有具备异步解码能力的子类才把它改成真实状态。BrotliStream 的初值是 true,因为 Brotli 恰好有原生实现;一旦回退,值永久变为 false。

这个布尔值被多处消费,形态几乎一致:先看 isAsync,为真则 await asyncGetBytes(),拿到非空结果就把它包成一个普通 Stream,然后继续同步解析。

  • PDFDocument.getContentStream 用它把页面内容流预先解压成 Stream。
  • PartialEvaluator.getOperatorList 在解释绘制指令前做同样的替换。
  • PartialEvaluator.getTextContent 为文本抽取准备连续字节。
  • 字体加载与 CMap 解析也会对字体文件、编码流做一次异步预取。

这样设计的结果是:原生路径成功时,解码在 Worker 的一个 await 里一次性完成,之后所有消费者拿到的都是普通 Stream,没有任何适配层;原生路径失败时,isAsync 已经是 false,消费者跳过异步尝试,继续使用 BrotliStream,由 readBlock 在需要时解压。两种情况都不会出现「重复尝试同一个失败调用」。

值得注意的是 null 的语义。空结果 new Uint8Array(0) 是合法数据,调用方会照常包装;只有 null 才表示「异步不可用,继续用这个流」。用一个专属哨兵区分「空」与「失败」,比用长度判断更可靠。

两条路径的对照

维度原生快路径自研回退路径
触发条件#isAsync 为真且浏览器支持该格式原生抛错,或 #isAsync 已为假
输入底层流 reset 后的完整字节同一份字节,被包成新的 Stream
解码器DecompressionStream("brotli")external/brotli 的 BrotliDecode
输出写入直接作为返回值,不碰输出缓冲readBlock 一次性替换 buffer
上层感知asyncGetBytes 返回数据,包成 Stream返回 null,继续同步读取
失败表现捕获后切换到回退异常在解码时向上抛出

一次读取的时序

把两条路径摊开成时序,可以更清楚地看到「压缩字节只读一次」这条约束是怎么落实的。

原生成功时:

  1. 上层拿到流,发现 isAsync 为真,调用 asyncGetBytes()。
  2. 父类工具先 reset() 底层流,再整段读出压缩字节。
  3. 这些字节被写入 DecompressionStream("brotli") 的 writable;写入动作不阻塞,读取端随后异步收集所有 chunk。
  4. chunk 被拼成一个连续 Uint8Array,作为 decompressed 返回;原始字节作为 compressed 一并带回。
  5. asyncGetBytes 直接返回解压结果。上层把它包成 Stream,BrotliStream 从此退出本次解析视野。
  6. 后续所有读取都发生在普通 Stream 上,不再经过任何解码器。

回退时:

  1. 第 2、3 步与上面相同,只是 DecompressionStream 的构造或写入阶段抛错。
  2. 工具函数捕获异常,返回 decompressed 为 null、compressed 保留原字节。
  3. asyncGetBytes 把底层流替换成包着 compressed 的新 Stream,关掉 #isAsync,复位游标,返回 null。
  4. 上层看到 null,决定继续使用原来的 BrotliStream。
  5. 第一次真正的 getBytes 触发 readBlock,BrotliDecode 一次性解完整段数据。
  6. 输出缓冲被整体替换,eof 置真,调用方拿到从 pos 到末尾的视图。

两条路径的差别只在「谁执行解码」和「结果放在哪里」:原生结果放在返回值里,回退结果放在 buffer 里。压缩字节的读取、字典的继承、流对象的身份,三者完全一致。

还有一个容易忽略的细节:异步路径是可选的,不是必需的。即使没有任何调用方去 await asyncGetBytes,直接对一个从未尝试过原生的 BrotliStream 调 getBytes,readBlock 依然会用自研解码器正常解压,#isAsync 保持为真只是意味着「还没试过」。类的正确性不依赖调用方选择哪条路,异步只是额外提供的一条加速通道。

未走异步路径时

以下场景会让这个流保持同步:

  • 调用方只看 getBytes,从不检查 isAsync,例如某些内部工具或测试代码。
  • 上层已经用别的理由判定该流不需要预取,例如内容流被切片处理。
  • 原生解码失败过一次,#isAsync 已经被置为假。

这三种情况的最终结果相同:readBlock 在第一次需要字节时运行,解码发生在 Worker 的同步栈上,调用方感受不到任何区别,只是首字节的等待时间包含了整段 Brotli 解码。

滤镜链与边界

Parser.makeFilter 里的分派只有一行:

        case "BrotliDecode":
          return new BrotliStream(stream, maybeLength);

对照 Flate 的分支可以看到一处差异:Flate 和 LZW 在存在 /DecodeParms 时会再包一层 PredictorStream,而 Brotli 直接返回,没有预测器包装。也就是说,这个版本不会对 Brotli 输出做行预测还原。若某份文档真的给 Brotli 配了预测参数,结果不会被纠正。这是实现范围的问题,不是解码器本身的缺陷。

几个边界情况值得单独说明。

  • 长度为零。makeFilter 在 maybeLength === 0 时会先返回 NullStream,根本不会构造 BrotliStream。因此构造函数不需要处理空流。
  • 尾部多余字节。原生实现通常要求压缩流在最后一个字节处干净结束,尾部残留会让它报错。PDF 里 /Length 写错、数据段补齐、多个过滤器拼接都很常见,这正是代码注释里点名的回退原因。回退解码器对尾部的容忍度更高,但也不保证一定能解出结果。
  • 数据被截断。两套解码器都可能在缺数据时报错。makeFilter 的 try 只覆盖流的构造,不覆盖惰性解码;真正抛错的位置是 readBlock,由调用方决定是忽略还是中断。
  • 滤镜叠加。若 Brotli 不是最后一环,外层解码器会通过 asyncGetBytesFromDecompressionStream 递归地向它要字节。异步能力沿链向上传递,isAsync 因此是一个流属性,而不是某个固定类型的能力。

这些边界共同说明了一件事:BrotliStream 只对「压缩字节是否完整」负责,不对「PDF 生成器是否规范」负责。前者由两套解码器判断,后者由上层解析器按容错策略处理。职责划清之后,这个不到百行的文件才能同时容纳原生与自研两种实现而不显得杂乱。

与 FlateStream 的对照

维度FlateStreamBrotliStream
规范来源PDF 1.0 起长期使用PDF 2.0 引入
头部校验zlib 两字节头,校验 FCHECK无固定头,不做前置校验
原生格式名deflatebrotli
回退起点压缩流偏移 2,跳过 zlib 头偏移 0
自研解码器文件内的位流状态机external/brotli 的 BrotliDecode
输出写入逐块解码、追加到缓冲一次性解码、整体替换缓冲
预测器可由 PredictorStream 包裹本版不包裹

两者共享同一套异步骨架:getImageData 的写法一字不差,asyncGetBytes 的失败处理也只差起点偏移。这种一致性不是巧合,而是把「原生优先、失败回退」提炼成父类工具之后,各过滤器只需要填写自己的格式名与回退细节。

总结

BrotliStream 是 PDF.js 的 Brotli 适配层:向上给内容流、字体、图像提供解压后的字节,向下把浏览器原生能力与自研解码器缝合成一条对上层透明的路径。

它在整条链路中的位置

阶段模块输入输出
字节供给Stream / ChunkedStream文件切片、网络分块加密或明文压缩字节
解密DecryptStream(可选)密文明文压缩字节
滤镜分派Parser.makeFilter/Filter /BrotliDecodeBrotliStream
原生解码DecompressionStream("brotli")完整压缩字节Uint8Array
回退解码BrotliDecode完整压缩字节Int8Array 视图
上层消费内容流 / 字体 / 图像原始字节operatorList、字形、像素

用一句话串起来:Parser 负责认出过滤器,BrotliStream 负责选择解码器,原生实现负责常规情况,自研实现负责兜底。

五条设计原则

  1. 平台能力优先,语义兜底。能用浏览器原生解压就用原生,遇到尾部多余字节、格式不支持等原生实现不接受的情况,就地换回自研解码器。标准路径追求速度,兜底路径追求能把文件读完。
  2. 一次读完,整块解码。readBlock 不逐块消费底层流,而是一次取全。这是 BrotliDecode 的接口决定的,也换来了没有增量状态机的简单实现。
  3. 失败即复用。回退时不在重新读取压缩数据,而是把已经拿到的 compressed 直接包成新 Stream。失败路径的成本只多一次解码,不多一次 I/O。
  4. 失败用返回值表达。null 是专属哨兵,表示「异步不可用」;空数组仍是合法数据。上层因此不需要捕获异常就能完成路径切换。
  5. 状态只增不减,且只增一次。#isAsync 从真到假后不再回头,避免对同一份坏数据反复尝试原生解码;每个实例独立记录,不影响同一文档里的其它流。

至此,Brotli 这条较少见的过滤器补齐了。它和 Flate 的关系不是替代,而是同一套骨架上的两个实现:一个自带完整位流状态机,一个把算法外包给平台与 external/。下一篇回到 decrypt_stream,看加密文档里「解密」这一环为什么必须在滤镜之前,以及它如何用 512 字节的分块和一次前瞻来找到真正的最后一块。

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

相关文章