Philipp Lehman 以 biblatex 与 csquotes 闻名,但在最多导言区里默默运转的,恐怕是他的第三件作品 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 的二选一分支。
\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},补丁完全兑现了承诺。
\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}{}当补丁悄无声息地失效——\tracingpatches 与 xpatch
\patchcmd 的失败是 彻底无声 的。实测显示:给它一个匹配不上的模式,并把成功与失败两个分支都留空,编译会以 零错误、零警告 结束,日志里也不留痕迹。因此铁律只有一条——绝不要把失败分支留空,往里放一个 \PackageWarning。 这样就会出现 Package mypkg Warning: bibliography patch failed on input line 5.,更新的第二天你就能发现,而不是几个月后。想查清原因,请在导言区放上 \tracingpatches:etoolbox.def 会被载入,每一处补丁的诊断都会写进日志。
[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} 搜的是门面,自然失败。这时请使用扩展了 etoolbox 的 xpatch 宏包 中的 \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 一样的警惕。
\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, ⟨键列表⟩} 的简写。把它包成一行的宏,用户就只需用短键名来配置。
\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(并且务必在失败分支放上警告)。想给自制宏包加上配置接口,就用 pgfkeys 或 l3keys。 想计算尺寸或比例,就用 \fpeval。 而首先要问的永远是:能不能根本不打补丁?依次考虑对公开命令用 \renewcommand、用 \AtBeginEnvironment 之类的钩子、使用正规的宏包选项——只有当这些都行不通时,才拔出 \patchcmd。补丁今天也许有效,但它的保质期只到明天的宏包更新为止。