CTeX-org / ctex-kit

Macro Packages and Scripts for Chinese TeX users
972 stars 124 forks source link

xeCJK: box 中直接使用 \noexpand 命令导致意料之外的结果 #604

Open Sophanatprime opened 2 years ago

Sophanatprime commented 2 years ago

环境: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

(虽然一般情况下不会这样写就是了)

muzimuzhi commented 2 years ago

这是使用 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[
muzimuzhi commented 2 years ago

首先,为什么「正常情况下」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)?

Sophanatprime commented 2 years ago

我的理解是,使用最后一个例子,xetex 在处理到 a 时,发现随后是一个特殊的字符类(4095),然后插入对应的 toks,然后继续处理,此时 \tmpb 已经恢复到了原始的定义,被展开为 b,和之后的 c 是同一个字符类,所以不插入 toks。 要想得到 “正常的结果”,就暂时取消字符类的功能。

muzimuzhi commented 2 years ago

我的理解是,使用最后一个例子,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.pdfxetex.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

Sophanatprime commented 2 years ago

再次表明我的偏好:在纯展开的 \edef 等地方把所有 \noexpand 都消灭,不要把它留给 expansion processor 和 execution processor 交替执行的阶段 (texdoc topic, sec. 1.4)。

嗯嗯。实际情况一般不会这么用,只是在个别时候会用到,比如像写 TeX by topic 1.3.2 节那样的教程时,或者做 TeX 学习笔记时,为了显示相应的效果,会需要这样写。

至于恢复原始定义的触发条件,从实用性角度讲,我觉得倒没有必要纠结了。

SainoNamkho commented 2 years ago

我的理解是,使用最后一个例子,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 读为一个特殊的 \relaxcur_cmd 等于 \relax, cur_chr\relax 大1,cur_tokcur_cs 为跟原来的 token 一样)。

执行和 \if 判断的时候看前两个,所以记号没有展开。\edef 时用 cur_cmd 发现无法展开,压栈 cur_tok,所以得到原来的命令。

xetex 插入 interchartoks 时又压了一次栈,用的是 cur_cs 来构造压栈所需的 token ,所以就恢复原来的定义了。

简而言之,只要展开之后没马上用,就会“恢复原始定义”。

SainoNamkho commented 2 years ago

我看了一下代码,大概意思是,tex 展开 \noexpand 时获取下一个 token 后又放回去了,如果下一个token是一个可以展开的控制序列,就再 push 一个 _frozen_dontexpand,下次读入的时候遇到这个 _frozen_dontexpand,再把下一个 token 读为一个特殊的 \relaxcur_cmd 等于 \relax, cur_chr\relax 大1,cur_tokcur_cs 为跟原来的 token 一样)。

代码里插入的规则跟我之前自己凭感觉总结的差不多。

  1. 如果第一个 token 不是 boundary,插入之后就不在它跟插入的记号之间再插入了,而是虚设 interchar tokens 记号列前面有一个 Boundary, 再考虑记号列之前要不要插入新的 interchar tokens。 所以 \XeTeXinterchartoks \classa \classA {A} 可行但 \XeTeXinterchartoks \classa \classA {a} aA 会造成死循环(假设 a, A 的字符类别分别是 \classa, \classA
  2. 两个 Boundary 之间不考虑插入 interchartoks。
  3. 如果 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,没有做这个标记。