StreamPDFStreamPDF
返回博客

PDF.js 源码解读: metadata_parser

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: metadata_parser

前言

这是 PDF.js 源码解读系列中关于「XMP 元数据」的一篇,介绍 src/core/metadata_parser.js(146 行)。

PDF 文档除了 /Info 字典里的标题、作者、创建时间,还可以带一个 Metadata 流。这个流的内容是 XMP,本质是一段 RDF/XML。阅读器用它读取更丰富的信息:dc:title、dc:creator、xmp:CreateDate、pdf:Producer,以及厂商自定义字段。

实际文件里的 XMP 经常并不规范:流前面可能有垃圾字节,Ghostscript 会把 UTF-16BE 文本写成八进制转义,命名空间前缀大小写不统一,数组与语言替代混在一起。metadata_parser.js 的任务不是做通用 XML 解析器,而是把这段可能损坏的 XMP 尽量修好,再压平成一张「字段名 → 字符串或字符串数组」的表。它不保留 DOM,不做 XPath,也不保留命名空间对象。

模块总览

区块关键成员职责
入口MetadataParser 构造函数先修复文本,再交给 SimpleXMLParser 建树
文本修复_repair去前导垃圾、还原八进制转义、转义非法字符
树遍历_parse、_parseArray、_getSequence找到 RDF 根与描述节点,收集字段
对外结果serializable返回解析后的 Map 与修复后的文本

它在整条链路里的位置:

Catalog.getMetadata
  → XRef.fetch(Metadata 流)
  → BaseStream.getString
  → stringToUTF8String
  → new MetadataParser(data)
  → _repair
  → SimpleXMLParser.parseFromString
  → _parse
  → serializable
  → Metadata(display 层)
  → app.js / pdf_document_properties.js

版本说明:本篇严格按当前仓库这一版 metadata_parser.js 讲解。MetadataParser 不继承 SimpleXMLParser,而是组合:构造函数里新建解析器,再把 data 交给它。源码里没有 beginElement / endElement,对应的是解析器的 onBeginElement / onEndElement;也没有 onclosetag / onopentag 这类回调,文本与 CDATA 分别走 onText 与 onCdata。同样没有 parse() 方法返回 xmp / xdc 两个字典;本版只有 serializable,返回一个 Map 与一段字符串。旧版本里的 _metadata、xmp、xdc 字段在本版都不存在。

入口:构造函数与 _repair

构造函数很短,但顺序很重要:先修复,再解析。

class MetadataParser {
  constructor(data) {
    // Ghostscript may produce invalid metadata, so try to repair that first.
    data = this._repair(data);
 
    // Convert the string to an XML document.
    const parser = new SimpleXMLParser({ lowerCaseName: true });
    const xmlDocument = parser.parseFromString(data);
 
    this._metadataMap = new Map();
    this._data = data;
 
    if (xmlDocument) {
      this._parse(xmlDocument);
    }
  }

四个动作是:修复文本、创建开启名称小写的解析器、建立轻量 DOM、初始化空表并在 DOM 存在时遍历。有两个容易忽略的点。

第一,this._data 保存的是修复后的文本,不是调用方传入的原始文本。serializable.rawData 因此是 _repair 的输出;如果修复改变了字符,它与流里的字节不再逐字一致。

第二,lowerCaseName: true 会作用到所有元素名。解析器在开始标签回调里把名字转小写,于是源码比较的都是 rdf:rdf、rdf:description、rdf:bag、rdf:li,字段名也统一成 dc:title、xmp:createdate、pdf:producer。消费端必须使用小写键。

_repair 方法

_repair 是一串正则替换,只处理两类已知坏数据。

  _repair(data) {
    // Start by removing any "junk" before the first tag (see issue 10395).
    return data
      .replace(/^[^<]+/, "")
      .replaceAll(/>\\376\\377([^<]+)/g, function (all, codes) {
        const bytes = codes
          .replaceAll(/\\([0-3])([0-7])([0-7])/g, function (code, d1, d2, d3) {
            return String.fromCharCode(d1 * 64 + d2 * 8 + d3 * 1);
          })
          .replaceAll(/&(amp|apos|gt|lt|quot);/g, function (str, name) {
            switch (name) {
              case "amp":
                return "&";
              case "apos":
                return "'";
              case "gt":
                return ">";
              case "lt":
                return "<";
              case "quot":
                return '"';
            }
            throw new Error(`_repair: ${name} isn't defined.`);
          });
 
        const charBuf = [">"];
        for (let i = 0, ii = bytes.length; i < ii; i += 2) {
          const code = bytes.charCodeAt(i) * 256 + bytes.charCodeAt(i + 1);
          if (
            code >= /* Space = */ 32 &&
            code < /* Delete = */ 127 &&
            code !== /* '<' = */ 60 &&
            code !== /* '>' = */ 62 &&
            code !== /* '&' = */ 38
          ) {
            charBuf.push(String.fromCharCode(code));
          } else {
            charBuf.push(
              "&#x" + (0x10000 + code).toString(16).substring(1) + ";"
            );
          }
        }
        return charBuf.join("");
      });
  }

按执行顺序拆成六步:

步骤代码作用
1replace(/^[^<]+/, "")删除第一个标签之前的所有字符
2replaceAll(/>\\376\\377([^<]+)/g, ...)找到「标签结束符 + 八进制 BOM + 文本」
3replaceAll(/\\([0-3])([0-7])([0-7])/g, ...)把三位八进制转义还原成单字节字符
4replaceAll(/&(amp|apos|gt|lt|quot);/g, ...)解码五个预定义实体
5每两个字符合成一个 16 位码元按 UTF-16BE 解释字节流
6可打印 ASCII 保留,其余转成数字字符引用避免控制字符与标记字符破坏后续解析

第一步对应 issue 10395。正则从开头匹配到第一个 <,把前面的说明文字整段删掉。如果数据里没有 <,结果变成空字符串,后续解析自然得到空表。

第二步针对 Ghostscript。它把 UTF-16BE 文本写完形如 \376\377 的八进制转义:\376 是 0xFE,\377 是 0xFF,合起来就是 BOM。正则捕获 >、这两个转义、以及后面直到下一个 < 之前的内容。> 被单独放进 charBuf 的第一个元素,原样保留。

第三步把每个八进制转义还原成一个字节字符。d1、d2、d3 是字符串,乘法会把它们转成数字:第一位乘 64,第二位乘 8,第三位乘 1。第一位取值 0 到 3,后两位 0 到 7,结果最大 0o377,正好一个字节。

第四步解码实体。先还原八进制再解实体,原本写成的 &amp; 会变成 &,不会在后续步骤里被误认为标记。只处理五个预定义实体,其它名字直接抛错。

第五步按两个字符一组解释。charCodeAt(i) * 256 + charCodeAt(i + 1) 把高低字节合成一个 16 位码元。这里假设长度是偶数;奇数时最后一组会读到 NaN,最终拼出无效实体,源码没有为这种极端情况加保护。

第六步决定每个码元怎么写回 XML:

码元范围输出形式原因
32 到 126,且不是 60、62、38原字符可打印 ASCII,直接写最省空间
其它&#xNNNN; 数字字符引用控制字符、非 ASCII、标记字符

60、62、38 分别是 <、>、&。这三个字符原样写回会被 XML 解析器当成标签开始、标签结束或实体开始,所以必须转义。

数字实体的构造方式值得细看:

'&#x' + (0x10000 + code).toString(16).substring(1) + ';';

code 最大 0xFFFF,加上 0x10000 后转十六进制一定得到五位数字,第一位固定是 1;substring(1) 去掉前缀,剩下正好四位。这是不依赖 padStart 的补零写法。

设计要点:_repair 只修复已知的两类损坏,不尝试理解 XML 结构。它把所有非 ASCII 码元写成数字字符引用,等于把编码问题转成纯 ASCII 文本,后续解析器不需要关心原始字节是 UTF-8、UTF-16BE 还是 PDFDocEncoding。修复边界很窄,但覆盖了真实文件里最常见的两种坏法。

SimpleXMLParser 的协作方式

MetadataParser 使用 src/core/xml_parser.js 里的 SimpleXMLParser。它不实现浏览器 DOM,只建立元素节点、文本节点与父子关系。解析器的状态由两个数组维护:_currentFragment 指向「当前应该把新节点放进去的数组」,_stack 保存外层数组。遇到开始标签就压栈并切换,遇到结束标签就弹栈并切回,整棵树因此是由数组嵌套出来的。

真正决定节点形态的是两个回调:

  onBeginElement(name, attributes, isEmpty) {
    if (this._lowerCaseName) {
      name = name.toLowerCase();
    }
    const node = new SimpleDOMNode(name);
    node.childNodes = [];
    if (this._hasAttributes) {
      node.attributes = attributes;
    }
    this._currentFragment.push(node);
    if (isEmpty) {
      return;
    }
    this._stack.push(this._currentFragment);
    this._currentFragment = node.childNodes;
  }
 
  onEndElement(name) {
    this._currentFragment = this._stack.pop() || [];
    const lastElement = this._currentFragment.at(-1);
    if (!lastElement) {
      return null;
    }
    for (const childNode of lastElement.childNodes) {
      childNode.parentNode = lastElement;
    }
    return lastElement;
  }

几个特征值得列出来:

特征实现后果
名称大小写lowerCaseName 为真时统一转小写后续比较只需写小写形式
自闭合标签isEmpty 为真时只创建节点,不压栈rdf:li 这类空元素不会留下多余层级
结束标签不校验名称onEndElement 直接弹出栈顶,不比较 name畸形嵌套不报错,按「最近未闭合元素」收尾
父指针结束标签时才把孩子的 parentNode 指向自己文本节点也能拿到父节点
多个根元素parseFromString 只取第一个 documentElement后面的根被忽略

构造时没有传 hasAttributes,所以元素上没有 attributes 字段。xml:lang 这类属性在解析阶段就被丢弃,对 dc:title 的语言替代来说,语言信息无法保留。

文本节点由 onText 与 onCdata 创建。onText 先用 isWhitespaceString 判断,纯空白直接返回,否则创建一个 #text 节点放进当前数组。这一点对遍历很重要:rdf:Description 的子节点里不会出现成片的空白 #text,源码只需要跳过非空白的散落文本。onCdata 结构相同,但不过滤空白,因为 CDATA 里的空白可能有意义。

SimpleDOMNode.textContent 递归拼接:

  get textContent() {
    return !this.childNodes
      ? this.nodeValue || ""
      : this.childNodes.map(child => child.textContent).join("");
  }

没有子节点时返回 nodeValue,有子节点时拼接所有孩子的文本。它不插入分隔符,所以嵌套多个 rdf:li 的字段会被拼成一串连续文本,而不是数组。数组语义必须由 _parseArray 单独处理。

parseFromString 在任何语法错误时设置错误码并返回 undefined;没有根节点时也返回 undefined。MetadataParser 看到 undefined 就不调用 _parse,结果是一张空表,而不是异常。

设计要点:SimpleXMLParser 是「够用就好」的解析器。它不校验标签配对、不展开自定义实体、不保留属性、不处理命名空间声明。它只保证把格式正确的常见 XMP 变成可遍历的树;坏掉的部分要么被跳过,要么整体返回 undefined。Worker 端因此不必引入完整的 XML 解析库。

定位 rdf:RDF 与 rdf:Description

拿到 xmlDocument 后,_parse 先找 RDF 根,再遍历每个描述节点。

  _parse(xmlDocument) {
    let rdf = xmlDocument.documentElement;
 
    if (rdf.nodeName !== "rdf:rdf") {
      // Wrapped in <xmpmeta>
      rdf = rdf.firstChild;
      while (rdf && rdf.nodeName !== "rdf:rdf") {
        rdf = rdf.nextSibling;
      }
    }
 
    if (!rdf || rdf.nodeName !== "rdf:rdf" || !rdf.hasChildNodes()) {
      return;
    }
 
    for (const desc of rdf.childNodes) {
      if (desc.nodeName !== "rdf:description") {
        continue;
      }
 
      for (const entry of desc.childNodes) {
        const name = entry.nodeName;
        switch (name) {
          case "#text":
            continue;
          case "dc:creator":
          case "dc:subject":
            this._parseArray(entry);
            continue;
        }
        this._metadataMap.set(name, entry.textContent.trim());
      }
    }
  }

标准的 XMP 有两种常见外层:

外层形态documentElement 是什么找 RDF 的路径
直接以 rdf:RDF 为根rdf:rdf直接使用
包在 x:xmpmeta 里x:xmpmeta从第一个孩子开始,沿兄弟链找

第二行是 _parse 里那段 while 的来源。它只扫描兄弟节点,不递归进子树。如果 xmpmeta 与 rdf:rdf 之间还隔着别的包装层,这段代码找不到目标,最终返回空表。真实文件通常只有一层包装。

找到之后还有三道检查:rdf 存在、名字是 rdf:rdf、至少有一个孩子。任何一道不满足都直接返回,不尝试修补。

rdf:rdf 的孩子里只处理 rdf:description,其它节点一律跳过。描述节点内部再逐个子节点处理。#text 被显式跳过:前面 onText 只过滤纯空白,如果描述节点里夹着非空白裸文本,它会变成 #text 节点,源码不想把它当成字段名。

switch 只对两个名字做特殊处理:

字段名处理方式结果
dc:creator_parseArray字符串数组
dc:subject_parseArray字符串数组
其它textContent.trim()单个字符串

其余字段全部走同一行写入。这意味着 dc:title 如果写成 rdf:Alt 多语言数组,会把所有语言文本拼成一个字符串,而不是数组;dc:description 同理;xmp:CreateDate、pdf:Producer 这类单值字段得到纯文本;嵌套结构会被递归拼接,层级关系丢失。

这是「按字段名特判」,不是「按值类型推断」。源码没有根据 rdf:Bag、rdf:Seq、rdf:Alt 来决定是否解析为数组,只看字段名。好处是实现极短;代价是只有 dc:creator 与 dc:subject 这两个最常见的多值字段能拿到数组。

_getSequence 与 _parseArray

数组字段由两个方法配合完成:

  _getSequence(entry) {
    const name = entry.nodeName;
    if (name !== "rdf:bag" && name !== "rdf:seq" && name !== "rdf:alt") {
      return null;
    }
    return entry.childNodes.filter(node => node.nodeName === "rdf:li");
  }
 
  _parseArray(entry) {
    if (!entry.hasChildNodes()) {
      return;
    }
    // Child must be a Bag (unordered array) or a Seq.
    const [seqNode] = entry.childNodes;
    const sequence = this._getSequence(seqNode) || [];
 
    this._metadataMap.set(
      entry.nodeName,
      sequence.map(node => node.textContent.trim())
    );
  }

_getSequence 接受三种容器名:

容器语义PDF.js 的处理
rdf:Bag无序集合取所有 rdf:li
rdf:Seq有序序列取所有 rdf:li
rdf:Alt多语言替代取所有 rdf:li

三者都按出现顺序收集 rdf:li,不区分无序与有序语义。Alt 的语言属性在解析阶段已经丢失,拿到的只是各语言文本的数组。

_parseArray 只取 entry 的第一个孩子作为容器。const [seqNode] = entry.childNodes 是解构赋值,后面的孩子全部忽略。由于空白文本已被过滤,正常情况下第一个孩子就是三种容器之一。如果第一个孩子不是,_getSequence 返回 null,|| [] 把它变成空数组,最终写入一个空数组而不是跳过字段。

如果 entry 完全没有孩子,方法直接返回,字段不会出现在 Map 里。于是「空数组」与「字段不存在」是两种不同结果,消费端需要自己区分。

值的处理统一是 node.textContent.trim()。对 rdf:li 来说,这通常就是语言文本;如果内部还有嵌套标签,仍然会被递归拼接。

设计要点:_parseArray 把「容器类型」与「条目类型」分开检查。容器必须是三种 RDF 集合之一,条目必须是 rdf:li。但源码只检查第一个孩子,不处理多个容器并排的情况。这是面向常见 XMP 的最小实现,不是完整的 RDF 解析器。

对外接口:serializable 与消费端

解析结果通过访问器暴露:

  get serializable() {
    return {
      parsedData: this._metadataMap,
      rawData: this._data,
    };
  }

两个字段用途不同:

字段类型内容用途
parsedDataMap小写字段名到字符串或字符串数组结构化查询
rawData字符串_repair 之后的完整 XML 文本原样展示、调试、二次解析

Worker 端不直接把这个对象交给主线程,而是经过 Catalog.getMetadata:

const data = stringToUTF8String(stream.getString());
if (data) {
  metadata = new MetadataParser(data).serializable;
}

主线程的 Metadata 包装层只做三件事:get(name) 查表、getRaw() 返回文本、迭代器暴露所有键值。

  get(name) {
    return this.#map.get(name) ?? null;
  }
 
  getRaw() {
    return this.#data;
  }

键名在 SimpleXMLParser 里已经转成小写,所以消费端必须用小写:

消费位置查询的键说明
web/app.js 标题dc:title单值字符串
web/app.js 作者dc:creator数组,需要 join
web/app.js 生成器pdf:producer单值字符串
pdf_document_properties.jsxmp:createdate小写键,不是 CreateDate
pdf_document_properties.jsdc:creator数组,展示时按换行拼接

这也解释了为什么 lowerCaseName 不能随便关掉:消费端已经按小写键写死。如果解析器保留原始大小写,xmp:CreateDate 与 xmp:createdate 会变成两个不同的键。

设计要点:serializable 把结构化结果与原始文本一起返回。上层既可以用 Map 做快速查询,也可以在需要时拿到完整 XMP 做展示或二次解析。键在解析阶段统一小写,等于把「大小写不敏感」前置到 Worker,主线程不必每次比较都做归一化。

容错边界

情况发生位置结果
第一个标签前有垃圾_repair被删除
Ghostscript 八进制 UTF-16BE_repair还原成字符或数字实体
文本里有控制字符或非 ASCII_repair转成 &#xNNNN;
XML 语法错误SimpleXMLParser返回 undefined,得到空表
根不是 rdf:rdf,也不含它_parse直接返回,空表
rdf:rdf 没有孩子_parse直接返回,空表
描述节点里夹着裸文本_parse#text 被跳过
dc:creator 没有孩子_parseArray字段不写入
集合容器不是三种之一_getSequence返回 null,最终写空数组
字段重复_metadataMap.set后一个覆盖前一个
自定义实体XMLParserBase原样保留为 &name;,不展开

最值得注意的是最后一行。SimpleXMLParser 的实体解析只处理五个预定义实体与数字字符引用,其它实体名交给 onResolveEntity,默认实现返回原字符串。它不会读取 DTD,也不会加载外部实体。因此 XMP 里的自定义实体不会造成网络请求或文件读取,也不会被展开成意外内容。

另一个边界是多个 rdf:Description。_parse 会遍历所有直接孩子,把每个描述里的字段都写进同一张 Map。两个描述写同一个字段时,后一个覆盖前一个,源码不合并、不比较、不报错。

总结

metadata_parser.js 是 PDF.js 的元数据扁平化层:向下接收一段可能损坏的 XML 文本,向上输出一张大小写统一、值类型有限的 Map。

它在整条链路中的位置

阶段模块输入输出
流读取Catalog.getMetadataMetadata 流引用字节
解码stringToUTF8String字节JavaScript 字符串
修复MetadataParser._repair原始字符串纯 ASCII 化的 XML 文本
建树SimpleXMLParserXML 文本轻量 DOM
遍历_parse / _parseArrayDOMMap<字段名, 值>
传输serializableMap 与文本可结构化克隆的对象
消费Metadata(display 层)parsedData、rawDataget、getRaw、迭代器

用一句话串起来:_repair 把坏文本变成可解析的 ASCII,SimpleXMLParser 把文本变成树,_parse 把树压成表,Metadata 把表包装成主线程可查询的接口。

四条设计原则

  1. 先修复,再解析。XMP 的损坏往往发生在文本层,而不是结构层。把前导垃圾、八进制转义、控制字符在进入解析器之前处理掉,后续状态机就不需要为这些情况写分支。
  2. 组合而不是继承。MetadataParser 需要一个能建树的 XML 解析器,但不需要暴露解析器的全部回调。组合让它只依赖 parseFromString 一个入口,也避免了基类状态与自身字段混在一起。
  3. 按字段名特判,而不是按值类型推断。只有 dc:creator 与 dc:subject 走数组路径,其余统一取 textContent。实现极短,覆盖最常见的多值字段;不足的部分由消费端按需处理。
  4. 失败降级为空表,而不是抛错。XML 语法错误、找不到 RDF 根、描述节点缺失,最终都表现为「元数据为空」。对阅读器来说,没有 XMP 仍然可以正常打开文档,这比因为一段可选信息而中断整个加载更合理。

下一篇回到内容流一侧。XMP 描述的是「文档是什么」,而默认外观字符串描述的是「注释和表单字段长什么样」。default_appearance.js 会把一段 /Helvetica 0 Tf 0 g 拆成字号与颜色,还会在没有可用字体时造一个假字体。

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

相关文章