编程辅助工具

Philipp Lehman 以 biblatexcsquotes 闻名,但在最多导言区里默默运转的,恐怕是他的第三件作品 etoolbox。原因几乎归结为一条命令:\patchcmd,它能 只替换别人宏定义中的一部分,而不必整个重写。不过这里有个陷阱:如果找不到搜索文本,\patchcmd 什么也不做——不报错,也不警告。宏包更新的第二天,导言区的调整「莫名其妙不灵了」,多半就是这个原因。本页讲解 etoolbox 的判定、标志、钩子与补丁,接着是 LaTeX 宏包用来搭建 key=value 接口的引擎 pgfkeys,最后是用于实数运算的 \fpeval

etoolbox 是什么——给 e-TeX 工具箱套上 LaTeX 的外观

etoolbox 是给编写文档类和宏包的人准备的编程工具箱。 它把 e-TeX 新增的低层原语重新包装成 符合 LaTeX2e 习惯的写法,并在其上添加了大量通用便利工具。TeX Live 2024 中收录的是 2.5k 版(2020 年 10 月 5 日),版权声明里并列着两个名字:Philipp Lehman(2007–2011)与 Joseph Wright(2015–2020)。现代 TeX 引擎都内建 e-TeX,所以只需 \usepackage{etoolbox}。即便 expl3(LaTeX3 的编程层)已经普及,etoolbox 依然存活,原因是 它能直接融入 LaTeX2e 的世界:参数就写作 #1,分支是熟悉的 {真}{假} 二选一,更有可以事后修补他人宏包的 \patchcmd。对于真实的导言区工作,这套组合很难被取代。

写判定——\ifdef\ifdefempty\ifstrequal

etoolbox 的判定全都是同一个形状:最后接一对 {⟨为真时的代码⟩}{⟨为假时的代码⟩}。不必记着写 \fi,也不必纠结 \else 放哪里。「这个命令是否已定义」用 \ifdef{\cmd}{真}{假};若手上是名称字符串,则用 \ifcsdef{name}{真}{假}(否定式为 \ifundef\ifcsundef)。字符串方面有:判断「是否只有空格」的 \ifblank、其否定 \notblank、判断两个字符串是否相等的 \ifstrequal{字符串}{字符串}{真}{假},判断「宏体是否为空」的 \ifdefempty{\cmd}{真}{假},以及判断「给定字符串本身是否为空」的 \ifstrempty{字符串}{真}{假}。请勿把它们与名字相近的 \ifdefined 混淆——那是 e-TeX 的原语,并非 etoolbox 的二选一分支。

latex
\usepackage{etoolbox}

% provide a command only if nobody defined it yet
\ifdef{\highlight}
  {}                                    % already there: leave it alone
  {\newcommand{\highlight}[1]{\textbf{#1}}}

% behave differently on an empty argument
\newcommand{\field}[1]{\ifblank{#1}{(none)}{#1}}

% numeric tests, same two-way shape
\ifnumcomp{\value{page}}{>}{10}{late}{early}
\ifnumodd{\value{page}}{recto}{verso}

这里有一处连读文档也容易忽略的差别:\ifstrequal\ifdefstring 不可展开。 查看 etoolbox 源码可以发现,这两条都是用 \newrobustcmd 定义的——也就是带上了 e-TeX 的 \protected——因此在 \edef\typeout\csname 内部不会如预期般工作。写下 \typeout{\ifstrequal{abc}{abc}{SAME}{DIFF}},日志里出现的不是 SAME,而是原样的 \ifstrequal {abc}{abc}{SAME}{DIFF}。相比之下,\ifdefempty 是可展开的,放进 \edef 里只会留下结果。在正文中分支时它们都没有问题,差别只在放进 \edef 时才显现——记住这条界线,就不会为一个「判定不灵」的谜团耗掉一整天。

布尔标志——\newtoggle\newbool 该用哪个

默认应当选 \newtoggle,理由在于命名空间:toggle 有自己的命名空间,绝不会与已有命令冲突。\newtoggle{draft} 声明,用 \toggletrue{draft}\togglefalse{draft}(或 \settoggle{draft}{true})切换,用 \iftoggle{draft}{⟨真⟩}{⟨假⟩} 分支,用 \nottoggle 取反。另一族 bool 提供同样的形状——\newbool{draft}\setbool{draft}{true}\booltrue\boolfalse\ifbool{draft}{⟨真⟩}{⟨假⟩}——但内部使用与 LaTeX 的 \newif 相同的机制,因此会 占用一个命令名 \ifdraft。这正是取舍的分界:只有当需要与既有的基于 \newif 的代码互操作时才选 bool,其余情况 toggle 就够了。

命令含义注意
\newtoggle{f}声明标志 f(初值为假)独立命名空间;不占用命令名
\settoggle{f}{v}把 f 设为 v(true / false)\toggletrue / \togglefalse 等效
\iftoggle{f}{T}{F}真则 T,假则 F三个参数;不需要 \fi
\newbool{f}bool 版本的标志声明\newif 同一机制;占用一个命令名
\ifbool{f}{T}{F}bool 版本的二选一分支可与既有的基于 \newif 的代码混用

\newrobustcmd\robustify——造一个不会崩的宏

\newrobustcmd 的写法与 \newcommand 完全相同,但产出的是稳健(robust)命令。 差别在 \meaning 下一眼可见:用 \newcommand 造的命令报告 \long macro:->…,而用 \newrobustcmd 造的报告 \protected\long macro:->…。也就是说,它跳过了 LaTeX 传统的 \protect 两步走,直接使用 e-TeX 的 \protected 前缀。因此它可以放进标题或题注这类「移动参数」中,而不会在写入目录文件的途中被展开弄坏。对于别人已经定义好的脆弱命令,可以用 \robustify{\cmd} 就地把现有定义变得稳健。

\patchcmd——只改写别人宏的一部分

\patchcmd 会在已定义宏的宏体中查找搜索字符串,并只替换那一处。 它接受五个参数:\patchcmd{\cmd}{⟨查找⟩}{⟨替换⟩}{⟨成功时⟩}{⟨失败时⟩}。找到则替换并执行第四个参数;找不到则完全不动这个宏,转而执行第五个。被替换的只有 第一处——宏体里若有两个 \small,只有靠前的那个会变。举一个真正有用的例子:article 类的 thebibliography 环境以 \section*{\refname} 开头,因此把那个 \section* 换成 \section,就能 让参考文献变成带编号的节,并出现在目录里。实测中 .toc 文件确实收到了 \contentsline {section}{\numberline {2}References},补丁完全兑现了承诺。

document.tex
\usepackage{etoolbox}

\makeatletter                    % the target usually contains @
\patchcmd{\thebibliography}
  {\section*}                    % search
  {\section}                     % replace
  {\typeout{bibliography patch applied}}                        % on success
  {\PackageWarning{mypkg}{bibliography patch failed}}           % on failure
\makeatother

% result: "References" becomes a numbered section and enters the ToC
%   .toc -> \contentsline {section}{\numberline {2}References}{1}{}

当补丁悄无声息地失效——\tracingpatchesxpatch

\patchcmd 的失败是 彻底无声 的。实测显示:给它一个匹配不上的模式,并把成功与失败两个分支都留空,编译会以 零错误、零警告 结束,日志里也不留痕迹。因此铁律只有一条——绝不要把失败分支留空,往里放一个 \PackageWarning 这样就会出现 Package mypkg Warning: bibliography patch failed on input line 5.,更新的第二天你就能发现,而不是几个月后。想查清原因,请在导言区放上 \tracingpatchesetoolbox.def 会被载入,每一处补丁的诊断都会写进日志。

log
[debug] tracing \patchcmd on input line 5
[debug] analyzing '\thebibliography'
[debug] ++ control sequence is defined
[debug] ++ control sequence is a macro
[debug] ++ macro can be retokenized cleanly
[debug] -- search pattern not found in replacement text

[debug] analyzing '\nosuchcommand'
[debug] -- control sequence is undefined or \relax

[debug] analyzing '\LaTeX'
[debug] -- macro cannot be retokenized cleanly
[debug] -> the macro may have been defined under a category
[debug]    code regime different from the current one

诊断分为三类。「宏体中找不到搜索模式」-- search pattern not found in replacement text)是宏包更新后定义变了的典型信号;用 \show 查看新定义,然后改写搜索字符串。「命令未定义」-- control sequence is undefined or \relax)说明打补丁的时机太早——把补丁往后挪,例如放进 \AtBeginDocument。第三类 「无法干净地重新分词」-- macro cannot be retokenized cleanly)是类别码问题:该宏是在与当前不同的 catcode 环境下定义的,请确认补丁是否写在 \makeatletter 内部。

还有一种失败连诊断都不会显示:\patchcmd 对带可选参数的命令无效。\meaning 查看以 \newcommand{\opt}[2][X]{...} 定义的 \opt,会得到 \@protected@testopt \opt \\opt {X}——也就是说,\opt 只是一个负责分派的门面,真正的宏体在另一个叫 \\opt 的命令里。因此 \patchcmd{\opt}{small}{LARGE} 搜的是门面,自然失败。这时请使用扩展了 etoolboxxpatch 宏包 中的 \xpatchcmd:实测中同样的参数成功了,内部宏被改写为 \long macro:[#1]#2-><#1|#2|LARGE>xpatch 也提供了针对环境的配套命令。

钩子、追加与列表——把自己的代码插进别人的流程

能不改写宏体就不要改写。etoolbox 提供了丰富的钩子,用于「在某个时刻运行这段代码」。 文档的开始与结束由 LaTeX 内核的 \AtBeginDocument\AtEndDocument 负责,而 etoolbox 补上了在导言区最末运行的 \AtEndPreamble、真正最后的 \AfterEndDocument,以及围绕特定环境的 \AtBeginEnvironment{⟨env⟩}{⟨代码⟩}\AtEndEnvironment\BeforeBeginEnvironment\AfterEndEnvironment。要事后 往已有的宏或钩子里追加,可用 \appto{\cmd}{⟨代码⟩}(追加到末尾)与 \preto{\cmd}{⟨代码⟩}(插到开头);\gappto 是全局版本,\eappto 会先展开待追加的代码。对带参数的宏,请用带成功与失败分支的 \apptocmd / \pretocmd——它们对未定义命令同样只是执行失败分支而不报错,所以需要与 \patchcmd 一样的警惕。

latex
\usepackage{etoolbox}

% run code every time an environment starts -- no patching required
\AtBeginEnvironment{quote}{\itshape}
\AtBeginEnvironment{itemize}{\setlength{\itemsep}{2pt}}

% append to a macro that takes an argument (note the two branches)
\newcommand{\greet}[1]{Hello #1}
\apptocmd{\greet}{!}{}{\PackageWarning{mypkg}{could not extend \string\greet}}
% \greet is now  \long macro:#1->Hello #1!

% lightweight lists and loops
\listadd{\mylist}{alpha}\listadd{\mylist}{beta}
\newcommand{\asitem}[1]{\item #1}
\begin{itemize}\forlistloop{\asitem}{\mylist}\end{itemize}
\begin{itemize}\forcsvlist{\asitem}{apples, pears, plums}\end{itemize}

列表方面也一应俱全。\listadd{\mylist}{⟨元素⟩} 向内部列表追加元素,\forlistloop{⟨处理器⟩}{\mylist} 对每个元素应用一个单参数处理器。若手上已经是逗号分隔的字符串,\docsvlist{a,b,c}\forcsvlist{⟨处理器⟩}{a,b,c} 更为便捷;想自定分隔符,则可用 \DeclareListParser 构建解析器。实务中最常见的用法,是接收宏包选项并把它当作列表来遍历。

pgfkeys——给自己的工具装上 key=value 接口

pgfkeys 是随 PGF/TikZ 一同发行的 key=value 引擎。 TikZ 里人们熟悉的 [draw, thick, fill=blue] 写法,以及许多宏包的 \…setup{...} 式接口,大半都建立在它之上(TeX Live 2024 中的 PGF 为 3.1.10 版,版权归 Till Tantau)。其核心只有一条命令:\pgfkeys{/my/key=value}。键通过以 / 分隔的 路径(族) 划分命名空间,每个键都被赋予一个处理器,决定「被调用时做什么」。简而言之,定义一个键就是挑选一个处理器。

.store in.code.is choice——处理器该怎么选

三个处理器就能应付大部分实际工作:.store in=\macro 原样保存值;.code={... #1 ...} 用值去执行代码(传入的值出现在 #1);.is choice 枚举一组固定选项。此外,.default=值 提供不带 =值 调用时所用的值,.initial=值 给键一个初始值(可用 \pgfkeysvalueof{/path/key} 读出)。如果你的宏包对外提供 \mypkgsetup{...} 这样的入口,惯用写法是 \pgfqkeys{/mypkg}{⟨键列表⟩}——「q」意为 quick,它是 \pgfkeys{/mypkg/.cd, ⟨键列表⟩} 的简写。把它包成一行的宏,用户就只需用短键名来配置。

document.tex
\usepackage{pgfkeys}

\pgfkeys{
  /book/title/.store in    = \bookTitle,
  /book/edition/.store in  = \bookEd,
  /book/edition/.default   = 1,          % value used when called bare
  /book/pages/.initial     = 100,        % starting value
  /book/layout/.is choice,               % a fixed set of options
  /book/layout/wide/.code   = {\def\bookLayout{WIDE}},
  /book/layout/narrow/.code = {\def\bookLayout{NARROW}},
  /book/note/.code = {\def\bookNote{<<#1>>}},   % #1 is the value passed in
}

\pgfkeys{/book/title=TeX by Topic, /book/edition, /book/layout=wide}
\pgfkeysvalueof{/book/pages}          % -> 100

% a one-line entry point for your users
\newcommand{\mypkgsetup}[1]{\pgfqkeys{/book}{#1}}
\mypkgsetup{title = My Report, edition = 2}

pgfkeys 的错误信息相当具体,也是很好的检索线索。传入未定义的键会得到 ! Package pgfkeys Error: I do not know the key '/book/nosuchkey', to which you passed '1', and I am going to ignore it. Perhaps you misspelled it.;传入 .is choice 之外的选项会得到 ! Package pgfkeys Error: Choice 'sideways' unknown in choice key '/book/layout'. I am going to ignore this key.。两者都是 忽略问题继续执行——排版不会停下,所以除非去读日志,否则拼错的键不会被察觉。LaTeX3 一侧则有等价的 l3keys\keys_define:nn 等)。取舍的大致标准是:用 expl3 编写新宏包就选 l3keys,要与 TikZ 系代码或既有资产配合就选 pgfkeys

小数运算——\fpeval 已不再需要载入 xfp

TeX 的整数运算一遇到小数就捉襟见肘——比如 \numexpr 的除法会四舍五入。\fpeval 正是为此而生:\fpeval{1/3} 得到 0.3333333333333333\fpeval{sqrt(2)} 得到 1.414213562373095\fpeval{sind(30)} 得到 0.5\fpeval{round(2/3, 4)} 得到 0.6667。要与长度结合,只需在后面接上单位:\setlength{\x}{\fpeval{345/7}pt}。有一点值得明确标注时点:在 TeX Live 2024 所带的 LaTeX2e(2023-11-01 版)中,\fpeval\inteval\dimeval 已在内核之中,无需 \usepackage{xfp} xfp 自身如今也用 \ProvideExpandableDocumentCommand 定义它们——「缺了才提供」——所以载入也无妨;若还要兼顾旧环境,载入它更稳妥。

最后,给出这三样工具的搭配大纲。想在导言区稍微改变别人的行为,就用 etoolbox(并且务必在失败分支放上警告)。想给自制宏包加上配置接口,就用 pgfkeysl3keys 想计算尺寸或比例,就用 \fpeval 而首先要问的永远是:能不能根本不打补丁?依次考虑对公开命令用 \renewcommand、用 \AtBeginEnvironment 之类的钩子、使用正规的宏包选项——只有当这些都行不通时,才拔出 \patchcmd。补丁今天也许有效,但它的保质期只到明天的宏包更新为止。