StreamPDFStreamPDF
返回博客

PDF.js 源码解读: cmap

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: cmap

前言

前面三十二篇已经把「文档怎么打开、页面怎么求值」这条主线铺完了:xref 负责对象寻址,parser 负责字节到对象的词法与语法,document 负责页面模型,evaluator 负责内容流到 operatorList,stream 与各类解码器负责字节流,primitives 负责基础对象,catalog 负责文档级语义。到了内容求值这一层,还剩一个绕不开的问题:内容流里的字符串,到底是哪些字形?

对简单字体,字符码可以直接查 /Encoding 得到字形名;对复合字体,字符码要先经过一层 CMap 映射成 CID,再用 CID 去取字形宽度与轮廓。对文本提取,字符码还要经过 ToUnicode CMap 变成 Unicode。src/core/cmap.js 就是这一层的公共底座:它定义 CMap 数据结构、文本 CMap 的解析器、内建 CMap 的加载路径,以及统一的入口 CMapFactory。

用一句话概括:cmap.js 把「字符码」翻译成「CID 或 Unicode」,并把这份映射整理成渲染层与文本层都能直接下标访问的形状。

模块总览

cmap.js 共 724 行,主体可以切成八块:

区块关键成员或常量职责
内建清单BUILT_IN_CMAPS、MAX_MAP_RANGE列出可加载的内建 CMap,限制单次范围展开规模
核心容器CMap、codespaceRanges、numCodespaceRanges、_map保存码空间范围与「源码到目标值」的映射
写入接口addCodespaceRange、mapCidRange、mapBfRange、mapBfRangeToArray、mapOne把解析出的范围或单点写进映射表
查询接口lookup、contains、forEach、charCodeOf、readCharCode、getCharCodeLength、length、isIdentityCMap渲染层读取 CID 或 Unicode,文本层反查字符码
恒等映射IdentityCMap用隐式恒等关系替代 65536 项实体数组
文本解析strToInt、expectString、expectInt、parseBfChar、parseBfRange、parseCidChar、parseCidRange、parseCodespaceRange把 CMap 流里的操作符与十六进制串变成写入调用
继承链parseCMap、extendCMap、createBuiltInCMap处理 usecmap,合并父映射,加载内建映射
工厂CMapFactory.create根据 /Encoding 是名字还是流,选择正确的加载路径

它在整条链路里的位置可以画成这样:

// 字体字典 /Encoding、/ToUnicode
//   → CMapFactory.create
//   → createBuiltInCMap(name)          内建名称 → .bcmap → BinaryCMapReader
//   → parseCMap(lexer)                 嵌入流 → Lexer → 文本操作符
//   → CMap.codespaceRanges / _map
//   → evaluator: 复合字体取 CID,ToUnicode 取 Unicode
//   → font / text layer

版本说明:本篇严格按当前仓库这一版 cmap.js 讲解。本版 CMap 没有 adjustMapping、reverseMap、getCharCode 这三个方法,也没有把 codespace 范围做成二分查找;它提供的是 lookup、contains、charCodeOf、readCharCode、getCharCodeLength,_map 是普通数组而不是 Map。CMapFactory.create 接收一个解构对象 { encoding, fetchBuiltInCMap, useCMap },而不是位置参数。读源码时不要套用记忆里的其它版本 API。

两种 CMap:CID 映射与 ToUnicode 映射

PDF 规范里的 CMap 是一个「字符码到目标值」的映射表。目标值有两种形态,正好对应两条完全不同的使用链路:

  • CID CMap:目标值是整数 CID。复合字体的 /Encoding 指向它,字体层拿到 CID 后再去 /CIDToGIDMap、宽度表与轮廓里取字形。
  • ToUnicode CMap:目标值是字符串,通常是 UTF-16BE 的一个或多个码元。文本提取与复制粘贴依赖它,把字符码还原成 Unicode。

两者在文件里的写法不同,但在 CMap 里的表示是同一套:_map 的下标是源字符码,值要么是整数 CID,要么是「每个字符一字节」的字符串。构造函数把这一点写在注释里:

class CMap {
  constructor(builtInCMap = false) {
    // Codespace ranges are stored as follows:
    // [[1BytePairs], [2BytePairs], [3BytePairs], [4BytePairs]]
    // where nBytePairs are ranges e.g. [low1, high1, low2, high2, ...]
    this.codespaceRanges = [[], [], [], []];
    this.numCodespaceRanges = 0;
    // Map entries have one of two forms.
    // - cid chars are 16-bit unsigned integers, stored as integers.
    // - bf chars are variable-length byte sequences, stored as strings, with
    //   one byte per character.
    this._map = [];
    this.name = "";
    this.vertical = false;
    this.useCMap = null;
    this.builtInCMap = builtInCMap;
  }

几个字段的语义要先钉死:

字段结构含义
codespaceRanges四个数组,按码长 1 到 4 分桶每个桶是扁平的「低值, 高值, 低值, 高值」序列
numCodespaceRanges整数已登记的范围数量,extendCMap 用它判断是否需要继承父桶
_map稀疏数组下标是源字符码,值是 CID 整数或 UTF-16BE 字节串
name字符串CMapName,isIdentityCMap 与工厂回退都依赖它
vertical布尔值WMode 为 1 时表示竖排
useCMapCMap 或 null父映射,仅用于记录继承关系
builtInCMap布尔值是否来自内建 .bcmap

设计要点:CID CMap 与 ToUnicode CMap 共用一个类,不是偷懒,而是因为它们的索引方式完全一致:都是「源字符码 → 目标值」的稀疏表,都需要同一套 codespace 判定来决定一个字符码占几个字节。差异只在于目标值的类型,以及下游谁来消费。把差异留在调用方,CMap 本身就能同时服务字体层与文本层。

两者的差异可以集中成一张表:

维度CID CMapToUnicode CMap
入口复合字体的 /Encoding字体的 /ToUnicode
源键字符码,1 到 4 字节字符码,通常 1 到 2 字节
目标值整数 CID字符串,按字节存放 UTF-16BE 码元
典型操作符begincidchar、begincidrangebeginbfchar、beginbfrange
消费方properties.cMap,字体层按 CID 取字形readToUnicode,转成 ToUnicodeMap
内建形态Identity-H、UniGB-UCS2-H 等多数随文件嵌入,也可能指向内建名称
文本层用途不直接产出 Unicode,只决定字形编号直接产出可复制、可搜索的 Unicode

数据结构:codespace 与稀疏映射

addCodespaceRange:按码长分桶

  addCodespaceRange(n, low, high) {
    this.codespaceRanges[n - 1].push(low, high);
    this.numCodespaceRanges++;
  }

第一个参数 n 是码长,不是桶下标。源码用 n - 1 找到对应的桶,再把 low 与 high 连续推进去。于是:

n桶典型来源
1第一组单字节编码,如 Roman、WinAnsi 类 CMap
2第二组双字节编码,如 GB-EUC-H、UniJIS-UCS2-H
3第三组三字节编码,较少见
4第四组四字节编码,如 UTF-32 变体

桶里存的是扁平数组,而不是对象数组。这样匹配时只需要一个步长为 2 的循环,不需要为每个范围创建临时对象;对一份内建 CMap 里成百上千个范围来说,这个差别很实在。

_map 为什么是稀疏数组

_map 的下标就是源字符码。PDF 里常见的双字节码最大到 0xFFFF,所以数组长度通常不超过 65536;但规范允许四字节码,损坏文件甚至可能出现接近 0xFFFFFFFF 的码值。源码选择普通数组而不是 Map,原因有两个:

  1. 绝大多数查询是直接下标。lookup(code) 就是 this._map[code],V8 对整数下标的数组访问非常快。
  2. 稀疏数组的代价可控。真正巨大的码值只会留下空洞,不会为每个位置分配内存;遍历时再用 for..in 绕开空洞。

代价是「反查」与「遍历」必须自己处理稀疏性,后面的 charCodeOf 与 forEach 就是为此写的。

codespace:一个字符码占几个字节

PDF 内容流里的字符串没有自带长度信息。Tj 收到一串字节后,必须知道「第一个字符码是 1 字节还是 2 字节」,否则整个字符串都会错位。回答这个问题的就是 codespace。

  readCharCode(str, offset, out) {
    let c = 0;
    const codespaceRanges = this.codespaceRanges;
    // 9.7.6.2 CMap Mapping
    // The code length is at most 4.
    for (let n = 0, nn = codespaceRanges.length; n < nn; n++) {
      c = ((c << 8) | str.charCodeAt(offset + n)) >>> 0;
      // Check each codespace range to see if it falls within.
      const codespaceRange = codespaceRanges[n];
      for (let k = 0, kk = codespaceRange.length; k < kk;) {
        const low = codespaceRange[k++];
        const high = codespaceRange[k++];
        if (c >= low && c <= high) {
          out.charcode = c;
          out.length = n + 1;
          return;
        }
      }
    }
    out.charcode = 0;
    out.length = 1;
  }

这段循环有三个关键点:

  1. 按字节逐步累积。第一轮只读 1 个字节,第二轮把前两个字节拼成 16 位,第三轮拼成 24 位,第四轮拼成 32 位。>>> 0 保证结果是无符号整数,避免最高位被解释成负数。
  2. 每累积一轮,只检查对应码长的桶。1 字节候选只与单字节范围比较,2 字节候选只与双字节范围比较,不会出现「两字节码误匹配单字节范围」。
  3. 桶内线性扫描,先命中先返回。源码没有排序,也没有二分;每个桶就是一个扁平数组,从头到尾按 low、high 成对比较。对典型 CMap 来说,一个桶里的范围数量有限,线性扫描比维护排序结构更简单。

getCharCodeLength 是同一套逻辑的「只问长度」版本:

  getCharCodeLength(charCode) {
    const codespaceRanges = this.codespaceRanges;
    for (let n = 0, nn = codespaceRanges.length; n < nn; n++) {
      // Check each codespace range to see if it falls within.
      const codespaceRange = codespaceRanges[n];
      for (let k = 0, kk = codespaceRange.length; k < kk;) {
        const low = codespaceRange[k++];
        const high = codespaceRange[k++];
        if (charCode >= low && charCode <= high) {
          return n + 1;
        }
      }
    }
    return 1;
  }

它接收的是已经拼好的字符码,而不是字符串与偏移。两者都返回「第几个桶命中」,也就是码长。找不到时统一回退为 1 字节,这是一个容错选择:坏 CMap 缺少 codespace 时,至少还能按单字节继续解析,而不是直接抛错让整页失败。

设计要点:codespace 判定放在 CMap 上,而不是放在字体或内容流解析器里,是因为它描述的是编码本身的字节结构。同一个字符码在不同 CMap 下可能是 1 字节,也可能是 2 字节;字体层只需要问 CMap,不需要自己维护一套「这个编码是单字节还是双字节」的分支。这也是 PDF.js 能把 Identity-H、GBK-EUC-H、UniJIS-UCS2-H 统一处理的原因。

写入:单点与范围如何展开

mapOne

  // This is used for both bf and cid chars.
  mapOne(src, dst) {
    this._map[src] = dst;
  }

最小的写入接口。src 是字符码,dst 可以是 CID 整数,也可以是 UTF-16BE 字符串。单点写入不做任何范围展开,也不检查目标类型。

mapCidRange

  mapCidRange(low, high, dstLow) {
    if (high - low > MAX_MAP_RANGE) {
      throw new Error("mapCidRange - ignoring data above MAX_MAP_RANGE.");
    }
    while (low <= high) {
      this._map[low++] = dstLow++;
    }
  }

CID 范围是等差映射:源码从 low 到 high,CID 从 dstLow 开始逐项加一。源码用 while 直接把每一项写进数组,而不是保留「低值、高值、起始 CID」三元组留到查询时再算。这样查询是 O(1),代价是解析时就要把范围展开成实体项。

MAX_MAP_RANGE 是 2 的 24 次方减 1:

// Heuristic to avoid hanging the worker-thread for CMap data with ridiculously
// large ranges, such as e.g. 0xFFFFFFFF (fixes issue11922_reduced.pdf).
const MAX_MAP_RANGE = 2 ** 24 - 1; // = 0xFFFFFF

一份坏文件可能写一个从 0 到 0xFFFFFFFF 的 CID 范围。如果照单展开,worker 线程会在这一个循环里卡死,甚至耗尽内存。源码选择在展开前检查跨度,超过上限直接抛错;parseCMap 会捕获这个错误并 warn,然后继续解析后面的记录。这是「单条坏记录不拖垮整份 CMap」的典型做法。

mapBfRange

  mapBfRange(low, high, dstLow) {
    if (high - low > MAX_MAP_RANGE) {
      throw new Error("mapBfRange - ignoring data above MAX_MAP_RANGE.");
    }
    const lastByte = dstLow.length - 1;
    while (low <= high) {
      this._map[low++] = dstLow;
      // Only the last byte has to be incremented (in the normal case).
      const nextCharCode = dstLow.charCodeAt(lastByte) + 1;
      if (nextCharCode > 0xff) {
        dstLow =
          dstLow.substring(0, lastByte - 1) +
          String.fromCharCode(dstLow.charCodeAt(lastByte - 1) + 1) +
          "\x00";
        continue;
      }
      dstLow =
        dstLow.substring(0, lastByte) + String.fromCharCode(nextCharCode);
    }
  }

bf 范围的目标是字符串,增量规则比 CID 多一层:只递增最后一个字节。例如目标串是 "\x00A",范围展开时依次得到 "\x00A"、"\x00B"、"\x00C"。如果最后一个字节从 0xFF 溢出,就把倒数第二个字节加一,末字节归零。源码用字符串切片与 String.fromCharCode 完成这个进位,没有把字符串转成数字再转回来。

这和 PDF 规范里 bfrange 的「目标字符串按大端字节序递增」一致。注意它只处理最后一个字节的进位,没有做任意长度的通用大整数加法;这是规范常见形态的简化实现,长度更长的目标串通常由 beginbfchar 或数组形式表达。

mapBfRangeToArray

  mapBfRangeToArray(low, high, array) {
    if (high - low > MAX_MAP_RANGE) {
      throw new Error("mapBfRangeToArray - ignoring data above MAX_MAP_RANGE.");
    }
    const ii = array.length;
    let i = 0;
    while (low <= high && i < ii) {
      this._map[low] = array[i++];
      ++low;
    }
  }

这是 beginbfrange 的数组分支:目标不是单个起始串,而是一整个数组,源码与数组元素一一对应。循环同时受 high 与数组长度约束,数组短了不会越界,数组长了不会继续写。规范要求数组长度与范围长度一致,这里选择「取较短的那个」,属于容错而非严格校验。

三种范围写入可以并排比较:

方法目标值类型递增方式典型操作符
mapCidRange整数 CID每个源码的目标值加一begincidrange
mapBfRangeUTF-16BE 字符串只递增最后一个字节,溢出则进位beginbfrange
mapBfRangeToArray字符串数组按数组下标逐项取,不计算递增beginbfrange 的数组形态
mapOne整数或字符串不展开,单点写入begincidchar、beginbfchar

设计要点:范围在解析时就展开成逐项数组,而不是保留成区间树。这样 lookup 是一次下标访问,内容流里每个字符都不需要再做区间查找。代价是内存占用随范围跨度增长,所以源码用 MAX_MAP_RANGE 给展开设了硬上限。这是典型的「用一次解析成本换每次查询成本」:一份 CMap 会被同一页的每个字符复用,展开一次是划算的。

查询:lookup、contains 与反查

lookup 与 contains

  lookup(code) {
    return this._map[code];
  }
 
  contains(code) {
    return this._map[code] !== undefined;
  }

lookup 不做类型检查,也不做范围检查,直接返回数组下标的结果。找不到时返回 undefined,调用方通常用 if (cid) 或 !== undefined 判断。contains 只是把「是否为 undefined」包成一个语义更清楚的查询,extendCMap 合并父映射时用的就是它。

forEach

  forEach(callback) {
    // Most maps have fewer than 65536 entries, and for those we use normal
    // array iteration. But really sparse tables are possible -- e.g. with
    // indices in the *billions*. For such tables we use for..in, which isn't
    // ideal because it stringifies the indices for all present elements, but
    // it does avoid iterating over every undefined entry.
    const map = this._map;
    const length = map.length;
    if (length <= 0x10000) {
      for (let i = 0; i < length; i++) {
        if (map[i] !== undefined) {
          callback(i, map[i]);
        }
      }
    } else {
      for (const i in map) {
        callback(i, map[i]);
      }
    }
  }

这里有一个明确的分界:长度不超过 0x10000 时用普通 for 循环,超过时用 for..in。65536 是双字节码的完整空间,绝大多数 CMap 都在这个范围内;超过它通常意味着出现了三字节或四字节码,数组可能非常稀疏。普通 for 会遍历每一个空洞,for..in 只访问已赋值的下标,但代价是把下标字符串化。源码选择了后者,因为「遍历几十亿个空洞」比「字符串化少量真实项」更糟。

注意 for..in 分支传给回调的下标是字符串,而不是数字。readToUnicode 里因此专门判断 typeof token === "number",处理只含 CID 的 CMap。

charCodeOf

  charCodeOf(value) {
    // `Array.prototype.indexOf` is *extremely* inefficient for arrays which
    // are both very sparse and very large (see issue8372.pdf).
    const map = this._map;
    if (map.length <= 0x10000) {
      return map.indexOf(value);
    }
    for (const charCode in map) {
      if (map[charCode] === value) {
        return charCode | 0;
      }
    }
    return -1;
  }

这是反查:已知 CID 或 Unicode,问「哪个字符码映射到它」。小数组直接用 indexOf;大稀疏数组用 for..in 线性扫描。返回值用 charCode | 0 转回数字,因为 for..in 的键是字符串。找不到时返回 -1,而不是 undefined,方便调用方做数值判断。

charCodeOf 是线性复杂度。它没有维护反向索引,因为反查的使用频率远低于正向 lookup,为它再建一张表不划算。这是一个很典型的取舍:正向路径必须快,反向路径正确即可。

length 与 isIdentityCMap

  get length() {
    return this._map.length;
  }
 
  get isIdentityCMap() {
    if (!(this.name === "Identity-H" || this.name === "Identity-V")) {
      return false;
    }
    if (this._map.length !== 0x10000) {
      return false;
    }
    for (let i = 0; i < 0x10000; i++) {
      if (this._map[i] !== i) {
        return false;
      }
    }
    return true;
  }

length 返回数组长度,不等于「有效映射项数量」;稀疏数组可能有很大的 length 但只有少量项。isIdentityCMap 只在名字是 Identity-H 或 Identity-V 时才继续检查,并且要求数组长度正好是 65536、每一项都等于自身下标。它被 CMapFactory.create 用来判断「嵌入流解析出来其实就是一个恒等映射」,从而替换成更省内存的 IdentityCMap。

IdentityCMap:隐式恒等,不建实体数组

Identity-H 与 Identity-V 是 PDF 里最常见的编码:字符码就是 CID,不需要查表。如果照普通 CMap 展开,_map 会有 65536 项,每项都等于自身下标,纯属浪费。IdentityCMap 用子类覆盖查询方法,把这份关系变成隐式的:

// A special case of CMap, where the _map array implicitly has a length of
// 65536 and each element is equal to its index.
class IdentityCMap extends CMap {
  constructor(vertical, n) {
    super();
 
    this.vertical = vertical;
    this.addCodespaceRange(n, 0, 0xffff);
  }

构造时只登记一个 codespace 范围:码长 n(Identity-H 传入 2)、从 0 到 0xFFFF。所有会写 _map 的方法都被改成 unreachable:

  mapCidRange(low, high, dstLow) {
    unreachable("should not call mapCidRange");
  }
 
  mapBfRange(low, high, dstLow) {
    unreachable("should not call mapBfRange");
  }
 
  mapBfRangeToArray(low, high, array) {
    unreachable("should not call mapBfRangeToArray");
  }
 
  mapOne(src, dst) {
    unreachable("should not call mapCidOne");
  }

查询侧则直接返回源码本身:

  lookup(code) {
    return Number.isInteger(code) && code <= 0xffff ? code : undefined;
  }
 
  contains(code) {
    return Number.isInteger(code) && code <= 0xffff;
  }
 
  charCodeOf(value) {
    return Number.isInteger(value) && value <= 0xffff ? value : -1;
  }

forEach 按 0 到 0xFFFF 逐个回调,length 固定返回 0x10000。getMap 则按需生成实体数组:

  getMap() {
    // Sometimes identity maps must be instantiated, but it's rare.
    const map = new Array(0x10000);
    for (let i = 0; i <= 0xffff; i++) {
      map[i] = i;
    }
    return map;
  }

只有调用方明确需要一份普通数组时才生成,这也是注释里说的「很罕见」。isIdentityCMap getter 在子类里是 unreachable,因为类型本身已经说明了身份,不需要再扫描一遍。

形态构造方式lookup 成本内存
普通 CMap 解析出的恒等映射65536 次 mapOne一次数组下标65536 项
IdentityCMap只登记一个 codespace 范围两次整数比较常量级
IdentityCMap.getMap()显式要求实体数组时才生成生成后同上临时 65536 项

设计要点:IdentityCMap 把「映射关系」从数据变成了类型。普通 CMap 需要 65536 项来证明自己是恒等映射,IdentityCMap 只需要一个类名和一次 instanceof 判断。CMapFactory 在嵌入流解析完成后用 isIdentityCMap 做一次识别,就能把「碰巧长得像恒等映射」的普通对象替换成这个轻量类型;后续所有查询都走隐式路径,不再碰数组。

文本 CMap 的解析

嵌入流形式的 CMap 是纯文本,语法与 PDF 对象类似,但由一组专用操作符驱动。解析分成两层:外层 parseCMap 扫描操作符,内层各 parseXxx 读取对应块,直到遇到 end... 命令。

十六进制串与类型校验

CMap 里的字符码写成十六进制字符串,例如 <8140>。strToInt 把它按大端拼成整数:

function strToInt(str) {
  let a = 0;
  for (let i = 0; i < str.length; i++) {
    a = (a << 8) | str.charCodeAt(i);
  }
  return a >>> 0;
}

它依赖 Lexer 已经把十六进制串解码成「每个字符一字节」的字符串。a << 8 每轮左移 8 位,最多支持 4 字节;>>> 0 保证四字节码不会变成负数。

两个校验函数把「坏 CMap」的失败点集中起来:

function expectString(obj) {
  if (typeof obj !== 'string') {
    throw new FormatError('Malformed CMap: expected string.');
  }
}
 
function expectInt(obj) {
  if (!Number.isInteger(obj)) {
    throw new FormatError('Malformed CMap: expected int.');
  }
}

它们不返回布尔值,而是直接抛 FormatError。parseCMap 外层的 catch 会捕获它并 warn,然后继续读下一条记录。这样单个坏 token 不会终止整份 CMap。

bf 块:beginbfchar 与 beginbfrange

parseBfChar 处理「源码 → 目标串」的单点映射:

function parseBfChar(cMap, lexer) {
  while (true) {
    let obj = lexer.getObj();
    if (obj === EOF) {
      break;
    }
    if (isCmd(obj, 'endbfchar')) {
      return;
    }
    expectString(obj);
    const src = strToInt(obj);
    obj = lexer.getObj();
    // TODO are /dstName used?
    expectString(obj);
    const dst = obj;
    cMap.mapOne(src, dst);
  }
}

每轮读两个字符串:第一个是源码,第二个是目标串。源码注释里还留着「/dstName 是否被使用」的疑问,说明规范里的可选目标名字段在这里被忽略。遇到 endbfchar 正常返回;遇到 EOF 也返回,不抛错,这是对截断文件的容错。

parseBfRange 多了一个数组分支:

function parseBfRange(cMap, lexer) {
  while (true) {
    let obj = lexer.getObj();
    if (obj === EOF) {
      break;
    }
    if (isCmd(obj, 'endbfrange')) {
      return;
    }
    expectString(obj);
    const low = strToInt(obj);
    obj = lexer.getObj();
    expectString(obj);
    const high = strToInt(obj);
    obj = lexer.getObj();
    if (Number.isInteger(obj) || typeof obj === 'string') {
      const dstLow = Number.isInteger(obj) ? String.fromCharCode(obj) : obj;
      cMap.mapBfRange(low, high, dstLow);
    } else if (isCmd(obj, '[')) {
      obj = lexer.getObj();
      const array = [];
      while (!isCmd(obj, ']') && obj !== EOF) {
        array.push(obj);
        obj = lexer.getObj();
      }
      cMap.mapBfRangeToArray(low, high, array);
    } else {
      break;
    }
  }
  throw new FormatError('Invalid bf range.');
}

目标值允许三种形态:整数、字符串、数组。整数会被 String.fromCharCode 转成单字符字符串,再交给 mapBfRange;数组则逐项收集后交给 mapBfRangeToArray。如果第三项既不是整数/字符串,也不是左方括号,循环 break 后统一抛 Invalid bf range。

cid 块:begincidchar 与 begincidrange

CID 块的解析与 bf 块同构,只是目标值必须是整数 CID:

function parseCidChar(cMap, lexer) {
  while (true) {
    let obj = lexer.getObj();
    if (obj === EOF) {
      break;
    }
    if (isCmd(obj, 'endcidchar')) {
      return;
    }
    expectString(obj);
    const src = strToInt(obj);
    obj = lexer.getObj();
    expectInt(obj);
    const dst = obj;
    cMap.mapOne(src, dst);
  }
}

parseCidRange 则读「低值、高值、起始 CID」三个值,然后调用 mapCidRange:

function parseCidRange(cMap, lexer) {
  while (true) {
    let obj = lexer.getObj();
    if (obj === EOF) {
      break;
    }
    if (isCmd(obj, 'endcidrange')) {
      return;
    }
    expectString(obj);
    const low = strToInt(obj);
    obj = lexer.getObj();
    expectString(obj);
    const high = strToInt(obj);
    obj = lexer.getObj();
    expectInt(obj);
    const dstLow = obj;
    cMap.mapCidRange(low, high, dstLow);
  }
}

CID 块没有数组分支,因为 CID 目标只能是整数。遇到非整数目标时 expectInt 直接抛错,外层 parseCMap 捕获后继续。

codespace 块

function parseCodespaceRange(cMap, lexer) {
  while (true) {
    let obj = lexer.getObj();
    if (obj === EOF) {
      break;
    }
    if (isCmd(obj, 'endcodespacerange')) {
      return;
    }
    if (typeof obj !== 'string') {
      break;
    }
    const low = strToInt(obj);
    obj = lexer.getObj();
    if (typeof obj !== 'string') {
      break;
    }
    const high = strToInt(obj);
    cMap.addCodespaceRange(obj.length, low, high);
  }
  throw new FormatError('Invalid codespace range.');
}

码长取自高值的字符串长度,而不是低值。正常情况下两者长度一致;如果写入器把低值省略前导零、高值写全,取高值能拿到更可靠的长度。obj.length 的单位是字节,因为 Lexer 把十六进制串解码成了「一字节一字符」的字符串。

WMode 与 CMapName

function parseWMode(cMap, lexer) {
  const obj = lexer.getObj();
  if (Number.isInteger(obj)) {
    cMap.vertical = !!obj;
  }
}
 
function parseCMapName(cMap, lexer) {
  const obj = lexer.getObj();
  if (obj instanceof Name) {
    cMap.name = obj.name;
  }
}

这两个解析器都只读一个值。WMode 不是整数时静默忽略,CMapName 不是 Name 时也静默忽略。它们不抛错,因为这两个字段只影响排版方向与后续识别,不影响码值映射本身。

外层循环:parseCMap

async function parseCMap(cMap, lexer, fetchBuiltInCMap, useCMap) {
  let previous, embeddedUseCMap;
  objLoop: while (true) {
    try {
      const obj = lexer.getObj();
      if (obj === EOF) {
        break;
      } else if (obj instanceof Name) {
        if (obj.name === 'WMode') {
          parseWMode(cMap, lexer);
        } else if (obj.name === 'CMapName') {
          parseCMapName(cMap, lexer);
        }
        previous = obj;
      } else if (obj instanceof Cmd) {
        switch (obj.cmd) {
          case 'endcmap':
            break objLoop;
          case 'usecmap':
            if (previous instanceof Name) {
              embeddedUseCMap = previous.name;
            }
            break;
          case 'begincodespacerange':
            parseCodespaceRange(cMap, lexer);
            break;
          case 'beginbfchar':
            parseBfChar(cMap, lexer);
            break;
          case 'begincidchar':
            parseCidChar(cMap, lexer);
            break;
          case 'beginbfrange':
            parseBfRange(cMap, lexer);
            break;
          case 'begincidrange':
            parseCidRange(cMap, lexer);
            break;
        }
      }
    } catch (ex) {
      if (ex instanceof MissingDataException) {
        throw ex;
      }
      warn('Invalid cMap data: ' + ex);
      continue;
    }
  }
 
  if (!useCMap && embeddedUseCMap) {
    // Load the useCMap definition from the file only if there wasn't one
    // specified.
    useCMap = embeddedUseCMap;
  }
  if (useCMap) {
    return extendCMap(cMap, fetchBuiltInCMap, useCMap);
  }
  return cMap;
}

这段循环是整份文件的调度中心,几个细节值得单独说明:

  1. previous 记录上一个 Name。CMap 里的 usecmap 写成「/Identity-H usecmap」,usecmap 是命令,名字在它前面。所以解析到 usecmap 时,源码取的是 previous,而不是再读一个 token。
  2. WMode 与 CMapName 是前缀记号。它们本身是 Name,后面紧跟一个值;源码在遇到 Name 时就地调用对应的解析器,把值读掉,避免主循环把值误当成新命令。
  3. endcmap 用标签跳出。break objLoop 直接结束外层循环,而不是依赖 EOF。很多 CMap 在 endcmap 后面还有空白或注释,读到 EOF 也能结束,但标签更明确。
  4. 异常分类。MissingDataException 必须原样上抛,因为它是「字节还没到」,不是「数据坏了」;其它异常只 warn 并 continue,让下一条记录继续解析。

各操作符与解析器的对应关系可以整理成表:

操作符解析器写入方法
begincodespacerangeparseCodespaceRangeaddCodespaceRange
begincidcharparseCidCharmapOne,目标为整数
begincidrangeparseCidRangemapCidRange
beginbfcharparseBfCharmapOne,目标为字符串
beginbfrangeparseBfRangemapBfRange 或 mapBfRangeToArray
usecmap主循环直接处理记录 embeddedUseCMap,最后走 extendCMap
WModeparseWMode设置 vertical
CMapNameparseCMapName设置 name

useCMap:父映射如何合并

PDF 允许一份 CMap 只写增量,其余部分继承另一份 CMap:

if (!useCMap && embeddedUseCMap) {
  // Load the useCMap definition from the file only if there wasn't one
  // specified.
  useCMap = embeddedUseCMap;
}
if (useCMap) {
  return extendCMap(cMap, fetchBuiltInCMap, useCMap);
}

优先级是「调用方显式传入的 useCMap 高于流里写的 embeddedUseCMap」。这对应 CMapFactory.create 的场景:调用方已经知道父映射是谁时,不需要再解析流里的声明。

async function extendCMap(cMap, fetchBuiltInCMap, useCMap) {
  cMap.useCMap = await createBuiltInCMap(useCMap, fetchBuiltInCMap);
  // If there aren't any code space ranges defined clone all the parent ones
  // into this cMap.
  if (cMap.numCodespaceRanges === 0) {
    const useCodespaceRanges = cMap.useCMap.codespaceRanges;
    for (let i = 0; i < useCodespaceRanges.length; i++) {
      cMap.codespaceRanges[i] = useCodespaceRanges[i].slice();
    }
    cMap.numCodespaceRanges = cMap.useCMap.numCodespaceRanges;
  }
  // Merge the map into the current one, making sure not to override
  // any previously defined entries.
  cMap.useCMap.forEach(function (key, value) {
    if (!cMap.contains(key)) {
      cMap.mapOne(key, value);
    }
  });
 
  return cMap;
}

合并规则有三条:

  1. 父映射只按名字加载。createBuiltInCMap 只接受内建名称;usecmap 指向的必须是内建 CMap,不能是另一个嵌入流。这是规范里的常见用法,也把父映射的加载范围限制在内建集合内,避免嵌入流之间出现任意链式引用。
  2. codespace 只在子映射完全没有定义时继承。numCodespaceRanges === 0 才克隆父桶;只要子映射写了至少一个范围,就完全用自己的。这避免了父子范围叠加导致码长判定歧义。
  3. 子映射优先。父映射的每一项只在子映射没有该键时才写入。contains 判断的是 _map[code] !== undefined,所以子映射显式写过的键不会被覆盖。

createBuiltInCMap 是这条链的终点:

async function createBuiltInCMap(name, fetchBuiltInCMap) {
  if (name === 'Identity-H') {
    return new IdentityCMap(false, 2);
  } else if (name === 'Identity-V') {
    return new IdentityCMap(true, 2);
  }
  if (!BUILT_IN_CMAPS.includes(name)) {
    throw new Error('Unknown CMap name: ' + name);
  }
  if (!fetchBuiltInCMap) {
    throw new Error('Built-in CMap parameters are not provided.');
  }
 
  const { cMapData, isCompressed } = await fetchBuiltInCMap(name);
  const cMap = new CMap(true);
 
  if (isCompressed) {
    return new BinaryCMapReader().process(cMapData, cMap, useCMap =>
      extendCMap(cMap, fetchBuiltInCMap, useCMap),
    );
  }
  const lexer = new Lexer(new Stream(cMapData));
  return parseCMap(cMap, lexer, fetchBuiltInCMap, null);
}

三个分支对应三种内建形态:

名称走的分支结果
Identity-H、Identity-V直接构造 IdentityCMap不读任何外部数据
BUILT_IN_CMAPS 中的名字fetchBuiltInCMap 后按压缩标志分流.bcmap 走 BinaryCMapReader,否则走文本解析
其它名字抛 Unknown CMap name明确失败,不静默回退

BUILT_IN_CMAPS 是一个静态字符串数组,包含 Adobe 的四个 UCS2 映射、各种 EUC/RKSJ/GBK/B5 编码,以及大量 UniGB-UTF16-H、UniJIS-UCS2-H 之类的名字。它只负责「名字白名单」,不携带任何映射数据。真正的数据由 fetchBuiltInCMap 从 .bcmap 或 .cmap 资源里取回,路径由上层配置决定。

CMapFactory:统一的入口

class CMapFactory {
  static async create({ encoding, fetchBuiltInCMap, useCMap }) {
    if (encoding instanceof Name) {
      return createBuiltInCMap(encoding.name, fetchBuiltInCMap);
    } else if (encoding instanceof BaseStream) {
      if (encoding.isAsync) {
        const bytes = await encoding.asyncGetBytes();
        if (bytes) {
          encoding = new Stream(bytes, 0, bytes.length, encoding.dict);
        }
      }
      const parsedCMap = await parseCMap(
        /* cMap = */ new CMap(),
        /* lexer = */ new Lexer(encoding),
        fetchBuiltInCMap,
        useCMap,
      );
 
      if (parsedCMap.isIdentityCMap) {
        return createBuiltInCMap(parsedCMap.name, fetchBuiltInCMap);
      }
      return parsedCMap;
    }
    throw new Error('Encoding required.');
  }
}

它只认两种输入:

输入形态判断处理
Name内建 CMap 名字直接 createBuiltInCMap,不碰嵌入流
BaseStream嵌入的 CMap 流异步流先 asyncGetBytes 读全,再 parseCMap;解析后识别恒等映射
其它既不是名字也不是流抛 Encoding required.

异步流分支值得注意:parseCMap 需要随机读取,而 isAsync 的流(例如网络分块流)不能保证同步 getByte 总是命中,所以源码先把整份字节读完,换成内存 Stream 再解析。这会把 CMap 流一次性加载进内存,但 CMap 通常只有几十到几百 KB,换来的稳定性更重要。

解析完成后的 parsedCMap.isIdentityCMap 判断是最后一个优化点:如果嵌入流里写的是完整的 Identity-H 映射,工厂会丢弃刚解析出来的 65536 项数组,改走 createBuiltInCMap 的轻量 IdentityCMap。名字不是 Identity-H/Identity-V 时,isIdentityCMap 直接返回 false,不会做 65536 次扫描。

调用方:字体层与文本层

CMapFactory 在 evaluator.js 里有两个主要调用点。复合字体加载时,/Encoding 被传进来:

if (composite) {
  const cidEncoding = baseDict.get('Encoding');
  if (cidEncoding instanceof Name) {
    properties.cidEncoding = cidEncoding.name;
  }
  const cMap = await CMapFactory.create({
    encoding: cidEncoding,
    fetchBuiltInCMap: this._fetchBuiltInCMapBound,
    useCMap: null,
  });
  properties.cMap = cMap;
  properties.vertical = properties.cMap.vertical;
}

这里 encoding 可能是 Name,也可能是流;工厂内部已经处理了两种形态。properties.cMap 后续被字体层用来把内容流里的字符码翻成 CID,properties.vertical 则直接影响排版方向。

文本提取走 readToUnicode:

  async readToUnicode(cmapObj) {
    if (!cmapObj) {
      return null;
    }
    if (cmapObj instanceof Name) {
      const cmap = await CMapFactory.create({
        encoding: cmapObj,
        fetchBuiltInCMap: this._fetchBuiltInCMapBound,
        useCMap: null,
      });
 
      if (cmap instanceof IdentityCMap) {
        return new IdentityToUnicodeMap(0, 0xffff);
      }
      return new ToUnicodeMap(cmap.getMap());
    }

Name 形态的 ToUnicode 直接转成 ToUnicodeMap;如果工厂返回的是 IdentityCMap,则改用 IdentityToUnicodeMap,不需要复制 65536 项。流形态在同一个方法的下半段,会逐项把 bf 目标串从 UTF-16BE 转成 Unicode 码点,并处理代理对。这个转换不在 CMap 里做,而是留在 evaluator,因为 CMap 只需要忠实地保存「每字节一字符」的字符串。

内建数据的取回由 PartialEvaluator.fetchBuiltInCMap 负责:

  async fetchBuiltInCMap(name) {
    const cachedData = this.builtInCMapCache.get(name);
    if (cachedData) {
      return cachedData;
    }
    let data;
 
    if (this.options.useWorkerFetch) {
      // Only compressed CMaps are (currently) supported here.
      data = {
        cMapData: await fetchBinaryData(`${this.options.cMapUrl}${name}.bcmap`),
        isCompressed: true,
      };
    } else {
      if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) {
        throw new Error("Only worker-thread fetching supported.");
      }
      // Get the data on the main-thread instead.
      data = {
        cMapData: await this.handler.sendWithPromise("FetchBinaryData", {
          kind: "cMapUrl",
          filename: `${name}${this.options.cMapPacked ? ".bcmap" : ""}`,
        }),
        isCompressed: this.options.cMapPacked,
      };
    }
    // Cache the CMap data, to avoid fetching it repeatedly.
    this.builtInCMapCache.set(name, data);
 
    return data;
  }

builtInCMapCache 的键是 CMap 名字,值是「字节 + 是否压缩」的对象,不是已经解析好的 CMap。这意味着同一份内建数据被多页复用时,字节只取一次,但每份 CMap 仍然各自解析。这与 Catalog.builtInCMapCache 的清理策略一致:cleanup 时清空它,释放原始字节,不保留解析结果。

容错、边界与性能

cmap.js 的容错策略可以归纳成一张表:

场景源码行为目的
范围跨度超过 MAX_MAP_RANGE抛错,由 parseCMap 捕获并 warn防止 worker 线程在巨大循环里卡死
bfrange 第三项非法跳出循环后抛 Invalid bf range明确告知这一段不可解析
codespace 项不是字符串跳出循环后抛 Invalid codespace range阻止用错位的 token 污染码长表
解析中出现其它异常warn 后 continue单条坏记录不影响后续记录
解析中出现 MissingDataException原样上抛交给上层补数据后重试
codespace 缺失readCharCode 回退为 1 字节至少能继续读,不整页失败
内建名字不在白名单抛 Unknown CMap name不静默返回空映射,避免掩盖配置错误

性能上有三条主线:

  1. 解析时展开,查询时下标。mapCidRange、mapBfRange 都把范围写成了逐项数组,lookup 与 contains 因此是 O(1)。范围越大,解析成本越高,但同一份 CMap 会被大量字符复用,整体仍然划算。
  2. codespace 桶内线性扫描。每个桶的范围数量通常不大,线性扫描的常数很低;源码没有为它维护排序数组或二分结构,减少了构造与更新成本。
  3. 恒等映射零数组。IdentityCMap 把 65536 项实体数组变成类型判断,既省内存也省查询时间;工厂在嵌入流解析后还有一次识别,避免「碰巧是恒等映射」的普通对象留在缓存里。

设计要点:cmap.js 的核心取舍是「把映射关系实体化」。它不保留 PDF 文件里的范围语法,而是在解析阶段就把范围展开成以字符码为下标的数组。这样内容流解析器每遇到一个字符,只需要一次数组访问;代价是内存与解析时间,所以又用 MAX_MAP_RANGE 限制最坏情况,用 IdentityCMap 消除最常见的浪费。这套取舍与 PDF.js 在 worker 端「一次解析、多次渲染」的整体策略一致。

总结

cmap.js 是 PDF.js 的字符编码翻译层:向下解析文本 CMap、加载内建 .bcmap,向上给字体层提供 CID、给文本层提供 Unicode。它不决定字形轮廓,也不决定文字位置,只负责回答「这个字符码对应哪个目标值」。

它在整条链路中的位置

阶段模块输入输出
文档与对象xref、parser、catalogPDF 字节、对象字典字体字典、/Encoding、/ToUnicode
编码解析CMapFactory、cmap.js内建名字或嵌入流CMap、IdentityCMap
字体加载evaluator、fonts.jsproperties.cMap、CID字形、宽度、轮廓
文本提取evaluator.readToUnicodeToUnicode CMapToUnicodeMap、可复制文本
渲染canvas、font_rendereroperatorList、字形页面像素

用一句话串起来:字体字典决定用哪份 CMap,cmap.js 把字符码翻译成 CID 或 Unicode,字体层与文本层再各自消费这些目标值。

五条设计原则

  1. 一个容器服务两种映射:CID CMap 与 ToUnicode CMap 共用 CMap,因为它们的索引结构相同,差异只在目标值类型与下游消费者。把差异留给调用方,容器本身保持简单。
  2. 解析时展开,查询时下标:范围在写入阶段就变成逐项数组,lookup 是一次数组访问。用一次性的解析成本换每个字符的查询成本,适合「一份 CMap 被大量字符复用」的场景。
  3. 恒等映射用类型表达:IdentityCMap 不建 65536 项数组,而是覆盖查询方法;工厂再通过 isIdentityCMap 把嵌入流里的恒等映射替换成这个轻量类型。最常见的编码不付最常见的内存代价。
  4. 继承只补缺,不覆盖:extendCMap 只在子映射没有该键时写入父项,codespace 也只在子映射完全没定义时继承。规则简单,优先级明确,避免父子映射互相污染。
  5. 坏记录降级,缺字节上抛:范围过大、bf 目标非法、codespace 格式错误都只影响当前记录;MissingDataException 原样上抛给上层补数据。每一类错误只在自己能处理的层级被处理。

至此,CMap 这一层补齐了:它把「字符码」翻译成「CID 或 Unicode」。下一篇进入 binary_cmap.js,看内建 .bcmap 如何用二进制记录、增量编码与 varint 把同一份映射压得更小、读得更快。

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

相关文章