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

前言
这是「字节与流」层里最短的两个过滤器之一,对象是 Worker 端 src/core/ascii_hex_stream.js(79 行)中的 AsciiHexStream。
前面几篇把解码链的骨架搭完了:base_stream 定义读取契约,decode_stream 提供输出缓冲与 readBlock 驱动,flate_stream 处理 DEFLATE 位流,predictor_stream 负责行差分的还原。它们都有真正的算法可讲:Huffman 树、LZ77 窗口、五种预测公式。本篇换一个极端:一个几乎不需要算法的过滤器。
PDF 规范里有一类过滤器不压缩数据,只负责「换一种表示」。ASCIIHexDecode 是最朴素的一种:每个字节写成两个十六进制字符,用空白分隔也不影响,最后以大于号收尾。它存在的理由是历史性的——早期传输通道只保证 7 位安全,二进制流必须先包装成可打印文本。今天它更多出现在内联图像、测试文件与某些扫描仪输出的 PDF 里。
规范对这一层的要求只有四条:
- 输入只应包含十六进制字符与空白。
- 每两个字符解释成一个字节,高位在前。
- 若最后剩下一个孤立的半字节,按后补 0 处理。
- 大于号是结束标记,之后的内容不再属于这个流。
AsciiHexStream 要做的就是把文本按这四条还原成字节。它在过滤器链里的入口很短:
case "AHx":
case "ASCIIHexDecode":
return new AsciiHexStream(stream, maybeLength);这段来自 Parser.makeFilter。上游可能是普通的 Stream,也可能是已经解密或分块的流;maybeLength 是流字典里的 /Length,只当提示用。还有一个容易忽略的分支:内联图像不走这个类,Parser.makeInlineImage 会先调用 findASCIIHexDecodeInlineStreamEnd 只扫描结束标记来定长度,再构造过滤器。扫描与解码因此分成两处,本篇讲的是解码这一处。
一句话概括它的职责:把「两个字符一个字节」的文本流还原成二进制字节,并在块边界上保留半个字节,遇到结束标记立即收尾。
模块总览
ascii_hex_stream.js 只有 79 行,去掉版权头与 import,实际逻辑集中在两个方法:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 构造 | constructor | 尺寸提示减半、接管上游流与字典、初始化半字节状态 |
| 解码 | readBlock | 取块、逐字符分类、拼字节、处理结束与补零 |
| 继承设施 | DecodeStream | 输出缓冲、bufferLength、eof、ensureBuffer |
| 对外读取 | getByte、getBytes | 按需触发 readBlock,返回连续字节 |
整条数据路径可以写成:
十六进制文本块
→ AsciiHexStream.readBlock (本文件,一次最多 8000 个输入字节)
→ DecodeStream.buffer (输出缓冲,最多 4000 个字节)
→ getBytes / getByte (按调用方需求继续驱动 readBlock)
→ Lexer / 图像解码器 / XRef 记录解析
这里要强调一个继承关系带来的分工:readBlock 一次只负责「把上游给的一块文本尽量解码」,它不保证产出调用方要的数量。真正的「凑够字节数」由 DecodeStream.getBytes 循环调用 readBlock 完成。这个分工让本类不必关心调用方要多少,只需要关心自己这一块的边界。
readBlock 没有参数。过滤器链里其它流可能接收 decoderOptions,本类完全忽略它,因为十六进制解码没有任何可调参数。
构造:把尺寸提示减半
constructor 方法
class AsciiHexStream extends DecodeStream {
constructor(str, maybeLength) {
// Most streams increase in size when decoded, but AsciiHex streams shrink
// by 50%.
if (maybeLength) {
maybeLength *= 0.5;
}
super(maybeLength);
this.stream = str;
this.dict = str.dict;
this.firstDigit = -1;
}构造函数只做四件事,但每一件都影响后面的行为。
第一件是尺寸提示。maybeLength 是流字典里的 /Length,含义是编码后的字节数,也就是十六进制字符加空白的总量。解码后的有效字节数等于十六进制字符数除以二,而十六进制字符数不会超过总量,所以「总量的一半」是输出的一个上界。乘以 0.5 不是精确计算,而是一个偏保守的起点。
这个值会进入 DecodeStream 的构造函数,被抬到「不小于它的最小 2 的幂」,成为 minBufferLength。之后 ensureBuffer 从这里开始翻倍。也就是说,/Length 只决定第一次分配多大,不决定最终能解码多少。Parser 里有一句注释说得很直接:流字典的 /Length 可能完全错误,例如非空流写成 0,所以它只能当提示。
第二件是保存上游流:this.stream = str。所有输入字节都从 str.getBytes 来,本类不缓存文本。
第三件是转发字典:this.dict = str.dict。过滤器链是多层嵌套的,字典必须一路传下去。下游的图像解码器要靠字典里的 /Width、/Height、/ColorSpace 解释像素,一旦在这一层断掉,图像就无法还原。这个赋值只有一行,却是过滤器链能成立的前提。
第四件是初始化 firstDigit = -1。这是一个跨块状态,用来保存「等待配对的半个字节」。有效取值是 0 到 15,-1 表示当前没有任何待配对的半字节。
| 字段 | 初值 | 作用 |
|---|---|---|
this.stream | 上游 BaseStream | 十六进制文本来源 |
this.dict | 上游字典 | 供后续过滤器与消费者读取 |
this.firstDigit | -1 | 跨块保留的半个字节,-1 表示没有 |
this.eof | false(基类) | 遇到结束标记或上游耗尽时置真 |
this.buffer | 空数组(基类) | 解码输出 |
this.bufferLength | 0(基类) | 已产出的有效字节数 |
设计要点:尺寸提示只影响「第一次分配多大」,不影响正确性。0.5 的缩放让常见流一次分配到位;即使
/Length完全错误,ensureBuffer的几何扩容仍能兜底。把提示与容量保证分开,是这一层所有解码流的共同做法。
readBlock:一次取八千字节
readBlock 方法
readBlock() {
const UPSTREAM_BLOCK_SIZE = 8000;
const bytes = this.stream.getBytes(UPSTREAM_BLOCK_SIZE);
if (!bytes.length) {
this.eof = true;
return;
}
const maxDecodeLength = (bytes.length + 1) >> 1;
const buffer = this.ensureBuffer(this.bufferLength + maxDecodeLength);
let bufferLength = this.bufferLength;开头这十几行决定了整个方法的粒度与内存行为。
UPSTREAM_BLOCK_SIZE = 8000 是每次向上游索取的输入字节数。选这个数有三个考虑:
- 上游
getBytes返回的是视图而不是副本,8000 字节的零拷贝成本很低。 - 一块输入最多产出 4000 字节输出,单次解码的内存增量有明确上界,不会因为某个巨大的流一次性吃满内存。
DecodeStream.getBytes会反复调用readBlock,块越大,调用开销越少;块再大,收益递减,反而增加单块的内存峰值。
bytes.length 为 0 表示上游已经空了,直接把 eof 置真并返回。这一步是终止条件,不需要再往下走。
maxDecodeLength = (bytes.length + 1) >> 1 是这一块最多能产出的字节数。加 1 再右移,是向上取整:如果上一块留下了一个半字节,本块的第一个字符就能和它拼成一个完整字节,所以理论上最多是「长度加一」的一半。这里用移位而不是 Math.ceil,既避免浮点,也统一了整数的语义。
ensureBuffer(this.bufferLength + maxDecodeLength) 一次性把容量扩到「已有输出加上本块最大产出」。这一步很关键:因为循环体内的产出量已经被上界约束,后面每写一个字节都不需要再检查容量。扩容只在块与块之间发生,不会落在逐字符的热循环里。
最后一行 let bufferLength = this.bufferLength 把长度复制到局部变量。循环里每次写入都只改局部值,方法结束前才写回 this.bufferLength。这样做的收益是减少属性访问,更重要的是让「本块解码了多少」与「对象当前状态」分开,避免中途扩容时出现半更新的状态。
设计要点:输出缓冲的容量预估发生在循环之外,循环内只做「判断字符、写字节」两件事。这是所有
DecodeStream子类的统一形态:ensureBuffer负责内存,readBlock负责内容,二者在块边界上对齐。
与 getBytes 的协作
readBlock 不保证一次产出调用方要的数量。真正把多个块缝起来的是基类的读取循环:
while (!this.eof && this.bufferLength < end) {
this.readBlock(decoderOptions);
}这段来自 DecodeStream.getBytes。它先按请求量扩容,再反复调用 readBlock,直到缓冲覆盖目标位置或者 eof 为真。对本类来说,每次调用最多贡献 4000 字节,所以请求 10 万字节会触发二十多次 readBlock,每次再向更上游索取 8000 字节文本。
这也解释了 eof 为什么必须在「上游返回 0 字节」和「遇到结束标记」两个地方都置真:基类的循环只看这一个布尔值。如果漏设,getBytes 会一直调用下去,而 readBlock 每次都返回空,形成死循环。契约很简单,代价是每个子类都必须守住它。
对调用方而言,结果长度可能小于请求长度:源文本提前结束时,getBytes 会把终点夹到 bufferLength,返回一段较短的视图。上层解析器习惯按实际返回的 length 推进,不会假设一定拿满。
字符判定:让 ASCII 布局当查表
十六进制字符分类
let firstDigit = this.firstDigit;
for (const ch of bytes) {
let digit;
if (ch >= /* '0' = */ 0x30 && ch <= /* '9' = */ 0x39) {
digit = ch & 0x0f;
} else if (
(ch >= /* 'A' = */ 0x41 && ch <= /* 'Z' = */ 0x46) ||
(ch >= /* 'a' = */ 0x61 && ch <= /* 'z' = */ 0x66)
) {
digit = (ch & 0x0f) + 9;
} else if (ch === /* '>' = */ 0x3e) {
this.eof = true;
break;
} else {
// Probably whitespace, ignoring.
continue;
}这段分支是全文唯一需要「算」的地方,但它算得极省:两次范围比较、一次掩码、一次加法,没有任何查表数组,也没有把字节转成字符串。
原理是 ASCII 的编码布局:
| 字符范围 | 示例 | digit 计算 | 说明 |
|---|---|---|---|
0x30 到 0x39 | 0 到 9 | ch & 0x0f | 低四位恰好就是数值 0 到 9 |
0x41 到 0x46 | A 到 F | (ch & 0x0f) + 9 | 低四位是 1 到 6,加 9 得 10 到 15 |
0x61 到 0x66 | a 到 f | (ch & 0x0f) + 9 | 小写字母同理 |
0x3e | 大于号 | 无 | 置 eof 并跳出循环 |
| 其它 | 空格、换行、NUL | 无 | 直接跳过 |
为什么字母要加 9 而不是加 10?因为 A 的低四位是 1 而不是 0:0x41 & 0x0f 等于 1,要让 A 对应 10,加 9 正好。F 的低四位是 6,6 加 9 是 15。这个偏置量之所以是常数,正是因为字母在 ASCII 表里连续排列。如果换成 EBCDIC 这类布局,这种写法就完全不成立。
源码注释在字母上界处写的是 'Z',实际值 0x46 是 F 的编码。判定必须停在 F:如果真按 Z 的上界放行,G 到 Z 会被算成 16 以上的非法值,拼出的字节就会错。注释是笔误,代码是对的。
else 分支的注释写「Probably whitespace」,但它的实际行为比空白更宽:任何既不是十六进制字符、也不是结束标记的字节都会被跳过,包括规范里没有列为空白的一些控制字符。这是一种容错取态:宁可放过垃圾,也不要因为一个多余字节让整份文档报错。代价是真正的损坏不会被发现,解码结果可能悄悄变短。
还有一个分支顺序的细节:结束标记的判断放在十六进制判定之后。两者在数值上不重叠,顺序不影响正确性;但把最常见、最快命中的分支放在前面,可以减少平均比较次数。对 8000 字节的循环来说,这点差异是可以被测量到的。
设计要点:ASCII 的连续性本身就是一张免费的表。数字与字母的低四位连续,因此「分类」与「取值」可以合并成同一次掩码运算。用查表实现同样的逻辑会多一次内存访问,用字符串比较则更慢。位运算在这里不是炫技,而是让热循环只剩算术。
拼字节:只保存半个字节
if (firstDigit < 0) {
firstDigit = digit;
} else {
buffer[bufferLength++] = (firstDigit << 4) | digit;
firstDigit = -1;
}这是整个解码器的状态机,只有两个状态:没有待配对的半字节、有一个待配对的半字节。源码用一个整数 -1 编码前者,用 0 到 15 编码后者,不再引入额外的布尔字段。
高位在前是规范要求:第一个字符是高四位,第二个是低四位。(firstDigit << 4) | digit 把待配对的值左移四位,再用按位或填入低位。这里用或而不是加也可以,因为低四位在移位后全为 0,两种运算结果一致;用或更能表达「填位」的意图。
写回时先取 bufferLength 再自增,是典型的「先用后加」。buffer 是 ensureBuffer 返回的引用,可能是扩容后的新数组;由于扩容发生在循环之前,循环内不会再有换数组的风险。
这个状态机还解释了一个容易被忽略的事实:解码进度不是按块对齐的。块边界可能正好落在两个字符之间,也可能落在两个字节之间。状态机只关心「当前有没有半个字节」,不关心块在哪里结束。
设计要点:跨块续接不需要缓存文本,只需要一个半字节。这是「按语义保存状态」而不是「按输入保存状态」的典型例子:无论上游把文本切成多大的块,解码器保存的信息量都是常数。
逐字符循环的代价
for (const ch of bytes) 遍历的是 Uint8Array 的迭代器,每次产出一个 0 到 255 的数字。写成索引循环会更贴近机器层,但这里选择可读性:类型固定、长度已知,引擎对这类迭代通常能做得很好;即使没有优化,单次迭代的额外开销也被后面的分支与写字节掩盖。
按输入字节分类,单字节的处理成本如下:
| 输入类别 | 比较次数 | 算术 | 产出 |
|---|---|---|---|
数字 0 到 9 | 2 | 1 次掩码 | 每两个字符 1 字节 |
| 大写或小写字母 | 4 到 5 | 1 次掩码、1 次加法 | 每两个字符 1 字节 |
| 空白或其它杂字节 | 4 到 5 | 无 | 0 |
| 结束标记 | 4 到 5 | 无 | 0,并跳出 |
最坏情况是 5 次比较,且没有任何内存分配。8000 字节的一块最多做四万次整数比较,对现代引擎来说可以忽略;真正的成本在输出写入与上游取块,而不是判断。
那为什么不用 256 项的查找表?查表把「判断」压成一次内存读,但需要初始化一张表,且随机访问的内存延迟不一定比几个可预测的分支低。对以十六进制字符为主的输入,分支预测命中率很高,范围判断反而是更稳的选择。换成以空白为主的异常输入,两者都无所谓,因为那时瓶颈在跳过的字节数上。
break 只会跳出当前块,不会回卷上游。被跳过的后续字节已经消费掉了,但流已经结束,它们本来就不属于这个过滤器。内联图像场景里,结束标记之后往往紧跟着 /EI,那部分由 Parser 的另一段逻辑负责,不归解码器管。
内联图像里的另一条路径
同样是 ASCIIHexDecode,内联图像不走本类来定长度。Parser.findASCIIHexDecodeInlineStreamEnd 只扫描结束标记:
findASCIIHexDecodeInlineStreamEnd(stream) {
const GT = 0x3e;
const startPos = stream.pos;
let ch;
while ((ch = stream.getByte()) !== -1) {
if (ch === GT) {
break;
}
}
const length = stream.pos - startPos;
if (ch === -1) {
warn(
"Inline ASCIIHexDecode image stream: " +
"EOD marker not found, searching for /EI/ instead."
);
stream.skip(-length); // Reset the stream position.
return this.findDefaultInlineStreamEnd(stream);
}
this.inlineStreamSkipEI(stream);
return length;
}内联图像没有流字典,也就没有 /Length。Parser 必须先知道图像数据在文件里占多少字节,才能把它切出来构造子流。此时最便宜的做法不是解码,而是「找标记」:逐字节读到大于号,位置差就是长度。这个扫描不做半字节配对,不分配输出缓冲,复杂度只有一次线性读。
找到标记后调用 inlineStreamSkipEI 跳过紧随其后的 /EI。这是一个小型状态机,按顺序匹配字母 E、I,匹配失败就回到初始状态,避免把数据里偶然出现的 E、I 误判成结束符。
找不到标记时,代码先 stream.skip(-length) 回卷到起点,再退回通用的 findDefaultInlineStreamEnd。回卷能成立,是因为 BaseStream.skip 接受负数,这也是游标必须可写的另一处体现。通用的扫描会去找 /EI,虽然不如显式标记可靠,但总比把整份文件读穿强。
两条路径的分工很清楚:扫描负责「切多长」,解码负责「变成什么字节」。扫描是近似判断,解码是精确还原;它们只在同一个结束标记上达成一致。还要注意返回的长度包含结束标记本身,所以下游 AsciiHexStream 仍会读到大于号,并按结束处理——扫描没有替解码器消费它,只是把它算进了切分范围。
设计要点:同样的数据格式,按用途可以有两套实现。流对象走「懒解码」,长度由字典或结束标记约束;内联图像走「先扫描后解码」,因为必须先切出边界。把这两件事分开,谁也不拖慢谁。
结束标记与补零
收尾逻辑
if (firstDigit >= 0 && this.eof) {
// incomplete byte
buffer[bufferLength++] = firstDigit << 4;
firstDigit = -1;
}
this.firstDigit = firstDigit;
this.bufferLength = bufferLength;收尾分三步。
第一步处理规范里的「奇数位补零」。如果循环结束时还有一个半字节,且 eof 已经为真,就把这个半字节左移四位写出去,低四位补 0。比如最后剩下一个 2,输出就是 0x20,也就是空格字符。补零只在 eof 时发生;如果只是块结束,半字节要留给下一块。
第二步把状态写回对象。即使 firstDigit 是 -1,这行赋值也要执行,因为它可能覆盖了上一块留下的值。第三步写回长度。
三个可能触发 eof 的位置,行为并不完全相同:
| 结束条件 | 触发位置 | 半个字节的处理 | 结果 |
|---|---|---|---|
| 遇到结束标记 | 块内任意位置 | 补 0 后输出 | 符合规范 |
| 上游返回 0 字节 | 下一次 readBlock | 不补,直接丢弃 | 截断文件 |
| 上游耗尽但没有结束标记,且跨块 | 块边界 | 不补,直接丢弃 | 与截断文件相同 |
第二、三行值得单独说明。if (!bytes.length) 那条提前返回路径只是把 eof 置真就退出了,不会执行补零逻辑。也就是说,如果一个流既没有结束标记、最后一个半字节又恰好落在块边界之前,这个半字节会被静默丢掉。规范要求流必须以结束标记收尾,正常文件不会走到这里;只有在文件被截断或生成器出错时才会出现。这是一种有意的取舍:为几乎不可能发生的非法输入增加一次判断不划算,而且丢掉半个字节本来也无法还原出正确数据。
再看一个具体的解码过程。输入是 48 65 6C 6C 6F 21(字符间用空格只是为了阅读,实际空格会被跳过):
| 输入片段 | digit 序列 | 输出 | 说明 |
|---|---|---|---|
48 | 4,8 | 0x48 | 高四位在前 |
65 | 6,5 | 0x65 | 小写字母也可以 |
6C 6C 6F | 6,12,6,12,6,15 | 0x6C 0x6C 0x6F | 一段连续的字节 |
21 | 2,1 | 0x21 | 标点 |
| 大于号 | 无 | 无 | 结束,后面的字节不再读取 |
如果把最后一个字节换成单独一个 2,输出就是 0x20。如果输入在中途被切成两块,例如第一块是 48 6,第二块是 5 3E,第一块结束时 firstDigit 保留 6,第二块读到 5 时拼成 0x65,随后遇到结束标记,eof 置真。两次 readBlock 之间只传递了一个整数。
设计要点:结束标记是唯一权威,
/Length只用于估算缓冲区。标记之后即使同一块里还有字节,也不再处理。这一条让解码器对错误的长度字段天然免疫——只要文本里有结束标记,解码结果就是对的。
与其它过滤器的差异
把三个「非压缩」或「简单编码」的过滤器放在一起,差异一目了然:
| 过滤器 | 输入字符集 | 结束标记 | 输出与输入之比 | 跨块状态 |
|---|---|---|---|---|
ASCIIHexDecode | 十六进制字符,其余字节全部跳过 | 大于号 | 最多 0.5 | 半个字节 |
ASCII85Decode | 33 到 117 的可打印字符 | 波浪号 | 约 0.8 | 无 |
RunLengthDecode | 任意二进制 | 0x80 | 复制型接近 1,重复型最高 64 倍 | 无 |
ASCII85Decode 的构造函数把提示乘以 0.8,而 AsciiHexStream 乘以 0.5,原因相同:让缓冲区的首次分配贴近真实输出。Ascii85Stream 每次读满一组五个字符,组内使用 isWhiteSpace 判断空白,因此不需要跨块保存状态;遇到波浪号或上游结束就收尾,末组不足时用 0x21 + 84 补齐。
RunLengthDecode 走的是完全不同的路:它处理的是二进制字节,不需要跳过任何字符,也不保存半个字节。ASCIIHex 与 RunLength 虽然都很短,但状态管理方式相反:一个靠一个常驻整数,一个靠「每次读满一个 run」。
这三个过滤器有一个共同点:它们都不改变数据的语义,只改变表示方式。也正因如此,它们都不需要像 Flate 那样维护字典、窗口或码表,readBlock 可以短到几十行。
总结
AsciiHexStream 是 PDF.js 的文本转二进制过滤器:向上接受任意字节源,向下输出连续的二进制字节,中间只靠一个半字节的状态完成跨块拼接。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 字典解析 | Parser.makeStream | /Filter、/Length | 过滤器名、提示长度 |
| 过滤器构造 | Parser.makeFilter | 上游 BaseStream | AsciiHexStream |
| 解码 | AsciiHexStream.readBlock | 十六进制文本块 | DecodeStream.buffer |
| 读取 | DecodeStream.getBytes | 调用方需要的字节数 | 二进制字节视图 |
| 消费 | Lexer、图像解码器、XRef | 字节 | token、像素、xref 记录 |
用一句话串起来:上游提供文本块,readBlock 把每两个字符拼成一个字节,getBytes 把多个块缝成一条连续字节流,解析器与图像解码器只看结果。
五条设计原则
- 提示归提示,容量靠兜底。
maybeLength乘以 0.5 只用于第一次分配;ensureBuffer的翻倍策略才是真正的容量保证。分离两者,使解码器能容忍错误的长度字段。 - 让编码布局承担分类工作。数字与字母的低四位连续,于是「判断是不是十六进制字符」与「算出数值」可以合并成一次掩码加一次加法。热循环里没有查表,也没有字符串转换。
- 状态按语义保存,不按输入保存。跨块续接只需要一个 0 到 15 的整数,外加
-1表示空。无论块多大、文本怎么切,状态大小恒定。 - 扩容在循环外,写字节在循环内。
(bytes.length + 1) >> 1给出本块产出的严格上界,因此循环体内永远不需要再检查容量。 - 显式结束标记优先于长度字段。遇到结束标记立即停止,不依赖
/Length的准确性;这也是它能处理内联图像这类长度不定的流的原因。
下一篇换一种展开方式:run_length_stream 处理的是「一个头字节加一段数据」的行程编码。它没有字符要跳过,也不需要半个字节的状态,复杂度落在 run 语义与缓冲区累积上。
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_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日