又踩了 CMap 的坑——探究字体与 PDF 文件中的字符映射表

 

今天是研究生毕业论文提交初稿的日子(当然和我没什么关系)。中午有组里的同学来找我,说 GPT 老师找出了如下的问题:

  1. (英文关键词部分)所有英文分号”;”显示异常(显示为希腊问号字符U+037E),应统一改为标准英文分号”;”。

打开 thuthesis 生成的 PDF 尝试复制了一下,确实是这样。我感觉有点奇怪,就去 thuthesis 的仓库看了一眼,对应代码是这样写的:

\thu@clist@use{\thu@keywords@en}{; }%

看起来完全没有问题。那是怎么回事呢?

TL;DR: 是 PDF feature,无法彻底解决。

问题复现

去除各种无关因素后,使用 XeLaTeX 编译如下的 MWE 就可以复现问题:

\documentclass{minimal}
\usepackage[utf8]{inputenc}
\usepackage{fontspec}
\newfontfamily{\tnr}{Times New Roman}

\begin{document}
Hello; World;
\tnr Hello; World;
\end{document}

其中每一行的第一个“分号”复制出来是 U+003B Semicolon,第二个分号是 U+037E Greek Question Mark。

为了更好地进行演示,我做了一个简单的 demo 仓库:Harry-Chen/font-pdf-cmap-analysis。本文的提及的所有源代码和结果文件都在仓库中,读者可以自己实验。

在各类桌面阅读器(如 Acrobat Reader、Foxit Reader、SumatraPDF)、浏览器(如 Chrome、Edge)中打开生成的 1.semicolon-tex.pdf 文件,分别复制两行的前后分号,就会发现第一行的两个“分号”是 U+003B,第二行的两个“分号”都是 U+037E。而如果使用 PDF.js 渲染(如 GitLab、TeXPage 等平台),所有的四个分号全变成了 U+003B。

再更换最新最热的排版引擎 Typst(仓库中的 1.semicolon-typ.typ),选择默认的 Libertine 时,表现符合预期;而如果用 Times New Roman 字体,则复制出来的分号都是 U+037E。

问题溯源

每次遇到 PDF 字体渲染、编码之类的问题,我都会第一时间想起 Clerk Ma,毕竟他是我心目中的世界级 PDF 专家。收到文件后,他立刻点明了导致问题的直接原因:Times New Roman 字体的 CMap 表把这两个 Unicode 码位都映射到了同一个 GID(Glyph ID,字形 ID)上,而 LaTeX 生成的 PDF 嵌入的 ToUnicode 表里面(通常也叫做 CMap),这个字形又唯一指向了 U+037E 的码位。同理可得,Typst 的表中,应该都指向了 U+003B。

等等,这都是什么鬼?

从码位到字形:字体 CMap 表

众所周知,PDF 是一种画板,你看到的一切都是用某种方式绘制出来的,文字也不例外。LaTeX 生成的 PDF 中,文字的绘制方式由字体(font)文件决定,而每个字体文件又可以看作大量相同风格字形(glyph)的集合,每个字形都有自己的特别的编号。

对于 TrueType 字体文件,可以想象每个字体文件都定义了两个映射 f: CH -> GIDg: GID -> glyph,其中 CH 是文字在字符集(这里只考虑 Unicode)中的码位(code point),GID 是字形的编号(通常是连续排列),glyph 是具体的字形。f 通常就存储在字体文件的 CMap 表中,而 g 存储在 glyf 表中。

需要注意到,f 通常既不是单射,也不是满射;前者是因为一个码位可以对应多个字形(如正常、粗体、斜体,这由字体中的不同属性控制),后者是因为多个语义不同的码位也可能共享同一个字形,就比如上文提到的分号和希腊问号,典型的例子还有多种不同语义的空格。

还以上文提到的 Times New Roman 字体中的分号为例,从文本到字形,经过的环节包括:

  • 解码:将以 UTF-8 编码的字符转换为 Unicode 码位:0x3B -> U+003B
  • 映射:在 Times New Roman 字体的 CMap 表中查找 U+003B 对应的 GID,是 semicolon,ID 为 30。
  • 生成:从字体的 glyf 表中找到 ID 为 30 的字形(其实是一段可执行的字节码),转化为可以用来绘制的曲线。

事实上,上面 “映射”这一步是经过简化的,比如每个字体文件中的 CMap 表很可能有多个,对应不同的平台、不同的字符集。此外,在 PostScript、OpenType 等字体技术中,为了节约资源或者其他考虑,码位需要先转化为 CID(这也是 CMap 的名字由来)然后再检索字形。具体的细节可以参考 CID-Key 在 OpenType 扮演什么角色?

再顺便提一句,后续的文字渲染和排印过程非常复杂,要考虑大量的因素——在这里指路喵喵上上周的精彩 Tunight 《金枪鱼之夜:PowerTUNA & 数字文本渲染 101》(以及也请务必关注可爱喵喵!)。

仓库中的 utils/font-cmap.py 可以从 TTF 文件中提取 CMap 表,并寻找映射了多于一个码位的字形。如对于 times.ttf 运行,就可以获得如下的结果(节选):

Subtable 1:
 Format: 4
 Platform ID: 0
 Platform Encoding ID: 3
 Language: 0
 Number of mappings: 3677
Glyph ID 3 name 'space':
 - ' ' (U+0020, SPACE)
 - ' ' (U+00A0, NO-BREAK SPACE)
Glyph ID 16 name 'hyphen':
 - '-' (U+002D, HYPHEN-MINUS)
 - '­' (U+00AD, SOFT HYPHEN)
Glyph ID 30 name 'semicolon':
 - ';' (U+003B, SEMICOLON)
 - ';' (U+037E, GREEK QUESTION MARK)
Glyph ID 257 name 'periodcentered':
 - '·' (U+00B7, MIDDLE DOT)
 - 'ꞏ' (U+A78F, LATIN LETTER SINOLOGICAL DOT)
Glyph ID 1316 name 'uni018F':
 - 'Ə' (U+018F, LATIN CAPITAL LETTER SCHWA)
 - 'Ә' (U+04D8, CYRILLIC CAPITAL LETTER SCHWA)

这里 Platform ID 0 代表 Windows,Encoding ID 3 代表 Unicode。可以看到,30 (semicolon) 这个字形映射了两个码位,正是 U+003B 和 U+037E。其他的多重映射也是可以理解的,比如空格、连字符、中心点等。

Clerk Ma: 实际上你用思源黑体,正文和康熙部首区也是共用一套glyph的

从字形到码位:PDF ToUnicode 表

在排版工具(如 LaTeX、Typst、Word)将渲染完的字形写入 PDF 时,通常会选择嵌入字体文件,这样可以保证在不同的设备上都能正确显示。换句话说,PDF 中的每个“字”其实都是对字体文件(可能被按需裁剪)中某个字形的引用,而并没有原始文本相关的信息(比如码位或者编码后的字节)。为了使得阅读器可以复制文本,PDF 采取的方式通常是嵌入一个 ToUnicode 表,用来存储字形到码位的映射 f': (FN, GID) -> [CH],其中 FN 是字体名称,而码位可以是一个序列。f' 被要求是单射,也就是一个字形只能有唯一的映射;但由于 f 既不是单射也不是满射,所以不存在直接可计算的逆映射。多个字形对应一个码位的情况显然是容易处理的,但如果多个码位对应一个字形,就面临如何选择码位的问题。

仓库中的 utils/pdf-cmap.py 可以从 PDF 文件中提取 ToUnicode 表。以 LaTeX 的例子 1.semicolon-tex.pdf 为例,可以看到如下的结果(节选):

Font F1:
  BaseFont: PKDHXX+LMRoman10-Regular-Identity-H
...
<002F> <0064>
<0032> <0065>
<003E> <0048>
<0048> <006C>
<0051> <006F>
<0060> <0072>
<0063> <003B>
<0071> <0057>
...

Font F3:
  BaseFont: SNVJFG+TimesNewRomanPSMT
...
<001E> <037E>
<002B> <0048>
<003A> <0057>
<0047> <0064>
<0048> <0065>
<004F> <006C>
<0052> <006F>
<0055> <0072>
...

注意力比较强的读者已经关注到了这两行:<0063> <003B><001E> <037E>,这就是导致问题的罪魁祸首。这里的 001E 是 GID,037E 是码位,因此在复制时,Time New Roman 中的分号字形就变成了希腊问号 U+037E,而 Latin Modern 中都是 U+003B(也可以推断这个字形的 GID 是 0x63 = 99)。

而在 Typst 的例子如下:

Font F0:
  Subtype: Type0
  BaseFont: NBEHZB+LibertinusSerif-Regular-Identity-H
...
<0002> <003B>
<0005> <037E>
...

Font F1:
  BaseFont: CTSHTC+TimesNewRomanPSMT
...
<0002> <003B>
...

同样可以看到,Libertine 中的两个码位映射到了两个不同的字形(尽管长得很像),所以也有不同的表项;而 Times New Roman 中的两个码位在映射到同一个字形后,就只存在一个表项。

注意到这里字体的 GID 是 2,与 LaTeX 和 TTF 文件中的 30 不同。这是因为 Typst 在裁剪字体(又称为子集化)时,使用 subsetter 对字形进行了重新编号。

最后一块拼图

那么为什么 LaTeX 和 Typst 会选择不同的码位作为逆映射?Clerk 告诉我前者的行为由 xdvipdfmx 决定。经过一通代码阅读,我最终只能找到以下的两个函数(LaTeX 部分参考了 tectonic 的代码,在 xdvipdfmx 部分是基本一致的):

也就是说,LaTeX 选择码位的方式与具体排版的内容无关,而 Typst 会选择某个字形第一次被使用时的码位作为逆映射。这可以通过交换 Typst 源文本中两个“分号”的顺序得到验证,此时复制出来的文本都会变成 U+037E。

等一下!那 PDF.js 呢?

刚才提到,如果用 PDF.js 打开 LaTeX 的文件,那么四个“分号”全变成了 U+003B——难道它不会尊重 ToUnicode 表的内容吗?

Clerk Ma: 做了normalization了

我恍然大悟,在 PDF.js 的文档进行检索后,确实找到了相关的内容。为了搜索方便,它会对 PDF 中的文本进行 Unicode 规范化——这又是一个巨大的坑,尤其是在文件系统等处,在此就不展开了。

事实上,阅读器也会对文本(以及搜索的关键词)做规范化,所以在阅读器中输入 U+003B 和 U+037E 进行搜索,都能搜到所有的“分号”。然而 PDF.js 或许是为了偷懒,直接把可复制的文本进行了规范化,就导致了这个令人困惑的现象。

解决方案和更多

再次请出伟大的 Clerk Ma:

解决方案是俩,一个是dvipdfmx增加一步normalization,另一个是字体同时有一套encoding和tounicode
后者就变成,用户自定义编码->glyph id, 用户自定义编码->unicode

前者似乎会导致和 PDF.js 一样的令人困惑的问题,因为规范化不一定是用户想要的结果;后面提到的 encoding 是一种很老的字体功能,用起来也不方便(需要自定义编码)。

常见解决方案是用 \XeTeXgenerateactualtext1

天神降临!确实在打开开关后,复制出来的文本就完全正确了。但这并不是修改了上述的映射,只是让 XeTeX 向 PDF 的 /ActualText 表中写入了实际的文本(见讨论),这样阅读器就可以直接使用这里的文本,而不需要通过上面的手段逆向映射字形。然而,并不是所有的阅读器都支持此特性(如 SumatraPDF 还不支持),并且启用后 Acrobat 的复制行为变得奇怪(无法单字选择)。

通过搜索此命令,还可以看到更多 CMap / ToUnicode 映射相关的问题,比如