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

前言
这是 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("");
});
}按执行顺序拆成六步:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 1 | replace(/^[^<]+/, "") | 删除第一个标签之前的所有字符 |
| 2 | replaceAll(/>\\376\\377([^<]+)/g, ...) | 找到「标签结束符 + 八进制 BOM + 文本」 |
| 3 | replaceAll(/\\([0-3])([0-7])([0-7])/g, ...) | 把三位八进制转义还原成单字节字符 |
| 4 | replaceAll(/&(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,正好一个字节。
第四步解码实体。先还原八进制再解实体,原本写成的 & 会变成 &,不会在后续步骤里被误认为标记。只处理五个预定义实体,其它名字直接抛错。
第五步按两个字符一组解释。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,
};
}两个字段用途不同:
| 字段 | 类型 | 内容 | 用途 |
|---|---|---|---|
parsedData | Map | 小写字段名到字符串或字符串数组 | 结构化查询 |
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.js | xmp:createdate | 小写键,不是 CreateDate |
pdf_document_properties.js | dc: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.getMetadata | Metadata 流引用 | 字节 |
| 解码 | stringToUTF8String | 字节 | JavaScript 字符串 |
| 修复 | MetadataParser._repair | 原始字符串 | 纯 ASCII 化的 XML 文本 |
| 建树 | SimpleXMLParser | XML 文本 | 轻量 DOM |
| 遍历 | _parse / _parseArray | DOM | Map<字段名, 值> |
| 传输 | serializable | Map 与文本 | 可结构化克隆的对象 |
| 消费 | Metadata(display 层) | parsedData、rawData | get、getRaw、迭代器 |
用一句话串起来:_repair 把坏文本变成可解析的 ASCII,SimpleXMLParser 把文本变成树,_parse 把树压成表,Metadata 把表包装成主线程可查询的接口。
四条设计原则
- 先修复,再解析。XMP 的损坏往往发生在文本层,而不是结构层。把前导垃圾、八进制转义、控制字符在进入解析器之前处理掉,后续状态机就不需要为这些情况写分支。
- 组合而不是继承。
MetadataParser需要一个能建树的 XML 解析器,但不需要暴露解析器的全部回调。组合让它只依赖parseFromString一个入口,也避免了基类状态与自身字段混在一起。 - 按字段名特判,而不是按值类型推断。只有
dc:creator与dc:subject走数组路径,其余统一取textContent。实现极短,覆盖最常见的多值字段;不足的部分由消费端按需处理。 - 失败降级为空表,而不是抛错。XML 语法错误、找不到 RDF 根、描述节点缺失,最终都表现为「元数据为空」。对阅读器来说,没有 XMP 仍然可以正常打开文档,这比因为一段可选信息而中断整个加载更合理。
下一篇回到内容流一侧。XMP 描述的是「文档是什么」,而默认外观字符串描述的是「注释和表单字段长什么样」。default_appearance.js 会把一段 /Helvetica 0 Tf 0 g 拆成字号与颜色,还会在没有可用字体时造一个假字体。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- 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 深入浅出 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 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日