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

前言
这是「字节与流」层里位置比较特殊的一篇,对象是 src/core/decrypt_stream.js。
前面讲过的 Flate、Brotli、Predictor 都在压缩链路上:输入是编码后的字节,输出是原始字节,目标是还原数据本身。DecryptStream 处在另一条链路——解密链路。它不解压,也不做行预测,只把密文字节按密码算法还原成明文;而还原出来的明文,往往还套着 Flate 或 Brotli,要继续交给后面的过滤器。所以它不是压缩链路的一员,而是压缩链路之前的一道门。
这道门的依据是 PDF 规范第 7.6 节。加密文档里的流与字符串默认都要加密,标准安全处理器根据修订版本与加密字典选择具体算法:较早的修订用 RC4,较新的修订引入 AES,密钥长度从 40 位一直到 256 位。加密单位是对象,每个流和每个字符串都用对象号与 generation 派生出的密钥单独处理。crypto.js 里的 CipherTransform 负责这套派生,本文的主角只负责最后一步:拿到密文块,调用密码对象,交出明文块。
真正把 DecryptStream 放进链路的动作发生在 Parser.readStream:
stream = stream.makeSubStream(startPos, length, dict);
const filter = dict.get('F', 'Filter');
// Streams that explicitly use `/Crypt` are decrypted in the filter chain,
// so avoid applying the default stream cipher up-front.
if (cipherTransform && !this.#hasCryptFilter(filter)) {
stream = cipherTransform.createStream(stream, length);
}也就是说,默认解密发生在滤镜之前。只有当 /Filter 数组里显式写了 /Crypt,解析器才会跳过这一步,让解密作为滤镜链的一环参与分派。两条路最终都会构造出 DecryptStream,区别只是构造位置与所选密码过滤器。
decrypt_stream.js 只有 55 行,一个类,一个常量,三个方法。它是这个系列里最短的流之一,但把「流式解密」的几处关键取舍都写在了明面上:块多大、怎么知道最后一块、末块标志交给谁、以及流的身份应该停在哪一层。
一句话概括:它把密文流按 512 字节切片解密,并且只有在确认读到末尾时,才把最后一块标记为 final。
模块总览
全文可以切成五块:
| 区块 | 关键方法 | 职责 |
|---|---|---|
| 分块常量 | chunkSize | 规定每次向底层流索取的字节数 |
| 构造 | constructor | 保存底层流、字典与解密回调 |
| 主循环 | readBlock | 预读下一块、判断末块、解密并追加到缓冲 |
| 预读缓存 | #nextChunk | 保存提前读出的下一块 |
| 流身份 | getOriginalStream | 返回自身,阻止身份继续向底层透传 |
它在整条链路里的位置:
PDF 字节
→ Parser.readStream
→ ChunkedStream / Stream
→ CipherTransform.createStream (crypto.js,选择 RC4 或 AES)
→ DecryptStream(str, length, decrypt) (本文件)
→ Parser.makeFilter (Flate / Brotli / DCT ...)
→ 上层: 对象流 / 内容流 / 图像 / 字体继承关系是 BaseStream → DecodeStream → DecryptStream。严格来说,解密并不属于「解码」,但 DecryptStream 仍然选择挂在 DecodeStream 下,因为它需要的东西完全一样:一个可增长的输出缓冲、一个读取游标、一个惰性驱动的 getBytes,以及一个由子类实现的 readBlock。复用这套骨架,比自己再写一套读取接口要划算得多。
父类提供的契约很短:readBlock 每次往 buffer 尾部追加一段数据,源耗尽时把 eof 置为真;getBytes 负责按调用方要的长度反复触发它,并在返回时给出从 pos 到末尾的零拷贝视图。DecryptStream 的实现完全落在这个契约内,没有额外的读取方法。
设计要点:密码算法有自己的块边界——AES 固定 16 字节,RC4 逐字节——而调用方要的长度是任意的。
DecryptStream用 512 字节的固定分块把两者隔开,再靠密码对象自身的残余缓冲吸收不整齐的尾巴。流层只管搬运,不试图让每次读取都对齐密码块。
解密为什么在滤镜之前
顺序不是实现偏好,而是格式决定的。
PDF 里的加密作用于「流对象的原始字节」,也就是文件里 stream 与 endstream 之间真正写下的那串数据;而 /Filter 描述的是这串数据经过解密之后,应该如何解压或反序列化。两者描述的其实是同一段数据的两个阶段。一个被加密的 Flate 内容流,在文件里是密文;解密后得到带 zlib 头的 DEFLATE 数据;再经过 FlateStream 才得到页面内容。顺序只能是先解密、后解压。
反过来也走不通。滤镜需要读取数据自身的特征来判断格式,例如 zlib 的两字节头、JPEG 的标记段、ASCII85 的字符集;而密文的开头是 AES 的初始化向量或 RC4 密钥流作用后的随机字节,滤镜在其中找不到任何可信的结构。把解密放在滤镜之后,等于要求解码器先对一个随机字节流做格式判断,这在原理上就不成立。
Parser.readStream 因此把默认解密放在 makeFilter 之前:
- 从文件里切出
startPos与length指定的原始数据段。 - 若存在加密上下文且滤镜链里没有
/Crypt,先用CipherTransform.createStream包一层DecryptStream。 - 再按
/Filter数组逐层包装解码器。 - 上层从最外层读取,实际的数据流方向是「密文 → 明文 → 解压 → 预测还原」。
那为什么还要允许 /Crypt 出现在滤镜链里?因为规范允许生成器把解密显式声明成过滤器,并可用 /DecodeParms 里的 /Name 指定某个命名密码过滤器,或指定 Identity 表示这一段不加密。解析器看到 /Crypt 就跳过默认包装,把解密交给 makeFilter 分派,避免同一段数据被解两次密。两条路最终都会落到 DecryptStream,区别只是谁负责构造、用哪个密码过滤器。
这也解释了 DecryptStream 为什么值得继承 DecodeStream:它不是滤镜链的固定前置,而是可以出现在链中任意位置的普通节点。只要读取接口一致,放在哪里都能工作。
构造与分块粒度
chunkSize 常量
const chunkSize = 512;这个数字和 AES 的 16 字节块没有直接关系,也不是 PDF 规范规定的读取单位。它是这一层选定的 I/O 粒度,同时服务于三个目标。
第一,控制单次分配的临时内存。512 字节足够小,即使一个几 MB 的图片流也不会因为一次多读而占用额外的大块缓冲;它又足够大,摊薄了每次调用 getBytes 与 decrypt 的函数开销。
第二,与密码块保持亲和。512 是 16 的整数倍,大多数情况下每次取出的数据正好落在 AES 块的边界上,密码对象内部几乎不需要跨调用保留残余。这不是正确性的前提——decryptBlock 本来就会把不完整的块留到下一次——但能让热路径更顺。
第三,给「末块判断」留出空间。要知道当前块是不是最后一块,必须向后看一块;块越小,预读的代价越低,首块数据的延迟也越短。
DecodeStream 的 minBufferLength 默认值同样是 512。两者数值相同,含义不同:一个决定从哪里读,一个决定输出缓冲从多大开始翻倍。它们碰巧一致,读源码时不要把二者当成同一个概念。
constructor 方法
constructor(str, maybeLength, decrypt) {
super(maybeLength);
this.stream = str;
this.dict = str.dict;
this.decrypt = decrypt;
}构造函数只保存三样东西。
maybeLength 交给父类,用来计算输出缓冲的起始容量。它来自流字典的 /Length,是密文长度,不是明文长度。由于 AES 流的前 16 字节是初始化向量,末块还要去掉 1 到 16 字节的填充,明文一定比这个值短。父类把它当作容量参考而不是精确长度,正好合适。
this.stream 是底层密文流,可能是 Stream,也可能是 ChunkedStream,甚至可能是另一层流。this.dict 直接引用底层流的字典,不复制:对象号、generation、加密上下文都挂在字典上,解密之后的明文流仍然需要这些信息来标识自己。
this.decrypt 是一个闭包,由 crypto.js 在构造时捕获一个新建的密码实例。这一点很重要:密码对象是有状态的。RC4 需要维护 256 字节的置换表与两个游标,AES 需要维护密钥、当前 IV 以及不足一块的残余。因此每个 DecryptStream 必须配一个独立的密码实例,绝不能跨流共享,否则密钥流的位置或 IV 会错位,解出来的字节会从某一块开始全部错误。
密码实例的生命周期
CipherTransform.createStream 每次都会执行 const cipher = new Cipher()。即使同一份文档里有两个流使用同一个对象密钥,它们拿到的也是两个独立的密码对象。这样做不是浪费,而是必要:解密是顺序的,密码状态只能对应一条流的一次从头到尾的读取。
这条约束也决定了流的克隆方式。DecodeStream.clone() 会先把当前流读到 eof,再把输出缓冲包成一个普通 Stream 返回。对于 DecryptStream,克隆不是复制密码状态,而是把「未来还没解的部分」提前解完:克隆体与原流从此共享同一份明文字节,底层密文已经耗尽,密码对象不会再收到新的输入。若两个消费者想同时读取同一个加密对象,安全的顺序是让一方读完并克隆出普通 Stream,而不是让两个读取者交错驱动同一个密码对象;若需要真正从头再读一份,则应由上层重新取对象、重新构造 DecryptStream。
主循环: readBlock
readBlock 方法
readBlock() {
let chunk = this.#nextChunk ?? this.stream.getBytes(chunkSize);
if (!chunk.length) {
this.eof = true;
return;
}
this.#nextChunk = this.stream.getBytes(chunkSize);
const hasMoreData = this.#nextChunk.length > 0;
const decrypt = this.decrypt;
chunk = decrypt(chunk, !hasMoreData);
const bufferLength = this.bufferLength,
newLength = bufferLength + chunk.length,
buffer = this.ensureBuffer(newLength);
buffer.set(chunk, bufferLength);
this.bufferLength = newLength;
}这十几行可以按四步读。
第一步,取当前块。 this.#nextChunk ?? this.stream.getBytes(chunkSize) 是一个空值合并:如果上一轮预读留下了缓存,就直接用它;否则向底层流要 512 字节。第一次调用必然走后一个分支。
第二步,判断是否到底。 如果当前块长度为 0,说明底层流已经耗尽,置 eof 并返回。注意这里没有任何解密动作:空块没有意义,AES 与 RC4 对空输入都只会返回空输出,调用它纯属浪费。这也是整个方法唯一设置 eof 的地方。
第三步,提前预读下一块。 在解密当前块之前,先把下一块读进 #nextChunk,再用它的长度是否为零得到 hasMoreData。这一步是全部设计的核心:密码回调需要知道「当前块是不是最后一块」,而只有读完下一块才能回答。多读的这 512 字节不会被丢弃,下一轮直接从 #nextChunk 取出,因此底层流仍然只被顺序消费一次,不需要回退游标。
第四步,解密并追加。 decrypt(chunk, !hasMoreData) 把「是否最后一块」作为第二个参数传下去。返回值可能比输入短:AES 首块会吞掉 16 字节 IV,末块会去掉填充。随后 ensureBuffer 保证输出缓冲够长,set 把明文写到尾部,bufferLength 随之更新。
预读带来一个可以验证的不变量:每个非末块都以 false 调用,末块以 true 调用,且 true 只出现一次。 如果流长度恰好是 512 的整数倍,最后一轮会先取到真实的末块,预读则拿到一个空块,于是末块正确地带上了 true;如果流长度不是整数倍,末块本身就不足 512 字节,预读同样返回空块。两种情况都不需要额外的分支。
还有一个细节:AES 在第一次调用时,如果拿到的数据不足 16 字节,会把它缓存起来并返回空数组。这时 chunk.length 为 0,追加操作什么也不做,bufferLength 不变,父类的循环会继续调用 readBlock,直到攒够一个完整块或者流结束。追加逻辑不需要为这种「零产出」做特殊处理,ensureBuffer 与 set 对零长度都是安全的。
为什么不一次读完
既然最终可能要把整段密文都解密,为什么还要分块?
原因不在密码算法,而在调用链的上游。PDF 的流通常是惰性读取的:一页内容流可能只有几百字节,一张扫描图却可能有几十 MB;如果 readBlock 第一次就把底层流全部读光,那么仅仅为了解析页面开头的一个操作符,也要等待整条流从网络或文件到位。分块让 DecodeStream.getBytes 可以按需驱动,调用方要 64 字节,就只解密到够 64 字节为止。
分块还与 ChunkedStream 的缺数据异常配合得很好。底层数据尚未下载完时,getBytes 会抛 MissingDataException;因为读取是按 512 字节推进的,异常会在真正碰到缺口时才出现,PDFManager 据此只请求缺失的区间,而不是盲目扩大预取范围。解密本身是顺序的,已经解出的前缀不需要重来。
final 标志的去向
readBlock 只负责把 !hasMoreData 传下去,真正解释这个布尔值的是密码对象。crypto.js 在构造流时把回调接成一个闭包:
return new DecryptStream(stream, length, function cipherTransformDecryptStream(data, finalize) {
return cipher.decryptBlock(data, finalize);
});回调的第二个参数沿用了 decryptBlock 的命名 finalize。它只做一件事:告诉密码对象「这是最后一段密文,可以收尾了」。
AES 的填充处理
AES 是块密码,工作在 CBC 模式。PDF 使用 PKCS#7 填充:明文长度补齐到 16 的整数倍,补几个字节就写几个几,例如缺 5 字节就补五个 0x05;如果明文本来就是 16 的整数倍,则额外补一整块 16 个 0x10。解密时必须把这部分去掉,否则输出尾部会多出无意义的填充字节。
AESBaseCipher._decryptBlock2 在 finalize 为真时执行这段逻辑:
if (finalize) {
// undo a padding that is described in RFC 2898
const lastBlock = result.at(-1);
let psLen = lastBlock[15];
if (psLen <= 16) {
for (let i = 15, ii = 16 - psLen; i >= ii; --i) {
if (lastBlock[i] !== psLen) {
// Invalid padding, assume that the block has no padding.
psLen = 0;
break;
}
}
outputLength -= psLen;
result[result.length - 1] = lastBlock.subarray(0, 16 - psLen);
}
}它取最后一块的最后一个字节作为填充长度,然后校验:长度必须不超过 16,且最后 psLen 个字节都要等于 psLen。任何一项不满足,就把 psLen 当作 0,保留整块。这是典型的容错读法——宁可多留几个字节,也不因为生成器的填充不规范而让整份文档解密失败。
finalize 为假时,函数完全跳过这段逻辑。原因很直接:只有最后一块才有填充,中间块如果也去填充,会把每块末尾的正常数据误删,输出立刻错位。
RC4 与空密码
RC4 是完全不同的情况:
decryptBlock(data) {
return this.encryptBlock(data);
}它只有一个参数。DecryptStream 传进来的第二个实参在 JavaScript 里会被直接忽略。RC4 是流密码,密钥流与明文逐字节异或,长度一一对应,没有块的概念,也没有填充需要去掉。无论 finalize 是真是假,输出长度都等于输入长度。
NullCipher 更简单,decryptBlock 直接返回原数据。它用于「声明了加密但实际不加密」或 Identity 密码过滤器的情况,同样不关心 finalize。
把三种密码放在一起看,final 的语义就很清楚了:它不是「最后一块要特殊解密」,而是「可以收尾了」。块密码借此去掉填充,流密码则无事可做。
| 密码实现 | 算法类型 | 对 finalize 的处理 | 输出长度 |
|---|---|---|---|
NullCipher | 空变换 | 忽略 | 等于输入 |
ARCFourCipher | RC4 流密码 | 忽略(方法签名只接收一个参数) | 等于输入 |
AES128Cipher | AES-CBC | 为真时校验并去掉末块 PKCS#7 填充 | 可能比输入少 1 到 16 字节 |
AES256Cipher | AES-CBC | 同上 | 同上 |
首块的 IV
AES 的密文流开头还有 16 字节初始化向量。decryptBlock 在第一次调用时,如果外部没有显式提供 IV,会先把这 16 字节攒进内部缓冲,攒够之后把它设为 iv,再从第 17 字节开始解密。因此首块的实际输出是「读到的 512 字节减去 16 字节 IV」,后续块才是完整的 512 字节,末块再去掉填充。
这也解释了为什么构造函数拿到的 maybeLength 只能当容量参考:真正的明文长度是密文长度减去 IV、再减去填充,而且填充长度要到解密最后一刻才知道。
流身份: getOriginalStream
getOriginalStream 方法
getOriginalStream() {
return this;
}方法体只有一行,却是这个文件里最容易被忽略的设计。
BaseStream 的默认实现是向下透传:
getOriginalStream() {
return this.stream?.getOriginalStream() || this;
}一个包在另一个流外面的解码器,会把「原始流」的询问一路传到最底层,最终返回文件里那段字节所在的流。这对 Flate、Brotli 这类纯解码器是合理的:它们包着的底层流,就是自己的数据来源。
DecryptStream 故意打断了这条链。它不把询问传给底层密文流,而是返回自己。原因是身份的定义在解密层发生了变化:对上层来说,真正可信的「原始字节」是解密后的结果,而不是文件里的密文。继续向下透传,会让调用方拿到错误的字节与错误的身份。
这里的「明文」是相对密码层而言的:它可能仍然是 Flate 或 Brotli 压缩的字节,甚至可能还要经过预测器还原,但已经不是密文。对写回与克隆来说,这正是需要的那一层——保留压缩与滤镜信息,只把密码层剥掉。
两个具体调用点可以说明后果。
writer.writeStream 在序列化流时先解包再取字节:
async function writeStream(stream, buffer, transform) {
stream = stream.getOriginalStream();
stream.reset();
let bytes = stream.getBytes();随后它按字典里的 /Filter 决定是否重新压缩,最后在写回时用 transform.encryptString 重新加密。如果 getOriginalStream 返回的是底层密文流,写回流程就会把密文再加密一次,得到双重加密的数据,同时字典里的 /Length 也与实际内容不符。返回解密层则恰好相反:这里拿到的是明文(对内层压缩而言仍是压缩字节),重新加密一次就回到正确状态。
编辑器复制对象时也依赖同一条规则。pdf_editor 在克隆流时调用 obj.getOriginalStream().clone();DecodeStream.clone() 会先把流读到 eof,再把输出缓冲包成一个普通 Stream。对 DecryptStream 来说,这个过程产出的是一份完整的明文字节,字典也一并克隆;如果身份透传到密文层,复制出来的对象就会带着密文字节和明文字典,后续任何读取都对不上。
所以 getOriginalStream 的返回自身并不是偷懒,而是明确声明:解密层就是这条流对外的最佳身份,再往下属于密码实现的私事。
与 DecodeStream 的契约: 追加而非替换
DecryptStream.readBlock 用的是「追加」写法:先 ensureBuffer 扩到新长度,再把解密结果 set 到 bufferLength 之后。这与 BrotliStream 的「整体替换」形成对照:
| 解码器 | 输入与输出的长度关系 | 写入方式 | 原因 |
|---|---|---|---|
DecryptStream | 逐块、近似一比一 | 追加到缓冲尾部 | 明文分块可用,边解密边供上层读取 |
FlateStream | 逐块、输出通常更长 | 追加到缓冲尾部 | 块与块之间是连续的字节流 |
BrotliStream | 整段解压,长度事先未知 | 直接替换整个缓冲 | 解码器没有增量接口,一次性给出全部结果 |
追加的前提是「每块解出来就能直接交给调用方」。解密满足这一点:除了首块的 IV 与末块的填充,块与块之间没有依赖,缓冲区里已经写入的部分不会因为后面的解密而改变。因此上层可以在只解出前几百字节时就开始解析内容流,不必等整条流完成。
替换的前提则是「结果必须一次成型」。Brotli 解码器不接受分段输入,输出长度也只有解完才知道,追加只会带来反复扩容和多余拷贝。两种写法没有优劣,只是各自适配解码器的形态。
追加写法还有一个内存上的好处:ensureBuffer 按 2 的幂扩容,容量增长与已解密的数据量成正比,不会出现「先分配整个明文长度」的峰值。对于大图片流,这一点比省几次拷贝更重要。
代价则是扩容时的旧数据搬迁。按 2 的幂增长之后,累计拷贝量与输出总长度同阶,不会随块数平方增长;对于几百 KB 的字体或内容流,实际搬迁只有几次。相比之下,若试图预估明文长度并一次性分配,就会重新依赖并不可信的 /Length,反而更容易出错。
边界与容错
几个边界情况值得单独说明。
- 空流。第一次
readBlock拿到的块长度为 0,直接置eof返回,解密回调一次都不会被调用。空明文与空密文在结果上一致,跳过调用没有副作用。 - 末块不足 16 字节。AES 的
_decryptBlock2会把不足一块的尾部留在内部缓冲里,即使finalize为真也无法解密这几十个字节。最终输出会丢掉这段不完整的尾巴,而不是抛出异常。这是「尽量读出能读的部分」的选择。 - 填充不合法。如上一节所述,校验失败时按「没有填充」处理,保留整块。对生成器不规范的文档,这比直接报错更有实际价值。
- 密文太短。若整个流不足 16 字节,AES 连 IV 都攒不够,第一次调用返回空数组,随后流结束,上层得到空明文。调用方按空数据处理,不会卡死。
/Crypt过滤器。当/Filter数组里显式包含/Crypt时,Parser.readStream不做默认解密,改由makeFilter在链中构造DecryptStream,并按/DecodeParms里的/Name选择密码过滤器。两条路的类相同,只是构造时机与所选密码不同。- 长度不可信。
/Length只用于缓冲起点;真实明文长度由 IV、块边界与填充共同决定。任何试图用/Length直接计算输出位置的代码都会在 AES 文档上出错。
这些情况有一个共同点:错误都发生在数据本身,而不是读取框架。DecryptStream 不负责判断文档是否合法,它只保证在给定字节上按顺序执行密码变换,并把能解出的前缀交出去。判断与策略留给上层,这让同一个类既能服务严格的标准文档,也能在畸形文件上尽量读出内容。
设计要点:这一层把「密码块」与「读取块」彻底解耦。密码对象自己处理不足一块的残余与末块填充,流层只负责按固定粒度搬运字节,并用一次前瞻确定末块。分工清楚之后,无论调用方一次要 1 字节还是整条流,输出都一致。
总结
DecryptStream 是 PDF.js 的解密适配层:把标准安全处理器算出的密文流还原成明文,向上交给滤镜链与解析器。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字节供给 | Stream / ChunkedStream | 文件切片、网络分块 | 密文字节 |
| 密钥派生 | CipherTransform | 加密字典、对象号、generation | 每对象独立的密码实例 |
| 解密 | DecryptStream | 密文(含 IV 与填充) | 明文(可能仍被压缩) |
| 滤镜 | Parser.makeFilter | 明文压缩字节 | 解压、预测后的字节 |
| 上层消费 | 对象流 / 内容流 / 图像 / 字体 | 原始字节 | 字典、operatorList、像素 |
用一句话串起来:CipherTransform 决定用什么密钥和算法,DecryptStream 决定按什么节奏把密文变成明文,滤镜链再把明文还原成可用数据。
五条设计原则
- 密码块边界与读取边界解耦。流层固定 512 字节搬运,不足一块的残余由密码对象自己缓存。调用方要多少给多少,不必关心 AES 的 16 字节或 RC4 的逐字节特性。
- 前瞻一次,末块标志只出现一次。先读下一块再解密当前块,用下一块是否为空判断当前块是否为末块。多读的字节留在
#nextChunk,底层流仍然只被顺序消费一遍。 - 边解密边追加,而不是整段替换。块与块之间没有依赖,已写入的明文不会被后续块改写,因此上层可以尽早开始解析;缓冲按需翻倍,避免预留整段明文的内存峰值。
- 身份在解密层截断。
getOriginalStream返回自身,使写回、克隆、编辑都停在明文这一层。再往下是密文,不属于上层应当看到的「原始字节」。 - 容错优先于报错。填充校验失败时保留整块,末块不完整时保留已解出的前缀,
/Length只当容量参考。损坏或不规范的文档尽量读出可用部分,而不是让整份文件失败。
至此,从文件字节到明文压缩字节的这段路补齐了。加密文档里真正被加密的往往不是语义数据,而是压缩后的流;所以解密必须发生在滤镜之前,DecryptStream 与 FlateStream 才会以这种顺序叠在一起。理解了这个顺序,再回头看 Parser.readStream 里那句「显式 /Crypt 时跳过默认解密」的注释,就能明白它是在避免同一段数据被解密两次。
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 源码解读: 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 源码解读: decode_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日