xeCJK 是 XeLaTeX 下中文排版的核心引擎——几乎所有使用 XeLaTeX 编译的中文文档,都直接或间接依赖它处理字体切换、字间距和标点压缩。但 xeCJK 的内部机制鲜有系统性的介绍:多数用户只知道 \setCJKmainfont 和 \xeCJKsetup,对「字符之间的间距是怎么插入的」「标点压缩的数据从哪里来」「为什么 \textcolor 会破坏间距」这类问题缺乏直觉。本文试图从 XeTeX 原语层出发,逐层拆解 xeCJK 的设计,让读者建立一个从整体到局部的心智模型。
XeTeX 的 interchar token 原语
xeCJK 的一切行为都建立在 XeTeX 提供的 interchar token 机制之上。理解这一原语是理解 xeCJK 的前提。
XeTeX 允许为每个 Unicode 码位分配一个整数「字符类」(character class),通过 \XeTeXcharclass 赋值:
1 | \XeTeXcharclass`你 = 1 % 将「你」归入类 1 |
当 \XeTeXinterchartokenstate = 1 时,XeTeX 在排版过程中检测相邻字符的类别变化。如果前一个字符属于类 $i$、后一个属于类 $j$($i \neq j$),XeTeX 会在两者之间自动插入 \XeTeXinterchartoks i j 中预存的 token 序列:
1 | \XeTeXinterchartoks 1 0 = {\hskip 0.25em} % 类 1 → 类 0 时插入间距 |
这个机制的关键特性是:它工作在 token 层,发生在字符被「吃进」主列表之前。这意味着插入的 token 序列可以包含任意 TeX 命令——切换字体、开关分组、插入 penalty、执行条件判断——而不仅仅是 kern 或 glue。xeCJK 正是利用这一点,在字符类边界处注入了完整的字体切换、间距计算和标点处理逻辑。
但 token 层工作也带来一个根本限制:interchar 机制无法区分一个字符是来自 Unicode 直接输入,还是来自 \char 原语的间接输出。这个限制后面会反复出现。
字符分类体系
xeCJK 在 XeTeX 原生的 Default(类 0)和 Boundary(类 4095)基础上,建立了一套面向中文排版的字符分类体系:
| 类别 | 来源 | 典型成员 | 排版语义 |
|---|---|---|---|
Default |
XeTeX 预定义 | abc123 | 西文,不主动干预 |
CJK |
XeTeX 预定义 | 汉字、假名 | 切换 CJK 字体,插入字间距 |
FullLeft |
XeTeX 预定义 | (《「 | 全角左标点,禁止出现在行尾 |
FullRight |
XeTeX 预定义 | ,。)」 | 全角右标点,禁止出现在行首 |
HalfLeft |
xeCJK 新增 | ( [ { | 半角左标点 |
HalfRight |
xeCJK 新增 | , . ? ) ] } | 半角右标点 |
NormalSpace |
xeCJK 新增 | - / \ | 前后保持原始间距 |
Boundary |
XeTeX 预定义 | 空格、段落边界 | 边界标记 |
CM |
xeCJK 新增 | IVS 选择符 | 组合标识,不打断 CJK 序列 |
HangulJamo |
xeCJK 新增 | ᄻᆟᇫ | 朝鲜文字母 |
分类初始化发生在 xeCJK.dtx 的 \xeCJKResetCharClass 中。全角标点的归类数据来自 XeTeX 的 unicode-char-prep.pl 脚本和 Unicode Line Break 属性数据库(UAX#14),涵盖数百个码位。这不是拍脑袋定义的——它与 Unicode 标准的断行算法保持对齐。
值得注意的是 CM 类(Combining Marks / IVS)的设计:异体字选择符(U+E0100–U+E01EF)在 Unicode 中紧跟基字符出现,用于指定字形变体。如果把它们归入 CJK 类,就会在基字符与选择符之间触发不必要的 interchar 动作。CM 类的行为与 CJK 几乎相同,唯一区别是 CJK → CM 过渡不插入任何内容——选择符「透明地」附着在前一个字符上。
类别转换矩阵
有了字符分类,核心问题就是:当类 $A$ 后面紧跟类 $B$ 时,该做什么?xeCJK 在源码中以一个 9×9 矩阵的形式定义了所有类别对的行为。下表给出完整的 81 种转换(省略 HangulJamo,其行为与 CJK 相同但自身之间不插入内容):
| 前\后 | Default | CJK | FullLeft | FullRight | HalfLeft | HalfRight | NormalSp | Boundary | CM |
|---|---|---|---|---|---|---|---|---|---|
| Default | — | 入CJK | 入CJK | 入CJK | — | — | — | 记default | =CJK |
| CJK | 出+ecglue | CJKglue | 标点glue | 标点glue | 出+ecglue | 出+ecglue | 出+ecglue | 记CJK | — |
| FullLeft | 出+ecglue | 标点→CJK | 标点glue | 标点glue | 出+ecglue | 出+ecglue | 出+ecglue | 记标点 | =CJK |
| FullRight | 出+ecglue | 标点→CJK | 标点glue | 标点glue | 出+ecglue | 出+ecglue | 出+ecglue | 记标点 | =CJK |
| HalfLeft | — | 入CJK | 入CJK | 入CJK | — | — | — | — | =CJK |
| HalfRight | — | 入CJK | 入CJK | 入CJK | — | — | — | 记default | =CJK |
| NormalSp | — | 入CJK | 入CJK | 入CJK | — | — | — | 记normalsp | =CJK |
| Boundary | 恢复 | 恢复+入CJK | 恢复+入标点 | 恢复+入标点 | 恢复 | — | 恢复NS | — | =CJK |
| CM | =CJK | =CJK | =CJK | =CJK | =CJK | =CJK | =CJK | =CJK | =CJK |
表中符号含义:
- —:不插入任何内容(纯西文之间、半角标点之间等)
- 入CJK:开启 CJK 分组 + 切换字体 + 输出字符
- 出+ecglue:关闭 CJK 分组(ecglue 由后续边界恢复处理)
- CJKglue:插入中文字间距
- 标点glue:触发标点压缩逻辑(kern 计算)
- 标点→CJK:从标点进入普通 CJK 字符的过渡
- 记xxx:写入标记 kern(为后续恢复留下线索)
- 恢复:读取标记 kern,决定插入 ecglue 或 CJKglue
- 恢复NS:NormalSpace 专用恢复路径
- =CJK:行为与对应的 CJK 行完全相同(CM/HangulJamo 透传)
矩阵的几个规律一目了然:CM 行和 CM 列几乎全部复制 CJK 的行为;Boundary 行是恢复逻辑的入口;对角线上只有 CJK→CJK 有实质操作(插入 CJKglue)。
核心转换规则可以进一步归纳为四条路径:
进入 CJK 区域
当字符从 Default、HalfLeft、HalfRight 或 NormalSpace 转入 CJK 时:
1 | \xeCJK_inter_class_toks:nnn { Default } { CJK } |
\xeCJK_class_group_begin: 做三件事:开启 TeX 分组(\bgroup)、设置一个标志表示当前处于 CJK 分组内、将 \XeTeXdashbreakstate 置零以避免破折号间折行。字体切换只在这个入口点发生——CJK 区域内部的字符间不再重复切换。
离开 CJK 区域
1 | \xeCJK_inter_class_toks:nnn { CJK } { Default } |
离开时只关闭分组。间距(\CJKecglue)并不在这里插入,而是延迟到后续的边界恢复阶段——这是 xeCJK 最微妙的设计之一。
CJK 内部
1 | \xeCJK_inter_class_toks:nnn { CJK } { CJK } |
CJK 字符之间插入 \CJKglue(默认 0pt plus 0.08\baselineskip)——这是中文排版中字间距的弹性胶,允许行末微调。
经过 Boundary
CJK 字符与西文之间如果有空格或命令间隔,流程变成:
1 | CJK → Boundary → Default |
这时简单的「相邻类检测」不再适用——CJK 和 Default 不直接相邻。xeCJK 需要在 CJK → Boundary 时记录状态,再在 Boundary → Default 时恢复间距。这就引出了 xeCJK 最复杂的子系统。
边界恢复状态机
这是 xeCJK 架构中最精密、也是历史上 Bug 最密集的部分。理解它需要一个三层模型。
第一层:标记 kern 判定
xeCJK 的核心思路是:在 CJK → Boundary 过渡时,向当前列表写入一个特殊的 kern 节点作为「标记」。后续 Boundary → Default 或 Boundary → CJK 过渡发生时,通过 \lastkern 读回这个标记,判断应该恢复什么间距。
不同的标记值代表不同的上下文。xeCJK 通过 \xeCJK_declare_node:n 为每种标记分配一个唯一的 kern 宽度(从 11sp 起递增),用 \lastkern 即可区分:
| 标记名 | 实际 kern 值 | 写入时机 | 含义 | 恢复时插入 |
|---|---|---|---|---|
CJK |
11sp | CJK→Boundary(无空格) | 上一个可见字符是 CJK | \CJKglue |
CJK-space |
12sp | CJK→Boundary(有空格) | CJK 后跟了源码空格 | \CJKecglue(缓存值) |
default |
13sp | Default/HalfRight→Boundary | 上一个可见字符是西文 | \CJKecglue(缓存值) |
CJK-widow |
14sp | 段末孤字检测 | CJK 孤字标记 | penalty + \CJKglue |
normalspace |
15sp | NormalSpace→Boundary | 上一个是 NormalSpace 类字符 | 按需恢复 |
default-space* |
16sp | Default→Boundary(有空格) | 西文后跟了空格 | \CJKecglue(缓存值) |
*注:default-space 使用 glue(而非 kern)作为标记,因为 kern 会干扰 XeTeX 的 character protrusion(字符突出)功能。判定方式相应改为 \lastskip 而非 \lastkern。
标记节点本身由两个背靠背的 kern 构成——先 \kern -Nsp 再 \kern Nsp,净宽度为零,不影响排版输出。\xeCJK_remove_node: 通过两次 \unkern 将其删除。
恢复逻辑集中在 \xeCJK_check_for_glue: 函数中:
1 | \cs_new_protected:Npn \@@_check_for_glue_auxi: |
\xeCJK_remove_node: 删除标记 kern 本身(它只是内部簿记,不应出现在最终排版输出中),然后插入正确的间距。
第二层:whatsit 定点恢复
问题在于,\lastkern 只能看到「当前列表最后一个节点」。如果标记 kern 写入之后、恢复判定之前,有其他节点被插入,\lastkern 就看不到标记了。
最常见的干扰源是 \special 产生的 whatsit 节点。例如 \textcolor{red}{...} 会在颜色切换时插入 PDF 颜色指令的 whatsit:
1 | ... [CJK 标记 kern] [color whatsit] ... |
xeCJK 曾尝试通用 whatsit 恢复——「只要上一节点是 whatsit,就根据保存的状态恢复 glue」。但这在 hyperref 等包的场景下导致误判(#803):biblatex 参考文献列表中 \raise\hbox{[} 后面的 hyperref 注释 whatsit 被错误恢复出 ecglue,产生 [ 1] 类的可见间距。
当前实现收窄为定点恢复——只对两个已知安全的 whatsit 来源做补丁:
\set@color(color/xcolor):颜色 whatsit 插入后,如果之前有 xeCJK 标记,立即重放标记 kern\Hy@BeginAnnot(hyperref):进入链接注释前保存节点状态并清空,注释开始后选择性重放
其他来源的 whatsit(如 \raise\hbox 内部的)不参与恢复。这等价于把设计哲学从「猜测恢复」改为「精确重建」。
第三层:ecglue 缓存取值
\CJKecglue 默认是 ~,即当前字体的词间空格。这个值依赖字体——不同字体、不同字号的空格宽度不同。
问题在于:如果恢复点处的字体已经不是 CJK 字体(比如中间经过了 \texttt 分组),重新展开 \CJKecglue 会拿到错误的字体度量。
解决方案是缓存:在 CJK → Boundary 过渡时(此时仍处于正确的 CJK 字体上下文),立即测量 \CJKecglue 并存入 \l_@@_ecglue_skip。后续所有恢复路径统一使用这个缓存值。
为什么选择 CJK→Boundary 作为缓存时机?
- 初始化时缓存——会随后续字号切换而过期
- 每个字符都缓存——频率过高、状态复杂度大
- CJK→Boundary——恰好是离开正确 CJK 上下文前的最后稳定时机
三层合在一起,构成了 xeCJK 边界恢复的完整心智模型:用 \lastkern 判定边界类型,被 whatsit 打断时走定点补丁,恢复时使用缓存的 ecglue 值。
字体管理
xeCJK 的字体管理建立在 fontspec 之上,但加了一层面向 CJK 的封装。
切换时机
CJK 字体切换只发生在 interchar token 触发的分组入口处。当 XeTeX 检测到字符从非 CJK 类切换到 CJK 类时,interchartoks 中的 \xeCJK_select_font: 执行字体切换。CJK 区域内部的字符间不重复切换——整个 CJK 连续序列共享同一次字体选择结果。
这意味着 xeCJK 的字体开销是 $O(\text{边界数})$ 而非 $O(\text{字符数})$。对于一段纯中文文本,边界只出现在段落首尾和中间夹杂的西文处。
字体族注册
\setCJKmainfont、\newCJKfontfamily 等用户命令最终调用 \xeCJK_set_family:nnn,后者完成两件事:
- 通过 fontspec 加载字体并注册 NFSS 字体族(全局)
- 定义用户可见的切换命令(局部)
「字体族注册是全局的,切换命令是局部的」这一区分是有意为之的——它与 fontspec 的 \newfontfamily 语义保持一致:在分组内声明的字体切换命令不会泄漏到组外,但已注册的字体族信息可被后续代码复用。
后备字体
当主 CJK 字体不包含某字符时,xeCJK 通过 \setCJKfallbackfamilyfont 设置的后备链逐一尝试。检测机制是 \xeCJK_fallback_symbol:NN——在 interchar 入口处、输出字符前检查当前字体是否包含该码位,不包含则按优先级切换到后备字体。
标点压缩系统
中文排版中标点符号的宽度处理是一个高频痛点:全角标点在视觉上占据一个汉字宽度,但其字形实际上只填充了一半空间,另一半是空白 side bearing。相邻标点如果都保持全角,会出现明显的视觉「空洞」。标点压缩的目标就是消除或减少这些多余空白。
数据来源
xeCJK 通过 XeTeX 的 \XeTeXglyphbounds 原语获取每个标点字符的左右 side bearing:
1 | \XeTeXglyphbounds 1 \the\XeTeXcharglyph`, % 左侧 bearing |
这些数据是字体相关的——不同 CJK 字体的标点字形设计差异很大。xeCJK 在文档初始化时测量并缓存所有全角标点的 bearing 数据。
标点样式
xeCJK 提供 PunctStyle 选项,预定义了多种压缩策略:
quanjiao(全角):所有标点占一个汉字宽,不压缩banjiao(半角):所有标点压缩到半个汉字宽kaiming(开明):句末标点(。!?)保持全角,其他压缩hangmobanjiao(行末半角):仅行末标点压缩CCT:模拟 CCT 系统的标点处理
每种样式实际上是一组规则,定义了「标点前方 kern」和「标点后方 kern」在不同上下文(行首、行末、相邻标点)中的取值。用户可通过 \xeCJKDeclarePunctStyle 声明自定义样式。
相邻标点的 kern 计算
当两个标点相邻时(如「」后跟「,」),它们之间的压缩量由两个标点的 bearing 之和决定。xeCJK 通过 \xeCJKsetkern 允许手动覆盖这些值,但通常由样式规则自动计算。内部存储在 g_@@_punct/kern/<char1>/<char2>/tl 属性表中。
间距系统总览
把前面各部分涉及的间距类型汇总:
| 间距 | 插入位置 | 默认值 | 来源 |
|---|---|---|---|
\CJKglue |
CJK ↔ CJK | 0pt plus 0.08\baselineskip |
interchar CJK→CJK |
\CJKecglue |
CJK ↔ 西文 | ~(字体空格) |
边界恢复状态机 |
| 标点 kern | 标点 ↔ 标点/CJK | 由 PunctStyle 计算 | interchar FullLeft/FullRight 路径 |
\CJKglue 和 \CJKecglue 都可通过 \xeCJKsetup 全局修改。但注意:\CJKecglue 的默认值 ~ 是字体相关的——换字体或字号后它的实际宽度会变化。这正是边界恢复第三层「缓存取值」要解决的问题。
xCJKecglue 选项:统一源码空格行为
默认情况下,xeCJK 对「源码中 CJK 与西文直接相邻」和「源码中 CJK 与西文之间有空格」的处理是不同的:
中文text(无空格)→ 边界恢复插入\CJKecglue中文 text(有空格)→ 保留 TeX 原生词间空格 glue
这意味着用户的源码书写习惯会影响最终排版的间距宽度。对于追求一致性的文档,xeCJK 提供了 xCJKecglue 选项:
1 | \xeCJKsetup{xCJKecglue=true} |
启用后,源码空格被「吃掉」并统一替换为 \CJKecglue——无论写 中文text 还是 中文 text,得到的间距完全相同。
这个选项与边界恢复状态机的关系值得一提:前面标记 kern 表中的 default-space(16sp,使用 glue 而非 kern 作为标记)正是为 xCJKecglue=true 路径服务的。它标记「这里原来有源码空格,恢复时需要替换为 \CJKecglue」。当 xCJKecglue=false 时,这个标记根本不会被写入——源码空格直接保留为正常 glue,无需标记和恢复。
选择用 glue 而非 kern 作为标记也是有原因的:default-space 标记的位置恰好是原来空格 glue 所在的位置,用同类型节点替换可以避免干扰 XeTeX 的 character protrusion(字符突出)功能。判定方式相应从 \lastkern 变为 \lastskip。
兼容性补丁:模式与哲学
xeCJK 的 interchar 机制对 TeX 的执行流有侵入性——它在字符之间注入了 token。这与很多第三方包的假设冲突。xeCJK 通过 \@@_package_hook:nn 延迟加载兼容补丁。
典型补丁遵循一个固定模式:
1 | \@@_package_hook:nn { ulem } |
\makexeCJKinactive 将 \XeTeXinterchartokenstate 设为 0,相当于告诉 XeTeX「在这段代码中不要做 interchar 检测」。这是最直接的隔离手段。
不同包的补丁复杂度差异很大:
- ulem:简单隔离——下划线渲染过程中关闭 interchar 即可
- pifont:需要先
\mode_leave_vertical:(进入水平模式),否则垂直模式下的分组赋值会泄漏到输出例程 - listings:需要完全重写字符转换机制——用
\scantokens替代\lccode+\lowercase路线,避免 CJK 字符被设为 active catcode - color/xcolor 和 hyperref:需要在 whatsit 插入点做状态保存/重放,这已经上升到边界恢复状态机的层面
这些补丁的共同哲学是最小侵入:不重定义第三方包的全部接口,只在已知冲突点做定点修复。
\char 原语:一条架构级红线
XeTeX 的 interchar 机制工作在 token 层——它看到的是「将要输出到当前列表的字符 token」,无法区分这个字符来自 Unicode 直接输入还是 \char"4E00 这样的原语调用。
这意味着 \char 输出的字符同样会触发 interchar 检测。对于大多数场景这没有问题——但在某些数学包(如 mtpro2)中,\char 被用来取字形而非输出文本字符,此时 interchar 的字体切换和间距插入完全是多余甚至有害的。
xeCJK 对此确立了一条架构约束:
\char必须始终保持 XeTeX primitive 身份,不得被重定义。
早期曾有方案尝试在 \AtBeginDocument 中重定义 \char,但这会破坏 xint 等包在加载期通过 \let 保存原语的假设。当前的稳定策略是三层的:
- 提供
\xeCJKchar——语义是「绕过 interchar 输出字符」,实现上在输出前临时关闭 interchar - 对已知受影响的第三方包做自动补丁(如 mtpro2 的
\overcbrace) - 其他场景需用户手动用
\makexeCJKinactive分组包装
这不是一个「没时间做完美方案」的妥协,而是一个刻意的设计选择:低侵入优先。改变原语身份的全局影响不可控,定点补丁的影响范围可预测。
Token 层 vs. 节点层:xeCJK 的根本约束
要真正理解 xeCJK 为什么是现在这个样子,需要先理解 TeX 引擎处理文档的两个阶段。
两个世界
Token 层(输入处理阶段):TeX 把源文件逐字符读入,转化为 token 流——字符 token、命令 token。宏展开、条件判断、赋值都在这里发生。此时还没有「排版」:没有盒子、没有 glue 节点、没有断行。XeTeX 的 interchar 机制正是工作在这一层——它在字符 token 被送入排版引擎之前,往 token 流里插入额外的 token。
节点层(排版阶段):token 被「消化」后变成排版节点——字符节点(glyph)、glue 节点、kern 节点、penalty 节点、whatsit 节点等。这些节点组成水平/垂直列表,最终经断行、分页算法处理后输出到 PDF。LuaTeX 的 callback 机制工作在这一层——它可以在断行前后直接遍历、修改完整的节点列表。
核心区别在于可见范围:
- Token 层只能看到「当前正在处理的 token」和有限的前瞻(
\peek),对已经写入列表的内容只能通过\lastkern、\lastpenalty、\lastnodetype等原语回溯最后一个节点 - 节点层能看到整段或整行的完整节点列表,可以任意向前、向后遍历和修改
这就是为什么 xeCJK 需要那套复杂的边界恢复状态机:它在 token 层做决策,看不到全貌,只能靠「提前埋标记 kern、事后读 \lastkern」来推断上下文。而 LuaTeX-ja 在节点层工作,判断「这个字符前面是 CJK 还是西文」只需要往前遍历一个节点——不需要标记,不需要状态机,不需要担心 whatsit 打断。
时间复杂度的差异
两者渐近复杂度相同(都是线性),但工作模式不同:
xeCJK 的代价分摊在整个输入过程中:每个字符边界触发一次 interchar toks 执行 O(1) 的工作(查标记、插 glue、切字体),没有额外的遍历。总代价 O(n),常数因子很小。
LuaTeX-ja 把间距处理集中在回调点:字符消化阶段不做间距处理,等一整段输入完毕后在 pre_linebreak_filter 回调中遍历整段节点列表。通常需要 2–3 趟遍历(kanjiskip、xkanjiskip、标点压缩各一趟),总代价 O(kn),常数因子更高。
但这并不意味着 xeCJK 在性能上占优。LuaTeX 引擎本身比 XeTeX 更快(现代化的内存模型、Lua 的 JIT 友好性),回调的额外开销被引擎层面的提速抵消。实际文档编译中,瓶颈通常在字体加载和 PDF 输出,不在间距计算。
复杂度的性质
更值得关注的是两者代码复杂度的性质差异:
- xeCJK 的复杂度大部分是偶然的(accidental complexity):标记 kern、whatsit 定点恢复、ecglue 缓存——这些机制的存在不是因为排版问题本身需要它们,而是因为 token 层看不到全貌,被迫发明的变通。如果能直接访问节点列表,这些东西根本不需要存在。
- LuaTeX-ja 的复杂度大部分是本质的(essential complexity):它实现了更完整的日文排版模型(基于 JIS X 4051 / W3C JLREQ),处理的规则本身就更丰富——行头/行末禁则、连续标点压缩、ruby 对齐、竖排。代码量更大,但每一段代码都在处理「真正的排版问题」,而不是在绕平台限制。
节点层拥有完整信息,反而让很多判断变得直截了当。xeCJK 做的事相对少,但每件事都要和平台限制搏斗。
小结
xeCJK 的设计可以从两个维度理解:
横向看,它是一个建立在 XeTeX interchar token 原语之上的字符间距与字体控制层。字符分类定义了「什么和什么相邻」,转换矩阵定义了「相邻时做什么」,标点压缩和字体切换是具体的「做什么」。这一层是声明式的、静态的——给定分类和规则,行为完全确定。
纵向看,边界恢复状态机处理的是「相邻」这个概念被打破时怎么办。空格、命令、颜色 whatsit、hyperref 注释——所有这些都可能在 CJK 与西文之间插入不可见的节点,打断 interchar 的直接相邻检测。状态机的三层设计(标记 kern → whatsit 定点恢复 → ecglue 缓存)是 xeCJK 历史上最密集的 Bug 来源,也是其最精密的工程成果。
xeCJK 的设计哲学是在 token 层模拟节点层的行为。这个根本约束决定了它的所有设计选择:为什么需要标记 kern 而不是直接查看列表内容,为什么 whatsit 恢复必须是定点的而不能是通用的,为什么 \char 不能被安全地重定义。理解了这个约束,就理解了 xeCJK 为什么是现在这个样子——不是因为它不够好,而是因为 XeTeX 的 interchar 机制本身就是一个 token 层的工具,xeCJK 在这个约束内做到了能做的一切。