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

前言
字体层走到这里,前面的文章已经把「字节怎么变成字形」讲了大半:encodings.js 把单字节字符码映射成字形名(StandardEncoding、WinAnsiEncoding、MacRomanEncoding、SymbolSetEncoding、ZapfDingbatsEncoding 等七张 256 项的表);cmap.js 处理复合字体的字符码到 CID,以及 /ToUnicode 流给出的字符码到 Unicode;fonts.js 的 Font 把字体字典、字形数据、宽度、编码、Unicode 映射组装成一个可渲染对象;fonts_utils.js 与 type1_font.js、cff_parser.js 负责字形名列表与字形程序。
中间一直缺一环:字形名本身该怎么念。encodings.js 只回答「这个字节对应哪个名字」,/ToUnicode 只回答「作者声称这个字节对应哪个 Unicode」。当一份 PDF 既没有 /ToUnicode,或者它的 /ToUnicode 残缺、错位、只覆盖部分字符时,查看器手上唯一的语义线索就是字形名。把 Aacute、afii10017、a1、f_f 这些名字翻译成码位的,就是 src/core/glyphlist.js。
这个文件 4557 行,其中 4531 行是数据赋值,逻辑只有两行 import 与一行 export:
import { getLookupTableFactory } from './core_utils.js';export { getDingbatsGlyphsUnicode, getGlyphsUnicode };它导出两个零参数工厂:getGlyphsUnicode 对应 Adobe Glyph List(下称 AGL),getDingbatsGlyphsUnicode 对应 ZapfDingbats 字形名。两者返回的都是「字形名字符串到码位整数」的普通对象。
一句话概括:它是字体层的名字字典,向下承接 encodings.js 与 /Differences 产出的字形名,向上为 fonts.js、evaluator.js、font_renderer.js、standard_fonts.js 提供码位,最终决定文本层能复制出什么字符、系统字体回退时用哪个字符顶替。
版本说明:本篇按当前仓库这一版
glyphlist.js讲解。本版没有紧凑字符串或扁平数组形式的数据,两张表都是在getLookupTableFactory的初始化函数里逐条写t.Name = 0xNNNN。历史上确实有过扁平数组版本(2020 年 10 月引入getArrayLookupTableFactory,同年 11 月回退),但当前源码里既没有getArrayLookupTableFactory,也没有按分隔符切分字符串的解析代码。另外,本版在 AGL 表末尾追加了 116 条 TeX 专用名字,并在f段插入了 5 条下划线式连字;旧版本或其它分支可能没有这些条目。读源码时不要套用记忆里的实现。
模块总览
glyphlist.js 的结构极简,只有四块:
| 区块 | 关键成员或表 | 职责 |
|---|---|---|
| 依赖 | getLookupTableFactory | 提供「首次调用建表、之后直接返回」的懒加载外壳 |
| 通用字形表 | getGlyphsUnicode,4328 条 | AGL 字形名到码位,覆盖拉丁、希腊、西里尔、亚美尼亚、希伯来、阿拉伯、印度系文字、泰文、假名、注音、韩文兼容字母、CJK 兼容区,以及 TeX 数学名 |
| 符号字形表 | getDingbatsGlyphsUnicode,203 条 | ZapfDingbats 的 a1 到 a206 一类编号名到码位,主要落在 U+2700 区 |
| 导出 | export { getDingbatsGlyphsUnicode, getGlyphsUnicode } | 只暴露两个工厂,不暴露表本身 |
它在整条链路里的位置可以画成这样:
// PDF 字体字典与内容流
// → encodings.js 字符码 → 字形名(七张 256 项编码表)
// → 字体内建编码 / Differences 字符码 → 更多字形名
// → glyphlist.js 字形名 → 码位(本篇)
// → cmap.js 的 ToUnicode 字符码 → Unicode(作者提供,可能缺失)
// → fonts.js 的 Font toUnicode / toFontChar / fontChar
// → canvas 绘制与文本层 可见字形与可复制文本注意图中 glyphlist.js 与 cmap.js 是并行的两条来源,不是前后串联:/ToUnicode 存在且可信时优先;缺失或不可信时才退回「字形名查表」。这也是为什么 fonts.js 里既有 adjustTrueTypeToUnicode、adjustType1ToUnicode 这种「事后修补」,又有 fallbackToSystemFont 里按字体名直接建 toFontChar 的分支。
glyphlist.js 不是解析器,也不做缓存管理。它不读字体数据,不认 PDF 语法,只在第一次被调用时把静态常量搬进对象。下面的章节按「为什么需要、两张表长什么样、表怎么建、数据有什么规律、谁在查、失败怎么办」展开。
两个工厂的调用契约
两个工厂的对外契约完全一致,非常窄:
| 约定项 | 表现 |
|---|---|
| 调用形式 | 零参数,getGlyphsUnicode() 或 getDingbatsGlyphsUnicode() |
| 返回类型 | 空原型对象,键是字形名字符串,值是整数码位 |
| 对象身份 | 同一 Worker 内始终返回同一个对象,不会每次新建 |
| 建表时机 | 第一次调用时执行初始化函数,之后只返回引用 |
| 生命周期 | 与 Worker 相同,不参与任何文档级 cleanup |
| 未命中 | 返回 undefined,不抛异常、不打日志 |
调用方的写法因此非常简单:
const glyphsUnicodeMap = getGlyphsUnicode(); // 建表或取现成引用
const unicode = glyphsUnicodeMap['A']; // 0x0041
const missing = glyphsUnicodeMap['A.sc']; // undefined契约窄带来的直接好处是「不需要上下文」:查表不关心当前是哪份文档、哪个字体、哪一页。坏处也很明确——表不能按文档定制。如果某份 PDF 需要额外的名字映射(例如某个厂商的私有字形名),只能在消费方建 overlay,而不能修改这张全局表。本版没有任何 overlay 机制,所有扩展都是直接往源码里加条目,这也解释了为什么文件末尾会出现 TeX 段、f 段里会看到下划线连字。
设计要点:全局共享对象一旦暴露,就无法按文档隔离。
glyphlist.js因此把「可扩展性」降到最低:不给写入接口、不提供注册函数、不接收参数。需要定制的部分交给上层的映射表,避免一份恶意 PDF 污染整个 Worker 的名字解析。
从字形名到 Unicode:链路里缺的一环
三条并行的码位解释路径
一份简单字体(Type1、TrueType、Type3)在渲染与文本抽取时会并行走几条路径,而 glyphlist.js 只服务其中一段:
| 路径 | 输入 | 中间结果 | 出口 | 缺失时的后果 |
|---|---|---|---|---|
| 渲染 | 字符码 | 字形名或 glyph id | fontChar / 字形程序 | 画面缺字或落到 .notdef |
| 文本 | 字符码 | /ToUnicode 或字形名 | unicode 字符串 | 复制、搜索、朗读出现乱码 |
| 回退 | 字体名 + 字符码 | 标准字体名表 | 系统字体与 toFontChar | 无嵌入字体时整段文字错位 |
渲染路径其实不一定需要 Unicode:只要字形数据在文件里,Font 可以按 glyph id 直接画。真正依赖「字形名到 Unicode」的是文本路径与系统字体回退路径。fonts.js 的 Glyph 类把这两种输出并排保存,正好说明了分工:
class Glyph {
constructor(
originalCharCode,
fontChar,
unicode,
accent,
width,
vmetric,
operatorListId,
isSpace,
isInFont
) {
this.originalCharCode = originalCharCode;
this.fontChar = fontChar;
this.unicode = unicode;
// ...
}fontChar 给画布用,unicode 给文本层用。两者可以不一致:一个只带 Type1 字形程序的字体,可能把 Aacute 画得很准,却在没有 /ToUnicode 时只能把 unicode 留空。
名字从哪来:PostScript 的遗产
PDF 不是凭空发明「字形名」的。它继承自 PostScript,而 PostScript 的字体格式从一开始就用名字索引字形:
| 格式 | 名字存放在哪 | 作用 |
|---|---|---|
| Type1 | /CharStrings 字典的键 | 每个字形程序的键就是名字,取程序时按名字查 |
| CFF | charset | 字形 id 到 SID 或自定义字符串,最终都还原成名字 |
| TrueType | post 表 2.0 | glyph id 到名字;前 258 个是 Mac 顺序的标准名 |
编码表与 /Differences | 字符码到名字 |
Type1 的解析器把名字当作普通 token 读出来:
if (token !== "/") {
continue;
}
const glyph = this.getToken();
length = this.readInt();
this.getToken(); // read in 'RD' or '-|'
data = length > 0 ? stream.getBytes(length) : new Uint8Array(0);CFF 的 charset 则把字形 id 逐个映射回名字:
const charset = [cid ? 0 : '.notdef'];
// ...
charset.push(cid ? id : strings.get(id));strings.get(id) 返回的就是标准字符串或自定义字符串里的名字。也就是说,一个 CFF 字体的字形顺序完全由名字序列决定,glyph id 只是这个名字序列的下标。
名字不只是内部标识,它还是跨字体格式的公共语言。PDF 的编码表、Type1 的 CharStrings、CFF 的 charset 用的是同一套命名习惯,所以一张 AGL 就能同时服务这三种来源。这也是 glyphlist.js 可以做成「与文档无关的全局表」的根本原因。
不过这套命名在真实文件里并不总是可靠:TrueType 的 post 表经常被生成器写坏,甚至直接填 0 表示「没有名字」。PDF.js 因此在多数情况下不走名字,而是用 cmap 直接拿码位;只有在标准字体被当作 CID 字体嵌入、又没有 glyph 映射时,才用 MacStandardGlyphOrdering 加 AGL 反推,具体例子在后面的消费方一节。
一个字符走完全程
同一个字符码 0x41,在不同字体里会走到完全不同的名字与码位:
| 字体 | 编码表 | 字符码 | 查到的名字 | 字形表 | 码位 |
|---|---|---|---|---|---|
| 普通文本字体 | StandardEncoding | 0x41 | A | getGlyphsUnicode | 0x0041 |
| Symbol | SymbolSetEncoding | 0x41 | Alpha | getGlyphsUnicode | 0x0391 |
| ZapfDingbats | ZapfDingbatsEncoding | 0x21 | a1 | getDingbatsGlyphsUnicode | 0x2701 |
0x41 本身没有任何语义,它只是一个字节。语义由「编码表 + 字形表」这一对决定:StandardEncoding 说 0x41 叫 A,SymbolSetEncoding 说 0x41 叫 Alpha。如果两者错配,A 在 Dingbats 表里查不到,文本层就会丢字。
这条链路还可以再往下走一步:0x2701 经过 String.fromCharCode 得到字符串,写进 Font.toUnicode;文本层取出这个字符串,用户选中时看到的就是它。整个过程中,glyphlist.js 只贡献了中间那一次「名字到数字」的转换,但它决定了最终能否得到正确的字符。
规范把这件事交给了 AGL
evaluator.js 的 _simpleFontToUnicode 把规范要求的步骤写成了带编号的注释:
const glyphsUnicodeMap = getGlyphsUnicode();
for (const charcode in encoding) {
// a) Map the character code to a character name.
let glyphName = encoding[charcode];
if (glyphName === "") {
continue;
}
// b) Look up the character name in the Adobe Glyph List (see the
// Bibliography) to obtain the corresponding Unicode value.
let unicode = glyphsUnicodeMap[glyphName];
if (unicode !== undefined) {
toUnicode[charcode] = String.fromCharCode(unicode);
continue;
}这段代码同时说明了三件事:
- 字形名到 Unicode 的官方依据是 Adobe Glyph List,不是字体里的任何一张表;
- 查表结果直接做
String.fromCharCode,说明表里存的是码位整数,不是字符串; - 查不到时返回
undefined,流程继续往下走启发式,而不是当场失败。
第三点很关键。glyphlist.js 不定义「坏名字」这个概念,它只回答「有没有」。名字是否合法、该不该修、修成什么,全部由调用方决定。这条边界让表可以保持纯数据,也让不同调用方按自己的容错策略处理同一个缺失。
表是协议常量,不是文档状态
AGL 与 ZapfDingbats 的名字集合来自 Adobe 的公开数据,对所有 PDF 都一样,不随文档变化。因此这张表可以被整个 Worker 共享、只构建一次、永不清理:
| 性质 | 表现 | 影响 |
|---|---|---|
| 全局 | 不挂在 Catalog 或 Font 上 | cleanup 不需要处理它 |
| 只读 | 没有任何写入路径 | 多字体、多页面并发查表安全 |
| 幂等 | 工厂每次返回同一个对象 | 调用方可以随意调,不必传递引用 |
| 静默 | 查不到返回 undefined | 错误处理责任在上层 |
设计要点:
glyphlist.js把「数据」与「策略」彻底分开。表只负责字面命中,缺失就交白卷;名字归一化、反向查找、启发式补码位,分别落在unicode.js、fonts_utils.js、evaluator.js里。这样表可以照着 Adobe 原始数据逐条核对,而容错策略可以独立演进、独立测试。
两套表:AGL 与 ZapfDingbats
AGL:一个名字一个码位
getGlyphsUnicode 的开头是一串按字母序排列的名字,大写名字集中在前半,小写名字在后半:
const getGlyphsUnicode = getLookupTableFactory(function (t) {
t.A = 0x0041;
t.AE = 0x00c6;
t.AEacute = 0x01fc;
t.AEmacron = 0x01e2;
t.AEsmall = 0xf7e6;
t.Aacute = 0x00c1;
t.Aacutesmall = 0xf7e1;
t.Abreve = 0x0102;
t.Abreveacute = 0x1eae;
t.Abrevecyrillic = 0x04d0;
t.Abrevedotbelow = 0x1eb6;
// ...
t.Alpha = 0x0391;
t.Alphatonos = 0x0386;
t.Amacron = 0x0100;
t.Amonospace = 0xff21;这段节选已经暴露了 AGL 的几种名字来源:
| 名字形态 | 例子 | 含义 |
|---|---|---|
| 单字母与拉丁扩展 | A、AE、Abreve | 基本拉丁与带变音符号的字母 |
| 希腊名 | Alpha、Alphatonos | 希腊字母及其声调形式 |
| 等宽形式 | Amonospace | 全角或半角等宽变体,落在 U+FF21 |
| small 变体 | AEsmall、Aacutesmall | Expert 字符集的小型大写,落在私用区 |
| afii 编号 | afii10017 | 早期字体交换标准的编号名,无法从名字反推码位 |
命名法:基名加后缀,但每个名字都独立登记
AGL 不是「按规则组合出无限名字」的生成系统,它是一份封闭清单。名字里能看出若干后缀家族,但每个组合都必须单独出现在表里:
| 后缀家族 | 条数 | 代表条目 | 码位去向 |
|---|---|---|---|
cyrillic | 222 | Acyrillic | U+0400 区 |
arabic | 244 | ainarabic | U+0600 区与 U+FE70 表现形式区 |
hebrew | 169 | alefhebrew | U+0590 区与 U+FB1D 呈现形式 |
katakana | 93 | akatakana | U+30A0 区 |
hiragana | 86 | ahiragana | U+3040 区 |
halfwidth | 62 | akatakanahalfwidth | U+FF00 区 |
monospace | 99 | Amonospace | U+FF00 区 |
thai | 109 | kothai 一类泰文字母名 | U+0E00 区 |
bopomofo | 37 | aibopomofo | U+3100 区 |
small | 100 | AEsmall | 私用区 U+F600 到 U+F8FF |
oldstyle | 12 | oneoldstyle | 私用区 |
superior | 42 | 上标数字与字母变体 | U+2070 区与私用区 |
inferior | 17 | 下标数字变体 | U+2080 区与私用区 |
display | 14 | summationdisplay | U+2200 区数学符号 |
这份统计说明了一个重要事实:后缀不构成可推导的语法。Acyrillic 存在,Acyrillicsmall 不存在;ainarabic 与 ainfinalarabic 是两个独立条目,后者的码位不在阿拉伯主区,而在 U+FE70 之后的阿拉伯表现形式区。你不能写一个「基名加后缀再算偏移」的函数来替代这张表,因为偏移量随字母形态变化,而形态名又来自不同历史时期的不同标准。
阿拉伯这条线最能说明问题。同一个字母 ain 有孤立、词首、词中、词尾四种形状,对应四个名字与四个码位:
| 名字 | 形态 | 码位 |
|---|---|---|
ainarabic | 独立 | U+0639 |
aininitialarabic | 词首 | U+FEBB 一带 |
ainmedialarabic | 词中 | U+FECC 一带 |
ainfinalarabic | 词尾 | U+FECA 一带 |
四个码位分布在两个不相邻的区段,中间还夹着别的文字。任何「压缩成公式」的尝试都会在类似的地方失效,这也是这张表必须以原始数据形式存在的原因。
统计一下这张表的实际规模:
| 指标 | 数值 | 说明 |
|---|---|---|
| 条目数 | 4328 | 4327 条点号赋值加一条 .notdef |
| 源码体积 | 约 115.7 KB | 仅表体部分,不含注释与导出 |
| 码位范围 | U+0000 到 U+FFE6 | 全部在基本多文种平面内 |
| 私用区值 | 192 条 | 落在 U+F600 到 U+F8FF,属于 PUA |
| ASCII 值 | 159 条 | 值不大于 U+007F,含 .notdef 的 0 |
| 补充平面值 | 0 条 | 表里没有大于 U+FFFF 的码位 |
最后两行值得单独想一下。表里没有任何补充平面码位,说明 AGL 这份数据本身是 BMP 时代的产物;而 getUnicodeForGlyph 的 uXXXX{XX} 分支却允许 5 到 6 位十六进制,能解析出 U+1D400 这样的数学字母。也就是说,表覆盖不到的字符,只能靠名字里的编码规则补上。这是后面「名字变体」一节的前提。
码位分布:数据自己说明了覆盖范围
把 4328 条按码位分段统计,主要分布如下(这些区间互不重叠,未列出的区间在表后单独说明):
| 码位区间 | 条数 | 对应内容 |
|---|---|---|
| U+0000 到 U+007F | 159 | 基本拉丁与控制符 |
| U+0080 到 U+00FF | 102 | 拉丁-1 补充 |
| U+0100 到 U+017F | 159 | 拉丁扩展 A |
| U+0180 到 U+024F | 152 | 拉丁扩展 B |
| U+0250 到 U+036F | 219 | 国际音标、修饰字母、组合符号 |
| U+0370 到 U+03FF | 111 | 希腊与科普特 |
| U+0400 到 U+052F | 327 | 西里尔 |
| U+0530 到 U+058F | 85 | 亚美尼亚 |
| U+0590 到 U+05FF | 259 | 希伯来 |
| U+0600 到 U+06FF | 175 | 阿拉伯主区 |
| U+0900 到 U+0DFF | 344 | 印度系诸文字 |
| U+0E00 到 U+0E7F | 87 | 泰文 |
| U+1E00 到 U+1EFF | 246 | 拉丁扩展附加 |
| U+3000 到 U+30FF | 224 | CJK 标点、平假名、片假名 |
| U+3100 到 U+312F | 37 | 注音符号 |
| U+3130 到 U+318F | 94 | 韩文兼容字母 |
| U+3200 到 U+33DD | 236 | 带圈 CJK 与 CJK 兼容 |
| U+FB00 到 U+FB4F | 78 | 拉丁连字 |
| U+F600 到 U+F8FF | 192 | 私用区 |
| U+FF00 到 U+FFEF | 162 | 半角与全角形式 |
上表合计 3448 条,其余 880 条集中在两片。第一片是 U+2000 到 U+27BF 的标点、货币、带圈字母、箭头、数学运算符、制表符与几何图形,共 674 条;第二片是 U+FB50 到 U+FEFF 的阿拉伯表现形式与 CJK 兼容形式,共 204 条。此外只剩两个孤立条目(U+1D40 与 U+5344)。这两片都是「同一个字符的排版变体或符号扩展」,不是新字母,所以条数虽多,实际覆盖的文字种类并没有看起来那么多。
两张表的分布差异也很直观:
| 表 | 拉丁 | 希腊与西里尔 | 中东与印度系 | 假名与 CJK | 符号与私用区 |
|---|---|---|---|---|---|
| AGL | 约 900 条 | 约 440 条 | 约 1150 条 | 约 600 条 | 约 1030 条 |
| Dingbats | 0 | 0 | 0 | 0 | 201 条编号符号,1 条空格,1 条 .notdef |
AGL 的覆盖是「按历史标准累积」的结果:先有西文字体交换需要的拉丁与希腊,后有 CID 字体与亚洲语言需要的印度系与假名,最后补上数学排版需要的符号。Dingbats 只是其中一类字体的专用清单。
afii 编号名:无法推导的一类
afii 前缀的条目有 246 条,它们在表里是纯手工数据:afii10017 是西里尔字母 А(U+0410),afii08941 是里拉符号(U+20A4),afii00208 是水平横线;编号与码位之间没有可推导的关系。这类名字正是「必须查表」的典型证据。
逐段读表之一:拉丁与变音符号
AGL 开头几百条几乎全是拉丁字母的变音组合。以 A 为中心(节选):
t.A = 0x0041;
t.AE = 0x00c6;
t.AEacute = 0x01fc;
t.AEmacron = 0x01e2;
t.AEsmall = 0xf7e6;
t.Aacute = 0x00c1;
t.Aacutesmall = 0xf7e1;
t.Abreve = 0x0102;
t.Abreveacute = 0x1eae;
t.Abrevecyrillic = 0x04d0;
t.Abrevedotbelow = 0x1eb6;
t.Abrevegrave = 0x1eb0;
t.Abrevehookabove = 0x1eb2;
t.Abrevetilde = 0x1eb4;
t.Acaron = 0x01cd;
t.Acircle = 0x24b6;这几行里出现了四种组合维度:
| 维度 | 例子 | 码位所在区 |
|---|---|---|
| 单变音 | Aacute、Agrave、Atilde | 拉丁-1 补充 |
| 多变音叠加 | Abreveacute、Acircumflexhookabove | 拉丁扩展附加 |
| 非拉丁适配 | Abrevecyrillic、Adieresiscyrillic | 西里尔区 |
| 样式或装饰 | Acircle、Asmall、Amonospace | 带圈字母区、私用区、全角区 |
最后一行值得注意:Acircle = 0x24b6 是带圈的 A,Amonospace = 0xff21 是全角 A。它们看起来都是「A 的变体」,但 Unicode 把它们当成独立字符。因此字形名必须区分到这一层,否则文本层会把全角 A 复制成半角 A,排版信息就丢了。
Abreve 系列还能说明为什么表必须有 4000 多条:五个变音组合(acute、grave、hookabove、tilde、dotbelow)乘以十几个基字母,再乘以大小写,就是一个几百条的笛卡尔积。这些组合在 Unicode 里都有独立码位,所以每一个都必须单独登记,没有捷径。
逐段读表之二:希腊字母与数学符号同名
希腊段有个容易踩的坑:几个名字默认指向数学符号,而不是希腊字母(节选):
t.Alpha = 0x0391;
t.Alphatonos = 0x0386;
t.Beta = 0x0392;
t.Delta = 0x2206;
t.Deltagreek = 0x0394;
t.Gamma = 0x0393;
t.Gammaafrican = 0x0194;
t.Omega = 0x2126;
t.Omegacyrillic = 0x0460;
t.Omegagreek = 0x03a9;
t.Omegatonos = 0x038f;
t.Upsilon = 0x03a5;
t.Upsilon1 = 0x03d2;
t.Upsilondieresis = 0x03ab;Delta 是 U+2206(增量符号),Deltagreek 才是 U+0394(希腊大写 delta)。Omega 是 U+2126(欧姆符号),Omegagreek 才是 U+03A9。这是 AGL 的历史遗留:早期字体把这两个字形当作数学符号使用,名字就固定下来了,后来补了带 greek 后缀的条目来表示真正的字母。
| 名字 | 码位 | 实际字符 | 用途 |
|---|---|---|---|
Delta | 0x2206 | 增量 | 数学排版 |
Deltagreek | 0x0394 | 希腊大写 delta | 希腊文 |
Omega | 0x2126 | 欧姆 | 物理与工程 |
Omegagreek | 0x03A9 | 希腊大写 omega | 希腊文 |
Upsilon1 | 0x03D2 | 带上钩的 upsilon | 希腊文与 Symbol 字体 |
这类「同名不同义」是查表最常见的错误来源。文本层如果只看名字不看字体上下文,就会把数学增量复制成希腊字母,或者反过来。PDF.js 不做语义纠正:表给什么就是什么,是否合理由字体作者与上层逻辑负责。
逐段读表之三:希伯来与阿拉伯的形态家族
希伯来与阿拉伯的条目最能体现「一个字母多种形态」(节选):
t.alef = 0x05d0;
t.alefarabic = 0x0627;
t.alefdageshhebrew = 0xfb30;
t.aleffinalarabic = 0xfe8e;
t.alefhamzaabovearabic = 0x0623;
t.alefhamzaabovefinalarabic = 0xfe84;
t.alefhamzabelowarabic = 0x0625;
t.alefhebrew = 0x05d0;
t.aleflamedhebrew = 0xfb4f;
t.alefmaddaabovearabic = 0x0622;
t.alefmaksuraarabic = 0x0649;
t.alefpatahhebrew = 0xfb2e;
t.alefqamatshebrew = 0xfb2f;三条信息可以从中读出:
| 现象 | 例子 | 说明 |
|---|---|---|
| 同名跨文种 | alef 与 alefarabic | 希伯来 alef 是 U+05D0,阿拉伯 alef 是 U+0627 |
| 同名字同码位 | alef 与 alefhebrew | 两个名字指向同一个希伯来字母 |
| 形态后缀 | ...finalarabic、...medialarabic | 词尾、词中形态,落在表现形式区 |
| 组合与注音 | alefdageshhebrew、alefpatahhebrew | 带 dagesh 或元音符号的组合,落在 U+FB1D 之后 |
alef 与 alefhebrew 同值说明 AGL 允许别名。正向查表两个名字都能命中;反向查表时 for...in 会先返回 alef,因为它在源码里更靠前。这是前面「多名同码位」规律的另一个实例,只是这里的两个名字在语义上完全等价,反查选哪个都不影响结果。
阿拉伯部分更是把「形态」写进了名字:
t.ainarabic = 0x0639;
t.ainfinalarabic = 0xfeca;
t.aininitialarabic = 0xfecb;
t.ainmedialarabic = 0xfecc;
t.alefmaksurafinalarabic = 0xfef0;
t.alefmaksurainitialarabic = 0xfef3;
t.alefmaksuramedialarabic = 0xfef4;四个名字对应四个码位,分散在两个区。字体如果只提供一种形态,另外三个名字就查得到但画不出——这是「名字表完备,字形不一定完备」的典型案例。表能回答「这个名字应该是什么字符」,不能回答「这个字体有没有这个字形」。
逐段读表之四:印度系、假名与注音
印度系文字按「元音字母加文字名后缀」排列(节选):
t.abengali = 0x0985;
t.adeva = 0x0905;
t.agujarati = 0x0a85;
t.agurmukhi = 0x0a05;四个名字都是「a」,码位分别落在孟加拉、天城文、古吉拉特、古木基文四个区段。名字结构相同,码位毫无规律,必须逐条登记。这一类条目共有 344 条,覆盖 U+0900 到 U+0DFF。
假名与注音则体现了「全角、半角、平假名、片假名」四个维度(节选):
t.ahiragana = 0x3042;
t.akatakana = 0x30a2;
t.akatakanahalfwidth = 0xff71;
t.aibopomofo = 0x311e;| 名字 | 码位 | 形式 |
|---|---|---|
ahiragana | 0x3042 | 平假名 あ |
akatakana | 0x30a2 | 片假名 ア |
akatakanahalfwidth | 0xff71 | 半角片假名 |
aibopomofo | 0x311e | 注音符号 ㄞ |
同一个读音有四种写法,每一种都是独立字符。日文 PDF 里如果字体只嵌入了半角片假名,文本层仍然需要能复制出半角形式;表提供的是「名字到码位」的对应,具体用哪种形式由字体的编码决定。
逐段读表之五:符号、箭头与数学
符号段的名字通常描述形状,而不是读音(节选):
t.arrowboth = 0x2194;
t.arrowdblboth = 0x21d4;
t.arrowdblleft = 0x21d0;
t.arrowdown = 0x2193;
t.arrowdownwhite = 0x21e9;
t.arrowleft = 0x2190;
t.integral = 0x222b;
t.integralbottom = 0x2321;
t.integraltp = 0x2320;
t.circle = 0x25cb;
t.circleplus = 0x2295;
t.filledbox = 0x25a0;这些名字的共同点是按视觉特征命名:arrowdblleft 双线左箭头,integralbottom 积分号下半段,circleplus 圆圈加号。名字与字符之间没有语义关系,只能靠表。
这一段的局部补丁也最集中。有一条带注释的本地扩展:
t.circlecopyrt = 0x00a9; // Glyph is missing from Adobe's original list.注释直接写明「Adobe 原始清单里没有这个字形」。这是全文件里少数几处作者主动偏离上游的地方之一。它说明表的维护原则:默认照抄 AGL,遇到真实文件必须支持的名字时才追加,并留下注释说明原因。
ZapfDingbats:编号名与符号区
第二张表的开头完全另一种风格(节选):
const getDingbatsGlyphsUnicode = getLookupTableFactory(function (t) {
t.space = 0x0020;
t.a1 = 0x2701;
t.a2 = 0x2702;
t.a202 = 0x2703;
t.a3 = 0x2704;
t.a4 = 0x260e;
t.a5 = 0x2706;
t.a119 = 0x2707;
t.a118 = 0x2708;
t.a117 = 0x2709;
t.a11 = 0x261b;
// ...
t.a191 = 0x27be;
// ...
t.a89 = 0x2768; // 0xF8D7
t.a90 = 0x2769; // 0xF8D8
t.a93 = 0x276a; // 0xF8D9
t.a94 = 0x276b; // 0xF8DA
// ...
t.a95 = 0x2774; // 0xF8E3
t.a96 = 0x2775; // 0xF8E4
t['.notdef'] = 0x0000;
});这张表的特征:
| 特征 | 表现 | 原因 |
|---|---|---|
| 名字形态 | a 加数字,从 a1 编号到 a206,缺 5 个编号 | 名字来自 ZapfDingbats 字体的字形清单,不是 Unicode 名称 |
| 顺序 | 不按名字排序,也不按码位排序 | 保留 Adobe 原始清单的顺序 |
| 主码位区 | 203 条里 174 条落在 U+2700 到 U+27BF | Unicode 的 Dingbats 区就是按这套符号编码的 |
| 越界条目 | 29 条落在该区之外 | 含 .notdef 的 0、空格 0x20,以及 U+2605 黑星、U+25A0 方块、U+2660 到 U+2666 花色、U+2460 到 U+2469 带圈数字、U+2192 与 U+2194 箭头等 |
| 重复值 | 0 条 | 每个名字对应唯一码位 |
| 尾注 | 14 条带 // 0xF8D7 一类注释 | 保留符号在原字体私用区的旧值,便于对照 |
尾注那一组最能说明这张表的来历:全表共有 14 条带尾注,就是末尾 a85 到 a96 以及 a205、a206 这 14 个符号。它们原本落在 U+F8D7 到 U+F8E4 的私用区,Unicode 后来把它们收录进 U+2768 到 U+2775,于是表里写新码位、注释留旧码位。这样既保留了新旧对照,也避免把私用区值当成最终结果输出。
编号不连续,顺序也不按编号
名字从 a1 编号到 a206,实际只有 201 条,缺的是 a80、a113、a114、a115、a116 五个编号。把表头的顺序列出来:
| 出现位置 | 名字 | 码位 |
|---|---|---|
| 第 2 条 | a1 | 0x2701 |
| 第 3 条 | a2 | 0x2702 |
| 第 4 条 | a202 | 0x2703 |
| 第 5 条 | a3 | 0x2704 |
| 第 6 条 | a4 | 0x260E |
| 第 7 条 | a5 | 0x2706 |
| 第 8 条 | a119 | 0x2707 |
| 第 9 条 | a118 | 0x2708 |
| 第 10 条 | a117 | 0x2709 |
| 第 11 条 | a11 | 0x261B |
三个反直觉之处:
a202夹在a2与a3之间。它的码位 0x2703 是下刃剪刀,序号 202 与排列位置没有关系,这个顺序来自 Adobe 原始清单。a4的码位是 0x260E(电话),跳出 U+2700 区。它是原始清单里的第 4 个符号,符号本身却被 Unicode 收在 Misc Symbols 区,所以名字序号与区块归属没有关系。a117、a118、a119的顺序与数字大小相反,码位还递减。三者的码位依次是 U+2709(信封)、U+2708(飞机)、U+2707(纸带机),原始清单里就这么排。
结论:名字里的数字只是字形序号,不是码位,也不是排序键。任何「按数字顺序生成码位」的尝试都会错,表必须逐条登记。
两张表的对比
| 维度 | AGL(getGlyphsUnicode) | ZapfDingbats(getDingbatsGlyphsUnicode) |
|---|---|---|
| 覆盖范围 | 拉丁、希腊、西里尔、亚美尼亚、希伯来、阿拉伯、印度系、泰文、假名、注音、韩文兼容字母、CJK 兼容区、数学符号 | ZapfDingbats 一种符号字体,201 个 a 编号字形 |
| 名字来源 | Adobe Glyph List,另有 afii 编号与 TeX 追加 | ZapfDingbats 字体的字形清单 |
| 名字形态 | 大小写混排的字母名、small 后缀、afiiNNNNN、数学名 | a 加数字 |
| 条目数 | 4328(4211 条字母序条目 + .notdef + 116 条 TeX) | 203(space + 201 条 a 编号名 + .notdef) |
| 源码体积 | 表体约 113 KiB(115,699 字节) | 表体约 3.9 KiB(3,972 字节) |
| 码位范围 | U+0000 到 U+FFE6 | U+0020 到 U+27BE |
| 主要用途 | 通用文本语义、系统字体回退、Type1 与 CFF 的字形名解析 | 只有 ZapfDingbats 字体才用 |
| 消费点 | fonts.js、evaluator.js、font_renderer.js、standard_fonts.js、fonts_utils.js | fonts.js 的 /Dingbats/i 分支 |
为什么不合成一张表
两张表都含 space,值都是 0x0020,合并不会冲突。但合成一张表会带来两个问题:
- 污染查询:AGL 里没有
a4,Dingbats 里a4是 U+260E。合并后,任何拿 AGL 查a4的路径都会突然命中一个电话符号。字体编码里出现a4本来就应该查不到,查得到反而掩盖了「这个字体不是 Dingbats」的事实。 - 选择权丢失:用哪张表不是由名字决定,而是由字体名决定。
fonts.js明确按字体名分流:
} else if (/Symbol/i.test(fontName)) {
// The non-embedded SymbolMT font in issue 21523 uses Identity encoding
// and an Identity CIDToGIDMap, hence its CIDs are glyph ids.
const isCidKeyed =
this.composite && this.cidEncoding.startsWith("Identity-");
this.toFontChar = buildToFontChar(
isCidKeyed ? getSymbolGlyphIdEncoding() : SymbolSetEncoding,
getGlyphsUnicode(),
this.differences
);
} else if (/Dingbats/i.test(fontName)) {
this.toFontChar = buildToFontChar(
ZapfDingbatsEncoding,
getDingbatsGlyphsUnicode(),
this.differences
);
} else if (isStandardFont || isMappedToStandardFont) {表的选择与编码表的选择在同一个分支里完成:SymbolSetEncoding 配 getGlyphsUnicode(),ZapfDingbatsEncoding 配 getDingbatsGlyphsUnicode()。两者必须成对,否则编码表给出的名字和字形表的名字空间就对不上。合成一张表会让「成对」这件事变得不可见,也会让错误更难发现。
Symbol 的配对还能说明另一件事:它的名字全部来自 AGL,并不需要第三张表:
SymbolSetEncoding 名字 | AGL 中的码位 | 后续规范化 |
|---|---|---|
universal | 0x2200 | 不变 |
existential | 0x2203 | 不变 |
suchthat | 0x220B | 不变 |
theta1 | 0x03D1 | 不变 |
Upsilon1 | 0x03D2 | 不变 |
arrowvertex | 0xF8E6 | 0x23D0 |
copyrightserif | 0xF6D9 | 0x00A9 |
registerserif | 0xF6DA | 0x00AE |
表里的私用区值(arrowvertex 的 U+F8E6、copyrightserif 的 U+F6D9)由 unicode.js 的 mapSpecialUnicodeValues 换成正式码位。字形表给出的是「Adobe 当年的映射」,规范化是另一层的事。如果为了「看起来更正确」直接在 glyphlist.js 里把 copyrightserif 写成 0x00A9,反而会丢掉原始对照关系,日后核对上游数据时无从比对。
设计要点:名字空间按来源切分,而不是按「都是字符串」合并。
a1在 Dingbats 里是符号,在任何其它字体里都不是;把两张表分开,等于把「字体族」这个语义编码进了模块结构。
表的构建:懒加载、空原型与逐条赋值
工厂只做三件事
core_utils.js 里的工厂只有十几行:
function getLookupTableFactory(initializer, useArray = false) {
let lookup;
return function () {
if (initializer) {
lookup = useArray ? [] : Object.create(null);
initializer(lookup);
initializer = null;
}
return lookup;
};
}三件事:第一次调用时创建容器、执行初始化函数、把 initializer 置空。之后每次调用都直接返回同一个 lookup。initializer = null 不只是防止重复执行,它也切断了闭包对初始化函数的引用,让那份函数对象可以被回收。数据本身留在 lookup 里,函数壳子不再需要。
第二个参数 useArray 在本文件里没有用到,默认的 false 走 Object.create(null)。它存在的理由是别处需要数组形态:fonts.js 的 getSymbolGlyphIdEncoding 用 /* useArray = */ true 建一个按 glyph id 索引的数组。
const getSymbolGlyphIdEncoding = getLookupTableFactory(t => {
let glyphId = 3;
for (const [firstCharCode, lastCharCode] of [
[0x20, 0x7e],
[0xa1, 0xfe],
]) {
for (let charCode = firstCharCode; charCode <= lastCharCode; charCode++) {
t[glyphId++] = SymbolSetEncoding[charCode];
}
}
}, /* useArray = */ true);同一个工厂,两种容器:字形表要字符串键,所以用空原型对象;Symbol 的字形 id 表要连续整数键,所以用数组。这正是工厂参数化的价值,也说明 glyphlist.js 选择对象不是随手决定,而是键类型决定的。
为什么必须是空原型对象
Object.create(null) 没有 Object.prototype,因此 constructor、toString、hasOwnProperty、__proto__ 这些继承来的名字不会出现在查询结果里。这一点在字形表上是硬需求,因为键来自 PDF 文件:/Differences 数组里的名字由生成器随意填写,完全可以写成 constructor。
如果表是普通对象字面量,glyphsUnicodeMap["constructor"] 会返回 Object 构造函数,一个真值。调用方拿到它之后会做 String.fromCharCode(Object),得到 "function Object() { [native code] }" 之类的垃圾,而且没有任何报错。空原型把这类假命中变成 undefined,让「查不到」保持诚实。
同理,普通对象上写 t.__proto__ = 0x1234 会试图改原型,而不是新增属性;空原型对象上 __proto__ 只是普通数据属性,赋值行为符合预期。虽然本文件的表里没有以 __proto__ 命名的字形,但字体数据是不可信的,容器必须先把这个坑填上。
空原型带来的限制也要说清楚:表上没有 hasOwnProperty、toString 这些继承来的方法,调用方只能用 in、for...in、Object.keys 这类不依赖原型的方式。本文件的消费方全都是直接下标访问,唯一需要遍历整张表的地方是 recoverGlyphName 的反查,用的是 for...in,不受影响。
为什么不是 Map,也不是数组
五种候选结构放在一起比较:
| 结构 | 按名字查 | 建表方式 | 内存 | 键与顺序 |
|---|---|---|---|---|
| 普通对象字面量 | 属性查找 | 模块求值时一次分配 | 中 | 字符串,受原型污染 |
Object.create(null) | 属性查找 | 初始化函数里逐条赋值 | 中 | 字符串,无原型 |
Map | get | 逐条 set | 较高,每个条目有额外开销 | 任意类型,插入序 |
| 数组 | 不适用 | 需按索引预分配 | 取决于稀疏程度 | 数字下标 |
| 扁平数组 | 需要先转成对象或线性查找 | 转换时峰值双份 | 高 | 混合 |
选择空原型对象的理由有三条:
- 键是字符串,而且大多像标识符。
Aacute、uni0041、afii10017都可以直接做属性名,V8 对这种访问有专门优化。Map.get需要方法调用与哈希查找,在热路径上略慢。 - 建表可以是一条条赋值。
t.A = 0x0041是属性写入;Map需要t.set("A", 0x0041),多一层调用。4328 条就是 4328 次调用差异,虽然不大,但没有理由选更慢的。 - 不需要
Map的额外能力。表不需要「任意类型键」、不需要size、不需要「删除后保持迭代顺序」、不需要按对象引用比较。用不上的特性只会增加内存。
数组则完全不适合:表的主键是名字,不是连续整数。用数组意味着要么把名字哈希成数字,要么线性扫描。getLookupTableFactory 的 useArray 选项之所以存在,是因为 getSymbolGlyphIdEncoding 的键恰好是连续的 glyph id;那是另一个场景,不是这张表。
本版也没有冻结表:glyphlist.js 里没有 Object.freeze,getLookupTableFactory 也不冻结结果。表从不直接暴露,调用方只能通过工厂拿到引用;核心代码里没有任何写入路径,因此冻结能带来的保护有限,却要在建表时多遍历一次 4000 个键。这是用「约定」代替「运行时强制」的取舍:只读靠使用方式保证,不靠对象状态保证。
与 encodings.js 的边界:数组与字典
encodings.js 存的是「字符码到名字」,glyphlist.js 存的是「名字到码位」。两者方向相反,容器也完全不同:
// prettier-ignore
const SymbolSetEncoding = [
"", "", "", "", "", "", "", "", "", "", "", "", "", "",
"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
"space", "exclam", "universal", "numbersign", "existential", "percent",
"ampersand", "suchthat", "parenleft", "parenright", "asteriskmath", "plus",
"comma", "minus", "period", "slash", "zero", "one", "two", "three", "four",
"five", "six", "seven", "eight", "nine", "colon", "semicolon", "less",| 维度 | encodings.js | glyphlist.js |
|---|---|---|
| 键 | 0 到 255 的整数 | 任意字形名字符串 |
| 容器 | 普通数组,下标即字符码 | 空原型对象,键即名字 |
| 规模 | 每张 256 项,共七张 | 4328 项与 203 项 |
| 加载 | 模块求值时直接建好 | getLookupTableFactory 懒加载 |
| 空值 | 空字符串 "" | undefined(键根本不存在) |
| 查找 | arr[charCode] | obj[glyphName] |
三种差异都有原因。键是连续整数,数组最自然,也不存在原型污染;256 项很小,模块求值时建好不会造成明显负担;空位用 "" 填满,数组里没有洞,slice 与 for...in 的行为因此可预期——编码表需要整体复制(defaultEncoding.slice()),洞会让复制结果多出不可枚举的位置。
glyphlist.js 则相反:键是字符串、规模大、只在需要时才用得上,所以选懒加载的字典。消费方也必须按各自的约定处理空值:_simpleFontToUnicode 里判断 glyphName === "" 跳过,而查 AGL 时判断 unicode === undefined。两套约定不能混用,这是读这两个模块时最容易混淆的地方。
设计要点:容器形状由键的形状决定。连续小整数用数组,稀疏字符串用空原型字典;这不是风格偏好,而是让「查不到」在数据结构层面就有明确表示,避免调用方再额外判断。
逐条赋值而不是对象字面量
表体的写法是每行一条赋值:
t.A = 0x0041;
t.AE = 0x00c6;
t.AEacute = 0x01fc;
t.AEmacron = 0x01e2;
t.AEsmall = 0xf7e6;为什么不用一个大对象字面量,比如 const map = { A: 0x0041, ... }?有三个直接原因:
| 原因 | 说明 |
|---|---|
| 懒加载 | 对象字面量在模块求值时就分配并填充;逐条赋值放进初始化函数,可以把成本推迟到第一次查表 |
| 键的合法性 | .notdef 带点,不能写成 t..notdef;只能用 t[".notdef"]。字面量里也必须加引号,两种写法都要特殊处理 |
| 生成与核对 | 一行一个名字,和 Adobe 原始清单逐行对照最方便;差异也只影响一行 |
第三条在维护上最重要。这份文件本质上是一份被翻译成 JavaScript 的数据清单,可读性目标是「像数据文件」,而不是「像程序」。一行一个名字、十六进制码位与 AGL 原文一致(0041 而不是 65),才能让人用文本 diff 直接比对上游更新。
名字带点的条目只有两处,都是 .notdef,写法一致:
t['.notdef'] = 0x0000;它出现在 AGL 主体末尾(TeX 段之前)与 Dingbats 表尾。0x0000 不是「缺失」,而是一个明确的映射,后面会单独讨论它带来的语义歧义。
启动成本与内存占用
这张表的成本集中在第一次调用:
| 阶段 | 发生次数 | 代价 |
|---|---|---|
| 模块求值 | 每个 Worker 一次 | 只创建两个闭包,不建表 |
首次 getGlyphsUnicode() | 每个 Worker 一次 | 执行 4328 条赋值,分配一个 4328 键的对象 |
| 后续调用 | 每次查表 | 返回同一引用,常数时间 |
| 单次查询 | 每个字形 | 一次字符串键属性查找 |
4328 个字符串键的对象在 V8 里超过一定属性数量后会转入字典模式(dictionary mode),每个条目带哈希表开销。键字符串本身来自模块里的字符串常量,已经被驻留,不会因为建表再复制一份;值都是小整数。整体量级是「几百 KB 以内」,相对于字体数据(一个 CJK 字体动辄几 MB)可以忽略,但它确实是每个 Worker 的固定开销。
这也解释了为什么必须懒加载:打开一份纯图片 PDF 或纯 CMap 文档时,可能一次都用不到 AGL。把 4328 条赋值推迟到首次使用,避免为用不到的功能付费。反过来,如果一份文档有几十个字体,第一次查表之后就是纯读,成本被摊薄到可以忽略。
查表本身的成本可以再估一下:
| 操作 | 量级 | 说明 |
|---|---|---|
| 单次查表 | 1 次属性访问 | 字典模式对象的字符串键查找 |
| 一次建表 | 4328 次赋值 | 每个 Worker 只发生一次 |
单个字体建 toFontChar | 256 次查表加差异项 | 每个简单字体一次 |
| 整份文档文本抽取 | 字符总数次 | 每个字符一次 |
一份 100 页、每页 2000 字符的文档,全量复制大约触发 20 万次查表。20 万次哈希查找是毫秒级,真正的成本仍然在建表那一步。这也说明为什么所有优化都集中在「什么时候建表」,而不是「怎么让查表更快」——后者已经没有多少空间了。
表被打进 Worker 包
glyphlist.js 位于 src/core/,随 pdf.worker.js 一起打包,因此它的体积直接影响 Worker 脚本的下载、解析与编译时间:
| 项 | 影响 |
|---|---|
| 打包位置 | 进入 pdf.worker.js,不进入主线程 bundle |
| 语法解析 | 4500 多行、4531 条赋值语句,引擎解析成本存在但远低于首次执行 |
| 传输压缩 | 重复的 t. 前缀与十六进制字面量对 gzip 友好,实际压缩率较高 |
| 树摇 | 两个工厂都被多个核心模块引用,无法被摇掉 |
| 拆分 | 拆成独立 chunk 需要额外的加载时序,反而增加复杂度 |
压缩后的传输体积、解析时间与「删掉 TeX 段能省多少」这类问题,不能凭感觉回答,要用真实构建产物测量。可以确定的只有一点:这张表的大小是固定的,与文档无关;一份 1 页的发票和一份 1000 页的手册,付出的建表成本完全一样。
设计要点:全局常量表的第一原则不是「查得快」,而是「不查就不付钱」。查表本身是 O(1),真正昂贵的是把 4000 多条数据搬进堆;把搬运放在首次访问之后,等于让不使用字体层的文档完全免疫这笔开销。
版本差异:2020 年的扁平数组实验
当前版本是逐条赋值,但 2020 年 10 月曾经改成过扁平数组。那次改动引入了新的工厂:
import { getArrayLookupTableFactory } from './core_utils.js';
var getGlyphsUnicode = getArrayLookupTableFactory(function () {
// prettier-ignore
return [
"A", 0x0041,
"AE", 0x00c6,
"AEacute", 0x01fc,
"AEmacron", 0x01e2,
"AEsmall", 0xf7e6,
// ...
];
});工厂把成对的数组转成对象:
function getArrayLookupTableFactory(initializer) {
let lookup;
return function () {
if (initializer) {
let arr = initializer();
initializer = null;
lookup = Object.create(null);
for (let i = 0, ii = arr.length; i < ii; i += 2) {
lookup[arr[i]] = arr[i + 1];
}
arr = null;
}
// ...这个版本的代价很直观:
| 项 | 逐条赋值 | 扁平数组 |
|---|---|---|
| 源码体积 | 每行一个名字加一个码位 | 每个名字与码位各占一个数组元素,行数减半 |
| 首次建表 | 直接写入目标对象 | 先分配数组,再遍历一半长度写入对象,最后丢弃数组 |
| 峰值内存 | 一个对象 | 一个数组加一个对象 |
| 可读性 | 一行一条记录 | 两列对齐,像 CSV |
| 格式化 | 正常格式化 | 必须 // prettier-ignore,否则数组会被折行 |
2020 年 11 月这次改动被整体回退,提交信息给出的理由是 SystemJS 已经移除。也就是说,扁平数组主要是为当时的模块加载方式服务的,与查表性能无关。回退后 getArrayLookupTableFactory 从 core_utils.js 里消失,当前源码中已经找不到它。
这段历史对读源码的实际意义是:不要以为数据表一定经过运行期解析。当前版本没有分隔符、没有切分、没有遍历转换,只有一次初始化函数调用。所有「紧凑字符串 + 分隔符解析」的想象都属于别的版本或别的项目。
维护:新增一条名字要动几处
这张表没有生成脚本,也没有校验器,新增条目的流程完全靠人工:
| 步骤 | 动作 | 理由 |
|---|---|---|
| 1 | 先查 Adobe 官方 AGL 里是否已有 | 自造名字无法与其它阅读器对齐 |
| 2 | 决定加进 AGL 还是 Dingbats | 决定消费方能不能命中 |
| 3 | 上游条目按字母序插入;本地补丁就近放或追加末尾 | 保持上游可对照 |
| 4 | 值统一写成小写、四位十六进制 | diff 噪音最小 |
| 5 | 手动调用 getUnicodeForGlyph 验证 | 直接确认解析结果 |
| 6 | 在 test/unit/unicode_spec.js 补一条断言 | 防止后续回退 |
第五步可以这样手测:
// 在 unit test 或调试环境里
const map = getGlyphsUnicode();
getUnicodeForGlyph('f_f_i', map); // 0xfb03
getUnicodeForGlyph('circlecopyrt', map); // 0x00a9第六步对应的单测已经存在,覆盖了两张表与 uniXXXX 解析:
it('should get Unicode values for valid glyph names', function () {
expect(getUnicodeForGlyph('A', standardMap)).toEqual(0x0041);
expect(getUnicodeForGlyph('a1', dingbatsMap)).toEqual(0x2701);
});
it('should recover Unicode values from uniXXXX/uXXXX{XX} glyph names', function () {
expect(getUnicodeForGlyph('uni0041', standardMap)).toEqual(0x0041);
expect(getUnicodeForGlyph('u0041', standardMap)).toEqual(0x0041);
expect(getUnicodeForGlyph('uni2701', dingbatsMap)).toEqual(0x2701);
expect(getUnicodeForGlyph('u2701', dingbatsMap)).toEqual(0x2701);
});
it('should not get Unicode values for invalid glyph names', function () {
expect(getUnicodeForGlyph('Qwerty', standardMap)).toEqual(-1);
expect(getUnicodeForGlyph('Qwerty', dingbatsMap)).toEqual(-1);
});第三组断言把 -1 这个哨兵固定下来:Qwerty 首字符是大写 Q,不进入 u 分支,两张表也都查不到,于是返回 -1。
注意第二组断言:uni2701 与 u2701 并不在 Dingbats 表里,它们是靠 getUnicodeForGlyph 的前缀解析得到 0x2701 的。测试同时验证了「表」与「解析」两层。
人工维护的风险集中在四件事上:
| 风险 | 后果 | 现有防线 |
|---|---|---|
| 键重复 | 后写入的值静默覆盖先写入的值 | 无自动检查,只能全局搜名字 |
| 插入位置 | 改变 for...in 顺序,反查选中项变化 | 补丁就近放在相关字母段,TeX 段集中在末尾 |
| 大小写写错 | 变成另一个字形,查得到但语义错 | 严格照抄上游命名 |
| 进制或位数不统一 | 值仍然正确,但 diff 噪音大 | 代码风格上统一小写四位 |
第一条最隐蔽:JavaScript 对象重复赋值不报错,t.X = 1; t.X = 2 的结果就是 2。这份文件靠人工避免,没有 CI 去重。读源码时如果怀疑某个名字有两条,最直接的办法是搜 t.名字。
数据里的规律
大小写并行,且不做归一化
AGL 里大小写是两套独立的名字,码位也各自不同:
t.A = 0x0041;
// ...
t.a = 0x0061;
t.aacute = 0x00e1;
// ...
t.Aacute = 0x00c1;
// ...
t.Alpha = 0x0391;
t.alpha = 0x03b1;查表是大小写敏感的:A 与 a 是两个键,Aacute 与 aacute 也是。PDF 字形名本身区分大小写,这是规范行为,不是实现疏忽。如果有人把 Aacute 写成 aacute,那是一个不同的字形(带重音的小写 a),表里也恰好有它,于是会「查得到但意思变了」。这类错误表无法发现,只能靠上游编码表保证名字正确。
small 变体与私用区
带 small 后缀的名字有 100 条,其中 99 条的值落在私用区,典型写法是把小型大写字母映射到私用区:
t.AEsmall = 0xf7e6;
t.Aacutesmall = 0xf7e1;
t.Acircumflexsmall = 0xf7e2;
t.Acutesmall = 0xf7b4;
t.Zsmall = 0xf77a;
t.Zcaronsmall = 0xf6ff;这些名字来自 Adobe 的 Expert 字符集。charsets.js 里的 ExpertCharset 就按顺序列着 Asmall、Bsmall、Csmall、Dsmall、Esmall、Fsmall、Gsmall 等名字,正好对应表里的条目。也就是说,表里那一串 small 不是随意加的别名,而是为了能解析 Expert 字体的内建字符集。
私用区值的处理需要和 unicode.js 一起看:
function mapSpecialUnicodeValues(code) {
if (code >= 0xfff0 && code <= 0xffff) {
// Specials unicode block.
return 0;
} else if (code >= 0xf600 && code <= 0xf8ff) {
return getSpecialPUASymbols()[code] || code;
} else if (code === /* softhyphen = */ 0x00ad) {
return 0x002d; // hyphen
}
return code;
}U+F600 到 U+F8FF 这一段的私用区值会先经过一张 27 项的白名单,把少数符号换成正式码位(例如 parenlefttp 的 U+F8EB 换成 U+239B),白名单之外的值原样返回。因此 Aacutesmall 查出来的 U+F7E1 会一路保留到文本层,既不会被清成 0,也不会自动变成 A。这是「表给出原始映射,规范化另有其表」的又一例证。
设计要点:数据表忠实保留上游的私用区映射,不在这里做「看起来更合理」的替换。私用区语义需要专门的白名单才能判断,放在
glyphlist.js会让纯数据表混入策略,也会让白名单难以独立更新。
多名同码位
同一张表里,多个名字映射到同一个码位非常常见:
| 名字 | 码位 | 关系 |
|---|---|---|
Dcroat | 0x0110 | 与 Dslash 完全同值 |
Dslash | 0x0110 | 同上 |
Zdot | 0x017b | 与 Zdotaccent 同值 |
Zdotaccent | 0x017b | 同上 |
radical | 0x221a | 与 TeX 段的 radicalbig、radicalBig 等同值 |
radicalbig | 0x221a | TeX 段,尺寸变体共用根号码位 |
parenleftBig | 0x0028 | TeX 段四种大小共用左圆括号码位 |
parenleftbigg | 0x0028 | 同上 |
space | 0x0020 | 与 spacehackarabic 同值 |
正向查表没有歧义:每个名字独立命中。歧义只出现在反向查表——给定一个码位,想找一个标准名时,多个候选都合法。fonts_utils.js 的 recoverGlyphName 用的是「遍历对象、返回第一个值匹配的键」:
function recoverGlyphName(name, glyphsUnicodeMap) {
if (glyphsUnicodeMap[name] !== undefined) {
return name;
}
// The glyph name is non-standard, trying to recover.
const unicode = getUnicodeForGlyph(name, glyphsUnicodeMap);
if (unicode !== -1) {
for (const key in glyphsUnicodeMap) {
if (glyphsUnicodeMap[key] === unicode) {
return key;
}
}
}
info('Unable to recover a standard glyph name for: ' + name);
return name;
}由于 for...in 的顺序就是插入顺序,而插入顺序就是源码顺序,反查结果是源码顺序的第一个匹配键。0x017b 会返回 Zdot(第 737 行)而不是 Zdotaccent(第 738 行);0x221a 会返回 radical(第 3183 行)而不是文件末尾的 TeX 变体。这不是随机的,但也谈不上经过挑选,只是「先写谁就用谁」。
理解这一点之后,recoverGlyphName 的行为就能预测:它恢复出来的名字不保证是排版上最常用的那个,只保证码位一致。对调用方来说够用,因为后续 glyphNames.indexOf 与 cmap 查询只关心码位和标准名是否在字体里存在。
.notdef 与 0x0000 的歧义
两张表都有这一条:AGL 里它在第 4230 行、TeX 段之前,Dingbats 里它在最后一行。
t['.notdef'] = 0x0000;0x0000 是合法的 Unicode(NUL),所以这条映射会让 .notdef 变成「查得到」的名字。这带来一个必须由调用方处理的歧义:
| 查询 | 表的回答 | 语义 |
|---|---|---|
| 不存在的名字 | undefined | 查不到 |
.notdef | 0 | 查到了,但码位是 NUL |
| 真正的空格 | 0x0020 | 查到了,码位是空格 |
如果只看返回值,0 与「查不到」不会混淆(前者是数字 0,后者是 undefined);但如果调用方把 0 当成「没有字符」,就会误判。evaluator.js 的选择是在查表之前显式跳过:
for (const charcode in differences) {
const glyphName = differences[charcode];
if (glyphName === '.notdef') {
// Skip .notdef to prevent rendering errors, e.g. boxes appearing
// where there should be spaces (fixes issue5256.pdf).
continue;
}
encoding[charcode] = glyphName;
}fonts.js 里也有对 .notdef 的专门判断,用来避免给空字形分配可见宽度。这些处理都在消费侧,而不是把 .notdef 从表里删掉。保留它的理由是:某些字体确实需要知道「这个字符码对应 notdef」,而 0 恰好是 .notdef 在字形 id 上的位置(glyph 0)。
设计要点:哨兵值必须选一个不可能是合法结果的值。查表函数用
undefined表示「没有」,用-1表示「解析失败」,就是因为0已经是合法码位;.notdef的存在把「0 是合法结果」这件事钉死了,任何用0表示失败的写法都会出错。
TeX 段:追加在 AGL 末尾的 116 条
AGL 表的主体在 .notdef 之后并没有结束,还接了一段带注释的分组(节选):
// TeX-specific glyph names.
t.angbracketleftbig = 0x2329;
t.angbracketleftBig = 0x2329;
t.angbracketleftbigg = 0x2329;
t.angbracketleftBigg = 0x2329;
t.angbracketrightBig = 0x232a;
t.angbracketrightbig = 0x232a;
t.angbracketrightBigg = 0x232a;
t.angbracketrightbigg = 0x232a;
t.arrowhookleft = 0x21aa;
t.arrowhookright = 0x21a9;
t.arrowlefttophalf = 0x21bc;
t.arrowleftbothalf = 0x21bd;
t.backslashbig = 0x2216;
t.backslashBig = 0x2216;
t.backslashBigg = 0x2216;
t.backslashbigg = 0x2216;
t.radicalvertex = 0x221a;
t.summationdisplay = 0x2211;
t.summationtext = 0x2211;
t.uniondisplay = 0x22c3;
t.unionmultidisplay = 0x228e;
t.vextenddouble = 0x2225;
t.vextendsingle = 0x2223;这 116 条是为数学排版字体补的。它们的共同点是尺寸变体共用同一个码位:angbracketleftbig 与 angbracketleftBigg 都是 U+2329,实际显示大小由字体字形本身决定,Unicode 只表达「这是一个左尖括号」。这也解释了为什么表里同名不同大小写的变体大量重复:big、Big、bigg、Bigg 四种写法都要覆盖,因为不同生成器的命名习惯不同。
这一段追加在 AGL 表的 .notdef 之后、而不是合并进字母顺序里。好处是上游 AGL 主体仍然保持完整、可对照,更新时一眼能看出哪些不是 Adobe 原始数据。
非标准连字:下划线变体
与 TeX 段不同,连字补丁没有集中在末尾,而是就地插在字母 f 段里、紧跟对应的标准名:
节选:
t.ff = 0xfb00;
t.f_f = 0xfb00;
t.ffi = 0xfb03;
t.f_f_i = 0xfb03;
t.ffl = 0xfb04;
t.f_f_l = 0xfb04;
t.fi = 0xfb01;
t.f_i = 0xfb01;
// ...
t.fl = 0xfb02;
t.f_l = 0xfb02;前八条确实紧挨着,fl 与 f_l 则排在后面几十行(中间隔着 fifteen 系列等条目)。这说明补丁是「就地插在每个标准名之后」,而不是集中成一段。
其中带下划线的几条没有上游依据。标准 AGL 只有 ff、fi、fl、ffi、ffl 这五个连字名。带下划线的 f_f、f_i、f_l、f_f_i、f_f_l 是部分 PDF 生成器的产物:它们把连字拆成组成字母,用下划线连接。表把它们映射到同样的 U+FB00 到 U+FB04,于是文本抽取不需要为这些名字写特例。
但这类补丁只覆盖「真实存在对应 Unicode 的连字」。像 f_h、f_t、T_h 这种没有官方码位的组合,表里没有条目,evaluator.js 用小段 switch 手工处理:
default:
// Support (some) non-standard ligatures.
switch (glyphName) {
case "f_h":
case "f_t":
case "T_h":
toUnicode[charcode] = glyphName.replaceAll("_", "");
continue;
}
break;同一种现象,有 Unicode 的进表,没有 Unicode 的进启发式。这条分界线说明表的定位:它只收「名字到码位」的确定映射,不收「名字到字符串」的猜测。f_h 的处理结果是两个字符 fh,不是某个码位,所以不适合放进按码位建的表。
谁在查表
字体构建:把初始化再推迟一层
fonts_utils.js 的 type1FontGlyphMapping 负责把字符码映射成 glyph id。它的主路径只用名字在字体内建列表里的下标,根本不需要 Unicode:
const glyphName = differences[charCode];
glyphId = glyphNames.indexOf(glyphName);
if (glyphId === -1) {
glyphsUnicodeMap ??= getGlyphsUnicode();
const standardGlyphName = recoverGlyphName(glyphName, glyphsUnicodeMap);
if (standardGlyphName !== glyphName) {
glyphId = glyphNames.indexOf(standardGlyphName);
}
}
charCodeToGlyphId[charCode] = glyphId >= 0 ? glyphId : /* notdef = */ 0;glyphsUnicodeMap ??= getGlyphsUnicode() 把懒加载又推迟了一层:只有「差异名在字体里找不到」时,才去建全局表。三种情况对比:
| 条件 | 是否建 AGL |
|---|---|
字体没有 /Differences | 不建 |
有差异,但每个名字都能在 glyphNames 里找到 | 不建 |
至少一个差异名找不到,需要 recoverGlyphName | 建 |
这不是多余的谨慎。Type1 字体的 /Differences 大多直接对应字体内建名,indexOf 一次就命中;只有生成器改了名字、或者名字来自另一套标准时,才需要退回 Unicode 反查。把建表推迟到这个分支里,等于让「名字完全匹配」的字体完全不碰这张 4000 键的表。
buildToFontChar:编码数组到码位数组
最直接的消费方式是「把一张 256 项编码表整体翻译成码位表」:
function buildToFontChar(encoding, glyphsUnicodeMap, differences) {
const toFontChar = [];
let unicode;
for (let i = 0, ii = encoding.length; i < ii; i++) {
unicode = getUnicodeForGlyph(encoding[i], glyphsUnicodeMap);
if (unicode !== -1) {
toFontChar[i] = unicode;
}
}
for (const charCode in differences) {
unicode = getUnicodeForGlyph(differences[charCode], glyphsUnicodeMap);
if (unicode !== -1) {
toFontChar[+charCode] = unicode;
}
}
return toFontChar;
}它的输出是稀疏数组,下标是字符码,值可能是洞(查不到时跳过赋值)。这个形状很重要:后续 Font.charToGlyph 用 toFontChar[charcode] 取值,洞就是 undefined,自然落到「没有 Unicode」的分支,不需要额外的哨兵。
differences 在后半段覆盖编码表的结果。也就是说,/Differences 优先于基础编码:同一个字符码,先按基础编码填一次,再被差异数组覆盖。这与 PDF 规范的语义一致——/Differences 是作者对基础编码的局部修正。
两条 ToUnicode 修补路径
TrueType 与 Type1 的修补函数写法不同,差别正好体现「名字是否可信」:
function adjustTrueTypeToUnicode(properties, isSymbolicFont, nameRecords) {
// ...
const encoding = WinAnsiEncoding;
const toUnicode = [],
glyphsUnicodeMap = getGlyphsUnicode();
for (const charCode in encoding) {
const glyphName = encoding[charCode];
if (glyphName === '') {
continue;
}
const unicode = glyphsUnicodeMap[glyphName];
if (unicode === undefined) {
continue;
}
toUnicode[charCode] = String.fromCharCode(unicode);
}
if (toUnicode.length > 0) {
properties.toUnicode.amend(toUnicode);
}
}function adjustType1ToUnicode(properties, builtInEncoding) {
// ...
const toUnicode = [],
glyphsUnicodeMap = getGlyphsUnicode();
for (const charCode in builtInEncoding) {
// ...
const glyphName = builtInEncoding[charCode];
const unicode = getUnicodeForGlyph(glyphName, glyphsUnicodeMap);
if (unicode !== -1) {
toUnicode[charCode] = String.fromCharCode(unicode);
}
}
if (toUnicode.length > 0) {
properties.toUnicode.amend(toUnicode);
}
}| 函数 | 名字来源 | 查表方式 | 原因 |
|---|---|---|---|
adjustTrueTypeToUnicode | 固定的 WinAnsiEncoding | 直接下标 glyphsUnicodeMap[glyphName] | 编码表里的名字都是标准名,不需要解析 uniXXXX |
adjustType1ToUnicode | 字体内建编码 | getUnicodeForGlyph | 内建编码可能含非标准名与 uniXXXX 形式 |
这是一个很细但很有用的设计判断:能确定名字来自标准表时,不必付出解析成本。直接下标只做一次属性查找;getUnicodeForGlyph 在未命中后还要做前缀判断与 parseInt。TrueType 那条路径的名字来自代码里写死的 WinAnsiEncoding,不可能出现 uni0041,所以省掉解析是安全的。
系统字体回退:按字体名选表
当字体没有嵌入时,Font.fallbackToSystemFont 需要给每个字符码指定一个 Unicode,才能让浏览器用系统字体画出正确字符。前面引用的 /Symbol/i 与 /Dingbats/i 分支就是这一步。第三个分支处理标准字体:
} else if (isStandardFont || isMappedToStandardFont) {
const map = buildToFontChar(
this.defaultEncoding,
getGlyphsUnicode(),
this.differences
);最后还有一个兜底分支,先按差异数组与默认编码逐个解析,再用 toUnicode 覆盖:
} else {
const glyphsUnicodeMap = getGlyphsUnicode();
const map = [];
this.toUnicode.forEach((charCode, unicodeCharCode) => {
if (!this.composite) {
const glyphName =
this.differences[charCode] || this.defaultEncoding[charCode];
const unicode = getUnicodeForGlyph(glyphName, glyphsUnicodeMap);
if (unicode !== -1) {
unicodeCharCode = unicode;
}
}
map[+charCode] = unicodeCharCode;
});这里 unicodeCharCode 的初值来自 toUnicode,但只要字形名查到了码位,它就会被覆盖。优先级与 amendFallbackToUnicode 正好相反:那个函数保护作者声明的 /ToUnicode,这个分支让字形名压过它。原因是用途不同——这里要挑的是「用系统字体画出来的那个字形该显示成什么字符」,编码表里的字形名比 /ToUnicode 更贴近字形;而 amendFallbackToUnicode 修补的是文本选择,作者的 /ToUnicode 才是权威。
两个方向同时存在,说明「谁的优先级高」并不是一个全局规则,而是逐个调用点决定的:文本抽取以作者声明为准,系统字体回退以字形为准。
font_renderer.js:seac 形式的复合字形
Type2 charstring 的 endchar 带四个参数时表示「由基字符与重音字符合成」,也就是 seac。合成需要把字符码变成字形名,再变成码位,最后在 CFF 的 cmap 里找 glyph id:
case 14: // endchar
if (stack.length >= 4) {
const achar = stack.pop();
const bchar = stack.pop();
y = stack.pop();
x = stack.pop();
cmds.save();
cmds.translate(x, y);
let cmap = lookupCmap(
font.cmap,
String.fromCharCode(font.glyphNameMap[StandardEncoding[achar]])
);
compileCharString(
font.glyphs[cmap.glyphId],
cmds,
font,
cmap.glyphId
);
cmds.restore();
cmap = lookupCmap(
font.cmap,
String.fromCharCode(font.glyphNameMap[StandardEncoding[bchar]])
);
compileCharString(
font.glyphs[cmap.glyphId],
cmds,
font,
cmap.glyphId
);
}font.glyphNameMap 就是 Type2Compiled 构造函数里保存的表:
this.glyphNameMap = getGlyphsUnicode();这条链路值得注意:StandardEncoding[achar] 给出名字,glyphNameMap 给出码位,String.fromCharCode 给出字符,lookupCmap 在 CFF 的 cmap 里按字符找 glyph id。四步里没有一步能省,因为 seac 的参数是「标准编码里的字符码」,而 CFF cmap 的键是 Unicode 字符。
standard_fonts.js:用名字表构造 glyph id 到 Unicode 的映射
Mac 顺序的 post 表字体需要一张「glyph id 到 Unicode」的表,而 MacStandardGlyphOrdering 提供的是「glyph id 到字形名」。两者之间正好用 AGL 连接:
const getGlyphMapForMacOrderedFonts = getLookupTableFactory(function (t) {
const glyphsUnicode = getGlyphsUnicode();
t[2] = 10;
for (let gid = 3; gid < MacStandardGlyphOrdering.length; gid++) {
const unicode = glyphsUnicode[MacStandardGlyphOrdering[gid]];
if (unicode !== undefined) {
t[gid] = unicode;
}
}
});这是「表套表」的写法:外层工厂在自己的初始化函数里调用另一个工厂。因为 getGlyphsUnicode() 已经记忆化,这里不会重复建表,只是拿到引用。遍历时跳过 undefined,所以 MacStandardGlyphOrdering 里那些不在 AGL 的名字不会产生条目。
t[2] = 10 这一行单独解释一下:glyph id 0 是 .notdef,1 和 2 在 Mac 顺序里没有对应字符,代码直接把 id 2 映射到 0x000A(换行),id 3 之后才开始按名字表遍历。这类硬编码说明「glyph id 到 Unicode」的表并不完全能从名字推导,前几个位置需要人工补。
真正用到这张表的地方很具体:标准字体被当作 CID 字体嵌入、使用 Identity 编码、又没有提供 glyph 映射时,PDF.js 需要自己造一张 cidToGidMap:
if (/Trebuchet/i.test(name)) {
// TrebuchetMS doesn't share the glyph ordering of the standard fonts,
// hence using the latter would map e.g. "š" to "ž" (issue 21713).
applyStandardFontGlyphMap(map, getGlyphMapForMacOrderedFonts());
applyStandardFontGlyphMap(map, getSupplementalGlyphMapForTrebuchetMS());
} else {
applyStandardFontGlyphMap(map, getGlyphMapForStandardFonts());多数标准字体的字形顺序相同,用 getGlyphMapForStandardFonts() 即可;Trebuchet MS 是例外,它的顺序接近 Mac 标准顺序,所以要先叠一层 Mac 顺序表,再叠它自己的补充表。注释里的例子很典型:用错表会把 š 映射成 ž——一个码位的偏移,用户看到的就是完全不同的字母。
四个消费层与调用时机
把前面的消费方按「什么时候查」重新排一遍,能看到这张表被反复使用,但只在少数时机付费:
| 消费方 | 查表时机 | 频率 | 用途 |
|---|---|---|---|
type1FontGlyphMapping | 字体构建,且差异名找不到时 | 每字体最多 256 次,且大多不触发 | 差异名恢复 |
buildToFontChar | 字体构建,fallbackToSystemFont 分支 | 每字体 256 次加差异项 | 系统字体回退 |
adjustType1ToUnicode / adjustTrueTypeToUnicode | 字体构建后期,修补 toUnicode | 每字体 256 次 | 补齐文本映射 |
_simpleFontToUnicode | 文本抽取初始化 | 每字体 256 次 | 主文本路径 |
recoverGlyphName | TrueType 3,1 cmap 分支 | 每字符码一次,命中即返回 | 标准名恢复 |
Type2Compiled 的 seac | 渲染时遇到 endchar 四参数 | 每复合字形 | 合成重音字形 |
getGlyphMapForMacOrderedFonts | 首次调用标准字体表 | 每 Worker 一次 | 构造 glyph id 映射 |
真正的高频路径只有两条:字体构建时的 256 次批量查表,和文本抽取时的逐字符查表。前者每字体一次,后者每字符一次,但都建立在「表已存在」的前提下。建表只发生一次,之后所有查询都是纯读。
evaluator.js:文本抽取的完整流程
文本抽取的 _simpleFontToUnicode 是查表最密集的地方,也是「表只提供事实、策略在上层」最完整的示例。它一共六步,前两步直接依赖 AGL。
第零步:合并差异并跳过 .notdef。
const toUnicode = [];
const encoding = properties.defaultEncoding.slice();
const baseEncodingName = properties.baseEncodingName;
// Merge in the differences array.
const differences = properties.differences;
for (const charcode in differences) {
const glyphName = differences[charcode];
if (glyphName === '.notdef') {
// Skip .notdef to prevent rendering errors, e.g. boxes appearing
// where there should be spaces (fixes issue5256.pdf).
continue;
}
encoding[charcode] = glyphName;
}defaultEncoding.slice() 先复制一份,避免污染字体对象;/Differences 覆盖上去;.notdef 被显式跳过。这一步不查表,但它决定了后面查表的输入。跳过 .notdef 的原因写在注释里:某些文件用 .notdef 表示空格,如果照常查表会得到 U+0000,文本层会出现方块。
第一步与第二步:规范要求的 a、b 两步。
const glyphsUnicodeMap = getGlyphsUnicode();
for (const charcode in encoding) {
// a) Map the character code to a character name.
let glyphName = encoding[charcode];
if (glyphName === "") {
continue;
}
// b) Look up the character name in the Adobe Glyph List (see the
// Bibliography) to obtain the corresponding Unicode value.
let unicode = glyphsUnicodeMap[glyphName];
if (unicode !== undefined) {
toUnicode[charcode] = String.fromCharCode(unicode);
continue;
}空名字被跳过,因为空字符串不在表里,继续走启发式也没有意义。命中后直接 String.fromCharCode 并 continue,不再进入后面的任何分支。这是热路径:绝大多数正常字符都停在这里。
第三步:首字母启发式。
只有 AGL 查不到时才进入这一步,按名字首字符分五类:
// (undocumented) c) Few heuristics to recognize unknown glyphs
// NOTE: Adobe Reader does not do this step, but OSX Preview does
let code = 0;
switch (glyphName[0]) {
case "G": // Gxx glyph
if (glyphName.length === 3) {
code = parseInt(glyphName.substring(1), 16);
}
break;
case "g": // g00xx glyph
if (glyphName.length === 5) {
code = parseInt(glyphName.substring(1), 16);
}
break;
case "C": // Cdd{d} glyph
case "c": // cdd{d} glyph
if (glyphName.length >= 3 && glyphName.length <= 4) {
const codeStr = glyphName.substring(1);
if (forceGlyphs) {
code = parseInt(codeStr, 16);
break;
}
// Normally the Cdd{d}/cdd{d} glyphName format will contain
// regular, i.e. base 10, charCodes (see issue4550.pdf)...
code = +codeStr;
// ... however some PDF generators violate that assumption by
// containing glyph, i.e. base 16, codes instead.
// In that case we need to re-parse the *entire* encoding to
// prevent broken text-selection (fixes issue9655_reduced.pdf).
if (Number.isNaN(code) && Number.isInteger(parseInt(codeStr, 16))) {
return this._simpleFontToUnicode(
properties,
/* forceGlyphs */ true
);
}
}
break;
case "u": // 'uniXXXX'/'uXXXX{XX}' glyphs
unicode = getUnicodeForGlyph(glyphName, glyphsUnicodeMap);
if (unicode !== -1) {
code = unicode;
}
break;
default:
// Support (some) non-standard ligatures.
switch (glyphName) {
case "f_h":
case "f_t":
case "T_h":
toUnicode[charcode] = glyphName.replaceAll("_", "");
continue;
}
break;
}五个分支的规则可以整理成表:
| 首字符 | 名字长度 | 解析方式 | 例子 | 结果 |
|---|---|---|---|---|
G | 恰好 3 | 后两位按十六进制 | G41 | U+0041 |
g | 恰好 5 | 后四位按十六进制 | g0041 | U+0041 |
C 或 c | 3 到 4 | 默认按十进制,forceGlyphs 时按十六进制 | C65 | 65 或 0x65 |
u | 由 getUnicodeForGlyph 判断 | uniXXXX 或 uXXXX{XX} | uni0041 | U+0041 |
| 其它 | 任意 | 先查三个手写连字,再放弃 | f_h | 字符串 fh |
G 与 g 的长度限制很严格:G41 只有三位,g0041 只有五位,多一位少一位都不解析。这是因为这两类名字来自特定生成器的固定格式,长度本身就是格式的一部分。
C 与 c 的十进制、十六进制歧义最麻烦。注释说明大多数生成器用十进制(C65 表示字符码 65),少数用十六进制。代码先按十进制解析;解析出 NaN 时再试十六进制。如果确实像十六进制,它不满足于修正当前字符,而是整个字体重新解析一遍,因为同一个字体里其它字符也可能受影响。这个 return 是整段代码里唯一的中途退出,代价是一次完整重扫,收益是文本选择不再错位。
第四步:码位合法性检查与基编码回查。
if (code > 0 && code <= 0x10ffff && Number.isInteger(code)) {
// If `baseEncodingName` is one the predefined encodings, and `code`
// equals `charcode`, using the glyph defined in the baseEncoding
// seems to yield a better `toUnicode` mapping (fixes issue 5070).
if (baseEncodingName && code === +charcode) {
const baseEncoding = getEncoding(baseEncodingName);
if (baseEncoding && (glyphName = baseEncoding[charcode])) {
toUnicode[charcode] = String.fromCharCode(
glyphsUnicodeMap[glyphName]
);
continue;
}
}
toUnicode[charcode] = String.fromCodePoint(code);
}
}
return toUnicode;四项检查:
| 检查 | 目的 |
|---|---|
code > 0 | 排除 NUL,避免把坏名字映射成空字符 |
code <= 0x10ffff | 排除超出 Unicode 范围的数值 |
Number.isInteger(code) | 排除 parseInt 产生的 NaN |
code === +charcode | 识别「启发式算出的码位恰好等于字符码」的情况 |
最后一项是 issue 5070 的修复:如果启发式算出的码位与字符码相同,说明这个字体很可能只是想复用基础编码,此时用基础编码表里的字形名重新查一次 AGL,结果通常比启发式更准。注意这里又查了一次表,且没有检查查到的值是否为 undefined——如果不幸落空,String.fromCharCode(undefined) 会产出 NUL。这是代码里少数没有防御的地方,实际触发需要名字恰好在基础编码表里且又不在 AGL 里,概率极低。
第五步:交给调用方。
_simpleFontToUnicode 返回一个稀疏数组,buildToUnicode 用它构造 ToUnicodeMap:
if (!properties.composite /* is simple font */) {
return new ToUnicodeMap(this._simpleFontToUnicode(properties));
}对有 /ToUnicode 但数据不完整的简单字体,它还会额外算一份 fallback:
if (!properties.composite && properties.hasEncoding) {
properties.fallbackToUnicode = this._simpleFontToUnicode(properties);
}这条分支说明为什么名字表在「有 ToUnicode」的文档里也不能省:不完整的 /ToUnicode 需要靠字形名补齐。补齐发生在 fonts.js 的 amendFallbackToUnicode 里,只填补 /ToUnicode 没有覆盖的字符码:
for (const charCode in properties.fallbackToUnicode) {
if (properties.toUnicode.has(charCode)) {
continue; // The font dictionary has a `ToUnicode` entry.
}
toUnicode[charCode] = properties.fallbackToUnicode[charCode];
}continue 那一行定了优先级:字体字典里的 /ToUnicode 永远优先,字形名只补空缺。这也是「作者声明优先于启发式」原则的具体体现。
注释里那句「Adobe Reader 不做这一步,OSX Preview 做」也很关键:这些启发式不是规范要求,而是为了让复制出来的文本更接近其它阅读器。表只提供确定映射,启发式补的是规范之外的现实世界。
设计要点:所有消费方共用同一个表引用,没有人复制它。复制一份 4000 键的对象,成本远高于一次属性查找,收益却为零;而共享引用让「表内容只存在于一处」这个不变量成立,修数据时不需要担心某个字体用了旧副本。
名字变体、uniXXXX 与查表失败
表只做字面匹配
这一点必须先说清楚:glyphlist.js 的表查询就是 glyphsUnicodeMap[name],没有 trim、没有大小写折叠、没有去掉点号后缀、没有把 uniXXXX 拆出来解析。表里有 4328 个键,键是什么就只认什么。
源码里也没有隐藏的归一化。核心层里按点号拆字符串的地方只有两处:core_utils.js 的 parseXFAPath(解析 XFA 路径的层级)与 annotation.js 里取附件文件名扩展名;两处都与字形名无关。也就是说:
| 名字 | 表里有吗 | 直接查表结果 |
|---|---|---|
Aacute | 有 | 0x00C1 |
aacute | 有 | 0x00E1 |
uni0041 | 没有 | undefined |
u0041 | 没有 | undefined |
A.sc | 没有 | undefined |
A.alt | 没有 | undefined |
Aalt | 没有 | undefined |
.notdef | 有 | 0 |
第一层结论:点号后缀不会被 glyphlist.js 消解。A.sc 与 A.alt 这类名字在表里没有条目,因为 AGL 只收标准名,不收字体的样式变体。它们的处理责任在字体层,不在名字表。
getUnicodeForGlyph:只处理两种前缀
真正处理 uniXXXX 与 uXXXX 的是 unicode.js 的小函数,不是 glyphlist.js:
function getUnicodeForGlyph(name, glyphsUnicodeMap) {
let unicode = glyphsUnicodeMap[name];
if (unicode !== undefined) {
return unicode;
}
if (!name) {
return -1;
}
// Try to recover valid Unicode values from 'uniXXXX'/'uXXXX{XX}' glyphs.
if (name[0] === 'u') {
const nameLen = name.length;
let hexStr;
if (nameLen === 7 && name[1] === 'n' && name[2] === 'i') {
// 'uniXXXX'
hexStr = name.substring(3);
} else if (nameLen >= 5 && nameLen <= 7) {
// 'uXXXX{XX}'
hexStr = name.substring(1);
} else {
return -1;
}
// Check for upper-case hexadecimal characters, to avoid false positives.
if (hexStr === hexStr.toUpperCase()) {
unicode = parseInt(hexStr, 16);
if (unicode >= 0) {
return unicode;
}
}
}
return -1;
}判定规则可以列成表:
| 名字 | 长度 | 走哪个分支 | 结果 |
|---|---|---|---|
uni0041 | 7 | uni 前缀,取后 4 位 | 0x0041 |
uniFFFF | 7 | uni 前缀,取后 4 位 | 0xFFFF |
u0041 | 5 | u 前缀,取后 4 位 | 0x0041 |
u0000 | 5 | u 前缀,取后 4 位 | 0,合法码位 |
u1D400 | 6 | u 前缀,取后 5 位 | 0x1D400 |
uABCDE | 6 | u 前缀,取后 5 位 | 0xABCDE |
uABCDEF | 7 | u 前缀,取后 6 位 | 0xABCDEF |
uFFFFFF | 7 | u 前缀,取后 6 位 | 0xFFFFFF,超出 Unicode 范围 |
uni004100 | 9 | 长度不在 5 到 7,也不是 7 位 uni | -1 |
uni123 | 6 | 长度 6 但第 2、3 位不是 ni,按 u 前缀取 ni123 | -1,小写不通过大写检查 |
uniform | 7 | 第 2、3 位是 ni,取 form | -1,小写不通过大写检查 |
uabcde | 6 | 小写十六进制 | -1,不通过大写检查 |
u00G1 | 5 | u 前缀,00G1 全大写通过检查 | 0,parseInt 在 G 处截断 |
U0041 | 5 | 首字符是大写 U,不是 u | -1 |
A.sc | 4 | 首字符不是 u | -1 |
几个细节:
- 长度必须精确。
uni只接受 4 位十六进制,写成uni004100反而不认。这是 AGL 规范的约定,不是实现的随意限制。 - 必须全大写。
uniform这种普通单词会先命中u分支,但form含小写字母,被大写检查挡掉。检查hexStr === hexStr.toUpperCase()是为了避免把英文单词误解析成码位。 - 只检查大小写,不检查字符是否都是十六进制。
u00G1里的G是大写字母,能通过检查,parseInt遇到非法字符会截断成 0。这是一个偏宽松的边界,实际字体里极少出现,但读代码时应该知道它存在。 - 不检查 Unicode 上限。
uFFFFFF会返回 0xFFFFFF,函数只保证「能解析出非负整数」,不保证「是合法码位」。范围检查在调用方:_simpleFontToUnicode会判断code <= 0x10ffff,超出就丢弃。职责分开的好处是getUnicodeForGlyph保持纯函数语义,不同调用方可以按自己的规则过滤。 - 失败返回
-1而不是undefined。因为 0 是合法码位(.notdef、U+0000),不能用 0 表示失败。-1也不可能被String.fromCharCode正常使用,是一个安全的哨兵。
后缀 .sc / .alt:没有处理,交回上层
A.sc、A.alt 这类名字的处理结论很简单:本版核心层没有任何代码会去掉点号后缀。查表只做字面匹配,getUnicodeForGlyph 只额外处理 uni/u 前缀,两者都不会把 A.sc 变成 A。
失败之后有两条现成的兜底路径,都在上层:
| 调用方 | 失败后的动作 | 最终结果 |
|---|---|---|
fonts_utils.js 的 recoverGlyphName | 反向遍历 AGL,按码位找标准名;找不到就 info 警告并返回原名 | 原名继续参与 glyphNames.indexOf,找不到则落到 .notdef |
evaluator.js 的 _simpleFontToUnicode | 首字符不是 G、g、C、c、u 时进入 default 分支 | 该字符码不产生 toUnicode 条目,文本层缺字 |
fonts.js 的 buildToFontChar | getUnicodeForGlyph 返回 -1 时跳过赋值 | toFontChar 留下洞,回退时该字符没有 Unicode |
fonts.js 的 _spaceWidth | 直接下标取 undefined,宽度查不到 | 试下一个候选名字 |
recoverGlyphName 的反向路径在 A.sc 上也会失败:A.sc 不在表里,getUnicodeForGlyph 因为首字符是 A 直接返回 -1,于是函数打印警告并返回原名。这正是「表不做猜测」的直接后果:A.sc 不会被「聪明地」猜成 A。
有些阅读器会把点号后缀当作样式提示直接剥掉,PDF.js 不这么做。原因可以从风险上看:.sc 可能表示 small caps,.alt 表示替换字形,剥掉后缀猜成基字符,在文本上可能对,在字形选择上可能错;而一旦猜错,用户复制到的字符就是错的。把这一步留给具体的字体解析器(它们知道该字体有哪些字形),比在全局名字表里猜更安全。
反查的成本:一次全表遍历
recoverGlyphName 是正向查表之外唯一会碰整张表的地方。它的第一步是正向查一次,命中就直接返回原名;未命中才走 getUnicodeForGlyph,然后遍历全表找同码位的名字:
function recoverGlyphName(name, glyphsUnicodeMap) {
if (glyphsUnicodeMap[name] !== undefined) {
return name;
}
// The glyph name is non-standard, trying to recover.
const unicode = getUnicodeForGlyph(name, glyphsUnicodeMap);
if (unicode !== -1) {
for (const key in glyphsUnicodeMap) {
if (glyphsUnicodeMap[key] === unicode) {
return key;
}
}
}
info('Unable to recover a standard glyph name for: ' + name);
return name;
}几种输入的结果:
| 输入 | 正向查表 | getUnicodeForGlyph | 反查结果 |
|---|---|---|---|
A | 命中 | 不执行 | A |
afii10017 | 命中 | 不执行 | afii10017 |
uni0041 | 未命中 | 0x0041 | A,表中第一个值为 0x0041 的键 |
u0041 | 未命中 | 0x0041 | A |
A.sc | 未命中 | -1 | 返回 A.sc,并打印一条 info |
a1 | 未命中 | -1 | 返回 a1,并打印一条 info |
第四行是关键能力:uni0041 这种非标准名可以恢复成标准名 A,进而参与 Type1 的 glyphNames.indexOf 查询。代价是一次 4328 键的遍历。
调用点有两处,成本差异很大:
| 调用点 | 触发条件 | 最多反查次数 |
|---|---|---|
type1FontGlyphMapping | /Differences 里的名字在字体名列表中找不到 | 差异项个数,最多 256 |
fonts.js 的 TrueType 3,1 cmap 分支 | 每个字符码都要解析成标准名 | 256 |
第二处是逐个字符码调用的:
for (let charCode = 0; charCode < 256; charCode++) {
// ...
// Ensure that non-standard glyph names are resolved to valid ones.
const standardGlyphName = recoverGlyphName(
glyphName,
glyphsUnicodeMap
);不过命中率高时成本可以忽略:recoverGlyphName 第一步正向查表,只要名字本身是标准的就直接返回,不会进入遍历。只有生成器写了非标准名时才付全表扫描的代价,而且每个字符码最多扫一次、扫到第一个匹配就停。与 256 次完整遍历的最坏情况相比,这个设计把成本压在了「坏名字的个数」上,而不是「字符码的总数」上。
设计要点:反向查找没有建反向索引。建一张 4328 项的「码位到名字」表,会多占一份内存,而且多个名字指向同一码位时仍要选一个;用
for...in现扫,命中即止,只在坏名字出现时才付费。用时间换空间,前提是坏名字很少——真实 PDF 确实如此。
查表失败的返回值契约
把三层返回值放在一起看:
| 层 | 未命中 | 命中 | 备注 |
|---|---|---|---|
glyphsUnicodeMap[name] | undefined | 数字码位 | 纯数据访问,无哨兵 |
getUnicodeForGlyph(name, map) | -1 | 数字码位 | 处理 uni/u 前缀 |
recoverGlyphName(name, map) | 原名 | 标准名 | 失败时打印 info |
buildToFontChar | 跳过该下标 | 写入码位 | 输出稀疏数组 |
_simpleFontToUnicode | 不写 toUnicode | 写字符串 | 继续下一个字符码 |
这张表说明了一个分工:越靠下,越只回答事实;越靠上,越需要决定怎么办。glyphlist.js 处在最下面,只回答「有没有、是多少」。
失败之后文本会怎样
很多人以为查表失败会导致渲染失败,实际不会。渲染路径依赖字体里的字形数据,只要嵌入字体存在,画面仍然正确。失败影响的是文本层:
| 场景 | 画面 | 文本层 |
|---|---|---|
有嵌入字体,无 /ToUnicode,名字标准 | 正常 | 查表成功,可复制正确文字 |
有嵌入字体,无 /ToUnicode,名字是 uniXXXX | 正常 | getUnicodeForGlyph 解析成功,可复制 |
有嵌入字体,无 /ToUnicode,名字带 .sc 后缀 | 正常 | 该字符没有 Unicode,复制出空或替换符 |
| 无嵌入字体,名字标准 | 系统字体回退,靠 toFontChar | 可复制 |
| 无嵌入字体,名字无法解析 | 系统字体回退可能选错字形 | 文本缺失 |
最后两行是真正的风险区:系统字体回退完全依赖 toFontChar,而 toFontChar 完全依赖名字解析。名字表在这里不是「优化」,而是唯一的数据来源。
五个常见误解
读这个文件时,有五个判断很容易想当然:
误解一:表在运行时从紧凑字符串里解析出来。
实际没有解析。getGlyphsUnicode 的初始化函数里是 4328 条逐行赋值,没有分隔符、没有 split、没有遍历转换。历史上确实有过扁平数组版本,但那只是数组转对象的转换,也不是字符串解析。把这张表想象成「编码后的数据 + 解码器」,会完全找错阅读方向。
误解二:名字查不到会导致页面渲染失败。
渲染路径用的是字体里的字形数据,不是 Unicode。名字查不到时,画布仍然按 glyph id 画,页面照常显示;受影响的只有文本层的复制、搜索与朗读。这也是为什么 PDF.js 可以在名字表不完整的情况下依然「看起来正常」。
误解三:每次调用工厂都会返回一个新对象,可以随便改。
getLookupTableFactory 记忆化了结果,同一 Worker 内每次返回同一个引用。往返回对象上写一个额外属性,会污染之后所有字体、所有页面的查询结果。表没有冻结,也没有防护,正确用法是只读。
误解四:uniXXXX 的解析在 glyphlist.js 里。
glyphlist.js 只做字面匹配,uni0041 直接查表会返回 undefined。解析发生在 unicode.js 的 getUnicodeForGlyph 里,而且只在调用方主动调用它时才发生。glyphlist.js 甚至不知道 uni 这个前缀有什么特殊含义。
误解五:表会随文档销毁而释放。
表挂在模块闭包里,生命周期与 Worker 相同。cleanup 不处理它,切换文档不重建它。这也意味着「打开过一份需要 AGL 的文档」之后,即使后续文档都是纯图片,这份 4000 键的对象也会一直留在内存里。这是懒加载的直接代价,也是选择「每 Worker 一份、永不释放」的必然结果。
把这五条反过来看,正好是这张表的边界:它只存数据、只做字面匹配、只读、全局、常驻。任何超出这个边界的行为,都在别的模块里。
设计要点:名字解析的失败必须是「可继续的失败」。每一层都返回一个能参与后续流程的值(
undefined、-1、原名、跳过的下标),而不是抛异常。这样一份有少量坏名字的 PDF 仍然能打开、能滚动、能显示绝大多数页面,只在个别字符的复制上降级。
总结
glyphlist.js 是字体层的名字字典:它把 PDF 里的字形名字符串翻译成 Unicode 码位,供 fonts.js 建 toUnicode 与 toFontChar、供 evaluator.js 做文本抽取、供 font_renderer.js 处理 seac 合成、供 standard_fonts.js 构造 Mac 顺序字体的映射。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 编码 | encodings.js | 单字节字符码 | 字形名 |
| 差异 | 字体字典 /Differences | 字符码与名字 | 覆盖后的字形名 |
| CMap | cmap.js | 字符码 | CID 或 /ToUnicode |
| 字形名解析 | glyphlist.js | 字形名 | 码位 |
| 字体组装 | fonts.js | 码位、字形、宽度 | toUnicode、toFontChar、fontChar |
| 渲染与文本 | canvas、文本层 | fontChar、unicode | 可见字形、可复制文字 |
用一段代码把顺序串起来:
// 字符码
// → encodings.js / Differences 字形名
// → glyphlist.js Unicode 码位(本篇)
// → unicode.js getUnicodeForGlyph uniXXXX / uXXXX 兜底
// → fonts.js buildToFontChar toFontChar 与 toUnicode
// → canvas 与文本层 字形与字符六条设计原则
- 数据与策略分离。表只回答「这个名字有没有码位」,不判断名字是否合法、不猜后缀、不做归一化。所有修复逻辑集中在
unicode.js、fonts_utils.js、evaluator.js,各自可以独立测试与演进。 - 按名字空间分表,不贪图一张大表。AGL 与 ZapfDingbats 各自对应一类字体,消费方按字体名成对选择编码表与字形表。合并会让「这个字体不该用这张表」的错误被掩盖。
- 懒加载,一次构建,全局共享。
getLookupTableFactory把 4328 条赋值推迟到首次使用,之后返回同一引用;表不随文档清理,也不挂在任何文档对象上。不用字体层的文档完全不为它付费。 - 空原型容器承载不可信键。键来自 PDF 文件,可能是
constructor、__proto__这类名字;Object.create(null)让继承属性不可能造成假命中。 - 哨兵值必须与合法值不相交。0 是合法码位,所以「查不到」用
undefined,「解析失败」用-1。.notdef映射到 0 是有意为之,消费方必须显式跳过。 - 失败要可继续。每一层都返回能继续流动的值,而不是抛异常。少量坏名字只影响对应字符的文本,不影响整份文档的打开与渲染。
这张表看起来只是数据,但它决定了 PDF.js 在「没有 /ToUnicode 的旧文件」上能不能复制出正确文字。理解它之后,再看 encodings.js 的编码表、cmap.js 的 ToUnicode 解析、fonts.js 的 toFontChar 构造,就会明白三者共享的是同一套名字与码位语义,而不是各自维护一份映射。
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 源码解读: 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 源码解读: metadata_parser2026年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日