前文链接:
确定目标
学习一门编程语言,如果只了解语法,必然十分枯燥而且并没有什么用。所以,我们准备从一个基本的例子讲起,尽可能地覆盖 LaTeX3 的重要知识。
这里的例子,是一个中文测试文字(乱数假文)宏包。假文的目的是生成大段没有实际含义的文字,常用来测试排版效果。对于西文,TeX 发行版中已经自带了几个宏包,包括 lipsum、kantlipsum 和 blindtext 等;而对于中文,则由我本人编写了 zhlipsum。我们的目标,就是在这几篇教程中,让大家完成一个类似 zhlipsum 的宏包。看过刘海洋《LaTeX 入门》一书的读者就会发现,这实际上正是书中的练习 8.6。
要编写中文测试文字宏包,首先要做一下整体规划与设计:
- 最简单的实现,只要定义一些含有大量文字的命令,并且能使用户方便使用。
- 接下来,为了能够改变假文片段的长度,我们就需要用到计数、循环等功能,同时还会引入一些基本的数据结构。
- 之后,因为面向的是中文测试,我们的宏包还要能够支持多种字符集和编码,这需要通过类别码机制来处理。
- 最后,作为一个完整的宏包,还需有一套良好的接口和完善的错误提示,以方便用户使用。
当然,如果可能,我们还会考虑介绍一些高级功能,比如:
- 利用伪随机数生成真正的「乱数」假文。
- 通过
DocStrip和doc宏包进行文学编程。 - 构建覆盖面足够的测试,完成宏包发布。
准备
开发环境配置
考虑到 LaTeX3 仍处于活跃开发状态,建议使用最新的 TeX 发行版。另一方面,开发 LaTeX 宏包与日常写作文档稍有区别,很多时候我们需要通过命令行输出进行调试,而不仅仅是编译 TeX 文档以生成 PDF。所以,建议使用命令行直接编译。为使用 TeX 写作开发的编辑器,如 WinEdt、TeXstudio 等,反而可能不太适用于此。我们的项目会涉及到汉字处理,因此要保证编辑器对多种编码均有良好支持。
我本人使用的是 TeX Live 2019 + Visual Studio Code。
此外,如果要开始一个正式的项目 / 工程,强烈建议建立一个 Git 仓库来管理。具体做法可参阅 廖雪峰的 Git 教程。
目录结构
我们打算把这个宏包取名为 zhdummy。名字当然可以任意取,但确定之前请务必在网上检索一下是否有冲突。在 TeX 发行版中,不可以出现名字相同的文件;类似的,宏包名也不可以有重复。
LaTeX 宏包的后缀名是 .sty,因此文件名即为 zhdummy.sty。宏包本身一般不能直接编译。因此,为了检查其正确性,还需要加入一些测试文件。我们暂时仅使用一个简单的 test.tex。这两个文件目前需要放在同一目录(不妨设为 zhdummy)下:
zhdummy/
├─zhdummy.sty
└─test.tex下面我们开始编写宏包:
% zhdummy.sty
\NeedsTeXFormat{LaTeX2e}
\RequirePackage{expl3}
\ProvidesExplPackage{zhdummy}{2019/11/20}{0.1}{Chinese dummy text (demo)}
\def\mypkgname{zhdummy}\NeedsTeXFormat{LaTeX2e} 表明宏包要求 LaTeX 2ε 格式,而不接受 plain TeX 和 ConTeXt 格式。其后的语句之前都介绍过,此处不再赘述。
测试文件则可以这样写:
% test.tex
\documentclass{ctexart}
\usepackage{zhdummy}
\begin{document}
你好,\mypkgname{}!
\end{document}使用 XeLaTeX 编译(以后没有特殊说明,我们将总是使用 XeLaTeX)test.tex,结果应如下所示:
你好,zhdummy!
此外,在日志文件 test.log 中,应当能找到以下信息:
(./zhdummy.sty
Package: zhdummy 2019/11/20 v0.1 Chinese dummy text (demo)
)这里 ./zhdummy.sty 表示读入当前目录(即 ./)中的 zhdummy.sty 文件,外面的括号会把读取过程中产生的所有信息(比如这里的宏包版本)包装起来。如果读入的文件又调用了新的文件,则会层层嵌套。我们的宏包虽然也调用了 expl3.sty,但之前的 ctexart 文档类实际上已进行了一次调用,因而宏包中的 \RequirePackage{expl3} 就被忽略了。这与 C/C++ 语言的头文件调用非常相似。
经过一次编译,现在的目录结构将变为这样:
zhdummy/
├─zhdummy.sty 宏包
├─test.tex 测试文件
├─test.aux 编译辅助文件
├─test.log 编译日志
└─test.pdf 生成的 PDF至此,我们就已经写好了一个最简单的宏包。当然,它除了打印自己的名字以外,什么功能都没有。
加入假文
我们的宏包现在其实只有一行是有实际作用的:
\def\mypkgname{zhdummy}它定义了一个名为 \mypkgname 的宏,并且可以展开为 zhdummy。实际上,所谓「假文」也无非是这样一些可展开为文本的宏,只不过文本要更长一点。
在 LaTeX3 中,常规文本很适合用一种称为记号列表的类型(token lists)存储。相关函数的前缀是 tl。
记号列表,顾名思义由一系列的记号(token,也称为字元)组成。而记号,要么是指一个附带有类别码的字符(character),要么是一个控制序列。比如,在标准情况下,{\hskip 36 pt} 就是下面的一组记号(下标表示类别码,␣ 表示空格,注意 \hskip 后的空格是被忽略掉的):
{1\hskip控制序列312612␣10p11t11}2
不过,就目前来说,我们可以先忽略这些技术细节。毕竟假文中几乎只含有汉字、标点和一些字母、数字,它们都是比较「正常」的东西,不需要特殊处理。
利用记号列表,我们可以把之前的宏定义改写为如下形式:
\tl_const:Nn \c_zhdummy_text_i { 天地玄黄,宇宙洪荒。 }\tl_const:Nn 表示创建一个 tl 常量,并用第二个参数作为其内容。这里还有几点需要注意:
- 常量以
c开头 - 我们把模块名起做
zhdummy,通常它应该与宏包名称一致 - 暂时把这一常量设置为公有(以
c_而非c__开头) - 空格在 LaTeX3 语法中是被忽略掉的
- 这里用《千字文》仅仅是做一个示范,实际使用的假文会长很多
类似地,我们可以加入更多的假文:
\tl_const:Nn \c_zhdummy_text_i { 天地玄黄,宇宙洪荒。 }
\tl_const:Nn \c_zhdummy_text_ii { 日月盈昃,辰宿列张。 }
\tl_const:Nn \c_zhdummy_text_iii { 寒来暑往,秋收冬藏。 }
\tl_const:Nn \c_zhdummy_text_iv { 闰馀成岁,律吕调阳。 }
\tl_const:Nn \c_zhdummy_text_v { 云腾致雨,露结为霜。 }
\tl_const:Nn \c_zhdummy_text_vi { 金生丽水,玉出昆冈。 }
\tl_const:Nn \c_zhdummy_text_vii { 剑号巨阙,珠称夜光。 }
\tl_const:Nn \c_zhdummy_text_viii { 果珍李柰,菜重芥姜。 }
\tl_const:Nn \c_zhdummy_text_ix { 海咸河淡,鳞潜羽翔。 }
\tl_const:Nn \c_zhdummy_text_x { 龙师火帝,鸟官人皇。 }
\tl_const:Nn \c_zhdummy_text_xi { 始制文字,乃服衣裳。 }
\tl_const:Nn \c_zhdummy_text_xii { 推位让国,有虞陶唐。 }
\tl_const:Nn \c_zhdummy_text_xiii { 吊民伐罪,周发殷汤。 }
\tl_const:Nn \c_zhdummy_text_xiv { 坐朝问道,垂拱平章。 }
\tl_const:Nn \c_zhdummy_text_xv { 爱育黎首,臣伏戎羌。 }
\tl_const:Nn \c_zhdummy_text_xvi { 遐迩壹体,率宾归王。 }
\tl_const:Nn \c_zhdummy_text_xvii { 鸣凤在树,白驹食场。 }
\tl_const:Nn \c_zhdummy_text_xviii { 化被草木,赖及万方。 }使用时,可以直接使用,也可以采用 \tl_use:N 命令:
% test.tex
\documentclass{ctexart}
\usepackage{zhdummy}
\begin{document}
\ExplSyntaxOn
\c_zhdummy_text_i
\tl_use:N \c_zhdummy_text_ii
\ExplSyntaxOff
\end{document}天地玄黄,宇宙洪荒。日月盈昃,辰宿列张。
这样的定义方式显然过于冗长和低效。然而,更严重的问题还在于,这样定义的 tl 变量只能用在 LaTeX3 环境中,直接使用会导致错误:
% test.tex
\documentclass{ctexart}
\usepackage{zhdummy}
\begin{document}
\tl_use:N \c_zhdummy_text_i
\end{document}编译后得到(中断时可按 return 键继续)
! Undefined control sequence.
l.6 \tl
_use:N \c_zhdummy_text_i
?
! Missing $ inserted.
<inserted text>
$
l.6 \tl_
use:N \c_zhdummy_text_i
?
LaTeX Warning: Command \c invalid in math mode on input line 6.
! Missing $ inserted.
<inserted text>
$
l.7 \end{document}
?_ 在常规的类别码设置下代表下标,必须用在数学环境中,所以用 _ 和 : 所定义的命令不能被 LaTeX 接受。这会给用户造成了极大的麻烦,显然有悖于我们编写宏包的初衷。因此,接下来我们要创建一些用户层(或文档层)命令,以区分于编程层。
用户接口
我们分析一下上面定义的假文命令,可以发现它们都有一些共同点:
- 前面都是统一的
\c_zhdummy_text_ - 后面则是小写罗马数字,如
i、ii
由此,可以让用户输入所需要的段落号,据此选择需要的假文。当然,大多数情况下用户所需要的很可能只是一个简单的命令。这可以通过默认参数来实现。
我们把 \zhdummy 作为用户层命令。规定它的用法如下:
\zhdummy
\zhdummy[<序号>]不带参数时,输出前四段假文;带参数时,输出指定 <序号> 的假文,其中 <序号> 以阿拉伯数字表示。
实现这一用户层命令,需要解决以下问题:
- 阿拉伯数字转换为(小写)罗马数字
- 拼合命令(控制序列)
- 以较为可靠方式定义用户层命令
从头开始做这些工作并不容易。所幸,LaTeX3 给我们提供了比较良好而易用的框架。以下我们将依次进行介绍。
数字转换
expl3 所提供的转换函数是 \int_to_roman:n。顾名思义,这个函数接受一个整型参数,再把它转换为小写的罗马数字。另有 \int_to_Roman:n,很容易就可以猜出它的意思。
我们可以做一些实验(~ 在 LaTeX3 中表示空格):
\int_to_roman:n { 1 } ~
\int_to_roman:n { 5 } ~
\int_to_roman:n { 4999 } ~
\int_to_Roman:n { 1 } ~
\int_to_Roman:n { 5 } ~
\int_to_Roman:n { 4999 }结果应当为:
i v mmmmcmxcix I V MMMMCMXCIX
拼合命令
类似于 C 语言的 ## 宏、Python 中的 eval 和 Mathematica 中的 Symbol,expl3 也提供了将「字符串」转换为命令的手段。
为此,我们先回顾一下之前所讲过的参数指定。它位于一个函数的 : 后面,描述了该函数的参数结构。基本的参数指定包括 n、N、p 等。例如 \tl_use:N,就表示接受一个 token(如一个控制序列)作为参数。
现在介绍一种新的参数指定 c,它表示将参数处理为一个控制序列的名称。例如,以下几种写法是等价的:
\tl_use:N \c_zhdummy_text_i
\tl_use:c { c_zhdummy_text_i }
\tl_use:c { c _ zhdummy _ text _ i } % 注意空格是忽略掉的xparse 宏包简介
正所谓「临门一脚」,我们上面的所有工作最终都要面向用户。LaTeX3 提供的方案是 xparse 宏包,它可以很方便地声明用户层(文档层)命令。
在代码底层,程序员应当控制合适的粒度,使得绝大多数函数都只完成单一的工作。因而,底层函数的参数应当是确定的。但在用户层,需求可以千变万化,但接口应当尽可能保持统一,这就要求参数形式具有一定的多样性。这与 C++ 中依靠 函数重载 实现的所谓 ad hoc 多态 有异曲同工之处。
xparse 宏包提供了 \NewDocumentCommand 函数,其语法如下:
\NewDocumentCommand <func> {<arg-spec>} {<code>}<func>即为我们最终提供给用户的命令,一般来说它应只包含字母,而不含_、:、@等特殊符号<arg-spec>是参数指定(注意与之前 LaTeX3 函数的参数指定相区分),可以是:m:表示标准必选(mandatory)参数,可以是单个 token,或者花括号{...}包围的一组 tokenso:表示标准可选(optional)参数,需用方括号[...]包围;若未给出,则返回一个特殊的-NoValue-标记O{<default>}:同样为可选参数,但在未给出时则返回默认值<default>其他更为复杂的参数指定,以及一些特殊情况,我们将在之后进行介绍
示例如下:
参数指定 输入值 #1#2#3m m{foo}{bar}foobaro m{foo}-NoValue-fooo o m[foo]{bar}foo-NoValue-barm O{default}{foo}foodefaultm O{default}{foo}[bar]foobarm O{default}[bar]报错
<code>为具体的实现代码,可以使用#1、#2这样的参数,这和传统 TeX 编程是一致的除此之外,
xparse还提供了几个函数,它们的用法和\NewDocumentCommand相同,但含义稍有区别:函数 <func>已定义<func>未定义\NewDocumentCommand报错 给出定义 \RenewDocumentCommand重新定义 报错 \ProvideDocumentCommand什么也不做 给出定义 \DeclareDocumentCommand重新定义 给出定义
上面我们提到,输入为空时,o 型参数会返回一个特殊的 -NoValue- 标记。这一标记不是简单的 token list,它必须通过 \IfNoValue(TF) 函数进行判断:
\IfNoValueTF {<arg>} {<true code>} {<false code>}
\IfNoValueT {<arg>} {<true code>}
\IfNoValueF {<arg>} {<false code>}根据参数 <arg> 是否为 -NoValue-,\IfNoValue(TF) 会决定执行 <true code> 还是 <false code>。
代码实现
最后,我们把以上分析综合起来,可以写出如下的代码:
% 定义命令 `\zhdummy`,允许带一个可选参数
\NewDocumentCommand \zhdummy { o }
{
% 根据参数 `#1` 是否为 `-NoValue-` 分别进行处理
\IfNoValueTF {#1}
{
% `#1` = `-NoValue-`,即不带参数
% 直接使用假文命令
\tl_use:N \c_zhdummy_text_i
\tl_use:N \c_zhdummy_text_ii
\tl_use:N \c_zhdummy_text_iii
\tl_use:N \c_zhdummy_text_iv
\tl_use:N \c_zhdummy_text_v
}
{
% `#1` ≠ `-NoValue-`,即带有可选参数
% 把 `#1` 转换为小写罗马数字,再拼合成假文命令
\tl_use:c { c_zhdummy_text_ \int_to_roman:n {#1} }
}
}此时,在 test.tex 中即可按照比较常规的方式来使用假文了:
% test.tex
\documentclass{ctexart}
\usepackage{zhdummy}
\begin{document}
\zhdummy
\zhdummy[1]
\zhdummy[2]
\zhdummy[18]
\end{document}编译后得到
天地玄黄,宇宙洪荒。日月盈昃,辰宿列张。寒来暑往,秋收冬藏。闰馀成岁,律吕调阳。云腾致雨,露结为霜。
天地玄黄,宇宙洪荒。日月盈昃,辰宿列张。化被草木,赖及万方。
参考
- zhlipsum: 中文乱数假文 (Lorem ipsum) -
texdoc zhlipsum.pdf - The LaTeX3 Interfaces -
texdoc interface3- Part VI - The
l3tlpackage: Token lists - Part XI - The
l3intpackage: Integers
- Part VI - The
- The
xparsepackage: Document command parser -texdoc xparse