PDF.js 源码解读: decode_stream
2026年10月27日 · StreamPDF Team

前言
这是「字节与流」层的第二篇。
上一篇 chunked_stream 解决的是「字节从哪里来」:网络分片如何按需变成一份可寻址的缓冲。本篇解决下一段路——字节怎么被解码。
PDF 的流对象几乎从不直接存放原始数据。页面内容流通常是 Flate 压缩,字体文件可能套了 ASCII85 与 LZW,图片则是 DCT、JPX、JBIG2、CCITT 中的一种,部分流还要再过一层 PNG 或 TIFF 预测器。解析器不会直接读文件里的字节,而是读「所有 filter 都解完之后」的字节。
PDF.js 把所有解码器组织成一条继承链:DecodeStream 是公共基类,FlateStream、LZWStream、Ascii85Stream、RunLengthStream、PredictorStream、JpegStream 等都继承它。基类不关心任何具体算法,只提供三样东西:
- 一个会自动增长的输出缓冲。
- 一套以
readBlock为唯一驱动源的读取接口。 - 把解码结果物化成普通
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);
}canAsyncDecodeImageFromBuffer | isAsyncDecoder | 路径 | 说明 |
|---|---|---|---|
| 否 | 否 | 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 };
}
}这是一个「用浏览器原生解压换取速度」的快路径,分几步:
- 重置上游流。
this.stream.reset()把读取位置拨回起点;如果上游是异步的,还要等它把整段编码数据取完。 - 一次性拿到压缩字节。
isAsync为真时走asyncGetBytes,否则走同步getBytes。两种方式都返回完整数据,后面不再需要读取上游。 - 构造
DecompressionStream。name是"deflate"、"brotli"这类 format 字符串,由浏览器的解压实现负责算法。 - 先等
writer.ready,再写数据。写操作会施加背压:当可读端没人消费时,write不会完成。所以这里故意不await写,而是启动写入后立刻进入读取循环;写入链在消费端启动后自然会完成,并在完成时close。 - 收集并拼接输出。
for await逐个拿到解压分片,累计总长度后一次性分配Uint8Array并复制。相比反复 push 再扩容,这次拼接只分配一次。 - 失败时保留压缩字节。整个流程包在
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.makeSubStream | ChunkedStream.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 只处理一个子流:
- 列表为空则置
eof,结束。 - 从队首
shift一个流,取出它的全部剩余字节。getBytes()不带长度,意味着一次性读完整条子流。 - 出错时,如果有
onError回调,就把错误和对象号交出去,然后直接返回;这条流被丢弃,下一次readBlock继续处理下一个。 - 正常时把
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 |
| 语法 | Parser | token | Dict、数组、BaseStream |
用一句话串起来:字节源提供编码数据,DecodeStream 用统一的 readBlock 契约把数据解成缓冲,解析器再把缓冲读成对象。
五条设计原则
- 单一抽象点,子类只写
readBlock。基类不关心算法,只负责缓冲、读取与派生;十几种 filter 因此可以共享同一套读取语义。 - 容量与长度分离,几何扩容。
buffer.byteLength管内存,bufferLength管有效数据;扩容从minBufferLength开始翻倍,均摊复制成本是常数。 - 读取即返回视图,零拷贝。
getBytes直接返回subarray,makeSubStream与clone共享底层缓冲;需要独立游标时只新建Stream包装,不复制字节。 - 异步解码前置,失败可回退。
getImageData优先走异步路径,DecompressionStream失败时把压缩字节交回调用方,由 JavaScript 解码器兜底,避免二次读取。 - 派生前先物化。
makeSubStream与clone都会先读满所需数据,再交给普通Stream;拼接流则逐个吞并子流。惰性只保留在「逐块解码」这一层,不扩散到派生对象。
「字节与流」这两篇到这里告一段落:chunked_stream 负责把网络字节变成可寻址缓冲,decode_stream 负责把编码字节变成解码缓冲。下一篇回到 parser.js,看 Lexer 如何在这层缓冲之上切出 token,Parser 又如何把 token 组装成 PDF 对象。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日