StreamPDFStreamPDF
返回博客

PDF.js 源码解读: encodings

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: encodings

前言

上一篇讲 fonts_utils.js,它负责把「字符码 + 名字数组」变成 glyph id。但名字数组从哪来、字符码对应哪个字形名,还没有交代。答案就在 src/core/encodings.js(301 行):这里存放 PDF 规范预定义的单字节编码表,把 0 到 255 的字符码翻译成字形名。

它在整条链路里位置很靠前:evaluator.js 从字体字典读出 /Encoding 与 /Differences,决定基准编码名;getEncoding 把名字换成表;fonts.js、type1_font.js、cff_font.js 再用这张表建立字符码到 glyph id 的映射。没有这张表,「Tj 里这个字节画哪个字形」就无从谈起。

用一句话概括:它是字体层的编码表层,只负责「code 到 glyph name」这一跳,不解析字形轮廓,也不决定 Unicode。

模块总览

encodings.js 没有导入,也没有类,全部内容可以切成五块:

区块关键成员职责
西文基础StandardEncoding、MacRomanEncoding、WinAnsiEncoding三种最常用的单字节编码
ExpertExpertEncoding、MacExpertEncoding小型大写、旧式数字、上下标与连字
符号SymbolSetEncoding、ZapfDingbatsEncodingSymbol 与 ZapfDingbats 的内置编码
分发getEncoding把编码名换成对应的 256 项数组
导出export 块七张表里只公开六张,MacExpertEncoding 私有

它在字体链路里的位置:

// 字体字典 /Encoding、/Differences
//   → evaluator.js            解析出 baseEncodingName 与 differences
//   → encodings.js            getEncoding(名字) → 256 项编码表     (本篇)
//   → fonts_utils.js          type1FontGlyphMapping(表 + glyphNames) → glyph id
//   → fonts.js                buildToFontChar(表 + glyphlist) → Unicode
//   → font_renderer.js        按 glyph id 取轮廓

版本说明:本篇按当前仓库这一版 encodings.js 讲解。有两点与旧资料不同。第一,本版没有 PDFDocEncoding,PDF 文本串的解码由 string_utils.js 的 PDFStringTranslateTable 承担,getEncoding("PDFDocEncoding") 会返回 null。第二,旧版常见的 getExtendedEncoding 与「基础表 + 扩展表工厂」的写法在本版不存在:七张表全都是字面量 256 项数组,0x80 到 0x9F 的高位区直接写在数组里。另外,MacExpertEncoding 虽然定义在文件中,却没有出现在 export 块里,只能通过 getEncoding("MacExpertEncoding") 取得。

编码表在字体链路中的位置

PDF 里的字符码有三条不同的解释路径,encodings.js 只负责第一条:

路径输入中间结果最终结果使用场景
单字节编码1 字节 code字形名glyph idType1、TrueType、Type3
CMap1 或 2 字节 codeCIDglyph idCIDFont、中文日文韩文字体
ToUnicode1 或 2 字节 codeUnicode文本字符文本提取、复制、搜索

三条路径互不替代。一个 CID 字体可以没有 encodings.js 里的任何表,却仍然正常显示;一个简单字体可以没有 /ToUnicode,却仍然能画出字形,只是复制出来的文字可能是乱码。encodings.js 的职责边界就在这里:它只描述「这个字节在视觉上对应哪个字形名」,不描述这个字形名最终念什么。

表的形状:256 项、下标即码位

七张表都是普通的 JavaScript 数组,长度固定 256,下标就是字符码:

// prettier-ignore
const StandardEncoding = [
  "", "", "", "", "", "", "", "", "", "", "", "", "", "", "",
  // ...
  "space", "exclam", "quotedbl", "numbersign", "dollar", "percent",
  "ampersand", "quoteright", "parenleft", "parenright", "asterisk", "plus",
  "comma", "hyphen", "period", "slash", "zero", "one", "two", "three",
  // ...
  "asciicircum", "underscore", "quoteleft", "a", "b", "c", "d", "e", "f",
  "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u",
  // ...
  "exclamdown", "cent", "sterling", "fraction", "yen", "florin", "section",
  "currency", "quotesingle", "quotedblleft", "guillemotleft", "guilsinglleft",
  // ...
  "Lslash", "Oslash", "OE", "ordmasculine", "", "", "", "", "", "ae",
  "", "", "", "dotlessi", "", "", "lslash", "oslash", "oe", "germandbls", "",
  "", "", ""];

空字符串 "" 表示该码位在这个编码里没有定义。用空串而不是 undefined 或 null,有两个直接好处:数组长度始终是 256,任何下标都能安全读取;调用方可以统一用 !== "" 判断「这个码位有没有字形名」,不需要区分「数组太短」与「值缺失」。

// prettier-ignore 不是装饰。七张表都是逐行手工排布的数据,Prettier 会按最大行宽重新折行,破坏「同一段码位排在一起」的可读性;关掉格式化,能让码位区间一眼可辨。

按码位区间看,所有表的结构一致:

区间含义典型内容
0x00-0x1F控制字符全部为空串
0x20-0x7EASCII 可打印区字母、数字、标点
0x7F-0x9F高位区,差异最大WinAnsi 放 Euro 与排版符号,MacRoman 放重音字母
0xA0-0xFF拉丁扩展、货币与符号重音字母、dagger、florin、germandbls

三种西文编码

StandardEncoding、MacRomanEncoding、WinAnsiEncoding 覆盖同样的 ASCII 区,差异集中在少数标点和整个高位区。先看 WinAnsi 的高位段:

  "asciitilde", "bullet", "Euro", "bullet", "quotesinglbase", "florin",
  "quotedblbase", "ellipsis", "dagger", "daggerdbl", "circumflex",
  "perthousand", "Scaron", "guilsinglleft", "OE", "bullet", "Zcaron",
  "bullet", "bullet", "quoteleft", "quoteright", "quotedblleft",
  "quotedblright", "bullet", "endash", "emdash", "tilde", "trademark",
  "scaron", "guilsinglright", "oe", "bullet", "zcaron", "Ydieresis", "space",

这段从 0x7E 开始。0x80 是 Euro,0x82 是 quotesinglbase,0x85 是 ellipsis,0x91 到 0x94 是弯引号。中间反复出现的 bullet 不是真实字形,而是 Windows 代码页里未定义位置的占位符;PDF.js 照抄这套映射,是为了和 Acrobat、Word 生成的 PDF 保持一致。

MacRoman 在同一区间放的是重音字母:

  "", "Adieresis", "Aring", "Ccedilla", "Eacute", "Ntilde", "Odieresis",
  "Udieresis", "aacute", "agrave", "acircumflex", "adieresis", "atilde",
  "aring", "ccedilla", "eacute", "egrave", "ecircumflex", "edieresis",
  // ...
  "Omega", "ae", "oslash", "questiondown", "exclamdown", "logicalnot",
  "radical", "florin", "approxequal", "Delta", "guillemotleft",
  "guillemotright", "ellipsis", "space", "Agrave", "Atilde", "Otilde", "OE",
  // ...
  "Icircumflex", "Idieresis", "Igrave", "Oacute", "Ocircumflex", "apple",
  "Ograve", "Uacute", "Ucircumflex", "Ugrave", "dotlessi", "circumflex",

注意 0xCA 位置的 space 与 0xF0 位置的 apple:MacRoman 用 space 表示不换行空格,apple 是苹果标志字形。这些名字只在 Mac 字体里有意义,放到别的字体上通常落回 .notdef。

把三个表放在同一批码位上对比,差异更直观:

码位StandardEncodingMacRomanEncodingWinAnsiEncoding
0x27quoterightquotesinglequotesingle
0x60quoteleftgravegrave
0x7F空空bullet
0x80空AdieresisEuro
0xA0空daggerspace
0xB5空mumu
0xCAringspaceEcircumflex
0xF0空appleeth

这张表解释了一个常见疑问:为什么同一段 Word 导出的文字在 PDF.js 里复制出来会变成弯引号。因为 WinAnsi 把 0x91 到 0x94 定义成排版引号,而 StandardEncoding 在同样位置没有定义;编码选错,名字就不同,最终 Unicode 也不同。

设计要点:编码表只描述「code 到字形名」,不描述「名字到 Unicode」。Unicode 解析交给 glyphlist.js,于是同一张表可以同时服务字形定位(名字到 glyph id)与文本提取(名字到 Unicode),不需要为两条路径维护两份数据。代价是名字必须足够规范,遇到 uniXXXX 之类的非标准名还得靠 recoverGlyphName 兜底。

符号与 Expert 编码

后四张表服务特殊字体,命名风格与西文表明显不同。Symbol 字体用的是希腊字母与数学符号:

  "space", "exclam", "universal", "numbersign", "existential", "percent",
  "ampersand", "suchthat", "parenleft", "parenright", "asteriskmath", "plus",
  "comma", "minus", "period", "slash", "zero", "one", "two", "three", "four",
  // ...
  "congruent", "Alpha", "Beta", "Chi", "Delta", "Epsilon", "Phi", "Gamma",
  "Eta", "Iota", "theta1", "Kappa", "Lambda", "Mu", "Nu", "Omicron", "Pi",
  "Theta", "Rho", "Sigma", "Tau", "Upsilon", "sigma1", "Omega", "Xi", "Psi",
  "Zeta", "bracketleft", "therefore", "bracketright", "perpendicular",
  // ...
  "Euro", "Upsilon1", "minute", "lessequal", "fraction", "infinity", "florin",
  "club", "diamond", "heart", "spade", "arrowboth", "arrowleft", "arrowup",
  "arrowright", "arrowdown", "degree", "plusminus", "second", "greaterequal",

universal、existential、suchthat、therefore 是数学逻辑符号的标准字形名;theta1、sigma1、Upsilon1 里的一后缀表示「同一字母的另一种字形」,不是数字 1。club、diamond、heart、spade 是扑克花色,说明这张表覆盖的不只是数学。

fonts.js 还用它反向构造 Symbol 字体的 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);

这段代码从 0x20 到 0x7E、0xA1 到 0xFE 顺序遍历,把每个码位的字形名写到 glyphId 递增的位置。它利用了 Symbol 字体「字形按码位顺序排列」这一事实,同时跳过 0x7F 到 0xA0 的空洞,以及 glyph id 0 到 2 的 .notdef、.null、nonmarkingreturn。同一张表在这里被当成「码位顺序」使用,下标不再是 glyph id,而是由外部计数器维护。

ZapfDingbats 更彻底:字形名就是 Adobe 的编号,没有语义:

  "space", "a1", "a2", "a202", "a3", "a4", "a5", "a119", "a118", "a117",
  "a11", "a12", "a13", "a14", "a15", "a16", "a105", "a17", "a18", "a19",
  // ...
  "a57", "a58", "a59", "a60", "a61", "a62", "a63", "a64", "a65", "a66",
  "a67", "a68", "a69", "a70", "a71", "a72", "a73", "a74", "a203", "a75",

编号并不按码位递增,a202 出现在 a2 后面,a203 夹在 a74 与 a75 之间。这些编号必须原样保留,因为 ZapfDingbats 字体的 post 表与内置编码就是用它们对齐的。

这些 aNN 名字也不在普通字形表里。glyphlist.js 为此单独导出了 getDingbatsGlyphsUnicode,与 getGlyphsUnicode 并列;fonts.js 在建立 ZapfDingbats 的 Unicode 映射时使用前者。编码表与字形表必须成对使用,拿错表会把 a1 解析成空值,文本提取随之丢字。

Expert 系列面向专业排版,装的是小型大写、旧式数字、上下标与连字:

  "space", "exclamsmall", "Hungarumlautsmall", "", "dollaroldstyle",
  "dollarsuperior", "ampersandsmall", "Acutesmall", "parenleftsuperior",
  "parenrightsuperior", "twodotenleader", "onedotenleader", "comma",
  "hyphen", "period", "fraction", "zerooldstyle", "oneoldstyle",
  // ...
  "Asmall", "Bsmall", "Csmall", "Dsmall", "Esmall", "Fsmall", "Gsmall",
  "Hsmall", "Ismall", "Jsmall", "Ksmall", "Lsmall", "Msmall", "Nsmall",
  // ...
  "questiondownsmall", "oneeighth", "threeeighths", "fiveeighths",
  "seveneighths", "onethird", "twothirds", "", "", "zerosuperior",

ExpertEncoding 与 MacExpertEncoding 覆盖的内容相似,码位安排不同。MacExpertEncoding 在 0x23 位置是 centoldstyle,而 ExpertEncoding 在同一位置是空串:

  "space", "exclamsmall", "Hungarumlautsmall", "centoldstyle",
  "dollaroldstyle", "dollarsuperior", "ampersandsmall", "Acutesmall",
  "parenleftsuperior", "parenrightsuperior", "twodotenleader",
  "onedotenleader", "comma", "hyphen", "period", "fraction", "zerooldstyle",

Ethsmall 等名字在两张表里也落在不同码位。两张表都不能靠猜,必须按名字查。

表码位范围命名风格示例
SymbolSetEncoding0x20-0xFE数学、希腊、箭头universal、theta1、arrowboth
ZapfDingbatsEncoding0x20-0xFEAdobe 编号a1、a202、a191
ExpertEncoding0x20-0xFF小型大写与旧式数字Asmall、zerooldstyle、oneeighth
MacExpertEncoding0x20-0xFF类似 Expert,码位不同centoldstyle、Ethsmall

编码名从哪里来:/Encoding 的三种形态

编码表本身不会出现在 PDF 里。PDF 里出现的是 /Encoding 条目,它可能是名字,也可能是字典。evaluator.js 负责把这几种形态收敛成「基准编码名 + 差异数组」:

if (dict.has('Encoding')) {
  encoding = dict.get('Encoding');
  if (encoding instanceof Dict) {
    baseEncodingName = encoding.get('BaseEncoding');
    baseEncodingName = baseEncodingName instanceof Name ? baseEncodingName.name : null;
    // Load the differences between the base and original
    if (encoding.has('Differences')) {
      const diffEncoding = encoding.get('Differences');
      let index = 0;
      for (const entry of diffEncoding) {
        const data = xref.fetchIfRef(entry);
        if (typeof data === 'number') {
          index = data;
        } else if (data instanceof Name) {
          differences[index++] = data.name;
        } else {
          throw new FormatError(`Invalid entry in 'Differences' array: ${data}`);
        }
      }
    }
  } else if (encoding instanceof Name) {
    baseEncodingName = encoding.name;
  } else {
    // ...
  }
  // According to table 114 if the encoding is a named encoding it must be
  // one of these predefined encodings.
  if (
    baseEncodingName !== 'MacRomanEncoding' &&
    baseEncodingName !== 'MacExpertEncoding' &&
    baseEncodingName !== 'WinAnsiEncoding'
  ) {
    baseEncodingName = null;
  }
}

/Differences 的解析采用「数字重置下标、名字写入并自增」的稀疏数组写法:数组里的数字项表示下一个名字要写到的码位,名字项写入后下标加一。因此 120 /a /b 表示 120 放 a、121 放 b,中间不需要写满 256 项。

最后那段白名单最容易被忽略:即使 /Encoding 是 /StandardEncoding 这样的合法名字,只要不在 MacRomanEncoding、MacExpertEncoding、WinAnsiEncoding 三者之内,baseEncodingName 也会被置空。最终形态只有四种:

/Encoding 形态baseEncodingNamedifferences后续处理
名字,且在三个白名单内对应名字空直接作为默认编码
字典,/BaseEncoding 在白名单内对应名字可能有默认编码加差异覆盖
字典,/BaseEncoding 缺失或不在白名单null可能有走符号/非符号默认规则
其它类型null空忽略错误或抛 FormatError

这也解释了 getEncoding 为什么需要七个分支、而 evaluator.js 实际只会传三个名字:另外四张表由字体程序解析器(type1_parser.js、cff_parser.js)直接引用,或由 fonts.js 在 TrueType 分支按显式白名单取用。分发函数服务所有调用方,白名单只约束其中一条路径。

getEncoding:七个名字的分发

七张表通过一个很短的 switch 暴露给外部:

function getEncoding(encodingName) {
  switch (encodingName) {
    case 'WinAnsiEncoding':
      return WinAnsiEncoding;
    case 'StandardEncoding':
      return StandardEncoding;
    case 'MacRomanEncoding':
      return MacRomanEncoding;
    case 'SymbolSetEncoding':
      return SymbolSetEncoding;
    case 'ZapfDingbatsEncoding':
      return ZapfDingbatsEncoding;
    case 'ExpertEncoding':
      return ExpertEncoding;
    case 'MacExpertEncoding':
      return MacExpertEncoding;
    default:
      return null;
  }
}

它的契约只有两条:名字精确匹配,未知名字返回 null。不做大小写折叠,不做别名,也不做前缀匹配。PDF 规范里这些名字是固定的,拼错就是拼错,返回 null 比猜一个近似值更安全。

调用方对 null 的处理并不统一,这一点读源码时要留意:

调用位置传入的名字来源对 null 的处理
evaluator.js 的 defaultEncoding/BaseEncoding 或 /Encoding 名存入属性,后续按假值判断回退
fonts.js 的 TrueType 分支仅 MacRomanEncoding 与 WinAnsiEncoding不会传其它名字,因此不会得到 null
type1_parser.js 的内置编码字体程序里的 /Encoding 名交给后续映射,未知名等同没有编码
cff_parser.js 的预定义 charset不使用名字直接引用 ExpertEncoding 与 StandardEncoding

导出面比定义面小,是这张表最容易被忽略的细节:

名字getEncoding 返回能否直接导入
WinAnsiEncoding是是
StandardEncoding是是
MacRomanEncoding是是
SymbolSetEncoding是是
ZapfDingbatsEncoding是是
ExpertEncoding是是
MacExpertEncoding是否,模块内私有

cff_parser.js 需要 ExpertEncoding 时直接导入;没有任何文件导入 MacExpertEncoding,它只作为 switch 的一个分支存在。这种不对称不是疏漏:CFF 的预定义 charset 只用 Expert 与 Standard 两种,MacExpert 只在字体字典声明时经 getEncoding 取用,减少导出面可以让依赖关系更清楚。

消费方:默认编码、内置编码与 ToUnicode

evaluator.js 把编码名变成属性,真正的选择留到字形映射阶段:

    if (baseEncodingName) {
      properties.defaultEncoding = getEncoding(baseEncodingName);
    } else {
      let isSymbolicFont = !!(properties.flags & FontFlags.Symbolic);
      const isNonsymbolicFont = !!(properties.flags & FontFlags.Nonsymbolic);
      // ...
      // According to "Table 114" in section "9.6.6.1 General" (under
      // "9.6.6 Character Encoding") of the PDF specification, a Nonsymbolic
      // font should use the `StandardEncoding` if no encoding is specified.
      encoding = StandardEncoding;

这段代码对应规范里的一条默认规则:非符号字体没有 /Encoding 时使用 StandardEncoding。properties.defaultEncoding 只是「候选基准」,后面 type1FontGlyphMapping 还会结合 /Differences 与字体程序内置编码再决定最终映射。

fonts.js 则把编码表转成 Unicode 表,供文本提取使用:

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

同一个 encoding 数组,在 type1FontGlyphMapping 里被拿去 glyphNames.indexOf,在这里被拿去查 glyphsUnicodeMap。两个消费者共享数据,却不共享结果:前者得到 glyph id,后者得到 Unicode。

CFF 的预定义 charset 也直接使用这两张表,但方向相反:

if (pos === 0 || pos === 1) {
  predefined = true;
  format = pos;
  const baseEncoding = pos ? ExpertEncoding : StandardEncoding;
  for (i = 0, ii = charset.length; i < ii; i++) {
    const index = baseEncoding.indexOf(charset[i]);
    if (index !== -1) {
      encoding[index] = i;
    }
  }
}

CFF 的 charset 是「按 glyph id 排列的名字数组」,这里拿 indexOf 反查码位,再把该码位映射到当前 glyph id。也就是说,同一张表既能正着用(码位取名字),也能反着用(名字找码位)。

文本提取与字形定位是两条平行链路,不要混成一条优先级:

维度主要输入输出缺失时的回退
字形定位/Encoding、/Differences、字体内置编码glyph idStandardEncoding 或 .notdef
文本提取/ToUnicode CMapUnicode编码表加 glyphlist 的字形名反查

/ToUnicode 存在时,文本提取直接用它,encodings.js 完全不参与;只有没有 /ToUnicode 时,才用编码表把码位变成字形名,再查 glyphlist 得到 Unicode。这就是为什么删掉 /ToUnicode 不会影响显示,却会让复制出的文字变乱。

设计要点:编码表刻意不包含「code 到 Unicode」的对应关系。Unicode 是可变的解释结果,同一个字形在不同字体的 ToUnicode 里可能给出不同码位;字形名是稳定的中间层。把这一层单独抽出来,使 ToUnicode 可以覆盖它,而不用修改任何一张预定义表。

总结

encodings.js 是字体层的第一跳翻译表:字节先变成字形名,之后才可能变成 glyph id 或 Unicode。它不解析字体程序,也不参与绘制,却决定了「同一个字节到底该长什么样」。

它在整条链路中的位置

阶段模块输入输出
读字典evaluator.js/Encoding、/Differences基准编码名与差异数组
取表encodings.js编码名256 项编码表
建映射fonts.js、type1_font.js、cff_font.js编码表、字形名数组字符码到 glyph id
文本fonts.js 的 buildToFontChar编码表、glyphlist字符码到 Unicode
绘制font_renderer.jsglyph id字形轮廓

用一句话串起来:evaluator 选出编码名,encodings.js 把名字变成表,fonts.js 再把表变成 glyph id 或 Unicode,绘制层只认 glyph id。

五条设计原则

  1. 数据与逻辑分离:301 行里绝大部分是数组字面量,唯一的函数是 getEncoding。表可以逐字节对照规范检查,逻辑只有一个 switch,没有隐藏分支。
  2. 下标就是码位:不建 Map,不做字符串键查找,table[charCode] 一次寻址完成。单字节编码的取值范围固定,数组是最贴合的容器。
  3. 空串是合法占位:未定义码位写 "",数组长度始终 256。调用方用 !== "" 判断有无名字,不需要额外处理越界。
  4. 名字是唯一外部契约:getEncoding 只认七个精确名字,未知输入返回 null。拼错编码名不会悄悄命中近似表,而是让上层明确走默认回退。
  5. 一层只做一件事:这里不解析 CMap,不做 Unicode 映射,也不决定 /Differences 的优先级。编码表只回答「这个字节对应哪个字形名」,其余判断交给 fonts_utils.js 与 fonts.js。

理解了 encodings.js,再看 fonts_utils.js 的 type1FontGlyphMapping,就能把「基准编码 + /Differences + 内置编码」三条来源串起来;再往上看 fonts.js 的 buildToFontChar,也能明白为什么同一个码位在不同字体里会得到不同的 Unicode。下一篇可以继续往字体格式内部走,看 Type1 的 charstring 是如何被解释成轮廓的。

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

相关文章