StreamPDFStreamPDF
返回博客

PDF.js 源码解读: stream

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: stream

前言

这是 PDF.js 源码解读系列「字节与流」层的第二篇,介绍 src/core/stream.js(97 行)里的 Stream、StringStream、NullStream 三个类。

再往前看,第 10 篇 XRef 与第 11 篇 Parser 已经把对象取回与语法解析讲完;这两篇默认脚下有一层稳定的字节来源,而这个来源的契约由上一篇 base_stream.js 定义:getByte、getBytes、peekByte、reset、makeSubStream、clone 等方法该做什么、不该做什么。本篇给出最基本的一种实现:在一段已经存在于内存的字节上开一个带游标的窗口。

Stream 是仓库里出现频率最高的流类型。LocalPdfManager 把整个文件包成它;Parser.makeStream 用 makeSubStream 从父流里切出每个 12 0 obj 的流数据;XRef.fetchUncompressed 也用它定位对象。StringStream 与 NullStream 则是两个只有几行的特化子类,分别解决「把字符串当成流解析」和「用空流占位」两个具体问题。

一句话概括:Stream 不负责下载、不解压、不加密,它只维护 start、pos、end 三个指针,并把所有读取动作变成对 Uint8Array 的索引与 subarray 视图。

模块总览

stream.js 由三个类组成,文件很短,职责却分得很清:

区块关键方法职责
Stream 窗口constructor、length、isEmpty在既有字节上建立逻辑区间与游标
顺序读取getByte、getBytes、getByteRange从 pos 读字节,返回视图或结束哨兵
游标与窗口reset、moveStart回到起点,或把逻辑起点推到当前位置
派生makeSubStream、clone共享底层内存,生成子窗口或独立副本
StringStreamconstructor把 JavaScript 字符串转成可解析的流
NullStreamconstructor用零长度流表示「没有内容」

Stream 覆写了 BaseStream 里的大部分抽象成员,但没有覆写 peekByte、peekBytes、getUint16、getInt32、getString、skip。这些方法都建立在 getByte、getBytes 和可写的 pos 之上,父类的默认实现已经足够。

三指针模型

Stream 没有「缓冲区偏移」这种额外状态。整个读取过程只由三个数字描述:start 是逻辑窗口的起点,pos 是当前游标,end 是逻辑窗口的终点。三者都是底层 bytes 数组里的绝对下标,窗口区间是左闭右开的 [start, end)。

constructor 方法

  constructor(arrayBuffer, start, length, dict) {
    super();
 
    this.bytes =
      arrayBuffer instanceof Uint8Array
        ? arrayBuffer
        : new Uint8Array(arrayBuffer);
    this.start = start || 0;
    this.pos = this.start;
    this.end = start + length || this.bytes.length;
    this.dict = dict;
  }

第一段处理输入形态。传入 Uint8Array 时直接使用,不复制;传入 ArrayBuffer 时用 new Uint8Array(arrayBuffer) 包一层视图。两种情况都共享同一块内存,区别只在于调用方是否已经持有类型化数组。

第二段建立三指针。start 用 start || 0 归一化,undefined、NaN、0 都会落到 0;pos 初始等于 start;end 用 start + length || this.bytes.length 计算。只要 length 是 undefined 或 NaN,加法结果就是 NaN,回退到整个字节数组的长度。

这里有一个容易被忽略的边界:length 为 0 且 start 也为 0 时,start + length 是 0,属于假值,同样会回退到 bytes.length。也就是说,在偏移 0 处显式创建零长度流,得到的却是整段数据。仓库里的空流由 NullStream 负责,StringStream 用 NaN 表示未知长度,正常解析路径不会踩到这个组合;但读源码时要知道构造器只做算术,不做语义校验。

dict 直接按引用保存。过滤器链、加密流与子流可能共享同一个字典对象;需要独立字典时,由 clone 负责复制。

字段含义约定
bytes底层字节视图Uint8Array,可能是整块缓冲或一个子视图
start逻辑窗口起点绝对下标,moveStart 会修改它
pos当前读取位置绝对下标,初始等于 start
end逻辑窗口终点绝对下标,不包含在窗口内
dict流字典可以为 null,默认按引用共享

end 并没有被截断到 bytes.length。构造器相信调用方给出的 length;如果长度超过底层数组,getByte 可能读出 undefined。实际路径里,Parser 会用 endstream 扫描修正错误长度,makeSubStream 的起点与长度也来自已经校验过的偏移,所以这个信任是可控的。

一个 Stream 从建立到销毁通常只经历三个阶段:

  1. 建立窗口。PDFManager 用整个文件建立顶层流;Parser 算出某个流对象的 startPos 与 length,再用 makeSubStream 建立子窗口。此阶段只写 start、pos、end,不复制数据。
  2. 推进游标。Lexer 与 Parser 通过 getByte、getBytes、skip 前进;pos 单调增长,end 保持不变。只有调用 getBytes 时会创建一个轻量视图对象,字节本身不动。
  3. 复位或派生。解析同一个流对象的字典与数据时可能需要多次读取,reset 把 pos 拉回 start;写出或编辑时用 clone 复制窗口描述;需要长期保留某个位置时用 makeSubStream 开新窗口。

这三个阶段对应三组方法:构造器负责第一阶段,读取与 skip 负责第二阶段,reset、moveStart、makeSubStream、clone 负责第三阶段。它们之间没有隐藏状态传递,任何时候只靠三个数字就能还原流的完整位置。

设计要点:用绝对下标而不是「相对偏移加基址」来描述窗口,读取逻辑就退化成一次比较和一次自增。getByte 不需要先算 start + pos,getBytes 不需要维护额外的读取偏移;所有派生流也只需要复制三个数字。状态越少,越不容易在嵌套流里错位。

length 与 isEmpty 方法

  get length() {
    return this.end - this.start;
  }
 
  get isEmpty() {
    return this.length === 0;
  }

length 是窗口的总长度,不是剩余长度。读完整个流之后,pos 等于 end,length 仍然是原来的值;此时判断是否读完要看 pos,不能看 length。

isEmpty 同样只看窗口,不看游标。一个长度为 10、已经读到末尾的流不是空流,只是「读完了」;真正的空流是 start 与 end 相等。BaseStream 与 DecodeStream 里反复出现的 isEmpty 判断,语义都建立在这条区分上。

moveStart 会改变 start,因此 length 会随之变小。这个 getter 不是缓存的只读快照,而是对当前窗口的实时计算。

顺序读取与零拷贝

getByte 方法

  getByte() {
    return this.pos >= this.end ? -1 : this.bytes[this.pos++];
  }

这是整个流最热的路径。先比较 pos 与 end,到达终点就返回 -1;否则读取 bytes[pos],再让 pos 自增。由于 end 不包含在窗口内,最后一个可读字节的下标是 end - 1。

-1 同时承担「文件结束」和「没有更多数据」两种含义。PDF 的字节范围是 0 到 255,负数不可能成为合法字节,所以这个哨兵不会与真实数据冲突。Lexer 里的 while 循环因此可以只判断 -1,不必再额外查询长度。

方法没有检查 pos 是否超过 bytes.length。它只信任 end,这也是 Stream 比 DecodeStream 快的原因:没有缓冲区扩容、没有解码状态、没有缺失分块检查,只有一次数组索引。

getBytes 方法

  getBytes(length) {
    const pos = this.pos;
    const endPos = !length ? this.end : Math.min(pos + length, this.end);
 
    this.pos = endPos;
    return this.bytes.subarray(pos, endPos);
  }

先保存旧游标,再计算新的终点。!length 为真时取窗口末尾,所以「不传参数」和「传 0」都表示读取剩余全部字节;想读固定长度时必须传正整数。Math.min 负责截断:请求长度超过剩余数据时,只返回到 end 为止的部分。

subarray 是零拷贝的关键。它返回一个新的 Uint8Array 视图,底层 ArrayBuffer 与父数组完全相同,只是 byteOffset 与 length 不同。读取几百 KB 的内容流时,不会产生一次额外的内存分配与复制;Lexer、Parser、bytesToString 都直接消费这个视图。

零拷贝也带来两个必须记住的约束:

  1. 返回数组的 buffer 是整块底层缓冲,不是这段数据的专属缓冲。拿到视图后不能假设 buffer.byteLength 等于 bytes.length,也不能直接按 buffer 的下标访问这段数据。
  2. 视图与流共享内存。修改返回数组会改到流的原始字节;需要独立副本时必须显式调用 slice。

pos 在返回之前就已经推进到 endPos,所以调用方即使立刻再次调用 getByte,也会从新位置继续。若请求长度在末尾被截短,游标前进的是实际长度,peekBytes 正是利用这一点保持位置准确。

几种读取方式放在一起对比:

调用返回区间pos 变化是否复制典型用途
getByte()当前字节前进 1否词法扫描
getBytes()pos 到 end前进到 end否一次读完剩余数据
getBytes(n)pos 到 pos + n,受 end 限制前进实际读取量否定长读取
getByteRange(a, b)绝对区间 [a, b)不变否文件指纹、签名校验
peekBytes(n)同 getBytes(n)先前进再退回否试读、判断后续语法

设计要点:subarray 把「复制」从读取路径里彻底拿掉。PDF 解析时同一段内容往往要被读多次:Parser 先试读,Lexer 再正式读,Evaluator 又按算子消费。如果每次读取都 slice,大文件会产生大量短命数组;返回视图则只付出一个对象头的成本。

getByteRange 方法

  getByteRange(begin, end) {
    if (begin < 0) {
      begin = 0;
    }
    if (end > this.end) {
      end = this.end;
    }
    return this.bytes.subarray(begin, end);
  }

它与 getBytes 的区别不在实现复杂度,而在语义:区间是底层 bytes 的绝对下标,与当前 pos 无关,调用后游标不动。

两个裁剪只处理最常见的方向。begin 为负数时拉到 0;end 超过逻辑窗口末尾时截到 this.end。它没有把 begin 限制在 end 之前,也没有让 end 不小于 0;这些情况交给 subarray 自己处理,结果是一个空视图或更短的视图。

注意裁剪的上界是 this.end,不是 bytes.length。这说明它仍然尊重当前逻辑窗口:即使底层还有更多字节,也不会越过 end 返回。反过来说,它也没有把 begin 抬到 this.start;如果传入比窗口起点更早的绝对下标,仍能读到窗口之前的字节。这是「绝对区间」语义的自然结果。

ChunkedStream 覆写了这个方法:先做同样的裁剪,再对尚未下载的区间调用 ensureRange,缺数据时抛 MissingDataException。上层看到的接口不变,但读取可能变成一次网络请求。

游标与窗口

reset 与 moveStart 方法

  reset() {
    this.pos = this.start;
  }
 
  moveStart() {
    this.start = this.pos;
  }

reset 把游标放回逻辑起点,而不是数组下标 0。一个已经 moveStart 到文件头部的流,复位后会回到头部,不会回到文件最开头。它只改 pos,不清空 bytes、不修改 dict,可以反复调用。

moveStart 则把逻辑起点推到当前游标处。pos 不动,start 变大,end 不变,于是 length 变小。PDFDocument.checkHeader 的用法最典型:先 reset,用 find 找到 %PDF-,再 moveStart,把文件开头可能存在的空白、BOM 或垃圾字节排除在逻辑窗口之外;随后 skip 头部签名,读取版本号。

按公开方法看,moveStart 只会让窗口变窄,不能把已经丢弃的前缀重新纳入。若之后再次 reset,游标回到新的 start,也就是头部,而不是原始文件起点。

skip 没有在 Stream 里覆写,用的是 BaseStream 的默认实现:this.pos += n || 1。它不检查边界,负数表示后退;Parser 扫描 JPEG 内联图像时就用 skip(-2) 从错误长度处回退到 marker 起点。由于 skip 直接改 pos,它与 moveStart 配合时要求调用方清楚当前游标的绝对位置。

边界行为与常见误用

Stream 的代码很短,边界语义却不少。下面这些组合在调用方看来都是「同一个方法」,实际结果差异很大:

场景实际行为需要注意
getBytes() 或 getBytes(0)从 pos 一直读到 end传 0 不是「读零个字节」
getBytes(n) 超过剩余数据截到 end,返回更短的视图以返回数组的 length 为准
游标已经到 end 后调 getByte返回 -1,不抛错适合循环终止,不适合区分错误类型
getByteRange 的 begin 为负拉到 0不会抛错
getByteRange 的 end 过大截到 this.end不会读到逻辑窗口之外
getByteRange 的 begin 大于 endsubarray 返回空视图调用方要自己保证区间顺序
手动把 pos 推到 end 之后getByte 仍是 -1,getBytes 为空skip 不校验,越界不会被发现
修改 getBytes 返回的视图改到底层 bytes需要独立副本时显式 slice
直接使用返回视图的 bufferbuffer 是整块底层缓冲数据起点是视图的 byteOffset
start 为 0 且 length 为 0end 回退到 bytes.length空流请用 NullStream
对子视图流调用 makeSubStream下标从整块缓冲区起点算,不从子视图算主路径的 bytes 从 0 开始,不受影响
克隆后修改字典的嵌套值顶层键独立,值与引用仍共享需要完全隔离时必须自行深拷贝

前几行都是「静默截断」:方法不抛异常,也不返回错误码,只给一个更短的结果。这是流式解析必须的选择——数据边界经常由损坏文件或错误长度决定,抛错会让整份文档无法打开;返回可用部分,至少还能继续扫描。

需要特别区分的是「读到末尾」与「空流」。pos 到达 end 只说明当前窗口读完了,length 仍然非零,isEmpty 为假;只有 start 与 end 相等才是空流。很多解析循环用 getByte 的 -1 终止,而不是用 isEmpty 判断,就是因为两者含义不同。

最后两行涉及所有权。零拷贝视图让「读取」不分配,但也让「修改」具有传染性;需要把字节交给外部修改时,先 slice 出一份独立副本。clone 的浅克隆字典同理:改顶层键安全,改嵌套对象仍然会影响原文档。

派生流:makeSubStream 与 clone

makeSubStream 方法

  makeSubStream(start, length, dict = null) {
    return new Stream(this.bytes.buffer, start, length, dict);
  }

这是 Stream 里最重要的一个优化,也是理解 PDF.js 内存模型的关键。它没有复制字节,没有调用 slice,甚至没有复用 this.bytes;它把 this.bytes.buffer 整块交给构造器,让新 Stream 用 new Uint8Array(arrayBuffer) 重新包一层视图。新流与父流共享同一块 ArrayBuffer,只拥有自己的 start、pos、end。

start 是底层缓冲区的绝对下标,不是相对于父流 start 的偏移。XRef.fetchUncompressed 里可以看得很清楚:

const stream = this.stream.makeSubStream(xrefEntry.offset + this.stream.start);

xref 条目里的 offset 是相对文件头的偏移,而 makeSubStream 要的是相对底层缓冲区的偏移,所以这里加上 this.stream.start。Parser.makeStream 传入的 startPos 也是 Lexer 游标的绝对位置,而不是相对父窗口的差值。

dict 按引用传递,不做克隆。解析器在切出流数据之后会设置 stream.dict = dict,父子流因此共享同一个字典对象;这与 PDF 语义一致:子流就是那个流对象本身的数据窗口。

省略 length 时,新流构造器的 end 会回退到底层缓冲区的总长度,而不是父流的 end。对顶层文件流来说,这两个值通常相同;如果父流本身已经是一个子窗口,二者可能不同。调用方要么显式传入长度,要么明确知道自己要一直读到缓冲区末尾。

还有一个隐含前提:this.bytes 通常是从缓冲区下标 0 开始的完整视图,此时 bytes.buffer 与 bytes 的坐标系一致。如果 this.bytes 本身是带非零 byteOffset 的 subarray,再用 bytes.buffer 配绝对下标,坐标系就会偏移父视图的起点。仓库里的主路径满足这个前提;从 peekBytes 临时包出来的 Stream 只用于小范围试读,不走这条派生路径。

makeSubStream 的复杂度是 O(1)。一份几十 MB 的 PDF 里可能有成千上万个流对象,每个都复制自己的数据会直接拖垮内存;共享底层缓冲、只建对象头,才能让「解析一个流对象」保持常数成本。

设计要点:子流共享的是字节,不是状态。bytes.buffer 让内存只有一份,start、pos、end 让每个流拥有独立窗口和独立游标。同一份内容可以被 Parser、Evaluator、字体解析器同时持有,各自复位、各自读取,互不干扰。

clone 方法

  clone() {
    return new Stream(
      this.bytes.buffer,
      this.start,
      this.length,
      this.dict?.clone()
    );
  }

clone 与 makeSubStream 共享同一条优化路线:不复制字节,只新建对象。区别在于它复制的是当前窗口,start 与 length 原样保留,而不是重新指定起点与长度。新流的 pos 由构造器初始化为 start,因此旧流读到一半也不影响副本。

字典的处理是两者最大的差别。makeSubStream 直接复用传入的 dict,clone 调用 this.dict?.clone()。Dict.clone 在 primitives.js 里做的是浅克隆:新建一个 Dict,沿用同一个 xref,把原字典的键值逐条复制过去。字典本身是新的,顶层增删不会影响原对象;值仍然指向原来的对象、数组或引用。

这个设计服务于写出与编辑场景。写出 PDF 时需要修改 Length、Filter、DecodeParms 等条目,或者给流换一个字典;如果直接改原字典,已加载文档的语义就被破坏了。克隆一层字典,既隔离了可变外壳,又避免深拷贝整棵对象图。

可选链 this.dict?.clone() 处理了没有字典的流。NullStream 与临时 Stream 都没有字典,克隆时直接得到 undefined,不会抛错。

与 makeSubStream 一样,clone 也使用 this.bytes.buffer,因此同样隐含「bytes 从缓冲区下标 0 开始」的前提。另一个需要知道的点是:clone 固定返回 Stream,不保留子类。ChunkedStream 继承了这个实现,克隆出来的是普通 Stream,不再有分块管理器与缺失区间检查;它适合数据已经完整、只需要独立游标的场景。

与其它流实现的对照

把 Stream、DecodeStream、ChunkedStream 放在一起,能看出「共享字节、独立游标」在不同数据源上的三种做法:

能力StreamDecodeStreamChunkedStream
底层存储外部传入的 ArrayBuffer 视图内部按需扩容的解码缓冲预分配整文件长度的 bytes
读取未就绪数据不存在,字节已经在内存里调用 readBlock 解码抛 MissingDataException 请求下载
makeSubStream用 bytes.buffer 新建窗口先读完整个解码缓冲,再包成 Stream原型链子流,共享 bytes 并按需校验
clone共享缓冲区,字典浅克隆先读完,再包成 Stream继承 Stream,得到普通 Stream
getByteRange裁剪后返回视图未覆写先 ensureRange 再返回视图
游标复位pos = startpos = 0继承 Stream

这三套实现共同证明了一件事:BaseStream 的抽象不是「同一种存储的不同包装」,而是「同一种读取语义的不同数据来源」。Lexer 只关心 getByte 是否返回下一个字节,完全不关心这个字节是来自本地数组、解压缓冲还是刚下载的分块。

两种特殊流

StringStream 方法

class StringStream extends Stream {
  constructor(str, dict = null) {
    super(stringToBytes(str), NaN, NaN, dict);
  }
}

StringStream 把 JavaScript 字符串变成可解析的流。stringToBytes 在 shared/util.js 里逐字符调用 charCodeAt,再按位与 0xff 截到低字节,放进新建的 Uint8Array。它假设字符串已经是 PDF 语法所需的单字节表示,适合内容流片段、外观流与 Latin-1 文本;真正的 Unicode 文本要先经过调用方编码,不能指望它自动完成。

传入两个 NaN 是一个小技巧。start 的 NaN || 0 归零,end 的 NaN + NaN || bytes.length 取整个字节数组长度。两个「未知」分别被构造器的两个回退分支消化,最终得到一个从 0 开始、覆盖全部字符串字节的窗口。

dict 可选,用于给合成出来的流挂字典。annotation.js 生成注释外观流、default_appearance.js 解析 /DA 字符串、writer.js 写 XFA 数据、pdf_editor.js 组装编辑后的内容,都是先构造 StringStream,再交给同一套 Lexer、Parser、Evaluator。调用方不需要为「内存里的字符串」准备一条特殊解析路径。

default_appearance.js 里的用法最直观:一个负责解析默认外观的求值器,构造函数只需要把字符串包成 StringStream:

  constructor(str) {
    super(new StringStream(str));
  }

求值器本身并不知道 BaseStream 的具体类型,它只依赖基类提供的读取与复位接口。StringStream 把「字符串」这个数据源伪装成「流」,于是 /DA 字符串与页面内容流走完全相同的解析代码。这正是抽象基类存在的意义:数据来源可以替换,解析器不需要分支。

NullStream 方法

class NullStream extends Stream {
  constructor() {
    super(new Uint8Array(0));
  }
}

NullStream 是零长度流。构造器传入一个空数组,start 落到 0,end 因为 start + undefined 是 NaN 而回退到 bytes.length,结果也是 0;于是 length 为 0、isEmpty 为真、getByte 永远返回 -1、getBytes 返回空视图。

它的价值不在读取,而在占位。Parser.makeFilter 遇到 Length 为 0 的流,或者过滤器名称不支持、解码抛错时,会在警告之后返回 NullStream;Page.getContentStream 在页面根本没有内容流时,也返回 NullStream。这样 Evaluator、OperatorList 与上层调用方不需要在每个入口判断 null,空流会自然地产生零个算子。

源码里两个最直接的入口是:

if (maybeLength === 0) {
  warn(`Empty "${name}" stream.`);
  return new NullStream();
}

以及过滤器链的异常兜底:

    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn(`Invalid stream: "${ex}"`);
      return new NullStream();
    }

第一处只把「长度为 0」当成确定为空;注释特意说明,流字典里的 Length 经常不可靠,所以长度为 0 以外的值不能直接当成空流,否则会把非空数据误判。第二处把解码失败降级为空流,但必须先放过 MissingDataException:它不是坏数据,而是网络分块还没到,必须让上层去请求,不能吞掉。

这也是空对象模式在解析器里的具体用法:用一个满足完整 BaseStream 契约的对象表示「没有数据」,比到处散布 if (stream) 更稳定;一旦某个调用方忘了判空,也不会因为 null.getByte 直接崩掉。

共享与复制一览

把文件里所有会创建新对象或新字节的操作放在一张表里,内存关系就清楚了:

操作分配新字节共享底层缓冲区独立游标独立字典
new Stream(Uint8Array)否是是否,按引用
new Stream(ArrayBuffer)否是是否,按引用
getByte / getBytes否是否不涉及
getByteRange否是否不涉及
makeSubStream否是是否,共享同一份
clone否是是是,浅克隆
new StringStream是,字符串转字节否是否,按引用
new NullStream是,零长度数组否是无

唯一真正分配字节的入口是 StringStream;NullStream 只分配一个空数组;其余操作都在既有缓冲上开视图。这个分布决定了 PDF.js 的内存曲线:解析大文件时,内存主要由原始文件与解码缓冲占据,而不是由成千上万个流对象各存一份数据。

设计要点:复制只发生在「数据形态改变」的时候,例如字符串变成字节、压缩数据变成解码结果。只要形态不变,就只复制描述信息,也就是指针。把「分配」与「读取」分开,是流式 PDF 能在大文件上保持低内存的前提。

总结

Stream 是 PDF.js 的内存字节窗口:它把一块 Uint8Array 包装成带 start、pos、end 的流,提供顺序读取、区间读取、复位、派生与克隆。StringStream 与 NullStream 则用最小的代码覆盖「合成内容」与「空内容」两个边界。

它在整条链路中的位置

阶段模块输入输出
文件入口PDFManagerArrayBuffer 或视图Stream
网络入口ChunkedStream分块下载的数据带缺失区间检测的流
对象切分Parser.makeStream / XRef绝对偏移与长度子流视图
解码DecodeStream 子类压缩或加密字节原始字节
内容合成StringStream / NullStream字符串或空内容可交给 Lexer 的流

用一句话串起来:PDFManager 把文件变成 Stream,Parser 用 makeSubStream 把文件切成对象级窗口,DecodeStream 再把压缩字节变成可读字节,最后 StringStream 与 NullStream 补齐合成内容与空内容的边界。

五条设计原则

  1. 三指针是唯一真相。start、pos、end 决定窗口、游标与长度,没有隐藏的缓冲区偏移。所有读取方法都从这三个数字推导,派生流也只复制这三个数字。
  2. 视图优先,复制兜底。getBytes、getByteRange、makeSubStream 都返回或共享 subarray 视图,只有改变数据形态时才分配新字节。省掉的是每次读取的分配与拷贝,不是可读性。
  3. 子流共享内存,游标独立。makeSubStream 用 bytes.buffer 建立 O(1) 子窗口,父子流共享字节但各自维护 pos。这让同一份文件可以同时被多个解析器读取。
  4. 克隆只隔离可变外壳。clone 共享底层缓冲,复制窗口描述,浅克隆字典。字节是只读的,字典是可变的;隔离真正会被修改的那一层,避免深拷贝整棵对象图。
  5. 特殊流用最少量代码解决。StringStream 与 NullStream 各自只有几行,却让「字符串内容」与「空内容」完全复用现有解析链路。调用方不需要为边界情况写分支。

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

相关文章