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

前言
前面三十二篇已经把「文档怎么打开、页面怎么求值」这条主线铺完了: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 时表示竖排 |
useCMap | CMap 或 null | 父映射,仅用于记录继承关系 |
builtInCMap | 布尔值 | 是否来自内建 .bcmap |
设计要点:CID CMap 与 ToUnicode CMap 共用一个类,不是偷懒,而是因为它们的索引方式完全一致:都是「源字符码 → 目标值」的稀疏表,都需要同一套 codespace 判定来决定一个字符码占几个字节。差异只在于目标值的类型,以及下游谁来消费。把差异留在调用方,
CMap本身就能同时服务字体层与文本层。
两者的差异可以集中成一张表:
| 维度 | CID CMap | ToUnicode CMap |
|---|---|---|
| 入口 | 复合字体的 /Encoding | 字体的 /ToUnicode |
| 源键 | 字符码,1 到 4 字节 | 字符码,通常 1 到 2 字节 |
| 目标值 | 整数 CID | 字符串,按字节存放 UTF-16BE 码元 |
| 典型操作符 | begincidchar、begincidrange | beginbfchar、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,原因有两个:
- 绝大多数查询是直接下标。
lookup(code)就是this._map[code],V8 对整数下标的数组访问非常快。 - 稀疏数组的代价可控。真正巨大的码值只会留下空洞,不会为每个位置分配内存;遍历时再用
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 个字节,第二轮把前两个字节拼成 16 位,第三轮拼成 24 位,第四轮拼成 32 位。
>>> 0保证结果是无符号整数,避免最高位被解释成负数。 - 每累积一轮,只检查对应码长的桶。1 字节候选只与单字节范围比较,2 字节候选只与双字节范围比较,不会出现「两字节码误匹配单字节范围」。
- 桶内线性扫描,先命中先返回。源码没有排序,也没有二分;每个桶就是一个扁平数组,从头到尾按
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 |
mapBfRange | UTF-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;
}这段循环是整份文件的调度中心,几个细节值得单独说明:
previous记录上一个Name。CMap 里的usecmap写成「/Identity-H usecmap」,usecmap是命令,名字在它前面。所以解析到usecmap时,源码取的是previous,而不是再读一个 token。WMode与CMapName是前缀记号。它们本身是Name,后面紧跟一个值;源码在遇到Name时就地调用对应的解析器,把值读掉,避免主循环把值误当成新命令。endcmap用标签跳出。break objLoop直接结束外层循环,而不是依赖EOF。很多 CMap 在endcmap后面还有空白或注释,读到EOF也能结束,但标签更明确。- 异常分类。
MissingDataException必须原样上抛,因为它是「字节还没到」,不是「数据坏了」;其它异常只warn并continue,让下一条记录继续解析。
各操作符与解析器的对应关系可以整理成表:
| 操作符 | 解析器 | 写入方法 |
|---|---|---|
begincodespacerange | parseCodespaceRange | addCodespaceRange |
begincidchar | parseCidChar | mapOne,目标为整数 |
begincidrange | parseCidRange | mapCidRange |
beginbfchar | parseBfChar | mapOne,目标为字符串 |
beginbfrange | parseBfRange | mapBfRange 或 mapBfRangeToArray |
usecmap | 主循环直接处理 | 记录 embeddedUseCMap,最后走 extendCMap |
WMode | parseWMode | 设置 vertical |
CMapName | parseCMapName | 设置 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;
}合并规则有三条:
- 父映射只按名字加载。
createBuiltInCMap只接受内建名称;usecmap指向的必须是内建 CMap,不能是另一个嵌入流。这是规范里的常见用法,也把父映射的加载范围限制在内建集合内,避免嵌入流之间出现任意链式引用。 - codespace 只在子映射完全没有定义时继承。
numCodespaceRanges === 0才克隆父桶;只要子映射写了至少一个范围,就完全用自己的。这避免了父子范围叠加导致码长判定歧义。 - 子映射优先。父映射的每一项只在子映射没有该键时才写入。
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 | 不静默返回空映射,避免掩盖配置错误 |
性能上有三条主线:
- 解析时展开,查询时下标。
mapCidRange、mapBfRange都把范围写成了逐项数组,lookup与contains因此是 O(1)。范围越大,解析成本越高,但同一份 CMap 会被大量字符复用,整体仍然划算。 - codespace 桶内线性扫描。每个桶的范围数量通常不大,线性扫描的常数很低;源码没有为它维护排序数组或二分结构,减少了构造与更新成本。
- 恒等映射零数组。
IdentityCMap把 65536 项实体数组变成类型判断,既省内存也省查询时间;工厂在嵌入流解析后还有一次识别,避免「碰巧是恒等映射」的普通对象留在缓存里。
设计要点:
cmap.js的核心取舍是「把映射关系实体化」。它不保留 PDF 文件里的范围语法,而是在解析阶段就把范围展开成以字符码为下标的数组。这样内容流解析器每遇到一个字符,只需要一次数组访问;代价是内存与解析时间,所以又用MAX_MAP_RANGE限制最坏情况,用IdentityCMap消除最常见的浪费。这套取舍与 PDF.js 在 worker 端「一次解析、多次渲染」的整体策略一致。
总结
cmap.js 是 PDF.js 的字符编码翻译层:向下解析文本 CMap、加载内建 .bcmap,向上给字体层提供 CID、给文本层提供 Unicode。它不决定字形轮廓,也不决定文字位置,只负责回答「这个字符码对应哪个目标值」。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 文档与对象 | xref、parser、catalog | PDF 字节、对象字典 | 字体字典、/Encoding、/ToUnicode |
| 编码解析 | CMapFactory、cmap.js | 内建名字或嵌入流 | CMap、IdentityCMap |
| 字体加载 | evaluator、fonts.js | properties.cMap、CID | 字形、宽度、轮廓 |
| 文本提取 | evaluator.readToUnicode | ToUnicode CMap | ToUnicodeMap、可复制文本 |
| 渲染 | canvas、font_renderer | operatorList、字形 | 页面像素 |
用一句话串起来:字体字典决定用哪份 CMap,cmap.js 把字符码翻译成 CID 或 Unicode,字体层与文本层再各自消费这些目标值。
五条设计原则
- 一个容器服务两种映射:CID CMap 与 ToUnicode CMap 共用
CMap,因为它们的索引结构相同,差异只在目标值类型与下游消费者。把差异留给调用方,容器本身保持简单。 - 解析时展开,查询时下标:范围在写入阶段就变成逐项数组,
lookup是一次数组访问。用一次性的解析成本换每个字符的查询成本,适合「一份 CMap 被大量字符复用」的场景。 - 恒等映射用类型表达:
IdentityCMap不建 65536 项数组,而是覆盖查询方法;工厂再通过isIdentityCMap把嵌入流里的恒等映射替换成这个轻量类型。最常见的编码不付最常见的内存代价。 - 继承只补缺,不覆盖:
extendCMap只在子映射没有该键时写入父项,codespace 也只在子映射完全没定义时继承。规则简单,优先级明确,避免父子映射互相污染。 - 坏记录降级,缺字节上抛:范围过大、bf 目标非法、codespace 格式错误都只影响当前记录;
MissingDataException原样上抛给上层补数据。每一类错误只在自己能处理的层级被处理。
至此,CMap 这一层补齐了:它把「字符码」翻译成「CID 或 Unicode」。下一篇进入 binary_cmap.js,看内建 .bcmap 如何用二进制记录、增量编码与 varint 把同一份映射压得更小、读得更快。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制2026年10月27日
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日