TeX 主题选讲:TeX 处理器的结构

此篇基本上是译文,原文是 Victor Eijkhout 所著的《TeX by Topic》的第一章。译文项目参见:CTeX-org/tex-by-topic-cn
转载请保留本段文字,尊重原作者和译者版权。
由于原著使用 GFDL,故而本文也被传染地同样使用 GFDL 许可,而不是本站默认的 BY-NC-SA 4.0 许可。

TeX 处理输入的过程可分为 4 个层次。你可以认为 TeX 处理器(也称 TeX 引擎)有4 个独立的单元,每个单元接收前序步骤的输出,并将输出作为后续单元的输入。第一阶段的输入是 .tex 文件,最后一个阶段的输出是 .dvi 文件。

通常而言,这样理解上述 4 个层次方便且直观:每个层次完整地接收上一个层次的输出。然而,这样并不完全正确:4 个层次的处理是同时进行的,层次之间也有交互。

4 个层次列举如下(按 Knuth 最初的叫法,分别是「眼睛」、「嘴巴」、「胃」和「肠道」):

  1. 输入处理器(input processor):负责为 TeX 从文件系统中读入输入,并处理为记号。记号是两种 TeX 内部对象的统称:构成排版文本的字符记号、待后续两个层次作为命令处理的控制序列记号。
  2. 展开处理器(expansion processor):负责展开输入处理器输出的部分记号:宏、条件式、以及部分 TeX 原语。记号展开的过程会将一些(连续的)记号删除或替换为其它记号。
  3. 执行处理器(execution processor):不可展开的控制序列即是可执行的;执行处理器负责执行这些可执行的控制序列。执行处理器的部分活动涉及到 TeX 的内部状态之变化:赋值(包括宏定义)是其中典型。除此之外,执行处理器负责构造水平列表、垂直列表和数学列表。
  4. 可视化处理器(visual processor):TeX 的最后一个层次负责排版内容的可视化。
    可视化处理器将水平列表分行成段,将垂直列表分割成页,将数学列表构建成数学公式;
    最终输出 .dvi 文件。可视化处理器的内部算法对于用户是不可见的;不过用户可以通过一系列参数来控制。

输入处理器

输入处理器是 TeX 的一部分,负责将 TeX 从输入文件中读入的内容翻译成记号。它的输出是记号流(stream of tokens):记号组成的列表。大部分记号属于以下两类:字符记号和控制序列记号。还有一类记号是参数记号,留待后续章节讨论。

字符的输入

对于简单的输入文本,其中的字符被输入处理器翻译为字符记号。在某些情况下,TeX 会忽略一些输入字符;例如,连续空格通常等价于单个空格。此外,TeX 也会向记号流中插入一些与输入字符无关的记号。例如在每一行的末尾插入一个空格,以及在每一个空行末尾插入一个 \par 记号。

并非所有的字符记号都会被最终排版出来。事实上,在 TeX 中,字符被分类为 16 类 —— 每一类都有特定的功能 —— 而只有两类最终会被排版出来。其它字符类,像 {}&#,都不会被排版出来。因此,TeX 的字符记号的含义由一对数字决定:字符编码 —— 通常是 ASCII 编码 —— 和分类码。具体某一编码的字符对应的分类码是可修改的。

当输入处理器遇到转义字符时(默认是 \),它会将转义字符之后的若干连续个字符拼在一起,形成一个控制序列记号。

对于分类码的处理,TeX 输入处理器的行为可想像为在三种内部状态($N$,新行;$M$,行内;$S$,忽略空格)中切换的机器。在下一篇中我们会讨论这些状态及其切换。

输入处理器的两个阶段

TeX 的输入处理器本身也可分为两个阶段。因为一些限制(来自终端、编辑器或者操作系统),某些字符可能很难输入。为此,TeX 设计了一套机制来表达这些字符:以两个连续的上标字符来获取所有可用字符。这套机制可视作输入处理器中相对独立的阶段;它运行于上述三状态机之前。

例如,由于 k+ 的 ASCII 码之差为 64,所以 ^^+ 会被输入处理器等价地转换为 k。由于这种替换机制发生在形成记号之前,所以输入 \vs^^+ip 5cm 与输入 \vskip 5cm 是等价的。某些情况下,这种用法是很有用的。

注意,这是 TeX 输入处理器的第一阶段,它不考虑分类码,而只是一个从字符到字符的变换。分类码只在输入处理器的第二阶段才起作用:字符的编码和分类码组合在一起,变成一个字符记号。

展开处理器

TeX 的展开处理器接受记号流,并将记号流里的记号逐一展开,直到再无可展开记号为止。宏展开即是其中一例:如果一个控制序列是一个宏名,则根据定义,它将(和可能的参数记号一起)被替换。

展开处理器的输入主要来自输入处理器。自输入处理器而来的记号流被展开处理器展开成仅含有不可展开记号的记号流,最终流向执行处理器。

此外,在处理类似 \edef\write 命令时,展开处理器也会参与工作。这些命令的参数记号会被直接展开,变成顶层的替换文本,而不是这些命令的参数。

记号的展开过程

记号展开的步骤如下:

  1. 确认当前待处理的记号是否可展开。
  2. 若当前待处理的记号不可展开,则直接将其加入正在构建的记号列表中,并读入下一个记号。
  3. 若当前待处理记号可展开,则将其替换为其展开。对不带参数的宏以及一些 TeX 原语(例如 \jobname),该过程只需简单地进行记号替换即可。不过,通常 TeX 需要从输入流中读取一些参数记号,以便构造当前待处理记号的替换形式。举例说,对于带参数的宏,TeX 将读取足够的参数(parameters)记号,而后将这些参数记号作为宏的参数(arguments)。
  4. 将展开结果的第一个记号作为待处理记号,继续展开。

宏的可展开性之判断很简单:所有的宏、活动字符、条件式以及部分 TeX 原语(后续文章将给出列表)是可展开的,其他记号都不可展开。因此,展开处理器会负责诸如将宏替换为其展开、对条件是求值而后忽略条件为假的部分的任务;诸如 \vskip、字符记号(包括美元符号和花括号)则原封不动地传到输出流。

几个特例:\expandafter\noexpand\the

如前所述,记号被展开后,TeX 会继续展开其展开后得到的记号。

\expandafter 是一个特例。初看之下,它只做一步展开,因而破坏了这个规则。
例如说,

1
\expandafter<token_1><token_2>

会被替换为

1
<token_1><expansion of token_2>

但实际上,这个替换结果还会被展开处理器再次处理。

\noexpand 是另一个特例。当 TeX 遇到 \noexpand,则其下一个记号会被展开处理器当做是不可展开的。因此,展开处理器会把 \noexpand 的下一个记号当做是 \relax 那样,直接将其加入正在构建的记号列表。

举例说,以下宏定义中,其替换文本 \noexpand\b 会在宏定义时被展开:

1
\edef\a{\noexpand\b}

\noexpand 展开后,其后的记号暂时会被当做是 \relax 的意思。于是,当展开处理器处理下一个记号 \b 时,认为它是不可展开的,并将其直接加入正在构建的记号列表中。因此 \b 就是这个宏的替换文本。

\the 也是一个特例。在 \edef 宏定义中,\the<token variable> 的展开结果不会被继续展开。

展开处理器中的花括号

之前提到,展开处理器将花括号视为不可展开的字符记号。通常而言,这没错。例如说 TeX 在展开下列 \romannumeral 控制序列时,会一直展开,直到遇见花括号:

1
\romannumeral1\number\count2 3{4 ...

因此,如果 \count2 的值是 0,那么这个控制序列的展开结果是 103 的罗马数字表示。又例如说,展开处理器对待

1
\iftrue {\else }\fi

的方式与对待

1
\iftrue a\else b\fi

的方式完全一致;即:此时 { 只是一个字符记号,与它的分类码无关。

不过,在宏展开的上下文中,展开处理器会识别和处理花括号。首先,配对的花括号内可用于将其内的一组记号标记为宏的参数。例如,下面定义的单参数宏:

1
\def\macro#1{ ... }

你可以单传一个参数来调用它:

1
\macro 1 \macro \$

也可以使用配对的花括号包裹一组记号作为宏的参数:

1
\macro {abc} \macro {d{ef}g}

其次,对于带参数的宏,其参数中不得包含未配对的花括号。例如,如下定义的 \a 带有一个参数:包含从 \a 之后开始,直到遇见第一个花括号外的 \stop 位置的全部记号。

1
\def\a#1\stop{ ... }

对于以下用法,\a 的参数应当是 bc{d\stop}e;因为宏的参数中花括号必须是配对的。

1
\a bc{d\stop}e\stop

执行处理器

执行处理器构建水平列表、竖直列表和数学列表。与之相应,执行处理器有三种工作模式:水平模式、竖直模式、数学模式。这三种模式又分别有「内部」和「外部」两种类型。除了构建列表,执行处理器还需要处理一些与模式无关的操作,例如赋值。

执行处理器的输入是展开处理器输出的不可展开记号组成的流。在执行处理器看来,这条记号流中有两种类型的记号:

  1. 触发赋值操作(包括宏定义)的记号,以及触发与模式无关的其他操作的记号(例如 \show\aftergroup)。
  2. 构建列表的记号:字符、盒子、粘连。执行处理器对它们的处理方式根据所处模式的不同而改变。

有些记号在所有模式下都可用;比如盒子可用于水平、竖直、数学三种模式。当然,这些记号的作用与效果,根据所处的模式不同而不同。也有记号只在特定模式下可用。例如说,字符记号(确切说是分类码为 11 和 12 的字符记号)则与水平模式密切相关:当执行处理器在竖直模式中遇到字符记号时,会切换到水平模式继续工作。

并非所有字符记号都是可排版的。执行处理器可能遇到数学模式切换标志(默认是 $)、分组起止符(默认是 {})。当执行处理器遇到数学模式切换标志时,它会进入或退出数学模式;而分组起止符则让执行处理器进入或退出新的一层分组。

控制序列 \relax 有些特别:该控制序列是不可展开的,同时执行时啥也不做。为说明 \relax 的作用,可与 \empty 进行比较。

\empty 的定义如下:

1
\def\empty{}

使用 \relax 时,因其不可展开,故而下例中计数寄存器被赋值为 1

1
\count0=1\relax 2

使用 \empty 时,因 \empty 的展开结果为空,故而下例中计数寄存器被赋值为 12

1
\count0=1\empty 2

可视化处理器

TeX 的可视化处理器使用了若干用户不可直接控制的算法:断行、切齐、分页、数学排版以及 dvi 文件生成算法。尽管用户不可直接控制这些算法,但可通过一些参数,间接控制它们。

这些算法当中,部分算法的输出结果可被执行处理器继续处理。例如说,分段成行得到一系列行组成的列表;这些行会被加入主竖直列表当中;而每一行又是由若干水平盒子及其中的行间粘连和惩罚组成的。又例如说,分页算法将其结果保存在 \box255 当中;而后交由输出例程继续处理。其余算法的输出结果则不然,例如数学公式不可以分解,又例如输出至 dvi 文件的盒子也是不可逆的。

示例

被忽略的空格

被忽略的空格可以反映数据在 TeX 各层处理器之间的流动情况。例如:

1
2
\def\a{\penalty200}
\a 0

展开的结果不是(设置惩罚项为 200,并排版数字 0

1
\penalty200 0

而是

1
\penalty2000

这是由于输入处理器会忽略 \a 后的空格,因此展开处理器的输入流中的内容是:

1
\a0

内部量值及其表示

TeX使用了多种内部量,比如说整数和尺寸。这些内部量的外部表示是同一的:字符组成的字符串。例如 471191.44cm

内部量与外部表示之间的转换发生在执行处理器或展开处理器中。具体来说,外部表示向内部量的转换发生在执行处理器中:

1
2
\pageno=12 \baselineskip=13pt
\vskip 5.71pt

内部量向外部表示的转换发生在展开处理器中:

1
2
\number\pageno \romannumeral\year
\the\baselineskip

最后再举一例。假设 \count2=45,则下列代码

1
\count0=1\number\count2 3

首先将 \number\count2 被展开为字符串 45,注意 \count2 后的空格被用于界定计数器编号而已被展开。因此,下一级处理时,执行处理器看到并执行的是:

1
\count0=1453