StreamPDFStreamPDF
返回博客

PDF.js 源码解读: base_stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: base_stream

前言

这是 PDF.js 源码解读系列进入「字节与流」层的第一篇,介绍 src/core/base_stream.js(168 行)里的 BaseStream 类。

第 10 篇 XRef 已经讲清楚对象怎么被定位与取回,第 11 篇 Parser 已经讲清楚字节怎么被分词、又怎么被组装成 Dict、数组与流对象。这两篇站在「对象」的高度,默认脚下已经有一层稳定的字节来源。本篇向下补上这个默认前提:一个流到底向解析器承诺了什么。

BaseStream 本身几乎不做实际读取工作。它是一个用普通 JavaScript 写出来的抽象基类:定义 getByte、getBytes、reset、makeSubStream 等方法名与语义,具体实现交给三类子类:

  • Stream:在一段已经存在于内存的 ArrayBuffer 上开窗口;
  • DecodeStream:把压缩或加密的字节一边解码一边缓冲;
  • ChunkedStream:面向网络分块加载,读到未下载区间时抛 MissingDataException。

一句话概括它的职责:它是所有字节源的统一门面,让 Lexer、Parser、XRef、PartialEvaluator 不需要知道自己拿到的是本地文件、网络分块还是 Flate 解压结果。

设计要点:PDF.js 没有用 TypeScript 的抽象类,而是让抽象方法直接调用 unreachable 抛错。这样契约不是文档里的约定,而是运行时会立刻暴露的失败点;子类漏实现某个方法时,不会静默返回 undefined 把错误带到下游。

模块总览

base_stream.js 只有 168 行,按职责可以切成八块:

区块关键方法职责
构造与抽象契约constructor、unreachable阻止直接实例化,声明必须覆写的接口
尺寸与状态length、isEmpty、isDataLoaded报告有效字节数与加载状态
原始读取getByte、getBytes、getByteRange、getString按字节、按长度、按区间取数据
前瞻与整数peekByte、peekBytes、getUint16、getInt32不移动或移动游标地读取定长字段
游标控制skip、reset、moveStart前进、回卷、重设逻辑起点
派生与克隆makeSubStream、clone生成共享字节的子流或独立副本
异步与图像getImageData、asyncGetBytes、isAsync 等五个能力 getter能力协商与异步解码入口
流链与溯源getBaseStreams、getOriginalStream找到真正需要下载或真正持有字节的流

这些方法里只有 peekByte、peekBytes、getUint16、getInt32、getString、skip、getImageData 和若干 getter 有默认实现;其余都是抽象方法。默认实现全部建立在两个更小的原语上:getByte、getBytes,以及一个可写的 pos 字段。

构造与抽象契约

BaseStream 没有声明 pos,却在默认实现的 peekByte、skip 里直接使用 this.pos。这是一个隐式契约:子类必须提供一个从 0 开始、可读可写的整数游标。JavaScript 不需要字段声明,但读源码时要把这一点记在心里。

constructor 方法

  constructor() {
    if (
      (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) &&
      this.constructor === BaseStream
    ) {
      unreachable("Cannot initialize BaseStream.");
    }
  }

PDFJSDev 是构建期注入的常量,这段条件在测试语境下才成立;this.constructor === BaseStream 则区分「直接 new 基类」与「子类调用 super()」。直接实例化会立刻抛出错误,子类构造不受影响。

unreachable 的实现只有一行,就是 throw new Error(msg)。它同时承担两个作用:向读代码的人标记「这里不应该被执行」,以及在测试或异常路径里留下明确堆栈。生产环境里这段构造检查会被预处理掉,不留下每次创建流的额外判断。

设计要点:抽象基类最怕的不是「没实现」,而是「没实现却返回了一个看起来能用的值」。undefined 参与算术会变成 NaN,参与比较会静默为假,错误往往在几百行之后才爆出来。这里选择在调用点立即抛错,把契约破坏压缩到最短距离。

length 与 isEmpty 方法

  get length() {
    unreachable("Abstract getter `length` accessed");
  }
 
  get isEmpty() {
    unreachable("Abstract getter `isEmpty` accessed");
  }

length 表示流的总有效字节数,不是「还剩多少」。Stream 用 end - start 实现它;DecodeStream 没有覆写这个 getter,因为解码流并不承诺一开始就知道最终长度,调用方只能按需读取。

isEmpty 表示「这个流整体是否为空」,与游标当前位置无关。Stream 直接看 length 是否为零;DecodeStream 的实现更主动:只要还没到文件末尾且缓冲区为空,就继续调用 readBlock,直到拿到数据或确认结束。

两个 getter 都带 // eslint-disable-next-line getter-return,因为函数体只有抛错、没有返回值。这不是疏忽,而是有意把「没有实现」和「返回空」区分开。

原始读取接口

getByte 与 getBytes 方法

  getByte() {
    unreachable("Abstract method `getByte` called");
  }
 
  getBytes(length) {
    unreachable("Abstract method `getBytes` called");
  }

getByte 的契约是:返回一个 0 到 255 的字节,或者在流结束时返回 -1。它不抛异常,也不使用 null 表示结束;解析器里的循环因此可以写成「读到 -1 就停」,不需要额外判断。

getBytes(length) 返回 Uint8Array,并推进游标。length 是可选参数,语义由子类决定,但仓库里的实现保持一致:不传或传 0 表示读到 end,传入正数表示最多读这么多,靠近末尾时会自动截短。调用方必须看返回数组的 length,不能假设一定等于请求值。

另一个没有写进签名的契约是「返回值可能是视图」。Stream 直接返回 bytes.subarray(...),零拷贝;DecodeStream 返回内部解码缓冲的 subarray。这意味着调用方不应随意修改返回的数组,否则会改到流的底层数据。

getImageData 与 asyncGetBytes 方法

  async getImageData(length, decoderOptions) {
    return this.getBytes(length, decoderOptions);
  }
 
  async asyncGetBytes() {
    unreachable("Abstract method `asyncGetBytes` called");
  }

getImageData 是一个异步门面,默认只是把同步的 getBytes 包成 Promise。注释特意说明:它只能用于「保证已经完整加载」的图像数据,否则读到一半的网络流可能给出间歇性错误,ObjectLoader 就是为这种场景准备的前置加载器。

decoderOptions 会继续往 getBytes 传。BaseStream 的 getBytes 签名只写了 length,多出来的参数在 JavaScript 里不会报错;DecodeStream 覆写时正好利用它,把选项交给具体的图像解码器。

asyncGetBytes 默认是抽象方法。只有能利用平台异步解码能力的流才覆写它,例如使用原生 DecompressionStream 的 FlateStream 与 BrotliStream。它返回 null 时表示「异步路径失败,请回退到同步解码」;调用方不能把 null 当成空数据。

getByteRange 方法

  getByteRange(begin, end) {
    unreachable("Abstract method `getByteRange` called");
  }

它按 [begin, end) 取一段字节,不移动游标。与 getBytes 不同,区间是底层缓冲区里的绝对位置,不是从当前 pos 起算的相对位置。

Stream 的实现会把负的 begin 拉到 0,把超过 this.end 的 end 参数截到流末尾,然后返回 subarray。ChunkedStream 还会先调用 ensureRange,确认这段区间已经下载,否则抛 MissingDataException 让上层去请求数据。

这个方法主要服务于两类场景:文档指纹只读文件开头固定长度的字节;数字签名校验按签名字典里的 ByteRange 分段读取原文。它们都不应该改变当前解析游标。

读取方法的语义对比

BaseStream 的默认实现把多个方法串成一条链,理解每个方法是否移动游标,比记住实现更重要:

方法是否推进 pos返回内容结束或越界行为
getByte是单个数字返回 -1
getBytes是Uint8Array,通常是视图自动截到 end
getByteRange否Uint8Array,通常是视图截到流末尾,不改游标
getString是字符串依赖 getBytes 的截断行为
peekByte否单个数字返回 -1
peekBytes否Uint8Array自动截到 end

getString(length) 的实现只有一行:bytesToString(this.getBytes(length))。bytesToString 内部按 8192 个参数一段调用 String.fromCharCode,避免超长字节数组触发参数个数上限。它是按字节到字符的低位映射,不是通用的 Unicode 解码器。

前瞻与多字节读取

peekByte 方法

  peekByte() {
    const peekedByte = this.getByte();
    if (peekedByte !== -1) {
      this.pos--;
    }
    return peekedByte;
  }

前瞻没有另起一套读取逻辑,而是「读一个,再退一格」。这样所有子类只要正确实现 getByte 和可写的 pos,就自动获得前瞻能力。

只有在读到真实字节时才回退。若已经到 end,getByte 返回 -1,pos 本来就没有前进,再减一反而会跑到末尾之前,因此这里必须判断 -1。

这个实现也解释了为什么 pos 是隐式契约的一部分:前瞻、跳过、复位都直接操作它,而不是通过额外的 tell、seek 方法。

peekBytes 方法

  peekBytes(length) {
    const bytes = this.getBytes(length);
    this.pos -= bytes.length;
    return bytes;
  }

这里回退的是实际返回的字节数,而不是请求的 length。靠近文件末尾时 getBytes 会截短,bytes.length 正好等于游标实际前进的距离,因此前瞻后位置仍然准确。

它同样依赖「getBytes 返回的内容与游标前进量一致」这条契约。仓库里的 Stream、DecodeStream、ChunkedStream 都满足它;如果某个子类返回了比游标前进量更短的数组,前瞻就会错位。

Parser 用它做两件典型的事:读取内联图像结束符后的若干字节,判断后面是否还是可见 ASCII;以及把一小段字节包成临时 Stream 交给新的 Lexer 试读。两者都只看不改,正适合前瞻。

getUint16 方法

  getUint16() {
    const b0 = this.getByte();
    const b1 = this.getByte();
    if (b0 === -1 || b1 === -1) {
      return -1;
    }
    return (b0 << 8) + b1;
  }

它按大端序拼出 16 位无符号整数:前一个字节在高 8 位,后一个在低 8 位。PDF、JPEG、TrueType 里大量长度字段都是这个字节序,因此没有再提供小端变体。

失败时返回 -1,与单字节读取的结束哨兵一致。要注意:一旦第一个字节读到了、第二个字节到了末尾,游标已经前进了 1,方法不会回滚。调用方看到 -1 后通常直接停止解析,所以这个副作用多数情况下不重要,但它确实存在。

Parser 在扫描 JPEG 内联图像时会用 stream.getUint16() 读取 marker segment 的长度,再根据长度 skip 到下一个 marker,这是该方法的典型用法。

getInt32 方法

  getInt32() {
    const b0 = this.getByte();
    const b1 = this.getByte();
    const b2 = this.getByte();
    const b3 = this.getByte();
    return (b0 << 24) + (b1 << 16) + (b2 << 8) + b3;
  }

它按大端序拼出 32 位有符号整数。JavaScript 的位运算先把操作数转成 32 位整数,所以最高字节左移 24 位后会自然带符号;四个字节全为 0xff 时结果是 -1。

与 getUint16 不同,它没有 EOF 检查。原因很实际:-1 本身就是一个合法的 32 位结果,没法再拿它当结束哨兵。调用方必须先用 getByteRange 或分块加载保证四个字节可读,或者像 fonts.js 读取表目录那样,在拿到结果后立刻转换成无符号偏移。

游标控制与派生流

skip 与 reset 方法

  skip(n) {
    this.pos += n || 1;
  }
 
  reset() {
    unreachable("Abstract method `reset` called");
  }

skip 默认前进 1 个字节,n 为负数时就是后退。它不做边界检查,也不管跳过的是空白、分隔符还是二进制数据;调用方必须自己保证落点合法。另一个细节是 n 为 0 时也会前进 1,因为 0 || 1 取到了 1。仓库里没有用 skip(0) 表达「不动」的调用,读源码时不要把它当成安全的空操作。

reset 把游标放回逻辑起点。Stream 回到 start;DecodeStream 回到 0。复位只改游标,不清空已经解码的缓冲区,也不重置 eof,所以它适合「同一段已读数据再解析一次」,不适合「重新解码」。

moveStart 方法

  moveStart() {
    unreachable("Abstract method `moveStart` called");
  }

moveStart 是 BaseStream 里最少见的抽象方法,只有 Stream 实现:this.start = this.pos。它把逻辑窗口的起点推到当前游标处,pos 本身不动,length 随之变短。

真实用途在 PDFDocument.checkHeader:文件开头可能混入空白、BOM 或垃圾字节,find 找到 %PDF- 之后调用 moveStart,让后续所有偏移都相对于真正的头部计算。之后的 skip(PDF_HEADER_SIGNATURE.length) 才是在头部内部前进。

DecodeStream 没有实现这个方法,因为解码流的 start 语义并不存在;它只有从 0 开始的解码缓冲区。

makeSubStream 与 clone 方法

  makeSubStream(start, length, dict = null) {
    unreachable("Abstract method `makeSubStream` called");
  }
 
  clone() {
    unreachable("Abstract method `clone` called");
  }

两个方法都返回新的 BaseStream,但目的不同。makeSubStream 在同一个字节源上开一个子窗口,start 是底层缓冲区里的绝对偏移,不是相对于当前流起点的偏移;length 可以省略,表示一直到父流末尾;dict 默认 null。

Parser.makeStream 解析出 stream 关键字之后,计算数据起点与长度,再调用 makeSubStream 把原始字节切出来;XRef.fetchUncompressed 更直接,传入的是 xrefEntry.offset + this.stream.start,因为偏移量是相对文件头,而 makeSubStream 要的是相对底层缓冲区。

clone 则复制「同一个逻辑窗口」:起点、长度不变,游标独立,字典做一次克隆。它用于需要保留原流、又要单独移动游标的场景,例如写出 PDF 时先复位、读完原始字节,再复用字典信息。

各子类的实现差异

抽象方法只是名字,真正决定行为的是子类。把三个主要实现放在一起看,BaseStream 的契约边界会更清楚:

方法StreamDecodeStreamChunkedStream
lengthend - start未覆写,访问会抛错继承 Stream
isEmptylength === 0循环 readBlock 直到有数据或结束继承 Stream
getByte读 bytes,末尾返回 -1按需解码到缓冲区,再读先 ensureByte,再读
getBytessubarray 视图,截到 end解码到内部 buffer,返回视图先 ensureRange,再返回视图
getByteRange截断后返回视图未覆写先 ensureRange,再返回视图
resetpos = startpos = 0继承 Stream
moveStartstart = pos未覆写继承 Stream
makeSubStream用 bytes.buffer 新建 Stream解码完再包成 Stream原型链子流,共享 bytes
clone共享缓冲区,字典克隆解码完再包成 Stream继承 Stream,得到普通 Stream
getBaseStreams默认 null委托给 this.stream返回 [this]

这张表里最值得注意的是 makeSubStream 的三套策略:Stream 零拷贝开窗口,DecodeStream 先把数据解码完整再开窗口,ChunkedStream 先用原型链复制状态、再按需校验缺失分块。它们对上层暴露同一组方法,代价却完全不同。

设计要点:BaseStream 只规定「读得到、能复位、能派生」,不规定「数据在哪里」。Stream 的字节在 ArrayBuffer 里,DecodeStream 的字节在解码缓冲区里,ChunkedStream 的字节在按块填充的预分配数组里。正是这层抽象让 Lexer 可以完全不知道网络分块的存在。

加载状态、异步与流链

isDataLoaded 方法

  get isDataLoaded() {
    return shadow(this, "isDataLoaded", true);
  }

默认实现假设「拿到这个流时数据已经完整」。shadow 会在实例上定义一个不可写、可配置的自有属性,第一次读取后就把 true 固定下来;测试构建里还会断言原型上确实存在同名属性,避免拼写错误。

ChunkedStream 覆写这个 getter,用已加载分块数与总分块数比较。ObjectLoader 用它决定是否值得遍历对象图:如果主流的 isDataLoaded 已经为 true,整棵资源树都不需要提前请求。

异步与图像能力 getter

  get isAsync() {
    return false;
  }
 
  get isAsyncDecoder() {
    return false;
  }
 
  get isImageStream() {
    return false;
  }
 
  get canAsyncDecodeImageFromBuffer() {
    return false;
  }
 
  async getTransferableImage(width, height) {
    return null;
  }

这五个成员不是「读取数据」,而是「声明能力」。默认全部为否,子类按需覆写,上层通过一次属性读取决定走哪条路径,不需要 instanceof 判断,也不需要试探性调用。

  • isAsync:整个流能否通过 asyncGetBytes 一次性给出字节。FlateStream、BrotliStream 默认以原生 DecompressionStream 的异步路径启动,失败后回退到同步解码并把标志置为 false;Page.getContentStream 看到它为 true 时,就会先等待异步字节,再包成普通 Stream。
  • isAsyncDecoder:解码器本身是否是异步的。JpxStream、Jbig2Stream、CCITTFaxStream 为 true;DecodeStream.getImageData 据此调用各自的 decodeImage。
  • isImageStream:这是图像流,不是页面内容流。StreamsSequenceStream 构造时会过滤掉它,避免把图像字节拼进内容序列;Page.getContentStream 也用它排除图像。
  • canAsyncDecodeImageFromBuffer:底层流能否异步提供完整字节,让图像解码器直接吃缓冲区。JPEG、JPX、JBIG2 的实现会转发 this.stream.isAsync。
  • getTransferableImage:尝试返回可直接交给渲染层的图像对象,默认 null。JpegStream 覆写后用浏览器 ImageDecoder 解码,失败时仍返回 null,让 image.js 回退到普通的像素解码。

设计要点:能力查询把「是否支持」和「怎么使用」分开。上层只问 isAsync,不需要知道对方是 Flate 还是 Brotli;只问 isAsyncDecoder,不需要知道对方是 JPX 还是 JBIG2。新增一种解码器时,只要覆写对应的布尔 getter,调用方的分支不需要改。

getBaseStreams 方法

  getBaseStreams() {
    return null;
  }

返回值有两种含义:null 表示「这个流没有需要额外下载的底层流」,数组表示「这些底层流可能还缺数据,请按它们的 start 与 end 请求区间」。

DecodeStream 把调用委托给 this.stream;ChunkedStream 返回 [this];StreamsSequenceStream 把所有子流的结果合并。ObjectLoader 遍历对象图时,就是靠这个接口找到真正需要网络数据的流,而不是盲目请求整个文件。

getOriginalStream 方法

  getOriginalStream() {
    return this.stream?.getOriginalStream() || this;
  }

过滤器链是一层套一层的:PredictorStream 包 FlateStream,FlateStream 包 Stream,加密流再包一层 DecryptStream。这个方法沿着 .stream 递归向下,直到某个流没有 stream 字段,返回最底层持有原始字节的对象。

默认实现对自己也成立:普通 Stream 没有 stream 字段,直接返回自身。DecryptStream 特意覆写为返回 this,因为它的下游是尚未解密的密文,写回 PDF 时需要的却是解密后的字节。

Writer.writeStream 与编辑器的 #rawStreamBytes 都先调用 getOriginalStream(),再 reset()、getBytes(),目的就是绕开所有解码器,拿到流的原始编码数据,连同字典一起重新写出去。

总结

BaseStream 是 PDF.js 的字节接口层:向下屏蔽本地内存、网络分块、压缩解码三种来源,向上给 Lexer、Parser、XRef、PartialEvaluator 提供统一的读取、前瞻、复位与派生能力。

它在整条链路中的位置

阶段模块输入输出
字节供给PDFManager / ChunkedStream文件、网络分块Stream / ChunkedStream
语法解析Lexer / ParserBaseStreamtoken、Dict、Stream
对象取回XRefRefDict / BaseStream
内容求值PartialEvaluator页面内容流operatorList
图像解码DecodeStream 子类压缩图像字节像素数据或可转移图像

用一句话串起来:PDFManager 提供字节,BaseStream 把字节包装成可读游标,Parser 把游标上的内容变成对象,XRef 与 Evaluator 再把这些对象组织成页面与绘制指令。

五条设计原则

  1. 抽象失败要显式。抽象方法不返回 undefined,而是调用 unreachable 抛错。子类漏实现时,错误发生在第一次调用,不会伪装成一个合法值继续传播。
  2. 只承诺读取,不承诺存储形态。getBytes 可以返回视图,也可以返回副本;可以一次给全,也可以在末尾截短。上层只看返回数组的 length,不假设底层是 ArrayBuffer、解码缓冲还是分块数组。
  3. 前瞻由读取加回退构成。peekByte、peekBytes 没有独立实现,而是复用 getByte、getBytes 再调整 pos。子类只需要把顺序读取做对,前瞻自动成立。
  4. 子流共享字节,游标独立。makeSubStream 与 clone 都返回新对象,但共享底层存储;新对象有自己的 pos,因此可以在同一份数据上并行解析多个对象。
  5. 能力查询优先于类型判断。isAsync、isAsyncDecoder、isImageStream 等布尔 getter 让上层按能力分支,而不是按具体类分支。新增流类型时,调用方无需修改。

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

相关文章