StreamPDFStreamPDF
返回博客

PDF.js 源码解读: binary_cmap

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: binary_cmap

前言

上一篇讲完了 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) + parseCMapBinaryCMapReader.process
中间对象token、字符串、命令对象少量 Uint8Array 缓冲
适用场景任意嵌入 CMap内建 CMap 的预压缩数据

差异不只是「体积更小」,还有解析路径更短:

  1. 没有词法分析。文本 CMap 里每个十六进制串都要经过 Lexer 扫描、解码、生成字符串;二进制读取器直接 getByte(),数字按位拼接。
  2. 范围用增量表达。一个 begincidrange 块里如果有几百项,文本形式要重复写 low high dst;二进制只写首项的完整值,后续项写「相对上一个 end 的 start 差值」与「end 差值」,数值通常很小,varint 只占一两个字节。
  3. 复用固定缓冲。start、end、char、charCode、tmp 都是长度 16 的 Uint8Array,在 process 开头一次性分配,循环里反复覆写,不产生逐项临时对象。
  4. 网络传输更省。内建 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 表示非负数,直接右移一位。映射关系是:

编码值原值
00
1-1
21
3-2
42

这样绝对值小的正负数都只需要很小的 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 数字,而是一个定长大端字节数组。过程分两段:

  1. 把每个字节的低 7 位压进 tmpBuf,顺序是「高位组在前」。
  2. 从后往前填 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 名字,不参与映射表,所以实现很直接。

六个原语的用途可以并排比较:

方法编码结果形态典型用途
readNumberbase-128JavaScript 非负整数记录数量、CID、差值
readSignedzigzagJavaScript 有符号整数cidchar 的 codeDelta
readHex原始大端定长 Uint8Array记录首项的 start/char
readHexNumberbase-128定长大端字节记录后续项的 delta
readHexSignedzigzag定长大端字节bfchar 的目标串差值
readStringvarint 序列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 到 5type0 到 5 是数据记录,7 是元数据,6 保留
4sequence后续项是否「连续递增」,为 1 时可以省略显式差值
3 到 0dataSize数据宽度减一,所以 0 表示 1 字节,15 表示 16 字节

类型 7 的记录用低 5 位区分元数据:0 是注释,1 是 usecmap。注释直接读掉丢弃,usecmap 先存进局部变量,等所有记录读完再处理。数据记录随后用 readNumber 读一个「子项数量」,再按类型进入对应分支。

type、sequence、dataSize 挤在一个字节里,是这套格式最核心的压缩手段:一条记录通常只需要一个头字节加几个 varint,文本形式却要写一整行关键字与十六进制串。

六种数据记录的语义如下:

type名称首项后续项
0codespacerangestart 原始字节,end 为相对 start 的差值start 相对上一个 end 的差值,end 相对 start 的差值
1notdefrange与 codespace 相同,外加一个 code同上,并重复读 code,读取器直接跳过
2cidcharchar 原始字节,code 为 varintchar 差值可选,codeDelta 为有符号数
3cidrangestart 原始字节,end 与 code 为 varintstart 差值可选,end 与 code 重复
4bfcharchar 为 2 字节,目标串为定长字节char 差值可选,目标串差值为有符号定长数
5bfrangestart 与 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 为 truecreateBuiltInCMap 的压缩分支是,BinaryCMapReader.process
Name,且 isCompressed 为 falsecreateBuiltInCMap 的文本分支否,Lexer + parseCMap
Name 为 Identity-H/Identity-V直接构造 IdentityCMap否
BaseStreamCMapFactory.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 个。两者都是「先分配、后复用」的前提。

性能上可以归纳成四点:

  1. 零 token。没有 Lexer 的字符分类、字符串构造与命令对象,只有 getByte() 与位移。
  2. 差分编码。同一块里只有首项写全量,后续项写小整数差值;sequence 位再把连续区间的差值全省掉。
  3. 固定缓冲。五个 Uint8Array 在整个 process 里复用,范围再大也不会为每一项分配对象。
  4. 与写入方法共用。mapCidRange 与 mapBfRange 直接展开成 _map,下游查询仍然是一次数组下标,二进制只优化「怎么送进来」,不改变「怎么查」。

设计要点:二进制 CMap 的加速来自消除通用解析器。文本 CMap 必须经过 PDF 词法器,因为它要和其它 PDF 对象共用语法;.bcmap 是私有格式,读取器知道每个字节的确切含义,可以直接按位取字段。格式设计与读取器是一对一写死的:记录头三个字段、六种类型、差分规则都由压缩工具与读取器共同约定,没有版本协商,也没有通用性负担。这正是它比文本路径快的原因,也是它只能用于内建资源的原因。

总结

binary_cmap.js 是 PDF.js 的内建 CMap 解码器:向下把 .bcmap 的字节流拆成 codespace、cid、bf 记录,向上产出与文本 CMap 完全同形的 CMap。它不参与嵌入流解析,也不决定字体如何取字形;它只负责把预压缩的数据尽快还原成 _map。

它在整条链路中的位置

阶段模块输入输出
资源取回PartialEvaluator.fetchBuiltInCMapCMap 名字、.bcmap 路径cMapData、isCompressed
工厂分派CMapFactory.createName 或 BaseStream文本路径或二进制路径
二进制解码BinaryCMapReaderUint8Array、空 CMap带 codespace 与 _map 的 CMap
继承合并extendCMap当前 CMap、usecmap 名字合并后的 CMap
字体与文本evaluator、fonts.jsCID 或 Unicode字形与可复制文本

用一句话串起来:fetchBuiltInCMap 取回压缩字节,BinaryCMapReader 按记录还原映射,extendCMap 补齐父映射,字体层与文本层最终拿到同一套 CMap 接口。

四条设计原则

  1. 私有格式,专用读取器:.bcmap 不需要兼容 PDF 语法,读取器可以按位取字段,省掉词法器与通用对象构造。格式越专用,解析路径越短。
  2. 差分优先于重复:同一记录块里只有首项写全量,后续项写相对上一项的差值;连续区间用 sequence 位省略差值。压缩比来自「重复信息只出现一次」,而不是通用压缩算法。
  3. 定长与变长各司其职:字符码、目标串用定长大端字节,保证宽度与进位正确;数量、CID、小差值用 varint,保证小数字只占一个字节。两者混用,既不会溢出,也不会为小值浪费空间。
  4. 解码与写入分离:读取器只负责把记录拆开,真正的范围展开仍然调用 CMap.mapCidRange 与 mapBfRange。二进制路径与文本路径共享同一套写入与查询逻辑,下游不需要知道自己拿到的 CMap 来自哪种格式。

至此,字体层的入口两块补齐了:cmap.js 定义映射与解析,binary_cmap.js 负责内建数据的快速解码。接下来可以继续往字体内部走,看 fonts.js 如何用 CID、宽度表与轮廓数据把字符真正变成可绘制的字形。

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

相关文章