PDF.js 深入浅出 11: Parser
2026年10月26日 · StreamPDF Team

前言
这是 PDF.js 系列文章的第 11 篇,介绍对象语法层 src/core/parser.js(1484 行)里的 Lexer 与 Parser 两个类。
前面几篇分别站在不同的高度看 PDF:
- 第 6 篇
PDFManager.initDocument回答「文件从哪里开始读」——找到startxref、读出 trailer、把交叉引用表交给XRef。 - 第 10 篇
XRef回答「某个编号的对象在文件的哪个偏移」——维护条目、处理增量更新、把偏移变成可读取的字节区间。 - 第 7 篇
PartialEvaluator回答「页面内容流在讲什么」——把q 1 0 0 1 0 0 cm /F1 12 Tf ...这样的算子序列翻译成 operatorList。
这三件事看起来毫不相干,但它们在最底层依赖同一个能力:把一段字节变成 PDF 的基本对象(数字、字符串、名字、数组、字典、间接引用、流),外加布尔值与 null。xref 条目里的偏移之所以能变成一个 Dict,内容流里的 q 之所以能被识别成算子,全靠本篇这两个类。
一句话概括分工:Lexer 是词法分析器,只负责把字节流切成 token;Parser 是语法分析器,用双 token 前瞻和递归下降把 token 组装成对象图,并在遇到 stream 关键字时接管原始字节。
设计要点:PDF 的语法小到可以手写——没有嵌套注释、没有运算符优先级、没有需要回溯的歧义。所以 PDF.js 没有用 parser generator,也没有用 parser combinator,而是两个类、一个
while循环、一个递归方法。可读性与性能都来自这份「小」。
模块总览
parser.js 的结构非常规整,六个区块:
| 区块 | 大致行号 | 内容 | 职责 |
|---|---|---|---|
| 缓存键工具 | 53–71 | getInlineImageCacheKey | 把内联图像的原始字节压成字符串键 |
Parser | 73–917 | 递归下降解析器与流处理 | 组装数组、字典、流、内联图像 |
specialChars | 919–939 | 256 项字符分类表 | 空白与分隔符的 O(1) 判定 |
toHexDigit | 941–952 | 十六进制字符转数字 | #xx 名字转义与 hex string 共用 |
Lexer | 954–1410 | 词法分析器 | 字节流变成 token |
Linearization | 1412–1482 | 线性化字典读取 | 文档首字节处的一种特殊 Parser 用法 |
两个类的关系可以先用一张表定住,后文所有细节都是这张表的展开:
| 维度 | Lexer | Parser |
|---|---|---|
| 输入 | BaseStream,可 getByte / peekByte | Lexer 产出的 token |
| 输出 | 数字、字符串、Name、布尔、null、Cmd、EOF | 数组、Dict、Ref、Stream、内联图像 |
| 状态 | currentChar 单字符 + strBuf 复用数组 | buf1 / buf2 双 token 前瞻 |
| 控制流 | 无递归,逐个 token 返回 | getObj 递归下降 |
| 字节操作 | 只读当前位置 | 直接移动 stream.pos 跳过流数据 |
| 错误策略 | 多数情况 warn 后继续 | 结构错误抛 FormatError,recoveryMode 下返回半成品 |
词法基础
specialChars 与字符判定
PDF 规范把字符分成三类:空白、分隔符、普通字符。PDF.js 没有为每一类写一个 isXxx 函数,也没有用正则表达式,而是查表:
// A '1' in this array means the character is white space. A '1' or
// '2' means the character ends a name or command.
// prettier-ignore
const specialChars = [
1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 0, // 0x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 1x
1, 0, 0, 0, 0, 2, 0, 0, 2, 2, 0, 0, 0, 0, 0, 2, // 2x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, // 3x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 4x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 0, // 5x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 6x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 0, // 7x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 8x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 9x
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ax
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // bx
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // cx
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // dx
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // ex
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // fx
];索引就是字节值,一共 256 项。1 是空白,2 是分隔符,0 是普通字符。逐行读出来的结论:
0x00行有五个1(0x00、0x09、0x0a、0x0c、0x0d),下一行的0x20也是空白。NUL 与换页符也被当作空白——这是宽松策略,规范只要求后者。0x25是%,0x28/0x29是圆括号,0x2f是斜杠,0x3c/0x3e是尖括号,0x5b/0x5d是方括号,0x7b/0x7d是花括号。它们全部是2。- 因此「是不是分隔符」就是
specialChars[ch] === 2,「是不是空白」就是specialChars[ch] === 1,「是不是名字/命令的合法组成」就是!specialChars[ch]。
| 类别 | 代表字节 | 表值 | 代码里的判定 |
|---|---|---|---|
| 空白 | 0x00、0x09、0x0a、0x0c、0x0d、0x20 | 1 | specialChars[ch] === 1 |
| 分隔符 | (、)、<、>、[、]、{、}、/、% | 2 | specialChars[ch] === 2 |
| 普通字符 | 其余 240 个 | 0 | !specialChars[ch] |
| 名字 / 命令 | 任意普通字符序列 | —— | 循环条件 !specialChars[ch] |
| 数字 | 0–9、+、-、. | —— | getObj 里按字节分支 |
需要澄清两个常见的误解:
- 这一版的
core_utils.js里没有isDelimiter,分隔符判定完全由这张表承担;也没有isNumber,数字判定直接写死在Lexer.getObj的switch与getNumber的字符范围里。 isWhiteSpace确实存在,但它只接受四个字节,比表更严格:
// src/core/core_utils.js
function isWhiteSpace(ch) {
return ch === 0x20 || ch === 0x09 || ch === 0x0d || ch === 0x0a;
}它被 findDefaultInlineStreamEnd、findASCII85DecodeInlineStreamEnd、#findStreamLength 用来判断「EI 或 endstream 后面是不是一个合法的空白边界」。表用于词法循环,函数用于流边界,两者故意不是同一个集合。
Name 与 Cmd 的判定同样简单,来自 primitives.js:
function isName(v, name) {
return v instanceof Name && (name === undefined || v.name === name);
}
function isCmd(v, cmd) {
return v instanceof Cmd && (cmd === undefined || v.cmd === cmd);
}Cmd / Name / EOF 单例
分词器每遇到一个算子都要返回一个对象。如果每次都 new Cmd("Tj"),一页内容流就会产生几万个短命对象。PDF.js 的解法是三张缓存表(节选,省略了测试环境的类型断言):
const EOF = Symbol('EOF');
let CmdCache = Object.create(null);
let NameCache = Object.create(null);
let RefCache = Object.create(null);
class Name {
constructor(name) {
this.name = name;
}
static get(name) {
// eslint-disable-next-line no-restricted-syntax
return (NameCache[name] ||= new Name(name));
}
}
class Cmd {
constructor(cmd) {
this.cmd = cmd;
}
static get(cmd) {
// eslint-disable-next-line no-restricted-syntax
return (CmdCache[cmd] ||= new Cmd(cmd));
}
}三个要点:
EOF用Symbol。Parser里大量写this.buf1 === EOF,只有「同一个 Symbol 引用」才成立,任何合法 token 都不可能误判。Symbol还天然是唯一值,不会被用户数据构造出来。||=缓存。Cmd.get("Tj")第一次创建实例,之后永远返回同一个。于是isCmd(x, "Tj")的两次instanceof与字符串比较都在稳定的小对象上做,缓存也不会膨胀——不同算子名只有几十个。Name同理。一份文档里/Type可能出现上万次,Name.get("Type")只创建一个实例。字典的键虽然存成字符串,值里的Name却全部被归一化。
Ref 与 Dict 的数据结构
Ref 是间接引用的三元组「对象号 + 代号」。它的字符串形式被预先算好并缓存:
class Ref {
#str;
constructor(str, num, gen) {
this.#str = str;
this.num = num;
this.gen = gen;
}
toString() {
return this.#str;
}
static get(num, gen) {
const str = gen === 0 ? `${num}R` : `${num}R${gen}`;
return (RefCache[str] ||= new Ref(str, num, gen));
}
}toString() 之所以直接返回 #str,是因为它出现在 RefSet、RefMap、XRef 的哈希路径上,每次拼接 ${num}R${gen} 都是浪费。Ref.get 也做了缓存,于是 Ref.get(12, 0) 在整份文档里只有一个实例。
Dict 本质是一个 Map 的包装,但它比 Map 多了三件事:自动解引用、长短键名、加密抑制。下面是结构节选:
class Dict {
#map = new Map();
objId = null;
suppressEncryption = false;
get(key1, key2) {
return this.#getValue(/* isAsync = */ false, key1, key2);
}
set(key, value) {
this.#map.set(key, value);
}
}三个设计点值得展开:
- 键是字符串,不带斜杠。
Lexer.getName返回Name("Type"),Parser取buf1.name再dict.set(key, ...)。所以dict.get("Type")而不是dict.get("/Type")。 get(key1, key2)支持缩写。源码里大量dict.get("F", "Filter")、dict.get("DP", "DecodeParms"),先查短键,缺失再查长键。#getValue里甚至有一条unreachable断言要求key2.length >= key1.length,把「先短后长」的顺序约定固化下来。get会自动解引用。#getValue发现值是Ref且this.xref存在时,直接调用xref.fetch;想要原始值必须用getRaw。suppressEncryption则一路透传给fetch,用于不加密的元数据读取。
至于「dict.set 时遇到 Name 的去重」,准确的说法是:Dict.set 本身不去重,它原样存字符串键;去重发生在更上游的 Lexer.getName——所有名字 token 都经过 Name.get,于是同名值共享实例。键只是一个普通字符串,字符串在 JS 引擎内部本来就会驻留。
Lexer 类
Lexer 从第 954 行开始,字段只有六个:stream、currentChar、strBuf、knownCommands、_hexStringNumWarn、beginInlineImagePos。它没有任何递归,所有方法都服务于「读出一个 token」。
constructor 方法
class Lexer {
constructor(stream, knownCommands = null) {
this.stream = stream;
this.nextChar();
// While lexing, we build up many strings one char at a time. Using += for
// this can result in lots of garbage strings. It's better to build an
// array of single-char strings and then join() them together at the end.
// And reusing a single array (i.e. |this.strBuf|) over and over for this
// purpose uses less memory than using a new array for each string.
this.strBuf = [];
// The PDFs might have "glued" commands with other commands, operands or
// literals, e.g. "q1". The knownCommands is a dictionary of the valid
// commands and their prefixes. The prefixes are built the following way:
// if there a command that is a prefix of the other valid command or
// literal (e.g. 'f' and 'false') the following prefixes must be included,
// 'fa', 'fal', 'fals'. The prefixes are not needed, if the command has no
// other commands or literals as a prefix. The knowCommands is optional.
this.knownCommands = knownCommands;
this._hexStringNumWarn = 0;
this.beginInlineImagePos = -1;
}三处注释已经把事情说清楚了,再补两点:
strBuf是复用的数组。每次读字符串或名字都先strBuf.length = 0,再逐字符push,最后join("")。这比+=少产生中间字符串,也避免为每个 token 新建数组。knownCommands是「粘连命令」的解药。PDF 允许q1这种写法,如果只看字符,q1会被读成一个命令。传入命令表后,q一旦被确认是已知命令,并且下一字符无法延续成另一个已知命令,就立即停住,1留给下一轮。表里的fa、fals、nu、nul这些值为null的前缀项,就是为false/null服务的。beginInlineImagePos初始化为-1,只有读到BI时才记录当前位置,供内联图像缓存使用。
nextChar / peekChar 方法
nextChar() {
return (this.currentChar = this.stream.getByte());
}
peekChar() {
return this.stream.peekByte();
}整个词法器的「单字符缓冲」就是 currentChar:nextChar 前进一字节并同步字段,peekChar 只看不动。stream.getByte() 的返回值是 0–255 或 -1(文件结束),所以所有循环都写成 >= 0 或 !== -1。它不缓存整段字节,也不做 slice,每个 token 只依赖当前与下一字节。
getNumber 方法
数字的读取难点不在整数,而在「点号之前的整数部分可以不写」「指数不存在」「负号可能出现在奇怪的位置」。getNumber 用 divideBy 一个变量同时表达「有没有小数点」和「小数点后有几位」:
let baseValue = ch - 0x30; // '0'
while ((ch = this.nextChar()) >= 0) {
if (ch >= /* '0' = */ 0x30 && ch <= /* '9' = */ 0x39) {
const currentDigit = ch - 0x30; // '0'
if (divideBy !== 0) {
// We are after a point.
divideBy *= 10;
}
baseValue = baseValue * 10 + currentDigit;
} else if (ch === /* '.' = */ 0x2e) {
if (divideBy === 0) {
divideBy = 1;
} else {
// A number can have only one dot.
break;
}
} else if (ch === /* '-' = */ 0x2d) {
// Ignore minus signs in the middle of numbers to match
// Adobe's behavior.
warn("Badly formatted number: minus sign in the middle");
} else {
// The last character doesn't belong to us.
break;
}
}
if (divideBy !== 0) {
baseValue /= divideBy;
}
return sign * baseValue;
}divideBy 的取值很有意思:读到整数部分的点号时设为 1,随后每读一位乘 10;如果点号出现在首位(.5),则直接设为 10。最终 baseValue /= divideBy 一步完成缩放,完全没有浮点累加误差,也不需要维护「小数位数」与「小数部分」两个变量。
方法开头还处理了几种历史遗留写法:
--5只应用一个负号,结果是-5(连续两个负号被忽略),与 Adobe Reader 一致。+被直接跳过。- 符号与首位数字之间的换行会被忽略(
-换行5仍是-5),因为 Adobe Reader 如此。 - 如果第一个有效字符不是数字、点号、正负号,那么当它是空白、
(、<或文件结束时,记一条info并返回0;否则抛FormatError。前一种宽松处理对应源码注释里列出的 issue9252、issue15604 等真实坏文档。
getString 方法
PDF 字符串用圆括号包裹,内部可以嵌套圆括号,反斜杠转义。转义分支是全方法最值得看的部分:
case 0x6e: // 'n'
strBuf.push("\n");
break;
case 0x72: // 'r'
strBuf.push("\r");
break;
case 0x74: // 't'
strBuf.push("\t");
break;
case 0x62: // 'b'
strBuf.push("\b");
break;
case 0x66: // 'f'
strBuf.push("\f");
break;
case 0x5c: // '\'
case 0x28: // '('
case 0x29: // ')'
strBuf.push(String.fromCharCode(ch));
break;
case 0x30: // '0'
case 0x31: // '1'
case 0x32: // '2'
case 0x33: // '3'
case 0x34: // '4'
case 0x35: // '5'
case 0x36: // '6'
case 0x37: // '7'
let x = ch & 0x0f;
ch = this.nextChar();
charBuffered = true;
if (ch >= /* '0' = */ 0x30 && ch <= /* '7' = */ 0x37) {
x = (x << 3) + (ch & 0x0f);
ch = this.nextChar();
if (ch >= /* '0' = */ 0x30 && ch /* '7' = */ <= 0x37) {
charBuffered = false;
x = (x << 3) + (ch & 0x0f);
}
}
strBuf.push(String.fromCharCode(x));
break;| 转义写法 | 产出 | 依据 |
|---|---|---|
\n \r \t \b \f | 对应控制字符 | 分别 push "\n"、"\r" 等 |
\( \) \\ | 字面括号与反斜杠 | 三个 case 共用 fromCharCode(ch) |
\ddd | 八进制 1–3 位 | x << 3 累加,最多三位 |
| 反斜杠 + CR/LF | 不产生字符(续行) | case 0x0d 吞掉可选的 LF,case 0x0a 直接忽略 |
其他 \x | 去掉反斜杠保留 x | default 分支 |
三个易错点:
charBuffered。八进制转义可能已经多读了 1–2 个字符,此时ch不属于下一轮,必须用标志位阻止外层再nextChar(),否则会吞掉字符。- 续行。反斜杠后跟 CR 或 CRLF,PDF 规定「行继续」且不产生字符。
case 0x0d里还要 peek 一下是否跟着 LF,避免把 CRLF 拆成两次处理。 - 嵌套括号计数。
numParen从 1 开始,遇(加一、遇)减一,减到 0 才结束并消费掉右括号。因此(a(b)c)是一个完整字符串,而不是在第一个)处截断。
设计要点:
getString返回的是「每个字符对应一个原始字节」的 JS 字符串。它不做 UTF-16BE BOM 识别,也不做 PDFDocEncoding 映射——那些属于文本语义,不属于词法。真正的解码在src/core/string_utils.js的stringToPDFString里,按FE FF、FF FE、EF BB BF三种 BOM 分派到 utf-16be / utf-16le / utf-8,其余走 PDFDocEncoding 的翻译表。分层的好处是:解析阶段永远不猜编码,同一段字节可以被不同语义层用不同方式解释。
getName 方法
名字以斜杠开头,结束于任意分隔符或空白。# 用来转义那些不能直接出现的字节:
getName() {
let ch, previousCh;
const strBuf = this.strBuf;
strBuf.length = 0;
while ((ch = this.nextChar()) >= 0 && !specialChars[ch]) {
if (ch === /* '#' = */ 0x23) {
ch = this.nextChar();
if (specialChars[ch]) {
warn(
"Lexer_getName: " +
"NUMBER SIGN (#) should be followed by a hexadecimal number."
);
strBuf.push("#");
break;
}
const x = toHexDigit(ch);
if (x !== -1) {
previousCh = ch;
ch = this.nextChar();
const x2 = toHexDigit(ch);
if (x2 === -1) {
warn(
`Lexer_getName: Illegal digit (${String.fromCharCode(ch)}) ` +
"in hexadecimal number."
);
strBuf.push("#", String.fromCharCode(previousCh));
if (specialChars[ch]) {
break;
}
strBuf.push(String.fromCharCode(ch));
continue;
}
strBuf.push(String.fromCharCode((x << 4) | x2));
} else {
strBuf.push("#", String.fromCharCode(ch));
}
} else {
strBuf.push(String.fromCharCode(ch));
}
}
if (strBuf.length > 127) {
warn(`Name token is longer than allowed by the spec: ${strBuf.length}`);
}
return Name.get(strBuf.join(""));
}#xx 必须恰好两位十六进制。只解出一位、或第二位非法时,PDF.js 不抛错,而是把 # 与原字符原样塞回结果并继续读——这是为了兼容真实文档里的坏名字。超过 127 字节只 warn,不截断也不抛错,因为规范限制在 127 但现实里存在更长的名字。
最后 Name.get 才是关键:它把「字节序列 → 字符串 → 归一化实例」串起来,保证 /Type 在整份文档里是同一个对象。
toHexDigit 与 getHexString 方法
十六进制字符串以 < 开始、> 结束。toHexDigit 先把字符转成 0–15:'0'–'9' 直接取低四位,'A'–'F' 与 'a'–'f' 取低四位再加 9,非法字符返回 -1。没有分支表,也没有 parseInt。
getHexString() {
const strBuf = this.strBuf;
strBuf.length = 0;
let ch = this.currentChar;
let firstDigit = -1,
digit = -1;
this._hexStringNumWarn = 0;
while (true) {
if (ch < 0) {
warn("Unterminated hex string");
break;
} else if (ch === /* '>' = */ 0x3e) {
this.nextChar();
break;
} else if (specialChars[ch] === 1) {
ch = this.nextChar();
continue;
} else {
digit = toHexDigit(ch);
if (digit === -1) {
this._hexStringWarn(ch);
} else if (firstDigit === -1) {
firstDigit = digit;
} else {
strBuf.push(String.fromCharCode((firstDigit << 4) | digit));
firstDigit = -1;
}
ch = this.nextChar();
}
}
// According to the PDF spec, section "7.3.4.3 Hexadecimal Strings":
// "If the final digit of a hexadecimal string is missing—that is, if there
// is an odd number of digits—the final digit shall be assumed to be 0."
if (firstDigit !== -1) {
strBuf.push(String.fromCharCode(firstDigit << 4));
}
return strBuf.join("");
}奇数位时低位补 0,所以 <4> 等于 <40>。空白在十六进制串里被忽略(PDF 允许任意空白分隔),非法字符只警告并跳过。_hexStringWarn 还做了限流:每个十六进制串最多警告 5 次非法字符,随后静默——注释里明确说是为了防止坏文档刷爆 console 拖慢整体性能。
getObj 方法
getObj 是词法器的入口,先跳过空白与注释,再按首字节分派:
getObj() {
// Skip whitespace and comments.
let comment = false;
let ch = this.currentChar;
while (true) {
if (ch < 0) {
return EOF;
}
if (comment) {
if (ch === /* LF = */ 0x0a || ch === /* CR = */ 0x0d) {
comment = false;
}
} else if (ch === /* '%' = */ 0x25) {
comment = true;
} else if (specialChars[ch] !== 1) {
break;
}
ch = this.nextChar();
}
// Start reading a token.
switch (ch | 0) {
case 0x30: // '0'
case 0x31: // '1'
case 0x32: // '2'
case 0x33: // '3'
case 0x34: // '4'
case 0x35: // '5'
case 0x36: // '6'
case 0x37: // '7'
case 0x38: // '8'
case 0x39: // '9'
case 0x2b: // '+'
case 0x2d: // '-'
case 0x2e: // '.'
return this.getNumber();
case 0x28: // '('
return this.getString();
case 0x2f: // '/'
return this.getName();
// array punctuation
case 0x5b: // '['
this.nextChar();
return Cmd.get("[");
case 0x5d: // ']'
this.nextChar();
return Cmd.get("]");
// hex string or dict punctuation
case 0x3c: // '<'
ch = this.nextChar();
if (ch === 0x3c) {
// dict punctuation
this.nextChar();
return Cmd.get("<<");
}
return this.getHexString();
// dict punctuation
case 0x3e: // '>'
ch = this.nextChar();
if (ch === 0x3e) {
this.nextChar();
return Cmd.get(">>");
}
return Cmd.get(">");
case 0x7b: // '{'
this.nextChar();
return Cmd.get("{");
case 0x7d: // '}'
this.nextChar();
return Cmd.get("}");
case 0x29: // ')'
// Consume the current character in order to avoid permanently hanging
// the worker thread if `Lexer.getObj` is called from within a loop
// containing try-catch statements, since we would otherwise attempt
// to parse the *same* character over and over (fixes issue8061.pdf).
this.nextChar();
throw new FormatError(`Illegal character: ${ch}`);
}产出形态可以列成一张表:
| 输入形态 | Lexer.getObj() 返回 | 说明 |
|---|---|---|
整数 123 | 123 | Number.isInteger 为真,Parser 可能用它拼 Ref |
实数 1.5 | 1.5 | 由 divideBy 缩放得到 |
/Name | Name | 经过 Name.get 归一化 |
(abc) | "abc" | 每个字符就是一个原始字节 |
<48656c6c6f> | "Hello" | 两位合一字节,奇数位补零 |
[ ] << >> | Cmd 单例 | 结构标记,由 Parser 解释 |
q BT Tj | Cmd 单例 | 内容流算子 |
true / false | true / false | 布尔值,不是 Cmd |
null | null | 真正的 null |
| 文件结束 | EOF | Symbol 单例 |
case 0x29 是一个值得单独说的防御:右括号出现在「期待新 token」的位置属于语法错误,但如果抛出后不前进,调用方在 try/catch 里循环重试就会永远读到同一个字节,把 worker 线程挂死(issue8061)。所以先 nextChar() 再抛。
非分隔符的另一半逻辑是读命令:
// Start reading a command.
let str = String.fromCharCode(ch);
// A valid command cannot start with a non-visible ASCII character,
// and the next character may be (the start of) a valid command.
if (ch < 0x20 || ch > 0x7f) {
const nextCh = this.peekChar();
if (nextCh >= 0x20 && nextCh <= 0x7f) {
this.nextChar();
return Cmd.get(str);
}
}
const knownCommands = this.knownCommands;
let knownCommandFound = knownCommands?.[str] !== undefined;
while ((ch = this.nextChar()) >= 0 && !specialChars[ch]) {
// Stop if a known command is found and next character does not make
// the string a command.
const possibleCommand = str + String.fromCharCode(ch);
if (knownCommandFound && knownCommands[possibleCommand] === undefined) {
break;
}
if (str.length === 128) {
throw new FormatError(`Command token too long: ${str.length}`);
}
str = possibleCommand;
knownCommandFound = knownCommands?.[str] !== undefined;
}
if (str === "true") {
return true;
}
if (str === "false") {
return false;
}
if (str === "null") {
return null;
}
if (str === "BI") {
// Keep track of the current stream position, since it's needed in order
// to correctly cache inline images; see `Parser.makeInlineImage`.
this.beginInlineImagePos = this.stream.pos;
}
return Cmd.get(str);
}这一段解决了三类现实问题:
- 二进制垃圾。如果首字节不是可见 ASCII(小于
0x20或大于0x7f),但下一个字节是可见 ASCII,就把它当成一个单字符命令返回。这样坏文档里的控制字节不会污染后续 token。 - 粘连 token。
knownCommands命中后,只要下一字符不能把当前串延续成另一个已知命令,就立刻停止。false因此不会被拆成f+alse。 - 命令长度上限。128 字符后抛错,避免坏数据导致无限增长。
true / false / null 在这个阶段就被降级成布尔与 null,而不是 Cmd,因此 Parser 不需要再认识它们。BI 则顺手记录 beginInlineImagePos,为内联图像缓存准备「字典起点」。
skipToNextLine 方法
它从 stream 关键字末尾跳到数据首行之后,正确处理 CRLF、单 LF、单 CR 三种行尾。makeStream 随后用 stream.pos - 1 作为数据起点——此时 currentChar 缓存的是换行后的第一个字节,pos 已经越过它一格。
Parser 类
Parser 是递归下降解析器。它不直接操作字节,而是维护两个 token 的滑动窗口。
constructor / refill / shift / tryShift 方法
constructor({ lexer, xref, allowStreams = false, recoveryMode = false }) {
this.lexer = lexer;
this.xref = xref;
this.allowStreams = allowStreams;
this.recoveryMode = recoveryMode;
this.refill();
}
refill() {
this.buf1 = this.lexer.getObj();
this.buf2 = this.lexer.getObj();
}
shift() {
if (this.buf2 instanceof Cmd && this.buf2.cmd === "ID") {
this.buf1 = this.buf2;
this.buf2 = null;
} else {
this.buf1 = this.buf2;
this.buf2 = this.lexer.getObj();
}
}
tryShift() {
try {
this.shift();
return true;
} catch (e) {
if (e instanceof MissingDataException) {
throw e;
}
// Upon failure, the caller should reset this.lexer.pos to a known good
// state and call this.shift() twice to reset the buffers.
return false;
}
}为什么只能看两个 token?因为 PDF 的语法只需要两格前瞻:
- 数组与字典的结束标记是单个
Cmd,看一眼buf1就够。 1 0 R需要三格,但可以拆成两步:buf1 = 1时看buf1(移动后是0)与buf2(R),判断成立后连续shift两次。实际只需要「当前 + 下一个」两格。- 字典后是否跟
stream,是在字典读完时看buf2。
shift() 对 ID 的特殊处理最容易看漏:内联图像的字典以 ID 收尾,ID 之后就是原始二进制。如果 shift 照常调用 lexer.getObj(),词法器会把图像数据当成 token 读,彻底跑偏。所以当 buf2 是 ID 时,只把它挪到 buf1,把 buf2 置空——前瞻到此为止,把控制权交给 makeInlineImage。
tryShift() 是为流长度容错准备的。它允许 shift 失败后回退:调用方把 lexer.pos 复原,再连续 shift 两次重建两个缓冲区。MissingDataException 必须原样抛出,因为那是「数据还没下载完」的信号,不是语法错误。
getObj 方法
getObj(cipherTransform = null) {
const buf1 = this.buf1;
this.shift();
if (buf1 instanceof Cmd) {
switch (buf1.cmd) {
case "BI": // inline image
return this.makeInlineImage(cipherTransform);
case "[": // array
const array = [];
while (!isCmd(this.buf1, "]") && this.buf1 !== EOF) {
array.push(this.getObj(cipherTransform));
}
if (this.buf1 === EOF) {
if (this.recoveryMode) {
return array;
}
throw new ParserEOFException("End of file inside array.");
}
this.shift();
return array;
case "<<": // dictionary or stream
const dict = new Dict(this.xref);
while (!isCmd(this.buf1, ">>") && this.buf1 !== EOF) {
if (!(this.buf1 instanceof Name)) {
info("Malformed dictionary: key must be a name object");
this.shift();
continue;
}
const key = this.buf1.name;
this.shift();
if (this.buf1 === EOF) {
break;
}
dict.set(key, this.getObj(cipherTransform));
}
if (this.buf1 === EOF) {
if (this.recoveryMode) {
return dict;
}
throw new ParserEOFException("End of file inside dictionary.");
}
// Stream objects are not allowed inside content streams or
// object streams.
if (isCmd(this.buf2, "stream")) {
return this.allowStreams
? this.makeStream(dict, cipherTransform)
: dict;
}
this.shift();
return dict;
default: // simple object
return buf1;
}
}
if (Number.isInteger(buf1)) {
// indirect reference or integer
if (Number.isInteger(this.buf1) && isCmd(this.buf2, "R")) {
const ref = Ref.get(buf1, this.buf1);
this.shift();
this.shift();
return ref;
}
return buf1;
}
if (typeof buf1 === "string") {
return cipherTransform ? cipherTransform.decryptString(buf1) : buf1;
}
// simple object
return buf1;
}递归下降的全部规则都在这 76 行里:
- 数组:循环递归直到
]。EOF 时,recoveryMode返回已经读到的部分,否则抛ParserEOFException。这保证坏文档在「容错模式」下仍能打开,正常模式下错误不会被悄悄吞掉。 - 字典:键必须是
Name。遇到非法键时打印info、shift()跳过一个 token、继续——不是throw。这是 PDF.js 对坏文档的一贯态度:能猜就猜。 - 字典 vs 流:字典闭合后看
buf2是否是stream。allowStreams为真才调用makeStream;内容流与 ObjStm 里不允许嵌套流,所以此时只返回Dict,让调用方自己处理后续 token。 Ref识别:buf1是整数,移动后的buf1也是整数,且buf2是R,三者凑成「对象号 + 代号 + R」。注意这里用的是移动之后的buf1,所以三格 token 被两格窗口巧妙地分解了。- 字符串解密:只有字符串会走
cipherTransform.decryptString。数字、名字、字典的加密在流层面或XRef层面处理。
设计要点:
getObj没有「token 类型枚举 + 状态机」,而是「先取出当前 token,再按它的实际类型分派」。因为Lexer已经把[、<<、>>都变成了Cmd,Parser只需switch (buf1.cmd)三个分支;其余类型直接用instanceof Cmd、Number.isInteger、typeof === "string"兜底。少一层映射,就少一处不一致。
obj / endobj 与调用方分工
Parser 本身不认识 obj 与 endobj。xref 条目把流定位到 12 0 obj 的起点,XRef 连续三次 getObj() 读出对象号、代号与 obj 这个 Cmd,校验通过后才第四次 getObj() 取真正的对象;endobj 则由下一次按偏移定位覆盖。垃圾字节的处理分两处:Lexer 跳过注释与非法字符,Parser 用 tryShift 回退、跳过非 Name 的字典键、并用 #findStreamLength 重扫坏长度。
makeStream 方法
流对象的语法是「字典 + stream + 原始字节 + endstream」。字典由 getObj 建好,stream 之后的字节不能经过词法器,必须由 Parser 直接按长度切:
makeStream(dict, cipherTransform) {
const lexer = this.lexer;
let stream = lexer.stream;
// Get the stream's start position.
lexer.skipToNextLine();
const startPos = stream.pos - 1;
// Get the length.
let length = dict.get("Length");
if (!Number.isInteger(length)) {
info(`Bad length "${length && length.toString()}" in stream.`);
length = 0;
}
// Skip over the stream data.
stream.pos = startPos + length;
lexer.nextChar();
// Shift '>>' and check whether the new object marks the end of the stream.
if (this.tryShift() && isCmd(this.buf2, "endstream")) {
this.shift(); // 'stream'
} else {
// Bad stream length, scanning for endstream command.
length = this.#findStreamLength(startPos);
if (length < 0) {
throw new FormatError("Missing endstream command.");
}
lexer.nextChar();
this.shift();
this.shift();
}
this.shift(); // 'endstream'
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);
}
stream = this.filter(stream, dict, length, cipherTransform);
stream.dict = dict;
return stream;
}这里的核心是「先信任 /Length,失败再扫描」:
- 规范里
/Length可以是间接引用,dict.get会自动解引用。拿到整数就按它跳到数据末尾。 - 跳过去之后,
lexer.nextChar()同步单字符缓冲,tryShift()试探「下一个 token 是不是endstream」。成功说明长度可信,直接shift()消费掉。 - 失败就调用
#findStreamLength从startPos开始扫描endstream字节序列。找不到就抛FormatError。这一步是为那些/Length写错(常见的是写成 0,或者指向了错误的偏移)的文档准备的。 - 无论哪条路径,最后都要
shift()跳过endstream,再用makeSubStream(startPos, length, dict)建立有界视图。之后的解密与过滤器链都作用在这个视图上,原始stream的位置不受影响。
#hasCryptFilter 与 cipherTransform 的配合也很讲究:如果过滤器链里显式写了 /Crypt,默认流加密就不能提前套一层,否则会解密两次。判断逻辑是「名字等于 Crypt」或「数组里任意一项等于 Crypt」,且数组元素允许是 Ref。
findStreamLength 方法(私有)
#findStreamLength(startPos) {
const { stream } = this.lexer;
stream.pos = startPos;
const SCAN_BLOCK_LENGTH = 2048;
const signatureLength = "endstream".length;
const END_SIGNATURE = new Uint8Array([0x65, 0x6e, 0x64]);
// Ideally we'd directly search for "endstream", however there are corrupt
// PDF documents where the command is incomplete; hence we search for:
// 1. The normal case.
// 2. The misspelled case (fixes issue18122.pdf).
// 3. The truncated case (fixes issue10004.pdf).
while (stream.pos < stream.end) {
const scanBytes = stream.peekBytes(SCAN_BLOCK_LENGTH);
const scanLength = scanBytes.length - signatureLength;
if (scanLength <= 0) {
break;
}扫描策略是「块扫描 + 前缀匹配」:
- 每次
peekBytes(2048),不复制整份文件。peekBytes返回的是当前缓冲窗口,扫描完只需stream.pos += scanLength。 - 先匹配三字节的
end。命中后继续检查后缀:完整的stream、拼错的steam、截断的strea都接受;后两种还要求后缀之后的第一个字节是空白,防止在图像数据里误命中。 - 返回
stream.pos - startPos,即数据长度;找不到返回-1。
设计要点:
/Length是「声明」,endstream是「事实」。声明可信时零成本跳过,声明不可信时退化为有界扫描,且扫描只在 2KB 窗口内做。这份「先快后慢、慢也有上界」的结构,是解析器能在畸形文档上仍然收敛的原因。
filter / makeFilter 方法
流字典里的 /Filter 决定如何解码原始字节。filter 负责把过滤器链展开,makeFilter 负责把每个名字映射成对应的 Stream 子类:
filter(stream, dict, length, cipherTransform = null) {
let filter = dict.get("F", "Filter");
let params = dict.get("DP", "DecodeParms");
if (filter instanceof Name) {
if (Array.isArray(params)) {
warn("/DecodeParms should not be an Array, when /Filter is a Name.");
}
return this.makeFilter(
stream,
filter.name,
length,
params,
cipherTransform
);
}length只对第一层有效。原始字节长度描述的是文件里的数据,经过第一层解码后长度就变了,所以循环里立刻maybeLength = null。DecodeParms与Filter按位置配对。数组长度不必相等,缺失的项当作null;元素可以是Ref,用#fetchIfRef解引用。
makeFilter 覆盖 PDF 规范里的全部标准过滤器:
| 缩写 | 全名 | 目标 Stream 类 | 备注 |
|---|---|---|---|
Fl | FlateDecode | FlateStream | 有 DecodeParms 时再包一层 PredictorStream |
LZW | LZWDecode | LZWStream | 读 EarlyChange,默认 1 |
DCT | DCTDecode | JpegStream | 保留 JPEG 原始数据,交给浏览器解码 |
JPX | JPXDecode | JpxStream | JPEG 2000 |
A85 | ASCII85Decode | Ascii85Stream | |
AHx | ASCIIHexDecode | AsciiHexStream | |
CCF | CCITTFaxDecode | CCITTFaxStream | 传真编码 |
RL | RunLengthDecode | RunLengthStream | |
JBIG2Decode | 同名 | Jbig2Stream | |
BrotliDecode | 同名 | BrotliStream | PDF 2.0 |
Crypt | 同名 | cipherTransform.createStream | 缺 cipherTransform 时只警告并原样返回 |
makeFilter 还做了两道保护:maybeLength === 0 直接返回 NullStream(注释说明长度可能是错的,只有确定为 0 才敢跳过);catch 里除了 MissingDataException 必须上抛,其余异常都 warn 后返回 NullStream,保证单个坏流不会让整页失败。
内联图像
内联图像的语法是 BI 键 值 ... ID 原始字节 EI。它没有 /Length,也没有独立的流对象,所有数据都嵌在内容流里。这一节是 parser.js 里最「不语法」的部分:边界靠猜,猜错靠校验。
makeInlineImage 方法
makeInlineImage(cipherTransform) {
const lexer = this.lexer;
const stream = lexer.stream;
const dict = new Dict(this.xref);
let dictLength;
while (!isCmd(this.buf1, "ID") && this.buf1 !== EOF) {
if (!(this.buf1 instanceof Name)) {
throw new FormatError("Dictionary key must be a name object");
}
const key = this.buf1.name;
this.shift();
if (this.buf1 === EOF) {
break;
}
dict.set(key, this.getObj(cipherTransform));
}
if (lexer.beginInlineImagePos !== -1) {
dictLength = stream.pos - lexer.beginInlineImagePos;
}字典循环与普通字典几乎一样,差别是终止条件从 >> 变成 ID,且非法键直接抛错(内联图像里没有恢复的余地,因为后续字节含义已经不确定)。字典读完后立刻按 filterName 分派探测方式:
const filter = dict.get('F', 'Filter');
let filterName;
if (filter instanceof Name) {
filterName = filter.name;
} else if (Array.isArray(filter)) {
const filterZero = this.#fetchIfRef(filter[0]);
if (filterZero instanceof Name) {
filterName = filterZero.name;
}
}代码只看第一个名字,数组则取 filter[0] 并允许它是 Ref(#fetchIfRef)。原因很实际:后续过滤器(例如 FlateDecode)需要先知道压缩数据的边界才能解码,而第一层如果是 DCT、ASCII85、ASCIIHex,它们各自有明确的终止标记,可以直接找到数据末尾;否则只能退回通用的 EI 猜测。
// Parse image stream.
const startPos = stream.pos;
let length;
switch (filterName) {
case 'DCT':
case 'DCTDecode':
length = this.findDCTDecodeInlineStreamEnd(stream);
break;
case 'A85':
case 'ASCII85Decode':
length = this.findASCII85DecodeInlineStreamEnd(stream);
break;
case 'AHx':
case 'ASCIIHexDecode':
length = this.findASCIIHexDecodeInlineStreamEnd(stream);
break;
default:
length = this.findDefaultInlineStreamEnd(stream);
}找到长度后,代码还会尝试缓存整张内联图像:把 lexer.beginInlineImagePos 到数据末尾的字节整体作为键(getInlineImageCacheKey 会带上原始长度防碰撞),只缓存 MAX_LENGTH_TO_CACHE = 1000 字节以内、且字典长度大于 0 的图像。命中后无需解码,cacheEntry.reset() 并合成一个 EI 交给 Parser 即可;未命中则走下面的正常路径。
findDefaultInlineStreamEnd 方法
这是最典型的「启发式边界」。规范只说数据后面跟 EI,但 EI 两个字节完全可能出现在二进制数据里。于是状态机先找 E、再找 I、再要求后面是空白:
findDefaultInlineStreamEnd(stream) {
const E = 0x45,
I = 0x49,
SPACE = 0x20,
LF = 0xa,
CR = 0xd,
NUL = 0x0;
const { knownCommands } = this.lexer,
startPos = stream.pos,
n = 15;
let state = 0,
ch,
maybeEIPos;
while ((ch = stream.getByte()) !== -1) {
if (state === 0) {
state = ch === E ? 1 : 0;
} else if (state === 1) {
state = ch === I ? 2 : 0;
}到达 state === 2 后,先看 EI 后面是不是空白,再 peekBytes(15) 检查接下来的字节是否都是可见 ASCII:
if (ch === SPACE || ch === LF || ch === CR) {
maybeEIPos = stream.pos;
// Let's check that the next `n` bytes are ASCII... just to be sure.
const followingBytes = stream.peekBytes(n);
const ii = followingBytes.length;
if (ii === 0) {
break; // The end of the stream was reached, nothing to check.
}ASCII 检查里对 NUL 有一个特判:单个 NUL 放行,连续 NUL 视为二进制。注释里解释了原因——有些生成器会在内联图像外写 NUL,完全禁止会误判,但无条件放行又可能把图像截短。
最关键的校验是「用临时 Lexer 试读后续算子」:
const tmpLexer = new Lexer(
new Stream(stream.peekBytes(5 * n)),
knownCommands
);
// Reduce the number of (potential) warning messages.
tmpLexer._hexStringWarn = () => {};
let numArgs = 0;
while (true) {
const nextObj = tmpLexer.getObj();
if (nextObj === EOF) {
state = 0; // No valid command found, resetting the state.
break;
}
if (nextObj instanceof Cmd) {
const knownCommand = knownCommands[nextObj.cmd];
if (!knownCommand) {
// Not a valid command, i.e. the inline image data *itself*
// contains an "EI" sequence. Resetting the state.
state = 0;
break;
} else if (
knownCommand.variableArgs
? numArgs <= knownCommand.numArgs
: numArgs === knownCommand.numArgs
) {
break; // Valid command found.
}
numArgs = 0;
continue;
}
numArgs++;
}
if (state === 2) {
break; // Finished!
}这一步把「EI 后面看起来像 ASCII」升级成「EI 后面确实是一个参数个数正确的合法算子」。比如图像数据里恰好出现 EI,后面跟着 x,再后面是乱码——临时词法器会读到不认识的命令,状态归零,继续扫描。取 75 字节(5 * n)而不是 15 字节,是为了容下 cm 这类多参数算子。
扫到文件末尾仍未确认时,PDF.js 会警告并回退到「最后一次见到的 EI」位置。确定结束点后还要修正一个字节的偏差:
let endOffset = 4;
stream.skip(-endOffset); // Set the stream position to just before "EI".
ch = stream.peekByte();
stream.skip(endOffset); // ... and remember to reset the stream position.
// Ensure that we don't accidentally truncate the inline image, when the
// data is immediately followed by the "EI" marker (fixes issue10388.pdf).
if (!isWhiteSpace(ch)) {
endOffset--;
}
return stream.pos - endOffset - startPos;
}endOffset 的 4 与 3 之争,本质是「EI 前的那个空白算不算图像数据」:先假设「空白 + EI + 空白」共四字节;如果倒数第四字节不是空白,说明只有「EI + 空白」三字节,于是减一。这一步保证紧贴 EI 的图像数据不会被截掉最后一个字节(issue10388)。
设计要点:内联图像的边界没有权威声明,只有三种弱证据——终止标记、后续字节的 ASCII 性、后续算子的合法性。PDF.js 把三者串成一条逐级加强的校验链,并且把「最后出现过的 EI」留作恢复点。这是「解析规范没规定的东西」时最务实的写法。
其他 find* 方法与 inlineStreamSkipEI
DCT、ASCII85、ASCIIHex 有明确的终止标记,因此不必猜 EI:
| 过滤器 | 终止标记 | 方法 | 找不到时 |
|---|---|---|---|
DCTDecode | JPEG 的 0xFFD9(EOI) | findDCTDecodeInlineStreamEnd | 回退到 findDefaultInlineStreamEnd |
ASCII85Decode | ~> | findASCII85DecodeInlineStreamEnd | 回退到 findDefaultInlineStreamEnd |
ASCIIHexDecode | > | findASCIIHexDecodeInlineStreamEnd | 回退到 findDefaultInlineStreamEnd |
| 其他 | EI | findDefaultInlineStreamEnd | 用最后一个 EI 位置恢复 |
findDCTDecodeInlineStreamEnd 不只找 0xFFD9,还按 JPEG 段长跳过 APPn、DQT、SOF、SOS 等段,避免把段内的 FF D9 字节误认成结束。ASCII85 的实现则容忍 ~ 与 > 之间夹空白,以及只写了 ~ 就接 EI 的坏文档。
三个专用方法都在成功后调用 inlineStreamSkipEI:
inlineStreamSkipEI(stream) {
const E = 0x45,
I = 0x49;
let state = 0,
ch;
while ((ch = stream.getByte()) !== -1) {
if (state === 0) {
state = ch === E ? 1 : 0;
} else if (state === 1) {
state = ch === I ? 2 : 0;
} else if (state === 2) {
break;
}
}
}因为专用方法停在 EOI / ~> / > 之后,EI 还在前面,必须再走一次两字节状态机把它吃掉,Parser 才能继续读后续算子。
这里顺便澄清一个容易传错的函数名:这一版源码里没有 findLengthOfInt。与内联图像相关的 find* 方法只有上表四个,加上 inlineStreamSkipEI 与 #findStreamLength。
对象流与复用方式
Parser 不只为普通间接对象服务。同一个类被四种场景复用:
| 场景 | Lexer 的命令表 | allowStreams | 数据来源 |
|---|---|---|---|
普通间接对象(XRef.fetchUncompressed) | 无 | true | 从 obj 偏移开始的子流 |
对象流(XRef.fetchCompressed) | 无 | true | 已解压的 ObjStm 子流 |
内容流(EvaluatorPreprocessor) | EvaluatorPreprocessor.opMap | false | 页面内容流 |
线性化字典(Linearization.create) | 无 | false | 文件开头 |
对象流的读取最能说明通用性:XRef.fetchCompressed 先解压 ObjStm,用一个 Parser 读 N 组「对象号 + 偏移」,再按相邻偏移的差为每个对象切有界子流,各建一个 Parser 调用 getObj()。没有为 ObjStm 写任何专用语法。
内容流则反过来:Lexer 拿到 EvaluatorPreprocessor.opMap,粘连命令被正确拆开;allowStreams 保持默认 false,内容流里即使出现 stream 关键字也只当普通字典处理,不会误吞后续字节。
线性化字典是更小的例子:Linearization.create 用 xref: null 建一个 Parser,连续四次 getObj() 读出「对象号、代号、obj、字典」,再用 isCmd(obj3, "obj") 与 linDict instanceof Dict 校验。xref 为空时 Dict 不会尝试解引用,正好适合「还没有 xref、只能读文件头」的时刻。
总结
parser.js 是整条链路的地基:第 6 篇找到的 xref 偏移、第 10 篇维护的条目、第 7 篇执行的算子,最终都要落到这里把字节变成对象。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 词法 | Lexer | 字节流 | 数字、字符串、Name、Cmd、布尔、null、EOF |
| 语法 | Parser | token 窗口 | 数组、Dict、Ref、Stream、内联图像 |
| 对象获取 | XRef | 引用与偏移 | 解引用后的对象、ObjStm 缓存 |
| 求值 | PartialEvaluator | 内容流 | operatorList |
| 渲染 | CanvasGraphics | operatorList | 像素 |
五条核心设计原则
- 词法与语法彻底分离。
Lexer不知道括号是否配对,Parser不知道数字由几个字节构成。分离让Lexer能被内容流、ObjStm、线性化字典、内联图像校验四处复用,让Parser能被 xref、ObjStm、内容流、线性化字典四处复用。 - 两格前瞻足够表达 PDF 语法。
buf1/buf2加上shift,用「移动后的buf1+buf2」识别1 0 R;字典后的stream也只看一格。没有回溯,没有 token 数组,内存是常量。 - 声明可信就用声明,不可信就探测。
/Length正确时直接stream.pos = startPos + length;错误时退化为 2KB 块扫描endstream。内联图像则完全没有声明,只能靠EI+ ASCII 检查 + 算子校验三级证据。 - 词法层不做语义解码。
getString只把字节一一映射成字符,UTF-16BE BOM 与 PDFDocEncoding 留到stringToPDFString。同一段字节因此可以被不同语义层用不同方式解释,解析器不必猜编码。 - 能复用就绝不新建。
Cmd、Name、Ref三张缓存、复用的strBuf、单字符缓冲、内联图像缓存,目标都是把「高频短命对象」压成「低频稳定对象」。这直接决定了 PDF.js 在几百页文档上的内存曲线。
系列小结
到这里,PDF.js 的「读」这一侧主链路已经完整:字节 → token → 对象 → 文档模型 → operatorList → 像素。每一层都只依赖下一层的稳定接口,也都是可中断、可恢复的。这种分层让 Worker 可以在只下载了部分文件时就开始解析,也让主线程永远不必接触原始 PDF 语法。Catalog 的页面树与命名树、加密流、字体子集化等细节还可以继续往下拆,但它们都挂在这条主链路的某个节点上,不会改变分层本身。
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 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- 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日