0%

CJKpunct 宏包的两个 Bug 修复与测试基础设施建设

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]

其中 lrulerrule 是宽度为标点挤压量的零高零深 \vrule——这是实现标点「占半格」视觉效果的关键;lgluerglue 则是标点与前后文字之间的弹性间距。这个模型在绝大多数场景下工作良好,但在两个边界条件上出了问题。

Bug #747:段首开标点的幽灵缩进

现象

minipagetcolorbox 内嵌 \newtheorem\parindent=0 的环境中,当段落以全角开标点(如「(」)开头时,第二段及之后的段落会出现约 5.4pt 的异常缩进,而首段正常。

1
2
3
4
5
6
7
\begin{minipage}{\linewidth}
(第一段)正常

(第二段)多了约 5.4pt 缩进

(第三段)同样多了约 5.4pt 缩进
\end{minipage}

根因分析

问题出在 CJKpunct 对 class 1 开标点的 lglue 插入逻辑。修复前的代码无条件地在开标点前插入 lglue

1
2
3
\ifnum\CJKpunct@currentcharclass=1\relax
\hskip \csname CJKpunct...@lglue@...\endcsname
plus 0.1em minus 0.1em

首段之所以不受影响,是因为 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
2
3
4
5
\ifnum\CJKpunct@currentcharclass=1\relax
\ifnum\CJKpunct@lastkern>0\relax
\hskip \csname CJKpunct...@lglue@...\endcsname
plus 0.1em minus 0.1em
\fi

这个条件精确地区分了「开标点跟在 CJK 字符后面」和「开标点出现在段首或非 CJK 内容后面」两种情况。段首时 \lastkern 为零(前面只有 indent box 或段首处理结构),lglue 被正确跳过。

Bug #671:段末闭标点后的多余空行

现象

在 pdfLaTeX 下,当段落末尾恰好是全角闭标点(如句号「。」),且该行被 TeX 断行算法挤压(glue set 为负)时,闭标点后会出现一个不期望的空行。用 XeLaTeX 或 LuaLaTeX 编译则无此问题。

1
2
3
这是一段文本这是一段文本这是一段文本这是一段文本这是一段 $abcde$

% ↑ pdfLaTeX 下,上面这段后面会多出一个空行

根因分析

闭标点(class 2)处理完毕后,CJKpunct 会插入一个 rglue\hskip)。修复前的代码直接插入:

1
2
3
4
\ifnum\CJKpunct@currentcharclass=2\relax
\hskip \csname CJKpunct...@rglue@...\endcsname
plus 0.1em minus 0.1em
\fi

当闭标点恰好位于段落最后一个字符时,这个 rglue 成为段末节点列表上的最后一个 glue。TeX 的断行算法允许在 glue 处断行——于是它在 rglue 处断出了一个空行(一个只包含 indent box 的 hbox),这就是用户看到的「多余空行」。

之所以只在 pdfLaTeX 下出现,是因为 XeTeX 和 LuaTeX 使用 xeCJK 或 LuaTeX-ja 处理标点,不走 CJKpunct 的代码路径。

修复

修复方法极其简洁——在 rglue 前加一个 \nobreak

1
2
3
4
5
\ifnum\CJKpunct@currentcharclass=2\relax
\nobreak
\hskip \csname CJKpunct...@rglue@...\endcsname
plus 0.1em minus 0.1em
\fi

\nobreak 等价于 \penalty 10000,告诉 TeX 的断行算法「此处禁止断行」。这样 rglue 就不会成为合法的断行点,段末不再产生空行。

需要注意的是,这并不会阻止闭标点出现在行尾。\nobreak 只禁止在 rglue 本身处断行;在正文中间,TeX 仍然可以在 rglue 之后、下一个字符之前的 glue(CJKgluelglue)处断行,视觉效果与之前完全一致——闭标点留在行尾,下一个字符去到新行。\nobreak 精确地堵住的是段末场景:此时 rglue 后面没有下一个字符,它是节点列表上最后一个 glue,如果允许在此断行就会断出空行。

这个修复与 CJKpunct 已有的处理风格一致——在标点间距序列中,kern 和 rule 之间也使用了类似的 \nobreak 来防止不期望的断行。

从零搭建 l3build 回归测试

ctex-kit 的 ctex 和 xeCJK 模块早已有完善的 l3build 测试框架,但 CJKpunct 一直没有。在修复上述两个 Bug 的过程中,我为 CJKpunct 建立了完整的测试基础设施。

测试框架搭建

首先是 build.lua 配置:

1
2
module  = "cjkpunct"
checkengines = { "pdftex" }

CJKpunct 只在 pdfTeX 引擎下工作(XeTeX 用 xeCJK,LuaTeX 用 LuaTeX-ja),因此测试引擎限定为 pdftex

三个测试用例

punct-basic.lvt:基础加载和标点样式测试,验证 CJKpunct 的核心功能——标点能被正确排版、标点样式(quanjiaobanjiao 等)切换生效。这是冒烟测试。

punct-indent.lvt:#747 的回归测试。核心策略是比较「新 hbox 中的孤立开标点」与「CJK 字符后的开标点」的宽度差异:

1
2
3
4
5
\TEST{Opening~punct~at~start~has~no~lglue}{
\hbox_set:Nn \l_tmpa_box { ( }
\dim_set:Nn \l_tmpa_dim { \box_wd:N \l_tmpa_box }
\TYPE { opening-punct-alone-width:~ \dim_use:N \l_tmpa_dim }
}

在新 hbox 中,\lastkern 为零,修复后不应插入 lglue;而在 CJK 字符之后,\lastkern > 0lglue 应正常插入。

punct-rglue.lvt:#671 的回归测试。策略更为精巧——将标点排入 hbox 后逆向拆解节点,验证 rglue 前存在 \penalty 10000

1
2
3
4
5
6
7
8
9
10
11
12
13
\TEST{Right~punct~rglue~preceded~by~nobreak~(github~\#671)}{
\hbox_set:Nn \l_tmpa_box { 测。 }
\hbox_set:Nn \l_tmpb_box {
\tex_unhbox:D \l_tmpa_box
\tex_unkern:D % 去掉尾部 kern
\tex_unkern:D % 去掉 rrule 的 kern
\tex_unskip:D % 去掉 rglue
\xdef \g_tmpa_tl { \the \tex_lastpenalty:D }
}
\int_compare:nNnTF { \g_tmpa_tl } = { 10000 }
{ \TYPE { PASS:~nobreak~before~rglue } }
{ \TYPE { FAIL:~penalty~is~\g_tmpa_tl } }
}

这种「拆节点验证」的测试方式直接检查 TeX 内部数据结构,比「看排版结果」可靠得多——它不受字体度量变化的影响。

CI 集成

CJKpunct 依赖 arphic 字体(gbsn 等),这些字体在所有平台的 TeX Live 中都可用。测试已接入 GitHub Actions CI,在全平台运行。.tlg 基线文件最初从 CI 输出中获取,因为 CJK 字体的度量数据在不同平台上可能有微小差异。

文档 driver 迁移

CJKpunct 的文档 driver 一直使用 ltxdoc + CJKutf8 + hypdoc + dvipdfmx 的古老组合。这套组合有几个实际问题:

  • CI 上需要安装 CJKutf8CJKspacehypdocatbegshi 等一系列额外依赖
  • 需要 CJK 字体通过 zhwindowsfonts 配置,对非 Windows 环境不友好
  • hypdocdtxchecksum 不兼容,导致 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 的文档都间接依赖它——而且它的代码风格属于「不敢轻易碰」的那类:全局状态多、条件嵌套深、缺乏注释。测试框架的存在让未来的维护者(包括未来的我)能够更有信心地修改代码,而不必担心引入不可见的回归。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。