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

前言
上一篇讲完了 cmap.js:文本 CMap 用 begincodespacerange、begincidrange、beginbfrange 这些操作符描述映射,CMap 把它们展开成以字符码为下标的数组。但 PDF.js 自带的内建 CMap 并不是这些文本文件,而是 external/bcmaps 下的 .bcmap 二进制包。
为什么要有二进制版本?内建 CMap 覆盖 Adobe-GB1、Adobe-CNS1、Adobe-Japan1、Adobe-Korea1 以及各种 EUC、RKSJ、UTF-16、UTF-32 编码,名字有上百个,文本形式的映射动辄几十万行。如果每次打开 PDF 都下载并解析一份文本 CMap,网络与词法分析的成本都很高。src/core/binary_cmap.js 就是这批 .bcmap 的读取器:它用大端字节、varint 与增量编码,把同一份映射压成更小的字节流,再用接近「线性读数组」的方式还原成 CMap。
用一句话概括:binary_cmap.js 是 CMapFactory 的内建数据解码器,负责把 .bcmap 的二进制记录翻译成上一篇那套 codespaceRanges 与 _map。
模块总览
binary_cmap.js 共 323 行,可以切成四块:
| 区块 | 关键成员 | 职责 |
|---|---|---|
| 十六进制工具 | hexToInt、hexToStr、addHex、incHex | 把定长大端字节转成整数或字符串,做带进位的加减 |
| 读取流 | BinaryCMapStream、readNumber、readSigned、readHex、readHexNumber、readHexSigned、readString | 在 Stream 之上提供 varint 与定长十六进制读取 |
| 主解码器 | BinaryCMapReader.process | 读头字节,按记录类型分派,写入 CMap |
| 常量 | MAX_NUM_SIZE、MAX_ENCODED_NUM_SIZE | 限制单个定长数字的字节数与 varint 缓冲区大小 |
它在整条链路里的位置可以画成这样:
// CMapFactory.create({ encoding: Name })
// → createBuiltInCMap(name)
// → fetchBuiltInCMap(name) 返回 { cMapData, isCompressed }
// → new BinaryCMapReader().process ← 本篇
// → CMap.addCodespaceRange / mapOne / mapCidRange / mapBfRange
// → evaluator: CID 字体或 ToUnicode与文本 CMap 的解析相比,这里没有 Lexer、没有 Parser、没有操作符分派。输入是一段连续的 Uint8Array,每个记录头就是一个字节,每个数字要么是 base-128 varint,要么是定长大端字节。解析器只需要按位取字段、按类型循环,不构造中间 token。
为什么二进制 CMap 更快
先用一张表把两种形态放在一起:
| 维度 | 文本 CMap | 二进制 CMap |
|---|---|---|
| 载体 | .cmap 文本文件或嵌入流 | .bcmap 资源 |
| 语法 | PDF 风格的对象与操作符 | 自定义记录头 + 大端字节 |
| 数字 | 十六进制字符串,逐个解析 | base-128 varint 或定长大端字节 |
| 范围表达 | 每项都写 low high dst | 首项写全量,后续只写 delta |
| 解析入口 | new Lexer(stream) + parseCMap | BinaryCMapReader.process |
| 中间对象 | token、字符串、命令对象 | 少量 Uint8Array 缓冲 |
| 适用场景 | 任意嵌入 CMap | 内建 CMap 的预压缩数据 |
差异不只是「体积更小」,还有解析路径更短:
- 没有词法分析。文本 CMap 里每个十六进制串都要经过
Lexer扫描、解码、生成字符串;二进制读取器直接getByte(),数字按位拼接。 - 范围用增量表达。一个
begincidrange块里如果有几百项,文本形式要重复写low high dst;二进制只写首项的完整值,后续项写「相对上一个 end 的 start 差值」与「end 差值」,数值通常很小,varint 只占一两个字节。 - 复用固定缓冲。
start、end、char、charCode、tmp都是长度 16 的Uint8Array,在process开头一次性分配,循环里反复覆写,不产生逐项临时对象。 - 网络传输更省。内建 CMap 是静态资源,压缩后的
.bcmap比文本小一个量级;配合builtInCMapCache,同一份数据在一次会话里只取一次。
evaluator.js 的 fetchBuiltInCMap 里有一句注释:「Only compressed CMaps are (currently) supported here.」当 useWorkerFetch 打开时,worker 只按 .bcmap 路径取数据,并把 isCompressed 置为 true。也就是说,内建 CMap 在默认部署形态下几乎总走本篇的二进制路径;文本路径主要留给嵌入流或显式的 .cmap 配置。
BinaryCMapStream:六种读取原语
BinaryCMapStream 继承自 stream.js 的 Stream,只增加了一个复用缓冲和六个读取方法:
const MAX_NUM_SIZE = 16;
const MAX_ENCODED_NUM_SIZE = 19; // ceil(MAX_NUM_SIZE * 7 / 8)
class BinaryCMapStream extends Stream {
tmpBuf = new Uint8Array(MAX_ENCODED_NUM_SIZE);
constructor(data) {
super(
/* arrayBuffer = */ data,
/* start = */ 0,
/* length = */ data.length,
/* dict = */ null
);
}MAX_NUM_SIZE 是定长数字最多占 16 字节,对应 128 位;MAX_ENCODED_NUM_SIZE 是 19 字节,因为每个原始字节最多编码成 8 个 7 位组,向上取整后需要 19 个字节的 varint 空间。tmpBuf 只给 readHexNumber 与 readHexSigned 做中转,避免每次调用都新建数组。
readNumber:base-128 varint
readNumber() {
let n = 0;
let last;
do {
const b = this.getByte();
if (b < 0) {
throw new FormatError("unexpected EOF in bcmap");
}
last = !(b & 0x80);
n = (n << 7) | (b & 0x7f);
} while (!last);
return n;
}每个字节的最高位是「是否继续」标志,低 7 位是数据。读到最高位为 0 的字节就结束,已经累积的 n 左移 7 位再加上新的 7 位。这样小数字只占一个字节,大数字按需增长;0x4207 会被拆成 0x81、0x84、 0x07 三个字节,读取时再拼回去。
b < 0 是 getByte() 读到流末尾的返回值。.bcmap 是静态资源,截断通常意味着文件损坏或下载不完整,源码直接抛 FormatError,不做静默补零。
readSigned:zigzag 编码
readSigned() {
const n = this.readNumber();
return n & 1 ? ~(n >>> 1) : n >>> 1;
}有符号数用 zigzag 编码:非负数写成 2 * n,负数写成 -2 * n - 1。解码时看最低位,低位为 1 表示负数,用 ~(n >>> 1) 还原;低位为 0 表示非负数,直接右移一位。映射关系是:
| 编码值 | 原值 |
|---|---|
| 0 | 0 |
| 1 | -1 |
| 2 | 1 |
| 3 | -2 |
| 4 | 2 |
这样绝对值小的正负数都只需要很小的 varint,适合 CID 与目标串的差值。
readHex:定长原始字节
readHex(num, size) {
num.set(this.getBytes(size + 1));
}size 是「字节数减一」,所以实际读取 size + 1 个字节,直接写进调用方提供的 Uint8Array。它不做任何数值转换,保留大端原始顺序,后续由 hexToInt 或 hexToStr 解释。
readHexNumber:varint 写进定长缓冲
readHexNumber(num, size) {
let last;
const stack = this.tmpBuf;
let sp = 0;
do {
const b = this.getByte();
if (b < 0) {
throw new FormatError("unexpected EOF in bcmap");
}
last = !(b & 0x80);
stack[sp++] = b & 0x7f;
} while (!last);
let i = size,
buffer = 0,
bufferSize = 0;
while (i >= 0) {
while (bufferSize < 8 && stack.length > 0) {
buffer |= stack[--sp] << bufferSize;
bufferSize += 7;
}
num[i] = buffer & 255;
i--;
buffer >>= 8;
bufferSize -= 8;
}
}它读的是 varint,但结果不是 JavaScript 数字,而是一个定长大端字节数组。过程分两段:
- 把每个字节的低 7 位压进
tmpBuf,顺序是「高位组在前」。 - 从后往前填
num:每次从栈顶弹出 7 位,拼进buffer,凑满 8 位就写一个字节。因为num也是从后往前写的,最终得到的就是大端表示。
这段代码里有一个容易看错的地方:内层 while 的条件写的是 stack.length > 0,而不是 sp > 0。stack 是长度 19 的复用 Uint8Array,条件几乎永远为真;sp 归零后仍会执行 stack[--sp],此时 stack[-1] 返回 undefined,左移运算把它当成 0。于是循环会继续用零填充高位,直到 bufferSize 达到 8。定长数字的宽度通常大于 varint 的实际字节数,这段「越界」正好用来补前导零,也是它能从后往前填满 num 的原因。
readHexSigned:定长有符号数
readHexSigned(num, size) {
this.readHexNumber(num, size);
const sign = num[size] & 1 ? 255 : 0;
let c = 0;
for (let i = 0; i <= size; i++) {
c = ((c & 1) << 8) | num[i];
num[i] = (c >> 1) ^ sign;
}
}有符号定长数同样用 zigzag:最低位是符号位。sign 取 255 或 0,表示负数时要对结果做按位取反。循环从最高字节开始,c = ((c & 1) << 8) | num[i] 把上一个字节的最低位带到当前字节,再整体右移一位,等价于对整段大端数做「除以 2 并保留跨字节的位」。最后与 sign 异或,完成负数的补码还原。
readString 用于元数据记录:先读 varint 长度,再逐个读 UTF-16 码元。它只服务注释与 usecmap 名字,不参与映射表,所以实现很直接。
六个原语的用途可以并排比较:
| 方法 | 编码 | 结果形态 | 典型用途 |
|---|---|---|---|
readNumber | base-128 | JavaScript 非负整数 | 记录数量、CID、差值 |
readSigned | zigzag | JavaScript 有符号整数 | cidchar 的 codeDelta |
readHex | 原始大端 | 定长 Uint8Array | 记录首项的 start/char |
readHexNumber | base-128 | 定长大端字节 | 记录后续项的 delta |
readHexSigned | zigzag | 定长大端字节 | bfchar 的目标串差值 |
readString | varint 序列 | JavaScript 字符串 | 注释、usecmap 名字 |
设计要点:所有「定长十六进制数」都统一走
Uint8Array,而不是 JavaScript 的 32 位数字。CMap 的源码可以长达 4 字节,目标串可以更长,使用定长字节数组能避免 32 位溢出,也让addHex、incHex这类带进位运算可以逐字节实现。只有 CID 与记录数量这种确定不会超过 32 位的值,才走readNumber。
文件格式:头字节与记录头
process 先读第一个字节:
async process(data, cMap, extend) {
const stream = new BinaryCMapStream(data);
const header = stream.getByte();
cMap.vertical = !!(header & 1);头字节的最低位是 WMode:1 表示竖排。压缩工具写入头字节时用的是 (inputData.type << 1) | inputData.wmode,所以 bit 1 与 bit 2 是 CMap 类型。读取器并不使用这两个位:它只按记录类型(cidchar、bfchar 等)决定写入整数 CID 还是字符串目标。换句话说,同一套读取器既能解 CID 型 .bcmap,也能解 ToUnicode 型 .bcmap,类型由文件内容与调用方选用的名字决定,读取器不需要再判一次。
之后是连续记录,每条记录至少一个头字节:
let b;
while ((b = stream.getByte()) >= 0) {
const type = b >> 5;
if (type === 7) {
// metadata, e.g. comment or usecmap
switch (b & 0x1f) {
case 0:
stream.readString(); // skipping comment
break;
case 1:
useCMap = stream.readString();
break;
}
continue;
}
const sequence = !!(b & 0x10);
const dataSize = b & 15;
if (dataSize + 1 > MAX_NUM_SIZE) {
throw new Error("BinaryCMapReader.process: Invalid dataSize.");
}
const ucs2DataSize = 1;
const subitemsCount = stream.readNumber();一个记录头字节被拆成三个字段:
| 位 | 字段 | 含义 |
|---|---|---|
| 7 到 5 | type | 0 到 5 是数据记录,7 是元数据,6 保留 |
| 4 | sequence | 后续项是否「连续递增」,为 1 时可以省略显式差值 |
| 3 到 0 | dataSize | 数据宽度减一,所以 0 表示 1 字节,15 表示 16 字节 |
类型 7 的记录用低 5 位区分元数据:0 是注释,1 是 usecmap。注释直接读掉丢弃,usecmap 先存进局部变量,等所有记录读完再处理。数据记录随后用 readNumber 读一个「子项数量」,再按类型进入对应分支。
type、sequence、dataSize 挤在一个字节里,是这套格式最核心的压缩手段:一条记录通常只需要一个头字节加几个 varint,文本形式却要写一整行关键字与十六进制串。
六种数据记录的语义如下:
type | 名称 | 首项 | 后续项 |
|---|---|---|---|
| 0 | codespacerange | start 原始字节,end 为相对 start 的差值 | start 相对上一个 end 的差值,end 相对 start 的差值 |
| 1 | notdefrange | 与 codespace 相同,外加一个 code | 同上,并重复读 code,读取器直接跳过 |
| 2 | cidchar | char 原始字节,code 为 varint | char 差值可选,codeDelta 为有符号数 |
| 3 | cidrange | start 原始字节,end 与 code 为 varint | start 差值可选,end 与 code 重复 |
| 4 | bfchar | char 为 2 字节,目标串为定长字节 | char 差值可选,目标串差值为有符号定长数 |
| 5 | bfrange | start 与 end 为 2 字节,目标串为定长字节 | start/end 差值与目标串重复 |
六种数据记录
codespacerange:首项全量,后续写差值
case 0: // codespacerange
stream.readHex(start, dataSize);
stream.readHexNumber(end, dataSize);
addHex(end, start, dataSize);
cMap.addCodespaceRange(
dataSize + 1,
hexToInt(start, dataSize),
hexToInt(end, dataSize)
);
for (let i = 1; i < subitemsCount; i++) {
incHex(end, dataSize);
stream.readHexNumber(start, dataSize);
addHex(start, end, dataSize);
stream.readHexNumber(end, dataSize);
addHex(end, start, dataSize);
cMap.addCodespaceRange(
dataSize + 1,
hexToInt(start, dataSize),
hexToInt(end, dataSize)
);
}
break;第一项直接读 start 的原始字节,end 读的是「相对 start 的差值」,所以 addHex(end, start, dataSize) 之后才是真正的结束值。后续项先把 end 加一,作为新的起点,再读 startDelta 与 endDelta,两次 addHex 分别得到 start 与 end。
dataSize + 1 就是码长。.bcmap 在记录头里只用一个 4 位字段表达 1 到 16 字节,正好覆盖 PDF CMap 最多 4 字节的码长,也兼容更宽的目标串。
notdefrange:读掉但不用
类型 1 的结构与 codespace 相同,只是每项多一个 code。读取器把 start、end 与 code 都读掉,然后什么也不写入。notdefrange 描述的是「这些字符码没有定义,使用 notdef 字形」。PDF.js 的字形层不依赖这张表来决定缺字,缺字由 _map 查不到时自然回退。因此这里只需要把字节消费掉,保证后续记录不错位。
cidchar:单点 CID
case 2: // cidchar
stream.readHex(char, dataSize);
code = stream.readNumber();
cMap.mapOne(hexToInt(char, dataSize), code);
for (let i = 1; i < subitemsCount; i++) {
incHex(char, dataSize);
if (!sequence) {
stream.readHexNumber(tmp, dataSize);
addHex(char, tmp, dataSize);
}
code = stream.readSigned() + (code + 1);
cMap.mapOne(hexToInt(char, dataSize), code);
}
break;首项是「字符码 + CID」。后续项先 incHex(char),如果 sequence 为 1,字符码就是简单的连续递增,不需要再读差值;如果为 0,则读一个 charDelta 加到当前值上。CID 的增量总是 readSigned(),基准是上一个 CID 加一,所以 code = delta + (code + 1)。
sequence 是压缩工具 optimize.mjs 的产物:当一段记录里的字符码严格连续时,把差值全部省掉,只留一个标志位。
cidrange:范围 CID
case 3: // cidrange
stream.readHex(start, dataSize);
stream.readHexNumber(end, dataSize);
addHex(end, start, dataSize);
code = stream.readNumber();
cMap.mapCidRange(
hexToInt(start, dataSize),
hexToInt(end, dataSize),
code
);
for (let i = 1; i < subitemsCount; i++) {
incHex(end, dataSize);
if (!sequence) {
stream.readHexNumber(start, dataSize);
addHex(start, end, dataSize);
} else {
start.set(end);
}
stream.readHexNumber(end, dataSize);
addHex(end, start, dataSize);
code = stream.readNumber();
cMap.mapCidRange(
hexToInt(start, dataSize),
hexToInt(end, dataSize),
code
);
}
break;第一项与文本 CMap 的 begincidrange 完全对应:start、end、起始 CID。后续项的处理分两种:
sequence为 1:start.set(end),即新起点就是上一个 end 加一后的值。sequence为 0:再读一个startDelta,把 start 设为「上一个 end 加一」再加差值。
end 始终由 endDelta 相对 start 计算。最后调用 mapCidRange,把整个范围展开成逐项数组;这一步与文本 CMap 走的是同一个方法,所以下游拿到的 CMap 形态完全一致。
bfchar:单点 Unicode
case 4: // bfchar
stream.readHex(char, ucs2DataSize);
stream.readHex(charCode, dataSize);
cMap.mapOne(
hexToInt(char, ucs2DataSize),
hexToStr(charCode, dataSize)
);
for (let i = 1; i < subitemsCount; i++) {
incHex(char, ucs2DataSize);
if (!sequence) {
stream.readHexNumber(tmp, ucs2DataSize);
addHex(char, tmp, ucs2DataSize);
}
incHex(charCode, dataSize);
stream.readHexSigned(tmp, dataSize);
addHex(charCode, tmp, dataSize);
cMap.mapOne(
hexToInt(char, ucs2DataSize),
hexToStr(charCode, dataSize)
);
}
break;这里有两个宽度:ucs2DataSize 固定为 1,也就是源码 2 字节;目标串用记录头里的 dataSize,可以是 2 字节、4 字节或更长。首项读源码与目标串原始字节,后续项对源码做与 cidchar 相同的差值处理,对目标串则先 incHex 再加 readHexSigned 的差值。
hexToStr 会把定长字节数组转成「每个字符一字节」的字符串,正好与 CMap 里 bf 目标值的表示一致。源码对 2 字节与 4 字节这两个最常见的 UTF-16 目标串长度做了特判,直接按下标构造字符串,避免 subarray() 产生新的视图对象;注释里的 This code is hot 说明这条路径在 CJK 文档里会被反复调用。
bfrange:范围 Unicode
case 5: // bfrange
stream.readHex(start, ucs2DataSize);
stream.readHexNumber(end, ucs2DataSize);
addHex(end, start, ucs2DataSize);
stream.readHex(charCode, dataSize);
cMap.mapBfRange(
hexToInt(start, ucs2DataSize),
hexToInt(end, ucs2DataSize),
hexToStr(charCode, dataSize)
);
for (let i = 1; i < subitemsCount; i++) {
incHex(end, ucs2DataSize);
if (!sequence) {
stream.readHexNumber(start, ucs2DataSize);
addHex(start, end, ucs2DataSize);
} else {
start.set(end);
}
stream.readHexNumber(end, ucs2DataSize);
addHex(end, start, ucs2DataSize);
stream.readHex(charCode, dataSize);
cMap.mapBfRange(
hexToInt(start, ucs2DataSize),
hexToInt(end, ucs2DataSize),
hexToStr(charCode, dataSize)
);
}
break;结构与 cidrange 几乎一致,只是源码范围固定为 2 字节,目标从「起始 CID」换成「起始字符串」。mapBfRange 负责把起始串按最后一个字节递增,逐项填进 _map;二进制格式只负责把起始串与范围送进去,展开逻辑与文本 CMap 完全共用。
把这些 case 放在一起看,会发现它们都在做同一件事:读起点、读终点、读目标、写入 CMap。首项用 readHex 读全量,后续项用 incHex 加 addHex 还原差值;目标值要么是 varint,要么是定长字节;最后统一交给 mapOne、mapCidRange 或 mapBfRange。整份读取器没有一个 case 需要回溯,也没有嵌套语法结构。
设计要点:二进制格式不是「把文本 CMap 逐行翻译成字节」,而是按记录块重排并做差分。同一块里的起点、终点、目标都相对上一项编码;连续区间用
sequence位省掉全部差值;小数字用 varint,大数字用定长大端字节。读取器的循环因此非常规整:每个case都是「读首项、循环读后续项、调用同一个写入方法」,没有嵌套语法分析,也不需要回溯。
useCMap 与工厂的接合
usecmap 在二进制里是一条元数据记录,不在数据块里:
if (type === 7) {
// metadata, e.g. comment or usecmap
switch (b & 0x1f) {
case 0:
stream.readString(); // skipping comment
break;
case 1:
useCMap = stream.readString();
break;
}
continue;
}读取器只把名字存进 useCMap,等所有记录处理完才回调:
if (useCMap) {
return extend(useCMap);
}
return cMap;
}extend 不是 BinaryCMapReader 自己实现的逻辑,而是 createBuiltInCMap 传进来的闭包:
if (isCompressed) {
return new BinaryCMapReader().process(cMapData, cMap, useCMap =>
extendCMap(cMap, fetchBuiltInCMap, useCMap),
);
}所以继承链最终仍然落到上一篇的 extendCMap:先按名字加载父 CMap,子映射没有 codespace 时克隆父桶,再逐项合并父映射且不覆盖子映射已有的键。二进制读取器只负责把「自己的记录」写进 cMap,父映射的加载与合并完全复用 cmap.js 的逻辑。
这条链可以画成这样:
// BinaryCMapReader.process
// → 读取 codespace / cid / bf 记录,写入当前 cMap
// → 遇到 metadata usecmap,记下名字
// → 记录读完,调用 extend(useCMap)
// → extendCMap:createBuiltInCMap(父名)
// → 父 CMap 可能是文本,也可能是另一个 .bcmap
// → 合并父项,返回当前 cMap父 CMap 自己也可能带 usecmap,于是形成一条有限的内建继承链。每一层都是一次 createBuiltInCMap,数据由 fetchBuiltInCMap 按名字取回,builtInCMapCache 只缓存原始字节,不缓存解析后的 CMap。
与 CMapFactory 的关系可以总结成一张表:
| 输入 | 工厂分支 | 是否走本篇 |
|---|---|---|
Name,且 isCompressed 为 true | createBuiltInCMap 的压缩分支 | 是,BinaryCMapReader.process |
Name,且 isCompressed 为 false | createBuiltInCMap 的文本分支 | 否,Lexer + parseCMap |
Name 为 Identity-H/Identity-V | 直接构造 IdentityCMap | 否 |
BaseStream | CMapFactory.create 的流分支 | 否,嵌入流一律按文本解析 |
最后一行值得强调:嵌入在 PDF 里的 CMap 不会被当作二进制解析,即使它的字节看起来像 .bcmap。二进制路径只服务内建资源,因为只有内建资源有固定的命名与打包流程;嵌入流的内容不可信,走文本解析器更容易做逐条容错。
边界与性能
binary_cmap.js 的失败点很集中:
| 场景 | 源码行为 | 结果 |
|---|---|---|
| varint 读到文件尾 | readNumber、readHexNumber 抛 FormatError | 明确报「unexpected EOF in bcmap」 |
记录头 dataSize 超过 15 | 抛 Invalid dataSize | 阻止 16 字节以上的定长数字 |
| 记录类型不是 0 到 5 或 7 | 抛 unknown type | 不猜测未知扩展 |
usecmap 指向不存在的内建名 | 由 createBuiltInCMap 抛 Unknown CMap name | 继承链明确失败 |
| 注释记录 | 读掉字符串后丢弃 | 不影响后续记录 |
两个常量是这套边界的核心:MAX_NUM_SIZE 限制 dataSize + 1,MAX_ENCODED_NUM_SIZE 限制 tmpBuf 的容量。前者保证 start、end、char、charCode 这些 16 字节数组不会被越界写入,后者保证 varint 的 7 位组不会超过 19 个。两者都是「先分配、后复用」的前提。
性能上可以归纳成四点:
- 零 token。没有
Lexer的字符分类、字符串构造与命令对象,只有getByte()与位移。 - 差分编码。同一块里只有首项写全量,后续项写小整数差值;
sequence位再把连续区间的差值全省掉。 - 固定缓冲。五个
Uint8Array在整个process里复用,范围再大也不会为每一项分配对象。 - 与写入方法共用。
mapCidRange与mapBfRange直接展开成_map,下游查询仍然是一次数组下标,二进制只优化「怎么送进来」,不改变「怎么查」。
设计要点:二进制 CMap 的加速来自消除通用解析器。文本 CMap 必须经过 PDF 词法器,因为它要和其它 PDF 对象共用语法;
.bcmap是私有格式,读取器知道每个字节的确切含义,可以直接按位取字段。格式设计与读取器是一对一写死的:记录头三个字段、六种类型、差分规则都由压缩工具与读取器共同约定,没有版本协商,也没有通用性负担。这正是它比文本路径快的原因,也是它只能用于内建资源的原因。
总结
binary_cmap.js 是 PDF.js 的内建 CMap 解码器:向下把 .bcmap 的字节流拆成 codespace、cid、bf 记录,向上产出与文本 CMap 完全同形的 CMap。它不参与嵌入流解析,也不决定字体如何取字形;它只负责把预压缩的数据尽快还原成 _map。
它在整条链路中的位置
| 阶段 | 模块 | 输入 | 输出 |
|---|---|---|---|
| 资源取回 | PartialEvaluator.fetchBuiltInCMap | CMap 名字、.bcmap 路径 | cMapData、isCompressed |
| 工厂分派 | CMapFactory.create | Name 或 BaseStream | 文本路径或二进制路径 |
| 二进制解码 | BinaryCMapReader | Uint8Array、空 CMap | 带 codespace 与 _map 的 CMap |
| 继承合并 | extendCMap | 当前 CMap、usecmap 名字 | 合并后的 CMap |
| 字体与文本 | evaluator、fonts.js | CID 或 Unicode | 字形与可复制文本 |
用一句话串起来:fetchBuiltInCMap 取回压缩字节,BinaryCMapReader 按记录还原映射,extendCMap 补齐父映射,字体层与文本层最终拿到同一套 CMap 接口。
四条设计原则
- 私有格式,专用读取器:
.bcmap不需要兼容 PDF 语法,读取器可以按位取字段,省掉词法器与通用对象构造。格式越专用,解析路径越短。 - 差分优先于重复:同一记录块里只有首项写全量,后续项写相对上一项的差值;连续区间用
sequence位省略差值。压缩比来自「重复信息只出现一次」,而不是通用压缩算法。 - 定长与变长各司其职:字符码、目标串用定长大端字节,保证宽度与进位正确;数量、CID、小差值用 varint,保证小数字只占一个字节。两者混用,既不会溢出,也不会为小值浪费空间。
- 解码与写入分离:读取器只负责把记录拆开,真正的范围展开仍然调用
CMap.mapCidRange与mapBfRange。二进制路径与文本路径共享同一套写入与查询逻辑,下游不需要知道自己拿到的 CMap 来自哪种格式。
至此,字体层的入口两块补齐了:cmap.js 定义映射与解析,binary_cmap.js 负责内建数据的快速解码。接下来可以继续往字体内部走,看 fonts.js 如何用 CID、宽度表与轮廓数据把字符真正变成可绘制的字形。
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 源码解读: 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日