StreamPDFStreamPDF
返回博客

PDF.js 源码解读: decode_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: decode_stream

前言

这是「字节与流」层的第二篇。

上一篇 chunked_stream 解决的是「字节从哪里来」:网络分片如何按需变成一份可寻址的缓冲。本篇解决下一段路——字节怎么被解码。

PDF 的流对象几乎从不直接存放原始数据。页面内容流通常是 Flate 压缩,字体文件可能套了 ASCII85 与 LZW,图片则是 DCT、JPX、JBIG2、CCITT 中的一种,部分流还要再过一层 PNG 或 TIFF 预测器。解析器不会直接读文件里的字节,而是读「所有 filter 都解完之后」的字节。

PDF.js 把所有解码器组织成一条继承链:DecodeStream 是公共基类,FlateStream、LZWStream、Ascii85Stream、RunLengthStream、PredictorStream、JpegStream 等都继承它。基类不关心任何具体算法,只提供三样东西:

  1. 一个会自动增长的输出缓冲。
  2. 一套以 readBlock 为唯一驱动源的读取接口。
  3. 把解码结果物化成普通 Stream 的两条通道:makeSubStream 与 clone。

子类要做的只有一件事:实现 readBlock,把解码结果写进缓冲(文本解码器逐段追加,图片解码器往往整体替换),并在源数据耗尽时把 eof 置为真。

一句话概括:DecodeStream 是「输出缓冲 + 读取游标 + 抽象 readBlock」的三件套,所有 filter 都在这套骨架上挂载。

模块总览

decode_stream.js 共 246 行,可以切成六块:

区块关键方法职责
状态与构造buffer、bufferLength、minBufferLength、constructor输出缓冲、逻辑长度、最小容量
缓冲管理ensureBuffer按需扩容,返回可写缓冲
读取getByte、getBytes、isEmpty按需驱动 readBlock,返回零拷贝视图
图片与解压getImageData、asyncGetBytesFromDecompressionStream异步取整段数据、原生 DecompressionStream
派生makeSubStream、clone、getBaseStreams把解码结果交给普通 Stream
多流拼接StreamsSequenceStream多个流首尾相接成一个流

它在整条链路里的位置:

层模块输入输出
字节源Stream / ChunkedStream文件或网络分片可读流
解码DecodeStream 及其子类编码字节解码后的输出缓冲
组合StreamsSequenceStream多个流单一流
解析Lexer / Parser解码后的字节token、Dict、对象
图像对象JpegStream / JpxStream 等整段图像数据ImageBitmap 或位图数据

整条链路可以概括为:字节源提供编码数据,DecodeStream 子类逐块解码到输出缓冲,解析器只看到解码后的连续字节。

基类契约:只实现 readBlock

const emptyBuffer = new Uint8Array(0);
 
// Super class for the decoding streams.
class DecodeStream extends BaseStream {
  buffer = emptyBuffer;
 
  bufferLength = 0;
 
  eof = false;
 
  minBufferLength = 512;
 
  pos = 0;
 
  constructor(maybeMinBufferLength) {
    super();
    this._rawMinBufferLength = maybeMinBufferLength || 0;
 
    if (maybeMinBufferLength) {
      // Compute the first power of two that is as big as maybeMinBufferLength.
      while (this.minBufferLength < maybeMinBufferLength) {
        this.minBufferLength *= 2;
      }
    }
  }
 
  readBlock() {
    unreachable("Abstract method `readBlock` called");
  }

emptyBuffer 是一个共享的零长度数组。注释解释了为什么要共享而不是用 null:大量 DecodeStream 在生命周期内根本不会产出数据,共享一个空数组既省内存,又避免每个读取方法都写「缓冲为空」的特判。

这些字段各自职责清晰:

字段含义
buffer输出缓冲,初始为共享的 emptyBuffer
bufferLength逻辑长度,表示已产出的有效字节数
buffer.byteLength容量,表示当前分配了多少内存
minBufferLength扩容起点,默认 512,按构造参数上调到不小于该值的 2 的幂
_rawMinBufferLength构造时传入的原始容量提示,供 StreamsSequenceStream 估算
pos读取游标,与 bufferLength 相互独立
eof源数据是否已耗尽

构造参数 maybeMinBufferLength 是一个提示值,不是精确长度。很多解码流能从字典里拿到压缩后的长度,解压后通常更大,于是把它当作起点,避免从 512 开始反复翻倍。minBufferLength 会被提升到「不小于提示值的最小 2 的幂」,这样后续扩容序列是整齐的 512、1024、2048 等。

设计要点:基类只规定接口,不规定算法。readBlock 的唯一契约是「产出一批字节并同步 bufferLength,或者把 eof 置为真」。基类不检查产出了多少字节、来自哪个 filter、是否一次解码完。这条极窄的契约让十几种完全不同的解码器可以共用同一套读取、扩容与派生逻辑。

缓冲增长:ensureBuffer

  ensureBuffer(requested) {
    const buffer = this.buffer;
    if (requested <= buffer.byteLength) {
      return buffer;
    }
    let size = this.minBufferLength;
    while (size < requested) {
      size *= 2;
    }
    const buffer2 = new Uint8Array(size);
    buffer2.set(buffer);
    return (this.buffer = buffer2);
  }

ensureBuffer 是所有子类唯一需要关心的内存函数。它的判断依据是容量而不是逻辑长度:只要当前分配的内存装得下请求量,就直接复用,不管里面有多少有效字节。

扩容策略是「从 minBufferLength 开始翻倍,直到装得下请求量」。这带来三个性质:

  • 分配次数是 O(log n),从 512 到几十兆只需要十来次分配。
  • 总复制量是 O(n):每次复制旧内容,但容量翻倍,均摊到每个字节上的复制次数是常数。
  • 峰值内存通常不超过请求量的两倍;请求量小于 512 时以下界 minBufferLength 为准。
策略分配次数总复制量峰值内存适用场景
每次精确分配O(n)O(n²)恰好一次性已知最终大小
本实现的几何扩容O(log n)O(n)不超过两倍大小未知、逐块产出的解码流

复制用 buffer2.set(buffer),会把旧缓冲的全部容量复制过去,包括 bufferLength 之后那些还没用到的字节。这些字节是垃圾数据,复制它们只是多花一点时间,不会影响正确性;换来的是实现上不需要区分「有效区」与「空闲区」。

设计要点:容量与逻辑长度分离,是解码流能反复追加的前提。bufferLength 随解码进展增长,buffer.byteLength 只在装不下时跳变。读取接口只依赖前者,扩容逻辑只依赖后者,两者互不干扰。

读取:按需驱动 readBlock

  getByte() {
    const pos = this.pos;
    while (this.bufferLength <= pos) {
      if (this.eof) {
        return -1;
      }
      this.readBlock();
    }
    return this.buffer[this.pos++];
  }

getByte 是整套机制的核心。它先用 while 而不是 if,因为一次 readBlock 可能一个字节都没产出:

  • Flate 的一个块可能只更新内部状态。
  • 预测器可能读了一行但还没凑够输出。
  • 图片解码器可能一次性解完整张图,也可能在等待异步数据。

循环条件 bufferLength <= pos 表示「当前游标处还没有有效字节」。只要没到 eof,就继续让子类产出;一旦 eof 为真且仍然没有字节,返回 -1,与 Stream 的末尾语义一致。

这里也隐含了子类的义务:readBlock 必须要么产出字节,要么置 eof。否则这个循环永远不会结束。基类不做超时或次数保护,因为它信任所有子类都遵守契约。

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

getBytes 有两种模式:

  • 给定长度:先把容量扩到 pos + length,然后循环解码直到覆盖 end 或遇到 eof。如果源数据提前结束,end 会被夹到 bufferLength,调用方拿到的是较短的一段,而不是报错。
  • 不给定长度:一直解码到 eof,返回从当前位置到结尾的全部字节。

提前调用 ensureBuffer(pos + length) 的作用是把本次请求量一次算进容量,避免解码逐字节推进时反复检查与多次小步扩容。读取完成后才取 subarray,返回的视图指向最终缓冲;即使某次 readBlock 产出的字节数超过请求量、触发了再次扩容,视图也是在所有追加之后生成的,不会指向旧数组。

decoderOptions 会原样传给 readBlock。普通文本解码器忽略它,图片解码器用它控制缩放、颜色变换等参数。基类不解释这个参数,只负责透传。

调用场景何时触发 readBlock返回
getByte游标处没有有效字节时一个字节或 -1
getBytes(n)直到覆盖 n 字节或 eof[pos, pos + n) 的视图
getBytes()直到 eof剩余全部字节的视图
isEmpty直到有输出或 eof布尔值
makeSubStream直到覆盖子流终点新的 Stream
clone直到 eof新的 Stream
  get isEmpty() {
    while (!this.eof && this.bufferLength === 0) {
      this.readBlock();
    }
    return this.bufferLength === 0;
  }

isEmpty 不是简单地比较长度,而是惰性判断:只要还没有输出且未到末尾,就继续解码,直到第一次产出字节或彻底结束。这样调用方问「这个流有没有数据」时,得到的是解码后的事实,而不是「当前缓冲恰好为空」这种中间状态。

代价是 isEmpty 可能触发大量解码工作。因此它只适合在真正需要判断空流时使用,不应该放在热路径里反复调用。

图片与异步解码

getImageData

图片流与文本流不同:解析器要的是整段解码后的像素数据,而不是边解析边读取的字节。getImageData 为此提供了三条路径:

  async getImageData(length, decoderOptions) {
    if (!this.canAsyncDecodeImageFromBuffer) {
      return this.isAsyncDecoder
        ? this.decodeImage(null, length, decoderOptions)
        : this.getBytes(length, decoderOptions);
    }
    const data = await this.stream.asyncGetBytes();
    return this.decodeImage(data, length, decoderOptions);
  }
canAsyncDecodeImageFromBufferisAsyncDecoder路径说明
否否getBytes(length, options)普通同步解码,直接返回字节
否是decodeImage(null, length, options)解码器自己从上游流读数据
是任意先 stream.asyncGetBytes(),再 decodeImage(data, ...)先把编码数据整段取回,再交给解码器

canAsyncDecodeImageFromBuffer 在 JpegStream、JpxStream、Jbig2Stream 里都被实现为「上游流是否异步」。为真时先把整段编码数据取回来,再解码,可以避免解码器在等待网络时反复中断。isAsyncDecoder 为真则表示解码器本身是异步的(JpxStream、Jbig2Stream、CCITTFaxStream 都是),此时传入 null,让 decodeImage 自己通过 this.bytes 从上游取整段数据。

decodeImage 由各个图片流子类实现,返回解码后的像素数据。基类不实现它,只负责选择路径。

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);
      const writer = writable.getWriter();
      await writer.ready;
 
      // We can't await writer.write() because it'll block until the reader
      // starts which happens few lines below.
      writer
        .write(bytes)
        .then(async () => {
          await writer.ready;
          await writer.close();
        })
        .catch(() => {});
 
      const chunks = [];
      let totalLength = 0;
 
      for await (const chunk of readable) {
        chunks.push(chunk);
        totalLength += chunk.byteLength;
      }
      const data = new Uint8Array(totalLength);
      let offset = 0;
      for (const chunk of chunks) {
        data.set(chunk, offset);
        offset += chunk.byteLength;
      }
      return { decompressed: data, compressed: bytes };
    } catch {
      return { decompressed: null, compressed: bytes };
    }
  }

这是一个「用浏览器原生解压换取速度」的快路径,分几步:

  1. 重置上游流。this.stream.reset() 把读取位置拨回起点;如果上游是异步的,还要等它把整段编码数据取完。
  2. 一次性拿到压缩字节。isAsync 为真时走 asyncGetBytes,否则走同步 getBytes。两种方式都返回完整数据,后面不再需要读取上游。
  3. 构造 DecompressionStream。name 是 "deflate"、"brotli" 这类 format 字符串,由浏览器的解压实现负责算法。
  4. 先等 writer.ready,再写数据。写操作会施加背压:当可读端没人消费时,write 不会完成。所以这里故意不 await 写,而是启动写入后立刻进入读取循环;写入链在消费端启动后自然会完成,并在完成时 close。
  5. 收集并拼接输出。for await 逐个拿到解压分片,累计总长度后一次性分配 Uint8Array 并复制。相比反复 push 再扩容,这次拼接只分配一次。
  6. 失败时保留压缩字节。整个流程包在 try 里,DecompressionStream 不存在、格式不支持、或数据尾部有多余字节导致解压失败,都会返回 decompressed: null,同时把已经取到的 compressed 一并交回。

第 6 步是这条快路径能安全使用的前提。调用方拿到空结果后可以回退到自己的 JavaScript 解码器,而且不需要重新读一遍上游——压缩字节已经在手里了。

FlateStream 与 BrotliStream 正是这样做的:

  • 解压成功,直接返回解压结果。
  • 解压失败,把 #isAsync 置为假,用 compressed 重新构造一个普通 Stream(Flate 跳过两字节头),再 reset() 后走自己的增量解码器;asyncGetBytes 返回 null,而它们自己覆盖的 getImageData 在拿到空结果时改调 getBytes(length)。

设计要点:原生快路径必须可失败、可回退、可复用输入。asyncGetBytesFromDecompressionStream 把这三件事都做进了返回值:失败以 null 表达,回退所需的压缩字节随结果返回,上游流从头读取保证输入完整。任何一步缺失,回退都会变成二次读取或状态污染。

子流与克隆:把解码结果物化

makeSubStream

  makeSubStream(start, length, dict = null) {
    if (length === undefined) {
      while (!this.eof) {
        this.readBlock();
      }
    } else {
      const end = start + length;
      while (this.bufferLength <= end && !this.eof) {
        this.readBlock();
      }
    }
    return new Stream(this.buffer, start, length, dict);
  }

DecodeStream 的 makeSubStream 没有惰性可言:它必须先让解码推进到子流终点,才能构造普通 Stream。

  • length 为 undefined 时,一直解码到 eof,然后把从 start 到缓冲末尾的区间包装成流。
  • 给定 length 时,解码到缓冲覆盖 end 为止,再构造 [start, start + length)。

返回的 Stream 与解码流共享同一个底层 Uint8Array,不复制数据。它相当于对解码结果拍了一张快照:此后解码流即使继续扩容或换数组,子流仍然持有旧缓冲,看到的内容不会变。

这与 ChunkedStream.makeSubStream 的「活视图」正好相反:

维度DecodeStream.makeSubStreamChunkedStream.makeSubStream
数据准备先解码到目标终点先 ensureRange,缺块抛异常
返回对象普通 Stream以父实例为原型的子流
底层内存共享解码缓冲共享父流缓冲
后续变化快照,不再变化活视图,父流新到的块自动可见
缺数据行为由 readBlock 驱动,可能阻塞抛 MissingDataException,由上层补数据

clone

  clone() {
    // Make sure it has been fully read.
    while (!this.eof) {
      this.readBlock();
    }
    return new Stream(this.buffer, 0, this.bufferLength, this.dict?.clone());
  }

clone 的要求更彻底:必须读完整条流。它返回的新 Stream 从 0 开始、长度等于 bufferLength,与解码流共享缓冲,但拥有独立的读取游标。字典也做了浅层克隆,避免两个流共用同一份可变字典。

为什么要读完整条流?因为 Stream 需要一个确定的 end,而解码流的 eof 只有真正解码完才会为真。如果只读一部分就克隆,新流拿到的长度是暂时的,后续解码不会反映到它身上。

clone 与 makeSubStream 的代价一样:调用时立即完成剩余解码。它们适合「解析器需要把流存起来、稍后从头再读一遍」的场景,不适合在只读一小段时频繁调用。

StreamsSequenceStream:把多个流接起来

构造

class StreamsSequenceStream extends DecodeStream {
  constructor(streams, onError = null) {
    streams = streams.filter(s => s instanceof BaseStream && !s.isImageStream);
 
    let maybeLength = 0;
    for (const stream of streams) {
      maybeLength +=
        stream instanceof DecodeStream
          ? stream._rawMinBufferLength
          : stream.length;
    }
    super(maybeLength);
 
    this.streams = streams;
    this._onError = onError;
  }

构造时先过滤,再估算容量:

  • 只保留 BaseStream 实例,顺带排除图片流。图片流的数据往往要作为可转移对象单独处理,混进文本拼接没有意义。
  • 容量估算把 DecodeStream 的 _rawMinBufferLength 与其他流的 length 相加。前者是构造时的原始提示,后者是已知长度;两者都只是估算,真正的增长仍然由 ensureBuffer 负责。

这次估算会通过 super(maybeLength) 影响 minBufferLength,让拼接流一开始就有接近最终大小的容量,减少中途扩容。

readBlock

  readBlock() {
    const streams = this.streams;
    if (streams.length === 0) {
      this.eof = true;
      return;
    }
    const stream = streams.shift();
    let chunk;
    try {
      chunk = stream.getBytes();
    } catch (reason) {
      if (this._onError) {
        this._onError(reason, stream.dict?.objId);
        return;
      }
      throw reason;
    }
    const bufferLength = this.bufferLength;
    const newLength = bufferLength + chunk.length;
    const buffer = this.ensureBuffer(newLength);
    buffer.set(chunk, bufferLength);
    this.bufferLength = newLength;
  }

每次 readBlock 只处理一个子流:

  1. 列表为空则置 eof,结束。
  2. 从队首 shift 一个流,取出它的全部剩余字节。getBytes() 不带长度,意味着一次性读完整条子流。
  3. 出错时,如果有 onError 回调,就把错误和对象号交出去,然后直接返回;这条流被丢弃,下一次 readBlock 继续处理下一个。
  4. 正常时把 chunk 追加到输出缓冲末尾,并更新 bufferLength。

shift 让每个子流只被消费一次,也意味着拼接是「边读边丢」的:已经并入输出的流不会再被引用。getBytes() 读完整条子流,所以拼接流的内存占用会随着输出增长,而不是保留所有子流。

错误处理的行为值得单独列出来:

情况行为
子流列表为空置 eof = true,结束
某个子流抛错,且有 onError调用 onError(reason, objId),跳过该流,不置 eof
某个子流抛错,且无 onError原样抛出,由上层决定如何处理
最后一个子流被消费本次正常追加;下一次 readBlock 发现列表为空才置 eof

onError 的存在让一页内容流里的单个坏流不会拖垮整页。PDFDocument 在组装页面内容时会把一个错误处理函数传进来,把失败子流的对象号报告出去,然后继续拼接其余部分。

  getBaseStreams() {
    const baseStreamsBuf = [];
    for (const stream of this.streams) {
      const baseStreams = stream.getBaseStreams();
      if (baseStreams) {
        baseStreamsBuf.push(...baseStreams);
      }
    }
    return baseStreamsBuf.length > 0 ? baseStreamsBuf : null;
  }

getBaseStreams 遍历的是还没被消费的子流。已经 shift 出去的流不再参与,因为它们的字节已经并入输出,原始来源对上层不再有意义。没有可追溯的来源时返回 null,与基类的默认语义一致。

各子类的 readBlock 策略

StreamsSequenceStream 只是众多实现之一。把常见的子类放在一起,可以看到 readBlock 这条契约的弹性:

子类readBlock 策略输出粒度备注
FlateStream增量 inflate,按块头与 Huffman 码表逐段解码若干字节优先走原生 DecompressionStream,失败回退
LZWStream增量字典解码,码宽从 9 位逐步扩到 12 位512 字节一块需要维护字典与前缀序列
RunLengthStream读两字节头,按计数复制或重复一段一段遇到 128 表示结束
Ascii85Stream每五个字符解出四个字节,z 表示四个零一组空白字符会被跳过
AsciiHexStream两个十六进制字符解出一个字节一批遇大于号结束,其他非十六进制字符跳过
PredictorStream包装上游流,按行做 TIFF 或 PNG 反预测一行构造时替换为对应 readBlock
DecryptStream每 512 字节解密一块,提前读下一块判断是否为末块512 字节需要知道最后一块才能正确填充
JpegStream、JpxStream 等一次性解码整幅图像,直接把 buffer 设为结果整幅多为异步解码,配合 getImageData
StreamsSequenceStream吞并下一个子流,全部字节追加到输出一个子流本文这一节
维度逐块产出的文本解码器(Flate、LZW 等)整幅产出的图片解码器(JPEG、JPX 等)
readBlock 调用次数多次,直到消费完或 eof通常一次
buffer 的写入方式多次 ensureBuffer 后追加直接替换整个 buffer
eof 的时机读到源末尾时解码完成时
读取接口的适配天然适配逐字节读取主要走 getImageData

设计要点:拼接流把「多个流」折叠成「一个流」,靠的是把子流当作数据块,而不是维护多个游标。每消费一个子流就丢弃它,输出缓冲只保留最终结果。这让上层解析器完全不需要知道页面内容原来被拆成了几段。

总结

decode_stream.js 是 PDF.js 的解码层基座:向上把各种 filter 解码成连续字节,向下复用 Stream 的读取与派生语义。

它在整条链路中的位置

阶段模块输入输出
字节供给Stream / ChunkedStream文件、网络分片编码字节
解码DecodeStream 子类编码字节输出缓冲
组合StreamsSequenceStream多个解码流单一流
词法Lexer解码后的字节token
语法ParsertokenDict、数组、BaseStream

用一句话串起来:字节源提供编码数据,DecodeStream 用统一的 readBlock 契约把数据解成缓冲,解析器再把缓冲读成对象。

五条设计原则

  1. 单一抽象点,子类只写 readBlock。基类不关心算法,只负责缓冲、读取与派生;十几种 filter 因此可以共享同一套读取语义。
  2. 容量与长度分离,几何扩容。buffer.byteLength 管内存,bufferLength 管有效数据;扩容从 minBufferLength 开始翻倍,均摊复制成本是常数。
  3. 读取即返回视图,零拷贝。getBytes 直接返回 subarray,makeSubStream 与 clone 共享底层缓冲;需要独立游标时只新建 Stream 包装,不复制字节。
  4. 异步解码前置,失败可回退。getImageData 优先走异步路径,DecompressionStream 失败时把压缩字节交回调用方,由 JavaScript 解码器兜底,避免二次读取。
  5. 派生前先物化。makeSubStream 与 clone 都会先读满所需数据,再交给普通 Stream;拼接流则逐个吞并子流。惰性只保留在「逐块解码」这一层,不扩散到派生对象。

「字节与流」这两篇到这里告一段落:chunked_stream 负责把网络字节变成可寻址缓冲,decode_stream 负责把编码字节变成解码缓冲。下一篇回到 parser.js,看 Lexer 如何在这层缓冲之上切出 token,Parser 又如何把 token 组装成 PDF 对象。

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

相关文章