ctex-kit
ctex-kit copied to clipboard
xeCJK: box 中直接使用 \noexpand 命令导致意料之外的结果
环境:Windows11, TexLive 2021, xeCJK: 2021/12/12, v3.8.8
\documentclass{article}
\begin{document}
\def\tmpa{b}
a\noexpand\tmpa c
\end{document}
这样一个 TeX 文件,(在常用编译器下)编译结果显示 ac
,中间的 \tmpa
也就是 b
是不显示的。
但是使用 XeLaTeX
,加载 xeCJK
宏包后,则编译结果为 abc
。
(虽然一般情况下不会这样写就是了)
这是使用 xetex 的 character class 的副作用,难以避免。
首先,为什么「正常情况下」a\noexpand\tmpa c
只输出 ac
,跳过了 \tmpa
:
The expansion of
\noexpand
is the next token, with a temporary meaning of\relax
.TeX by Topic, sec. 1.3.2 "Special cases:
\expandafter
,\noexpand
, and\the
" (可以通过texdoc texbytopic
甚至texdoc topic
打开)
然后,介绍 xetex 独有的 character class 功能:
它可以为字符分配类别,然后自动在类别 i 和类别 j 的字符之间,插入设定的内容。(不可展开的)非字符输入,如 \noexpand
后临时变成 \relax
的 \tmpa
,都归入特别的一类。参见 XeTeX Reference, sec. 3.1 "Character classes" (可以用 texdoc xetex
打开)
接着,因为 xecjk 使用并默认开启了 character class 这个功能,于是在 \noexpand\tmpa
展开一步后,会因为看到 \relax
而插入 xecjk 设定的 inter char tokens。等再来处理 \tmpa
时,它已经恢复 b
的定义了。使用
\xeCJKsetup{xeCJKactive=false} % 改成 true 再看看
\leavevmode % 避免 trace latex2e 的 paragraph hook
{\tracingall
a\noexpand\tmpa c
}
输出 tracing 信息到 log,可以看到在 xeCJKactive=true
时,在 \noexpand
展开后,插入的 inter char tokens。
下面的例子「在正常情况下」,也输出 abc
\def\id#1{#1} % identical function
a\expandafter\id\noexpand\tmpa c
最后,一个手动使用 character class 复现情景的例子:
\documentclass{article}
\begin{document}
\newXeTeXintercharclass \mycharclass
\XeTeXcharclass `\a \mycharclass
\XeTeXcharclass `\b \mycharclass
\XeTeXcharclass `\c \mycharclass
\XeTeXinterchartoks \mycharclass 4095={[} % 任意非空值均可
\XeTeXinterchartoks 4095 \mycharclass={]}
\newcommand\test[1]{%
\begingroup
\leavevmode\hskip-10pt input: \texttt{\detokenize{#1}}\par
off: \XeTeXinterchartokenstate=0
#1\par
on: \XeTeXinterchartokenstate=1
#1\par
\endgroup
}
\test{abc}
\def\tmpb{b}
\test{a\tmpb c}
\test{a\noexpand\tmpb c}
\end{document}
输出为
input: abc
off: abc
on: ]abc[
input: a\tmpb c
off: abc
on: ]abc[
input: a\noexpand \tmpb c
off: ac
on: ]a[bc[
首先,为什么「正常情况下」
a\noexpand\tmpa c
只输出ac
,跳过了\tmpa
:The expansion of
\noexpand
is the next token, with a temporary meaning of\relax
. TeX by Topic, sec. 1.3.2 "Special cases:\expandafter
,\noexpand
, and\the
" (可以通过texdoc texbytopic
甚至texdoc topic
打开)
更准确地,那个 "a temporary meaning of \relax
" 不是用户能输入的那个 \relax
,而是一个特殊的、用户不能直接输入的、不可展开的 token。[^1]
个人建议,只在要避免展开的时候使用 \noexpand
[^2],尽量不要故意利用这种特殊效果来实现功能。
[^1]: Statements about \noexpand
in source3.pdf and in the TeXbook
[^2]: Practical use of \noexpand
outside\edef
(\xdef
) and \write
(\messge
)?
我的理解是,使用最后一个例子,xetex 在处理到 a
时,发现随后是一个特殊的字符类(4095
),然后插入对应的 toks,然后继续处理,此时 \tmpb
已经恢复到了原始的定义,被展开为 b
,和之后的 c
是同一个字符类,所以不插入 toks。
要想得到 “正常的结果”,就暂时取消字符类的功能。
我的理解是,使用最后一个例子,xetex 在处理到
a
时,发现随后是一个特殊的字符类(4095
),然后插入对应的 toks,然后继续处理
遇到 a
,加入 horizontal list;遇到 \noexpand
,展开得到改变了意思的 \tmpb
,它不可展开,也加入 list;遇到 char classes 0 到 4095 的边界,于是在加入 list 的 \tmpb
之前,插入相应 tokens [^1];尝试展开插入的 tokens。
此时
\tmpb
已经恢复到了原始的定义,被展开为b
我也说不清,恢复原始定义的具体触发条件是什么。彻底地搞明白,恐怕要去看 (xe)tex 的源码 (knuth-pdf
包提供了排版了的带注释源码 tex.pdf
和 xetex.pdf
)。搜到的一些讨论:tex-sx 问题编号 12482, 387850, 609774;xetex 邮件组的邮件 https://tug.org/pipermail/xetex/2015-May/025984.html 。
再次表明我的偏好:在纯展开的 \edef
等地方把所有 \noexpand
都消灭,不要把它留给 expansion processor 和 execution processor 交替执行的阶段 (texdoc topic
, sec. 1.4)。有些时候 tex 很难,有些特定行为只能从源码中得到解释,例如 tex-sx 问题 62852。
[^1]: ... XeTeX inserts them between character nodes being added to the current list https://tex.stackexchange.com/questions/21625/in-luatex-is-it-possible-to-change-font-language-according-to-the-script-glyphs/21691#comment42657_21691
再次表明我的偏好:在纯展开的 \edef 等地方把所有 \noexpand 都消灭,不要把它留给 expansion processor 和 execution processor 交替执行的阶段 (texdoc topic, sec. 1.4)。
嗯嗯。实际情况一般不会这么用,只是在个别时候会用到,比如像写 TeX by topic 1.3.2 节那样的教程时,或者做 TeX 学习笔记时,为了显示相应的效果,会需要这样写。
至于恢复原始定义的触发条件,从实用性角度讲,我觉得倒没有必要纠结了。
我的理解是,使用最后一个例子,xetex 在处理到
a
时,发现随后是一个特殊的字符类(4095
),然后插入对应的 toks,然后继续处理,此时\tmpb
已经恢复到了原始的定义,被展开为b
,和之后的c
是同一个字符类,所以不插入 toks。 要想得到 “正常的结果”,就暂时取消字符类的功能。
我也说不清,恢复原始定义的具体触发条件是什么。彻底地搞明白,恐怕要去看 (xe)tex 的源码 (knuth-pdf 包提供了排版了的带注释源码 tex.pdf 和 xetex.pdf)。搜到的一些讨论:tex-sx 问题编号 12482, 387850, 609774;xetex 邮件组的邮件 https://tug.org/pipermail/xetex/2015-May/025984.html
我看了一下代码,大概意思是,tex 展开 \noexpand
时获取下一个 token 后又放回去了,如果下一个token是一个可以展开的控制序列,就再 push 一个 frozen_dont_expand,下次读入的时候遇到此 frozen_dont_expand,再把下一个 token 读为一个特殊的 \relax
(cur_cmd
等于 \relax
, cur_chr
比\relax
大1,cur_tok
跟 cur_cs
为跟原来的 token 一样)。
执行和 \if
判断的时候看前两个,所以记号没有展开。\edef
时用 cur_cmd
发现无法展开,压栈 cur_tok
,所以得到原来的命令。
xetex 插入 interchartoks 时又压了一次栈,用的是 cur_cs
来构造压栈所需的 token ,所以就恢复原来的定义了。
简而言之,只要展开之后没马上用,就会“恢复原始定义”。
我看了一下代码,大概意思是,tex 展开
\noexpand
时获取下一个 token 后又放回去了,如果下一个token是一个可以展开的控制序列,就再 push 一个 frozen_dont_expand,下次读入的时候遇到这个 frozen_dont_expand,再把下一个 token 读为一个特殊的\relax
(cur_cmd
等于\relax
,cur_chr
比\relax
大1,cur_tok
跟cur_cs
为跟原来的 token 一样)。
代码里插入的规则跟我之前自己凭感觉总结的差不多。
- 如果第一个 token 不是 boundary,插入之后就不在它跟插入的记号之间再插入了,而是虚设 interchar tokens 记号列前面有一个 Boundary, 再考虑记号列之前要不要插入新的 interchar tokens。
所以
\XeTeXinterchartoks \classa \classA {A}
可行但\XeTeXinterchartoks \classa \classA {a} aA
会造成死循环(假设a
,A
的字符类别分别是\classa
,\classA
) - 两个 Boundary 之间不考虑插入 interchartoks。
- 如果 interchartoks 记号列最后一个是 Boundary,不考虑它跟后面记号的 interchar tokens。
不过源码里发现第3点有一个例外。
\newXeTeXintercharclass \Xclass
\XeTeXcharclass `\X \Xclass
\XeTeXinterchartoks \Xclass \Xclass {*\relax}
\XeTeXinterchartoks \Xclass 4095 = {*\relax}
\XeTeXinterchartoks 4095\Xclass = {?}
\def\x{X}
[XX]
[X\noexpand\x]
第二个虽然是插入的最后一个是 Boundary,但还是要继续插入。
[X*X]
[X*?X]
因为 xetex 在处理第二个 token 不是 Boundary 时,会标记一个状态,处理到记号列的末尾,如果最后一个是 Boundary,又发现这个标记,就不再插入,而 \noexpand\x
第一次插入的状态是 Boundary,没有做这个标记。