CJKpunct 是 CTeX 套件中负责中文标点挤压和间距调整的宏包。它的代码年代久远——最早可追溯到 CJK 宏包时代——但至今仍是 pdfLaTeX + CJK 中文排版方案中不可替代的一环。这段时间我集中处理了 CJKpunct 上两个积压已久的 Bug(#747 和 #671),并从零搭建了 l3build 回归测试框架,顺带完成了文档 driver 的现代化迁移和 CI 打包流水线的接入。本文记录这些工作的技术细节和背后的思考。
背景:CJKpunct 的标点处理模型
在深入 Bug 之前,有必要简要回顾 CJKpunct 的核心模型。CJKpunct 将每个标点字符分为三类:
- class 0:非标点的 CJK 字符
- class 1:开标点(左括号、左引号等)
- class 2:闭标点(句号、逗号、右括号等)
当 \CJKpunct@CJKpunctsymbol 处理一个标点时,它按如下顺序插入节点:
1 | [lglue] → [lrule(零宽)] → 标点字符 → [rrule(零宽)] → [rglue] |
其中 lrule 和 rrule 是宽度为标点挤压量的零高零深 \vrule——这是实现标点「占半格」视觉效果的关键;lglue 和 rglue 则是标点与前后文字之间的弹性间距。这个模型在绝大多数场景下工作良好,但在两个边界条件上出了问题。
Bug #747:段首开标点的幽灵缩进
现象
在 minipage、tcolorbox 内嵌 \newtheorem 等 \parindent=0 的环境中,当段落以全角开标点(如「(」)开头时,第二段及之后的段落会出现约 5.4pt 的异常缩进,而首段正常。
1 | \begin{minipage}{\linewidth} |
根因分析
问题出在 CJKpunct 对 class 1 开标点的 lglue 插入逻辑。修复前的代码无条件地在开标点前插入 lglue:
1 | \ifnum\CJKpunct@currentcharclass=1\relax |
首段之所以不受影响,是因为 lglue 紧跟在段首的强制换行(\penalty -10000)之后,TeX 的断行算法将其作为可丢弃节点(discardable item)消除。但从第二段开始,情况不同:每段开头有一个宽度为 \parindent 的 indent box(即使 \parindent=0,这个零宽 box 依然存在)。indent box 是不可丢弃的,它阻止了 lglue 被消除——于是 lglue 的自然宽度(在 quanjiao 样式下约 5.4pt)变成了可见的「幽灵缩进」。
修复
修复策略是:仅在前方确有 CJK 字符时才插入 lglue。判据是 \CJKpunct@lastkern——CJKpunct 在每个 CJK 字符后都会留下一个特征 kern,其值大于零。
1 | \ifnum\CJKpunct@currentcharclass=1\relax |
这个条件精确地区分了「开标点跟在 CJK 字符后面」和「开标点出现在段首或非 CJK 内容后面」两种情况。段首时 \lastkern 为零(前面只有 indent box 或段首处理结构),lglue 被正确跳过。
Bug #671:段末闭标点后的多余空行
现象
在 pdfLaTeX 下,当段落末尾恰好是全角闭标点(如句号「。」),且该行被 TeX 断行算法挤压(glue set 为负)时,闭标点后会出现一个不期望的空行。用 XeLaTeX 或 LuaLaTeX 编译则无此问题。
1 | 这是一段文本这是一段文本这是一段文本这是一段文本这是一段 $abcde$。 |
根因分析
闭标点(class 2)处理完毕后,CJKpunct 会插入一个 rglue(\hskip)。修复前的代码直接插入:
1 | \ifnum\CJKpunct@currentcharclass=2\relax |
当闭标点恰好位于段落最后一个字符时,这个 rglue 成为段末节点列表上的最后一个 glue。TeX 的断行算法允许在 glue 处断行——于是它在 rglue 处断出了一个空行(一个只包含 indent box 的 hbox),这就是用户看到的「多余空行」。
之所以只在 pdfLaTeX 下出现,是因为 XeTeX 和 LuaTeX 使用 xeCJK 或 LuaTeX-ja 处理标点,不走 CJKpunct 的代码路径。
修复
修复方法极其简洁——在 rglue 前加一个 \nobreak:
1 | \ifnum\CJKpunct@currentcharclass=2\relax |
\nobreak 等价于 \penalty 10000,告诉 TeX 的断行算法「此处禁止断行」。这样 rglue 就不会成为合法的断行点,段末不再产生空行。
需要注意的是,这并不会阻止闭标点出现在行尾。\nobreak 只禁止在 rglue 本身处断行;在正文中间,TeX 仍然可以在 rglue 之后、下一个字符之前的 glue(CJKglue 或 lglue)处断行,视觉效果与之前完全一致——闭标点留在行尾,下一个字符去到新行。\nobreak 精确地堵住的是段末场景:此时 rglue 后面没有下一个字符,它是节点列表上最后一个 glue,如果允许在此断行就会断出空行。
这个修复与 CJKpunct 已有的处理风格一致——在标点间距序列中,kern 和 rule 之间也使用了类似的 \nobreak 来防止不期望的断行。
从零搭建 l3build 回归测试
ctex-kit 的 ctex 和 xeCJK 模块早已有完善的 l3build 测试框架,但 CJKpunct 一直没有。在修复上述两个 Bug 的过程中,我为 CJKpunct 建立了完整的测试基础设施。
测试框架搭建
首先是 build.lua 配置:
1 | module = "cjkpunct" |
CJKpunct 只在 pdfTeX 引擎下工作(XeTeX 用 xeCJK,LuaTeX 用 LuaTeX-ja),因此测试引擎限定为 pdftex。
三个测试用例
punct-basic.lvt:基础加载和标点样式测试,验证 CJKpunct 的核心功能——标点能被正确排版、标点样式(quanjiao、banjiao 等)切换生效。这是冒烟测试。
punct-indent.lvt:#747 的回归测试。核心策略是比较「新 hbox 中的孤立开标点」与「CJK 字符后的开标点」的宽度差异:
1 | \TEST{Opening~punct~at~start~has~no~lglue}{ |
在新 hbox 中,\lastkern 为零,修复后不应插入 lglue;而在 CJK 字符之后,\lastkern > 0,lglue 应正常插入。
punct-rglue.lvt:#671 的回归测试。策略更为精巧——将标点排入 hbox 后逆向拆解节点,验证 rglue 前存在 \penalty 10000:
1 | \TEST{Right~punct~rglue~preceded~by~nobreak~(github~\#671)}{ |
这种「拆节点验证」的测试方式直接检查 TeX 内部数据结构,比「看排版结果」可靠得多——它不受字体度量变化的影响。
CI 集成
CJKpunct 依赖 arphic 字体(gbsn 等),这些字体在所有平台的 TeX Live 中都可用。测试已接入 GitHub Actions CI,在全平台运行。.tlg 基线文件最初从 CI 输出中获取,因为 CJK 字体的度量数据在不同平台上可能有微小差异。
文档 driver 迁移
CJKpunct 的文档 driver 一直使用 ltxdoc + CJKutf8 + hypdoc + dvipdfmx 的古老组合。这套组合有几个实际问题:
- CI 上需要安装
CJKutf8、CJKspace、hypdoc、atbegshi等一系列额外依赖 - 需要 CJK 字体通过
zhwindowsfonts配置,对非 Windows 环境不友好 hypdoc与dtxchecksum不兼容,导致l3build ctan打包时 checksum 验证失败
迁移方案很直接:将 driver 切换到 ctxdoc(ctex-kit 各包通用的文档类),编译引擎改为 XeLaTeX。这样一来,CJK 字体由 ctex 自动配置,所有额外依赖全部移除,整个 driver 从 20 余行缩减到寥寥数行。
对于 checksum 与 hypdoc 不兼容的问题,在 build.lua 中将 checksum 函数覆盖为空操作:
1 | checksum = function() end |
这是一个务实的折中:CJKpunct 的代码量不大(约 940 行),CheckSum 的防篡改意义有限,而绕过这个不兼容可以让 CI 打包流水线跑通。
版本号与发布
上述所有改动合入主线后,CJKpunct 的版本号从 v4.8.4 提升到 v4.8.5。这是 CJKpunct 自 2014 年以来的首次版本更新。同时,CJKpunct 也被纳入了新建的 tag 触发自动 release 流水线——推送 CJKpunct-v* 格式的 tag 即可自动打包 CTAN zip 并创建 GitHub Release。
小结
CJKpunct 的这两个 Bug 有一个共同特点:它们都源于宏包对「边界条件」的处理不够精确。lglue 在段首不应出现,rglue 在段末不应成为断行点——这些约束在正文中间永远不会被触发,只有在段落的首尾才暴露。
修复本身都很小——一个是加了一层 \ifnum 判断,另一个是加了一个 \nobreak——但定位过程需要对 TeX 断行算法中可丢弃节点的规则、indent box 的存在性、\lastkern 的语义有清晰的理解。这也是为什么我在修复的同时投入了相当精力建设测试框架:对于这类依赖 TeX 内部行为的微妙 Bug,光靠肉眼看排版结果是不够的,必须有能直接验证节点结构的自动化测试。
从工程角度看,为一个只有约 940 行代码的宏包搭建完整的 l3build 测试、CI 流水线和自动发布机制,投入产出比看起来不高。但 CJKpunct 的用户基数不小——任何使用 pdfLaTeX + ctex 的文档都间接依赖它——而且它的代码风格属于「不敢轻易碰」的那类:全局状态多、条件嵌套深、缺乏注释。测试框架的存在让未来的维护者(包括未来的我)能够更有信心地修改代码,而不必担心引入不可见的回归。