StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 11: Parser

2026年10月26日 · StreamPDF Team

PDF.js 深入浅出 11: Parser

前言

这是 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–71getInlineImageCacheKey把内联图像的原始字节压成字符串键
Parser73–917递归下降解析器与流处理组装数组、字典、流、内联图像
specialChars919–939256 项字符分类表空白与分隔符的 O(1) 判定
toHexDigit941–952十六进制字符转数字#xx 名字转义与 hex string 共用
Lexer954–1410词法分析器字节流变成 token
Linearization1412–1482线性化字典读取文档首字节处的一种特殊 Parser 用法

两个类的关系可以先用一张表定住,后文所有细节都是这张表的展开:

维度LexerParser
输入BaseStream,可 getByte / peekByteLexer 产出的 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、0x201specialChars[ch] === 1
分隔符(、)、<、>、[、]、{、}、/、%2specialChars[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));
  }
}

三个要点:

  1. EOF 用 Symbol。Parser 里大量写 this.buf1 === EOF,只有「同一个 Symbol 引用」才成立,任何合法 token 都不可能误判。Symbol 还天然是唯一值,不会被用户数据构造出来。
  2. ||= 缓存。Cmd.get("Tj") 第一次创建实例,之后永远返回同一个。于是 isCmd(x, "Tj") 的两次 instanceof 与字符串比较都在稳定的小对象上做,缓存也不会膨胀——不同算子名只有几十个。
  3. 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去掉反斜杠保留 xdefault 分支

三个易错点:

  1. charBuffered。八进制转义可能已经多读了 1–2 个字符,此时 ch 不属于下一轮,必须用标志位阻止外层再 nextChar(),否则会吞掉字符。
  2. 续行。反斜杠后跟 CR 或 CRLF,PDF 规定「行继续」且不产生字符。case 0x0d 里还要 peek 一下是否跟着 LF,避免把 CRLF 拆成两次处理。
  3. 嵌套括号计数。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() 返回说明
整数 123123Number.isInteger 为真,Parser 可能用它拼 Ref
实数 1.51.5由 divideBy 缩放得到
/NameName经过 Name.get 归一化
(abc)"abc"每个字符就是一个原始字节
<48656c6c6f>"Hello"两位合一字节,奇数位补零
[ ] << >>Cmd 单例结构标记,由 Parser 解释
q BT TjCmd 单例内容流算子
true / falsetrue / false布尔值,不是 Cmd
nullnull真正的 null
文件结束EOFSymbol 单例

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);
  }

这一段解决了三类现实问题:

  1. 二进制垃圾。如果首字节不是可见 ASCII(小于 0x20 或大于 0x7f),但下一个字节是可见 ASCII,就把它当成一个单字符命令返回。这样坏文档里的控制字节不会污染后续 token。
  2. 粘连 token。knownCommands 命中后,只要下一字符不能把当前串延续成另一个已知命令,就立刻停止。false 因此不会被拆成 f + alse。
  3. 命令长度上限。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 行里:

  1. 数组:循环递归直到 ]。EOF 时,recoveryMode 返回已经读到的部分,否则抛 ParserEOFException。这保证坏文档在「容错模式」下仍能打开,正常模式下错误不会被悄悄吞掉。
  2. 字典:键必须是 Name。遇到非法键时打印 info、shift() 跳过一个 token、继续——不是 throw。这是 PDF.js 对坏文档的一贯态度:能猜就猜。
  3. 字典 vs 流:字典闭合后看 buf2 是否是 stream。allowStreams 为真才调用 makeStream;内容流与 ObjStm 里不允许嵌套流,所以此时只返回 Dict,让调用方自己处理后续 token。
  4. Ref 识别:buf1 是整数,移动后的 buf1 也是整数,且 buf2 是 R,三者凑成「对象号 + 代号 + R」。注意这里用的是移动之后的 buf1,所以三格 token 被两格窗口巧妙地分解了。
  5. 字符串解密:只有字符串会走 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;
      }

扫描策略是「块扫描 + 前缀匹配」:

  1. 每次 peekBytes(2048),不复制整份文件。peekBytes 返回的是当前缓冲窗口,扫描完只需 stream.pos += scanLength。
  2. 先匹配三字节的 end。命中后继续检查后缀:完整的 stream、拼错的 steam、截断的 strea 都接受;后两种还要求后缀之后的第一个字节是空白,防止在图像数据里误命中。
  3. 返回 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 类备注
FlFlateDecodeFlateStream有 DecodeParms 时再包一层 PredictorStream
LZWLZWDecodeLZWStream读 EarlyChange,默认 1
DCTDCTDecodeJpegStream保留 JPEG 原始数据,交给浏览器解码
JPXJPXDecodeJpxStreamJPEG 2000
A85ASCII85DecodeAscii85Stream
AHxASCIIHexDecodeAsciiHexStream
CCFCCITTFaxDecodeCCITTFaxStream传真编码
RLRunLengthDecodeRunLengthStream
JBIG2Decode同名Jbig2Stream
BrotliDecode同名BrotliStreamPDF 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:

过滤器终止标记方法找不到时
DCTDecodeJPEG 的 0xFFD9(EOI)findDCTDecodeInlineStreamEnd回退到 findDefaultInlineStreamEnd
ASCII85Decode~>findASCII85DecodeInlineStreamEnd回退到 findDefaultInlineStreamEnd
ASCIIHexDecode>findASCIIHexDecodeInlineStreamEnd回退到 findDefaultInlineStreamEnd
其他EIfindDefaultInlineStreamEnd用最后一个 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.opMapfalse页面内容流
线性化字典(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
语法Parsertoken 窗口数组、Dict、Ref、Stream、内联图像
对象获取XRef引用与偏移解引用后的对象、ObjStm 缓存
求值PartialEvaluator内容流operatorList
渲染CanvasGraphicsoperatorList像素

五条核心设计原则

  1. 词法与语法彻底分离。Lexer 不知道括号是否配对,Parser 不知道数字由几个字节构成。分离让 Lexer 能被内容流、ObjStm、线性化字典、内联图像校验四处复用,让 Parser 能被 xref、ObjStm、内容流、线性化字典四处复用。
  2. 两格前瞻足够表达 PDF 语法。buf1 / buf2 加上 shift,用「移动后的 buf1 + buf2」识别 1 0 R;字典后的 stream 也只看一格。没有回溯,没有 token 数组,内存是常量。
  3. 声明可信就用声明,不可信就探测。/Length 正确时直接 stream.pos = startPos + length;错误时退化为 2KB 块扫描 endstream。内联图像则完全没有声明,只能靠 EI + ASCII 检查 + 算子校验三级证据。
  4. 词法层不做语义解码。getString 只把字节一一映射成字符,UTF-16BE BOM 与 PDFDocEncoding 留到 stringToPDFString。同一段字节因此可以被不同语义层用不同方式解释,解析器不必猜编码。
  5. 能复用就绝不新建。Cmd、Name、Ref 三张缓存、复用的 strBuf、单字符缓冲、内联图像缓存,目标都是把「高频短命对象」压成「低频稳定对象」。这直接决定了 PDF.js 在几百页文档上的内存曲线。

系列小结

到这里,PDF.js 的「读」这一侧主链路已经完整:字节 → token → 对象 → 文档模型 → operatorList → 像素。每一层都只依赖下一层的稳定接口,也都是可中断、可恢复的。这种分层让 Worker 可以在只下载了部分文件时就开始解析,也让主线程永远不必接触原始 PDF 语法。Catalog 的页面树与命名树、加密流、字体子集化等细节还可以继续往下拆,但它们都挂在这条主链路的某个节点上,不会改变分层本身。

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

相关文章