ctex 是中文 LaTeX 排版的核心基础设施——几乎所有中文 LaTeX 文档都直接或间接依赖它。这段时间我集中处理了 ctex 上积压的数个 Bug,涉及字间距(CJKglue)被覆盖、macOS 15 字体检测失效、LuaTeX 下 \verb 前间距丢失、hyperref 选项冲突,以及 .spa 文件生成等问题。同时完成了旧字体钩子兼容代码的清理和版本号提升(v2.5.10 → v2.6.0)。本文逐一记录这些改动的技术细节。
CJKglue 导言区设置被覆盖(#761)
现象
用户在导言区通过 \xeCJKsetup{CJKglue=...} 设置自定义字间距,\begin{document} 后设置被 ctex 的默认值覆盖,完全不生效。同样的设置放在正文中则正常。
1 | \documentclass{article} |
根因
ctex 的 linestretch 机制通过 \selectfont 钩子链——\ctex_update_size: → \ctex_update_stretch: → \@@_update_stretch_auxii:——在每次字体切换时重新计算并设置 \CJKglue。问题在于 \@@_update_stretch_auxii: 无条件地调用 \ctex_update_ccglue: 覆盖 \CJKglue,而不检查用户是否已经手动修改过它。
有趣的是,linestretch 禁用时走的另一条路径 \@@_update_stretch_auxi: 已经有 \ctex_if_ccglue_touched:TF 检查——只在用户未修改 \CJKglue 时才覆盖。\@@_update_stretch_auxii: 缺少同样的守卫,是一个遗漏。
修复:两次迭代
第一次修复尝试用 docstrip 守卫 %<*pdftex|xetex> 在 \@@_update_stretch_auxii: 中直接加入 \ctex_if_ccglue_touched:TF 检查。但这个方案有一个隐蔽的问题:ctex.sty 是以 {style,ctex} 标签从 dtx 中提取的,不包含 pdftex / xetex 引擎标签,导致守卫代码被 docstrip 直接剥离,修复实际无效。
第二次修复换了策略——不在主体代码中做条件编译,而是利用引擎 .def 文件的加载时机:
1 | % 默认实现:所有引擎的原始行为 |
1 | % 在 pdftex/xetex 引擎 .def 中,通过 \ctex_at_end:n 在包加载末尾重定义 |
将 linestretch 的实际计算逻辑提取到 \@@_update_stretch_auxiii:,\@@_update_stretch_auxii: 默认直接调用它。在 ctex-engine-pdftex.def 和 ctex-engine-xetex.def 中,通过 \ctex_at_end:n 在包加载末尾重定义 \@@_update_stretch_auxii:,加入 \ctex_if_ccglue_touched:TF 守卫。这样一来:
- 用户已修改
\CJKglue→ 只更新\ccwd,不覆盖用户设置 - 用户未修改
\CJKglue→ 走原始路径,自动计算 - LuaTeX / upTeX 不受影响,保持原始行为
这个修复过程本身就是一个教训:在 dtx 中使用 docstrip 守卫做条件编译时,必须确认目标输出文件的标签集合包含所需的守卫标签。否则,看起来正确的代码会被静默剥离。
macOS 15 字体检测失效(#722)
背景
ctex 的 fontset=mac 选项通过检测苹方(PingFang)字体是否存在来区分 macnew(El Capitan 及之后)和 macold(之前)两套字库配置。检测方式是检查苹方字体文件是否位于 /System/Library/Fonts/ 路径下。
从 macOS 15 (Sequoia) 开始,Apple 将苹方、楷体、仿宋等字体改为「downloadable」——它们不再预装在系统字体目录中,而是在首次使用时按需下载,存放在 /System/Library/AssetsV2/ 下的缓存目录中。这导致 ctex 的文件存在性检测失效,macnew 被错误判断为 macold。
修复策略
修复分三层:
版本号检测作为后备判据。通过读取 /System/Library/CoreServices/SystemVersion.plist 获取 macOS 主版本号。XeTeX 通过 TeX I/O 逐行读取 plist 文件,LuaTeX 通过 io.open 直接解析。版本号 ≥ 15 时判定为 macnew。
运行时字体可用性检测。在 macnew 的 XeTeX 分支中,不再假设字体一定存在,而是用 \fontspec_font_if_exist:nTF 逐一检测可选字体(楷体、仿宋、隶书、圆体、苹方)的可用性。核心字体(宋体、黑体)直接加载——它们在 macOS 15 上始终可用;可选字体不可用时静默跳过。
LuaTeX 的 AssetsV2 扫描。LuaTeX 分支用 lfs(LuaFileSystem)扫描 AssetsV2 目录,定位 downloadable 字体的实际路径,动态注册到 luaotfload 的搜索路径中。
苹方字体不可用时回退到黑体(Heiti SC);版本检测失败时 warning 并回退到 macold。
LuaTeX 下 \verb 前间距丢失(#556)
现象
在 LuaTeX 下使用 ctex 排版时,\verb 命令前的 xkanjiskip(中西文间距)丢失:
1 | \documentclass{ctexart} |
根因
luatexja 的 lltjcore 模块对 \verb 做了一个关键补丁:将 \verb 内部使用的 \null(空 \hbox{})替换为 \vadjust{}。原因是空 hbox 会阻断 luatexja 的 xkanjiskip 自动插入机制——luatexja 在相邻字符间插入 xkanjiskip 时,中间如果有 hbox 节点就会被截断。
但 ctex 在加载 luatexja 时禁用了 ltj-latex(为了使用自己的字体管理机制),连带着 lltjcore 的 \verb 补丁也没有被加载。
修复
在 ctex 的 LuaTeX 引擎适配代码中移植 lltjcore 的两个补丁:
\verb重定义——将\null替换为\vadjust{}\do@noligspatchcmd——同样替换其中的\null
同时声明 ver@lltjcore.sty 防止 lltjcore 被重复加载。新增 verb01 回归测试验证 \verb 和 \texttt 在 LuaTeX 下产生相同的 hbox 宽度。
hyperref driverfallback 警告(#715)
当 beamer 等文档类在 ctex 之前加载 hyperref 时,ctex 通过 \hypersetup 设置 driverfallback,但此选项作为加载选项在 hyperref 已加载后被禁用,触发警告。
修复方式是将 driverfallback 从 \ctex_hypersetup:n 中分离:hyperref 未加载时用 \PassOptionsToPackage,已加载时跳过此选项。
旧字体钩子兼容代码清理(#746)
ctex 和 xeCJK 中保留了针对 LaTeX < 2020/10/01 的旧 NFSS 字体钩子回退路径——直接操作 \@rmfamilyhook 或 patch \rmfamily / \fontfamily。2020/10/01 的 LaTeX 内核引入了新的 NFSS 钩子机制,此后的版本不再需要这些回退。
考虑到 TeX Live 2020 已发布六年,继续维护旧路径的代价(代码复杂度、测试负担)高于收益。此次清理移除了所有旧路径,仅保留使用新 NFSS 钩子的代码。这是一个 breaking change:不再支持 LaTeX < 2020/10/01。
测试基线维护
除了上述功能性改动,还有一类不起眼但必要的工作:维护测试基线文件(.tlg)。ctex 的 config-contrib 测试套件中包含 pkuthss 和 thuthesis 等真实模板的回归测试,它们对 xeCJK 行为变化非常敏感。
本轮维护中,xeCJK 的多次行为修正(ecglue 缓存修复、whatsit 节点处理变化、\textcolor 场景修复)都触发了 ctex 侧测试基线的更新。例如 xeCJK #803 移除通用 whatsit 恢复后,pkuthss 测试中 hyperref \special 注释后的重复 ecglue 被正确消除,基线中对应的三处 \glue 条目需要删除。
这类更新没有代码改动,但对 CI 的持续通过至关重要。
版本号提升与发布
所有改动完成后,ctex 的版本号从 v2.5.10 提升到 v2.6.0,并移动 ctex-v2.6.0 tag 到最新提交。ctex 已接入 tag 触发的自动 release 流水线——推送 ctex-v* 格式的 tag 即可自动执行 l3build ctan、打包 zip 并创建 GitHub Release。
小结
ctex 作为中文 LaTeX 排版的基石,其维护工作有一个显著特点:大量问题来自外部环境的变化——操作系统升级(macOS 15 字体路径变更)、上游宏包更新(hyperref 选项语义变化)、TeX 引擎差异(LuaTeX 的 xkanjiskip 机制)——而非 ctex 自身的逻辑错误。这意味着维护者需要持续追踪外部生态的变化,并在 ctex 层面做适配。
CJKglue 被覆盖的问题则是另一类:内部代码路径之间的不一致。\@@_update_stretch_auxi: 有守卫而 \@@_update_stretch_auxii: 没有,这种不一致在最初编写时可能是有意为之(两条路径的语义不同),但随着功能演进变成了 Bug。docstrip 守卫失效的问题更是提醒我们:在 dtx 中做条件编译时,必须清楚地知道每个输出文件的标签集合——这不是一个直觉上容易把握的信息。
从工程角度看,这轮维护中一个反复出现的模式是:修复 xeCJK 的行为 → 更新 ctex 的测试基线 → 验证 CI 通过。ctex 和 xeCJK 虽然是独立的包,但通过 config-contrib 测试紧密耦合。这种耦合是有意为之——真实模板的回归测试能捕获单元测试发现不了的集成问题——但也意味着一个包的改动可能需要另一个包同步更新基线。保持这种跨包测试的健康运转,是 ctex-kit 作为 monorepo 的核心价值之一。