StreamPDFStreamPDF
返回博客

PDF.js 源码解读: glyphlist

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: glyphlist

前言

字体层走到这里,前面的文章已经把「字节怎么变成字形」讲了大半: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 idfontChar / 字形程序画面缺字或落到 .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 字典的键每个字形程序的键就是名字,取程序时按名字查
CFFcharset字形 id 到 SID 或自定义字符串,最终都还原成名字
TrueTypepost 表 2.0glyph id 到名字;前 258 个是 Mac 顺序的标准名
PDF编码表与 /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,在不同字体里会走到完全不同的名字与码位:

字体编码表字符码查到的名字字形表码位
普通文本字体StandardEncoding0x41AgetGlyphsUnicode0x0041
SymbolSymbolSetEncoding0x41AlphagetGlyphsUnicode0x0391
ZapfDingbatsZapfDingbatsEncoding0x21a1getDingbatsGlyphsUnicode0x2701

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

这段代码同时说明了三件事:

  1. 字形名到 Unicode 的官方依据是 Adobe Glyph List,不是字体里的任何一张表;
  2. 查表结果直接做 String.fromCharCode,说明表里存的是码位整数,不是字符串;
  3. 查不到时返回 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、AacutesmallExpert 字符集的小型大写,落在私用区
afii 编号afii10017早期字体交换标准的编号名,无法从名字反推码位

命名法:基名加后缀,但每个名字都独立登记

AGL 不是「按规则组合出无限名字」的生成系统,它是一份封闭清单。名字里能看出若干后缀家族,但每个组合都必须单独出现在表里:

后缀家族条数代表条目码位去向
cyrillic222AcyrillicU+0400 区
arabic244ainarabicU+0600 区与 U+FE70 表现形式区
hebrew169alefhebrewU+0590 区与 U+FB1D 呈现形式
katakana93akatakanaU+30A0 区
hiragana86ahiraganaU+3040 区
halfwidth62akatakanahalfwidthU+FF00 区
monospace99AmonospaceU+FF00 区
thai109kothai 一类泰文字母名U+0E00 区
bopomofo37aibopomofoU+3100 区
small100AEsmall私用区 U+F600 到 U+F8FF
oldstyle12oneoldstyle私用区
superior42上标数字与字母变体U+2070 区与私用区
inferior17下标数字变体U+2080 区与私用区
display14summationdisplayU+2200 区数学符号

这份统计说明了一个重要事实:后缀不构成可推导的语法。Acyrillic 存在,Acyrillicsmall 不存在;ainarabic 与 ainfinalarabic 是两个独立条目,后者的码位不在阿拉伯主区,而在 U+FE70 之后的阿拉伯表现形式区。你不能写一个「基名加后缀再算偏移」的函数来替代这张表,因为偏移量随字母形态变化,而形态名又来自不同历史时期的不同标准。

阿拉伯这条线最能说明问题。同一个字母 ain 有孤立、词首、词中、词尾四种形状,对应四个名字与四个码位:

名字形态码位
ainarabic独立U+0639
aininitialarabic词首U+FEBB 一带
ainmedialarabic词中U+FECC 一带
ainfinalarabic词尾U+FECA 一带

四个码位分布在两个不相邻的区段,中间还夹着别的文字。任何「压缩成公式」的尝试都会在类似的地方失效,这也是这张表必须以原始数据形式存在的原因。

统计一下这张表的实际规模:

指标数值说明
条目数43284327 条点号赋值加一条 .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+007F159基本拉丁与控制符
U+0080 到 U+00FF102拉丁-1 补充
U+0100 到 U+017F159拉丁扩展 A
U+0180 到 U+024F152拉丁扩展 B
U+0250 到 U+036F219国际音标、修饰字母、组合符号
U+0370 到 U+03FF111希腊与科普特
U+0400 到 U+052F327西里尔
U+0530 到 U+058F85亚美尼亚
U+0590 到 U+05FF259希伯来
U+0600 到 U+06FF175阿拉伯主区
U+0900 到 U+0DFF344印度系诸文字
U+0E00 到 U+0E7F87泰文
U+1E00 到 U+1EFF246拉丁扩展附加
U+3000 到 U+30FF224CJK 标点、平假名、片假名
U+3100 到 U+312F37注音符号
U+3130 到 U+318F94韩文兼容字母
U+3200 到 U+33DD236带圈 CJK 与 CJK 兼容
U+FB00 到 U+FB4F78拉丁连字
U+F600 到 U+F8FF192私用区
U+FF00 到 U+FFEF162半角与全角形式

上表合计 3448 条,其余 880 条集中在两片。第一片是 U+2000 到 U+27BF 的标点、货币、带圈字母、箭头、数学运算符、制表符与几何图形,共 674 条;第二片是 U+FB50 到 U+FEFF 的阿拉伯表现形式与 CJK 兼容形式,共 204 条。此外只剩两个孤立条目(U+1D40 与 U+5344)。这两片都是「同一个字符的排版变体或符号扩展」,不是新字母,所以条数虽多,实际覆盖的文字种类并没有看起来那么多。

两张表的分布差异也很直观:

表拉丁希腊与西里尔中东与印度系假名与 CJK符号与私用区
AGL约 900 条约 440 条约 1150 条约 600 条约 1030 条
Dingbats0000201 条编号符号,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 后缀的条目来表示真正的字母。

名字码位实际字符用途
Delta0x2206增量数学排版
Deltagreek0x0394希腊大写 delta希腊文
Omega0x2126欧姆物理与工程
Omegagreek0x03A9希腊大写 omega希腊文
Upsilon10x03D2带上钩的 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;
名字码位形式
ahiragana0x3042平假名 あ
akatakana0x30a2片假名 ア
akatakanahalfwidth0xff71半角片假名
aibopomofo0x311e注音符号 ㄞ

同一个读音有四种写法,每一种都是独立字符。日文 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+27BFUnicode 的 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 条a10x2701
第 3 条a20x2702
第 4 条a2020x2703
第 5 条a30x2704
第 6 条a40x260E
第 7 条a50x2706
第 8 条a1190x2707
第 9 条a1180x2708
第 10 条a1170x2709
第 11 条a110x261B

三个反直觉之处:

  1. a202 夹在 a2 与 a3 之间。它的码位 0x2703 是下刃剪刀,序号 202 与排列位置没有关系,这个顺序来自 Adobe 原始清单。
  2. a4 的码位是 0x260E(电话),跳出 U+2700 区。它是原始清单里的第 4 个符号,符号本身却被 Unicode 收在 Misc Symbols 区,所以名字序号与区块归属没有关系。
  3. 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+FFE6U+0020 到 U+27BE
主要用途通用文本语义、系统字体回退、Type1 与 CFF 的字形名解析只有 ZapfDingbats 字体才用
消费点fonts.js、evaluator.js、font_renderer.js、standard_fonts.js、fonts_utils.jsfonts.js 的 /Dingbats/i 分支

为什么不合成一张表

两张表都含 space,值都是 0x0020,合并不会冲突。但合成一张表会带来两个问题:

  1. 污染查询:AGL 里没有 a4,Dingbats 里 a4 是 U+260E。合并后,任何拿 AGL 查 a4 的路径都会突然命中一个电话符号。字体编码里出现 a4 本来就应该查不到,查得到反而掩盖了「这个字体不是 Dingbats」的事实。
  2. 选择权丢失:用哪张表不是由名字决定,而是由字体名决定。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 中的码位后续规范化
universal0x2200不变
existential0x2203不变
suchthat0x220B不变
theta10x03D1不变
Upsilon10x03D2不变
arrowvertex0xF8E60x23D0
copyrightserif0xF6D90x00A9
registerserif0xF6DA0x00AE

表里的私用区值(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)属性查找初始化函数里逐条赋值中字符串,无原型
Mapget逐条 set较高,每个条目有额外开销任意类型,插入序
数组不适用需按索引预分配取决于稀疏程度数字下标
扁平数组需要先转成对象或线性查找转换时峰值双份高混合

选择空原型对象的理由有三条:

  1. 键是字符串,而且大多像标识符。Aacute、uni0041、afii10017 都可以直接做属性名,V8 对这种访问有专门优化。Map.get 需要方法调用与哈希查找,在热路径上略慢。
  2. 建表可以是一条条赋值。t.A = 0x0041 是属性写入;Map 需要 t.set("A", 0x0041),多一层调用。4328 条就是 4328 次调用差异,虽然不大,但没有理由选更慢的。
  3. 不需要 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.jsglyphlist.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 只发生一次
单个字体建 toFontChar256 次查表加差异项每个简单字体一次
整份文档文本抽取字符总数次每个字符一次

一份 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 会让纯数据表混入策略,也会让白名单难以独立更新。

多名同码位

同一张表里,多个名字映射到同一个码位非常常见:

名字码位关系
Dcroat0x0110与 Dslash 完全同值
Dslash0x0110同上
Zdot0x017b与 Zdotaccent 同值
Zdotaccent0x017b同上
radical0x221a与 TeX 段的 radicalbig、radicalBig 等同值
radicalbig0x221aTeX 段,尺寸变体共用根号码位
parenleftBig0x0028TeX 段四种大小共用左圆括号码位
parenleftbigg0x0028同上
space0x0020与 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查不到
.notdef0查到了,但码位是 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 次主文本路径
recoverGlyphNameTrueType 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后两位按十六进制G41U+0041
g恰好 5后四位按十六进制g0041U+0041
C 或 c3 到 4默认按十进制,forceGlyphs 时按十六进制C6565 或 0x65
u由 getUnicodeForGlyph 判断uniXXXX 或 uXXXX{XX}uni0041U+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;
}

判定规则可以列成表:

名字长度走哪个分支结果
uni00417uni 前缀,取后 4 位0x0041
uniFFFF7uni 前缀,取后 4 位0xFFFF
u00415u 前缀,取后 4 位0x0041
u00005u 前缀,取后 4 位0,合法码位
u1D4006u 前缀,取后 5 位0x1D400
uABCDE6u 前缀,取后 5 位0xABCDE
uABCDEF7u 前缀,取后 6 位0xABCDEF
uFFFFFF7u 前缀,取后 6 位0xFFFFFF,超出 Unicode 范围
uni0041009长度不在 5 到 7,也不是 7 位 uni-1
uni1236长度 6 但第 2、3 位不是 ni,按 u 前缀取 ni123-1,小写不通过大写检查
uniform7第 2、3 位是 ni,取 form-1,小写不通过大写检查
uabcde6小写十六进制-1,不通过大写检查
u00G15u 前缀,00G1 全大写通过检查0,parseInt 在 G 处截断
U00415首字符是大写 U,不是 u-1
A.sc4首字符不是 u-1

几个细节:

  1. 长度必须精确。uni 只接受 4 位十六进制,写成 uni004100 反而不认。这是 AGL 规范的约定,不是实现的随意限制。
  2. 必须全大写。uniform 这种普通单词会先命中 u 分支,但 form 含小写字母,被大写检查挡掉。检查 hexStr === hexStr.toUpperCase() 是为了避免把英文单词误解析成码位。
  3. 只检查大小写,不检查字符是否都是十六进制。u00G1 里的 G 是大写字母,能通过检查,parseInt 遇到非法字符会截断成 0。这是一个偏宽松的边界,实际字体里极少出现,但读代码时应该知道它存在。
  4. 不检查 Unicode 上限。uFFFFFF 会返回 0xFFFFFF,函数只保证「能解析出非负整数」,不保证「是合法码位」。范围检查在调用方:_simpleFontToUnicode 会判断 code <= 0x10ffff,超出就丢弃。职责分开的好处是 getUnicodeForGlyph 保持纯函数语义,不同调用方可以按自己的规则过滤。
  5. 失败返回 -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 的 buildToFontChargetUnicodeForGlyph 返回 -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未命中0x0041A,表中第一个值为 0x0041 的键
u0041未命中0x0041A
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字符码与名字覆盖后的字形名
CMapcmap.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 与文本层                  字形与字符

六条设计原则

  1. 数据与策略分离。表只回答「这个名字有没有码位」,不判断名字是否合法、不猜后缀、不做归一化。所有修复逻辑集中在 unicode.js、fonts_utils.js、evaluator.js,各自可以独立测试与演进。
  2. 按名字空间分表,不贪图一张大表。AGL 与 ZapfDingbats 各自对应一类字体,消费方按字体名成对选择编码表与字形表。合并会让「这个字体不该用这张表」的错误被掩盖。
  3. 懒加载,一次构建,全局共享。getLookupTableFactory 把 4328 条赋值推迟到首次使用,之后返回同一引用;表不随文档清理,也不挂在任何文档对象上。不用字体层的文档完全不为它付费。
  4. 空原型容器承载不可信键。键来自 PDF 文件,可能是 constructor、__proto__ 这类名字;Object.create(null) 让继承属性不可能造成假命中。
  5. 哨兵值必须与合法值不相交。0 是合法码位,所以「查不到」用 undefined,「解析失败」用 -1。.notdef 映射到 0 是有意为之,消费方必须显式跳过。
  6. 失败要可继续。每一层都返回能继续流动的值,而不是抛异常。少量坏名字只影响对应字符的文本,不影响整份文档的打开与渲染。

这张表看起来只是数据,但它决定了 PDF.js 在「没有 /ToUnicode 的旧文件」上能不能复制出正确文字。理解它之后,再看 encodings.js 的编码表、cmap.js 的 ToUnicode 解析、fonts.js 的 toFontChar 构造,就会明白三者共享的是同一套名字与码位语义,而不是各自维护一份映射。

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

相关文章