使用 cleveref 为 \appendix 添加命令钩子

使用 cleveref 为 \appendix 添加命令钩子

\appendix我正在尝试使用新的钩子管理系统为命令设置钩子。但是,当cleveref加载时,钩子失败,错误为“\appendix 定义中的参数编号非法。” begindocument,因此推测钩子是由设置的ltcmdhooks。该消息通常出现 46 次,在不同的文档中出现相同的次数,我认为这是##定义中的数量cleveref

使用等效etoolbox代码不会导致同样的问题。

下面的 MWE 并不是真正的“真实用例”,但足以重现该问题:

\documentclass{book}

\usepackage{cleveref}

\AddToHook{cmd/appendix/before}{\label{appendix}}

% 'etoolbox' works though
% \usepackage{etoolbox}
% \preto{\appendix}{\label{appendix}}{}{}

\begin{document}

Hello world!

\appendix

\end{document}

我有点不知该如何处理这个问题。但是,如果能更好地了解罪魁祸首是什么,特别是为什么ltcmdhooks会出现故障etoolbox,以便我更好地了解我能用前者做什么,那就太好了。

编辑:如果可以的话,我想在这里为问题添加一个维度。我的实际用例是,我试图在启动时\appendix在用于发布的包中进行一些设置。现在,我无法确定\appendix可能包含的内容,因为它在一个或另一个包或类中的很多地方被重新定义。我原本希望保留钩子,但如果在一个或另一个用例中出现问题,我可以指示用户手动移除钩子并重新建立工作状态。但添加和移除钩子仍然会中断\appendix

\documentclass{book}

\usepackage{cleveref}

\AddToHook{cmd/appendix/before}[package-1]{\label{appendix}}
\RemoveFromHook{cmd/appendix/before}[package-1]

\begin{document}

Hello world!

\appendix

\end{document}

是否有一种安全的方法来在应用钩子之前禁用它begindocument

答案1

修补命令大致有两种方法:通过\scantokens,以及通过扩展+重新定义。本答案的末尾对这两种方法都有(不那么)简短的解释。当ltcmdhooks可以检测到命令的类型时,它确切地知道<parameter text>命令的 ,它通过扩展+重新定义进行修补,因此它对定义宏时生效的 catcode 设置没有任何限制。在 的情况下\appendix,它不带参数,因此可以将其视为标记列表并进行扩展,然后使用添加的材料重新定义。

例如,这里有一个关于其工​​作原理的简单示意图:

\def\appendix{%
  \typeout{This starts the appendix.}}

\def\append#1{%
  \expandafter\appendaux\expandafter{#1}#1}
\def\appendaux#1#2#3{%
  \def#2{#1#3}}

\append\appendix{\typeout{I added this.}}

\appendix

然而,当我编写该代码时我没有预料到的是,当的原始定义\appendix包含时会出现这种情况##(请在上面的代码中尝试这个定义):

\def\appendix{%
  \typeout{This starts the appendix. ##BOOM!}}

\appendix如此定义时,TeX 的定义扫描器会看到,并在 的定义中用单个参数标记替换它,到目前为止一切正常。但是,当您展开命令时,TeX 还会返回单个,然后当您尝试重新定义命令时,您会得到:#6#6#6\appendix#6

\def\appendix{%
  \typeout{This starts the appendix. #BOOM!}%
  \typeout{I added this.}}

其中包含非法参数(#B),并且定义错误。

我有已更改ltcmdhooks来处理这种情况(下面有一个简短的解释),但同时您可以使用\ActivateGenericHook(或\ProvideHook在 LaTeX 2021-06-01 中)来表示ltcmdhooks您已经修补了该命令,因此它不会尝试修补,然后您可以使用以下命令手动进行修补etoolbox

\documentclass{book}

\usepackage{cleveref}

\usepackage{etoolbox}
\IfFormatAtLeastTF{2021-11-15}%
  {\ActivateGenericHook}% LaTeX > 2021-11-15
  {\ProvideHook}%         LaTeX = 2021-06-01
    {cmd/appendix/before}
\pretocmd\appendix
  {\UseHook{cmd/appendix/before}}
  {}{\FAILED}

\AddToHook{cmd/appendix/before}{\label{appendix}}

\begin{document}

Hello world!

\appendix

\end{document}

为什么上述方法有效

ltcmdhooksin的接口\AddToHook应该按如下方式工作:

如果最终用户写入\AddToHook{cmd/name/before}{code},并且钩子cmd/name/before尚不存在(这意味着命令\name没有“安装”该钩子),那么代码会尝试在命令中修补该钩子。

如果最终用户写入\AddToHook{cmd/name/before}{code},并且钩子cmd/name/before已经存在,这(可能)意味着该命令\name已经有该钩子,因此它只需将代码添加到钩子中,并保留命令。

这意味着软件包作者可能想要微调cmd/name/before钩子的位置(例如\def\name{<some initialization>\UseHook{cmd/name/before}<definition>}),然后我们不想ltcmdhooks再次修补该命令(两次添加相同的钩子是错误的),因此我们ltcmdhooks通过说来表示钩子已经存在\ActivateGenericHook{cmd/name/before},然后不再尝试修补。

这适用于您的情况,因为您可以手动将钩子添加到命令中,然后告知ltcmdhooks不再需要 pathching。请参阅部分3 包作者界面ltcmdhooks文件。

因此从本质上讲,您作为软件包作者,是\appendix通过自己添加钩子(确切地说ltcmdhooks是在添加它的位置)来挪用该命令,然后ltcmdhooks通过使用来告知不要对其进行修补\ActivateGenericHook

\appendix如果您要将钩子添加到而不是\UniqueCommandFromMyPackage,那么您可以使用\NewHook而不是\ActivateGenericHook(效果相同),因为不存在名称冲突的可能性。

LaTeX2ε 现在如何处理这种情况

问题:事实证明,在上述情况下,我们陷入了死胡同。当你写出如下定义时

\def\foo#1{#1##X}

TeX 将其存储<replacement text>为一个标记列表,其中包含:

out_param 1, par_token #, letter X

(在宏展开时要替换为实际参数,out_param 1是catcode 6  ,是 catcode 11  )。#1par_token ##letter XX

然后,当你\foo#1( par_token #, character 1) 展开时,TeX 会进行替换out_param 1,你会得到:

par_token #, character 1, par_token #, letter X

这相当于输入#1#X。如果你把它带回到 的新定义中,\foo你将得到:

\def\foo#1{#1#X}

这显然是错误的(因此是Illegal parameter number错误)。此时,您无法分辨定义宏时哪个是实际参数,哪个是单个参数标记。

半解决方案: 有一种非常简单的情况很容易检测和解决(碰巧是您的问题中的情况):没有参数的宏。在这种情况下,宏没有参​​数,因此##其定义中的任何松散都不可能与参数混淆,因此我们可以将此类宏视为标记列表(在某种意义上expl3),并执行类似操作\tl_put_right:Nn并解决问题。

另一个相对简单的情况是宏定义中没有参数##。在这种情况下,我们不必担心参数混淆,因此我们正常处理宏(这是最初实现的情况)。LaTeX 使用一个相当简单的循环来检查宏定义中是否有参数标记(\__hook_if_has_hash:nTF):它查看定义中的每个标记,并将其与进行比较#

另一半: 当宏属于具有两个参数的一般情况时参数标记在其定义中(如上所示\foo),那么我们必须手动将定义中的每个参数标记重新加倍,以便可以重新制作。为此, LaTeX 不会使用\foo来扩展它#1,而是使用 来扩展它\c_@@_hash_tl,因此\foo{\c_@@_hash_tl}变成了这样的定义:

\foo#1{\c_@@_hash_tl 1#X}

然后我们循环遍历宏的替换文本(括号内)并将 every 翻倍##,并将 every 替换\c_@@_hash_tl为一个#,然后得到:

\foo#1{#1##X}

然后我们就可以正常地进行定义(


修补\scantokens

此处有更冗长的描述

假设一个宏定义为

\long\def\mycmd[#1]#2{\typeout{#1//#2}}

要通过 向其中附加一些代码\scantokens,首先要\meaning\mycmd获取如下字符串:

\long macro:[#1]#2->\typeout {#1//#2}

(使用通常的\detokenizecatcode:除空格外全部 12 个,空格是 catcode 10),然后使用分隔宏来分隔<prefixes><parameter text><replacement text>,大致如下所示:

\def\split#1{\expandafter\splitaux\meaning#1\relax}
\expanded{%
  \noexpand\def\noexpand\splitaux#1\detokenize{macro:}#2->#3\relax}{%
    \def\prefixes{#1}%
    \def\parameter{#2}%
    \def\replacement{#3}}

(我使用\def\prefixes{#1},等等,以便于理解,但实际上你会将所有东西都可扩展地注入;如果你够勇敢,请参阅,和\__kernel_prefix_arg_replacement:wNexpl3-code.tex定义)。\etb@patchcmdetoolbox.sty

此时,您将定义的每一部分都单独作为一个字符串。现在,您可以向其附加或添加一些代码\replacement(或替换其中的某些部分,如在中所做的那样\patchcmd),或者在极少数情况下更改\prefixes\parameter。此时,您有三个字符串,每个字符串都是定义的一部分。要重建定义,您需要:

<prefixes>\def\mycmd<parameter text>{<replacement text>}

但您拥有的三个部分仍然是 catcode 12 个标记,这没什么用。接下来是\scantokens:您将这些字符串重新扫描回“正常”标记:

\expanded{%
  \noexpand\scantokens{%
  % <prefixes>\def         \mycmd<parameter text>{<replacement text>}
    \prefixes \def\noexpand\mycmd\parameter      {\replacement      <added material>}%
  }%
}

\expanded完成任务后,变为:

\scantokens{%
  \long\def\mycmd[#1]#2{\typeout {#1//#2}<added material>}%
}%

然后\scantokens执行其操作并使用当前 catcode 设置将所有内容转换为标记,然后正常进行定义。

这种方法的优点是您可以在定义的任何部分进行几乎任何操作。

缺点如下:

  • 您需要知道在第一次定义时哪些 catcode 有效(修补时您通常需要验证简单的一轮\meaning\scantokens不会改变宏的含义)否则您无法安全地修补;
  • 如果该宏是使用和的某种组合创建的\edef\detokenize以强制创建某些 catcode 12 标记,则您可能无法修补该宏(例如,\splitaux如上文所定义,无法用它修补,\patchcmd因为它包含 catcodes 11 和 12 的字母(例如m);
  • 如果<parameter text>宏的包含字符->,则您将无法修补该宏。

通过扩展+重新定义进行修补

这种方法简单得多,但需要事先了解宏的定义方式。这可以在少数情况下完成,即当您确切知道<parameter text>宏的是什么时。内核知道的情况是宏使用 定义\DeclareRobustCommand,或使用ltcmd\NewDocumentCommand\NewExpandableDocumentCommand),或\newcommand使用可选参数,或宏不使用参数。

假设与之前相同的宏,但定义为:

\newcommand\mycmd[2][default]{\typeout{#1//#2}}

(它将有一个名为 的内部宏\\mycmd,但为了简单起见,我们\mycmd也将其称为 ),那么我们肯定知道它<parameter text>[#1]#2。知道宏需要什么参数,我们可以将#1#2、 ... 作为参数提供给它,因此对于\mycmd我们可以这样做:

\mycmd[#1]{#2}

然后它将扩展为<replacement text>宏的,其中第一个参数(#1)被替换为(参数标记后跟字符)。修补方案如下:#6112#1

\expanded{%
  \def\noexpand\mycmd[#1]#2{%
    \unexpanded\expandafter{\mycmd[#1]{#2}<added material>}%
  }%
}

完成后\expanded你将得到:

\def\mycmd[#1]#2{\typeout{#1//#2}<added material>}}

这正是您使用的方法\scantokens,只是您没有将标记转换为字符串,因此 catcodes 在这里根本不重要。

该方法的优点大致就是该\scantokens方法的缺点:

  • catcodes 根本不重要;
  • \splitaux只要您确切知道它<parameter text>是什么,您就可以使用此方法修补复杂的宏(包括之前的宏);
  • 宏的<parameter text>可以包含任何你想要的标记(只要你知道它是什么标记);并且
  • 此方法不需要健全性检查来确保宏可以正确修补。

缺点是该方法要起作用有一定的要求:你需要确切地知道是什么<parameter text>

相关内容