使用 groff 宏进行排版

作者:Wayne Marshall

“太初有道。” 从最初的文字混沌中,很快就出现了空白页、墨粉盒以及如今人类对印刷品无休止的渴望。如果您渴望在印刷品上看起来美观,或者只是需要起草一份备忘录、学期论文或给妈妈的信,您应该了解 groff。groff 是一套丰富且易于使用的文档格式化工具,并且在每个 Linux 系统上都作为标准设备提供。 groff 可以帮助您处理文字,并在印刷页面上精美地排版它们。

groff 特指 troff 的 GNU 和更新版本(troff 是为 UNIX 开发的古老的文档格式化系统,早于互联网、光盘和微波爆米花时代)。传统的 troff 最初由 Joseph Ossana 于 1970 年代早期在贝尔实验室编写,几年后由 Brian Kernighan 重写,专为当时的计算机和排版设备设计。GNU 版本的 troff——最初称为 gtroff,现在简称为 groff——由 James Clark 在 1990 年代早期编写。在保持与传统 troff 兼容的同时,groff 提供了几项关键增强功能,使其比它取代的程序更容易使用、更强大且限制更少。GNU groff 正在积极维护并不断发展。除了 Linux 和其他 UNIX/类 UNIX 系统之外,groff 的端口也适用于大多数其他平台。这种普遍性和开源自由使您可以在平台之间可移植且自由地发布和共享您的文档。

本文的重点是使用 groff 的宏功能生成打印输出。还应该提到的是,groff 用作 man 命令生成的在线手册页面的格式化引擎。如果您需要 groff 排版能力的示例,只需使用 -t 选项生成 man 的打印手册页面即可

man -t troff >troff.man.ps

这将生成 groff 手册页面的 PostScript 版本,然后您可以使用 PostScript 预览器(gv, mgv)在屏幕上查看,使用 PostScript 打印机直接打印,或者使用 PostScript 解释器(例如 GhostScript)打印到非 PostScript 打印机。(顺便说一句,您真的应该看看这个手册页面。它提供了 GNU groff 中所有附加功能的完整摘要,比此处介绍的更详细。)

groff 提供了计算机排版的所有优点,包括自动连字、字距调整、断字和句末间距。 groff 还通过嵌入到纯文本文件中的排版命令,提供对页面布局各个方面的低级控制。通常,这些命令——或者在 groff 术语中,请求——在包含命令的行的第一列用句点指定。例如,以下文档片段具有嵌入式命令,用于增加左缩进并减小当前行长

This is an example of a groff document..in  +0.5i
.ll  -0.5i
When formatted by groff, the text continuing
here will appear indented by one-half inch
from both of the previous margins.

虽然可以使用这种“原始” groff 请求完全格式化文档,但最终用户更典型地是使用预定义的 集合,这些宏将原始请求序列封装到单个命令中。例如,如果我们想为前面代码片段中的块缩进命令创建一个宏,它可能看起来像这样

.de  Bi.in  +0.5i
.ll  -0.5i
..
.de 请求开始定义我们名为 Bi 的宏,最后一行上的双句点标记结束。在文档中调用宏的语法与使用原始请求的语法相同(宏的名称跟在第一列带有句点的行上)。我们在文档中使用的新宏将如下所示
This is another section of my groff document..Bi
Oh boy, now the text continuing here is indented
from both margins!
如果稍后我们想将块缩进增加到四分之三英寸,我们只需要更改宏定义即可。然后,整个文档中 Bi 的所有实例都将以新尺寸进行格式化。

到目前为止,我们还没有看到太多令人兴奋的东西。传统 troff 的局限性之一是,所有命令、宏和其他变量的名称都限制为两个字符。仅仅两个可怜的字符?如前所述,troff 是在名副其实的计算机石器时代开发的,当时每一位都很重要,简洁是崇高的。虽然 troff 和标准宏包的开发人员已尽力设计命名方案,使其在此双字符约束内尽可能具有助记性,但由此产生的界面与 80x86 汇编语言一样用户友好(至少其大多数指令集使用三个字符!)。

幸运的是,GNU groff 消除了这种双字符命名限制。对于宏开发人员和最终用户来说,

groff 最重要的增强功能是,所有名称,包括宏、数字、字符串、字体和环境,都可以是任意长度的。 Groff 还允许 别名化 troff 命令、宏和变量,以便为现有命令、宏和变量提供备用名称。在本文的其余部分,我们将大量利用此功能。事实上,让我们现在就开始别名化 groff 别名命令本身

.als    ALIAS   als

我们现在可以使用此命令为其他关键 groff 命令提供一组更长的名称

.ALIAS  MACRO    de.ALIAS  NUMBER   nr
.ALIAS  STRING   ds
当然,您那些老派、硬核的 troff 爱好者会对这种语法糖嗤之以鼻。但是,当我们在一个漫长而愉快的周末之后——或者在其他回归现实生活之后——回到工作岗位时,我们其他人会更容易弄清楚 Sam Hill 的某些宏在做什么。
宏标记

列表 1 演示了一个使用 groff 宏实现的强制性“Hello, world!” 程序。在其中我们可以看到,groff 提供了用于创建数字和字符串类型的用户定义变量的命令,并且这些变量可以在我们开发的宏中使用。请注意,在这个更实际的示例中,添加了更多命令别名和数字寄存器

列表 1. “Hello World 程序”

.\"(this is a traditional troff comment).\"
\# (this is a gnu and improved comment!)
\#
\# define additional aliases:
.ALIAS   BRKFILL     br
.ALIAS   SKIP        sp
.ALIAS   NEED        ne
.ALIAS   TINDENT     ti
\#
\# define number registers:
.NUMBER  #PARINDENT  0.5i
.NUMBER  #PARSKIP    0.8v
.NUMBER  #ORPHANS    2
\#
\# define user interface
\# tag for a new paragraph:
.MACRO  <p>   __END__
.  BRKFILL \" break and spread pending output
.  SKIP    \\n[#PARSKIP]u       \" paragraph prespace
.  NEED    \\n[#ORPHANS]v-1v+1u \" orphan control
.  TINDENT \\n[#PARINDENT]u     \" indent 1st line
.__END__

从我们选择的别名名称和内联注释中可以清楚地看出,<p> 的宏定义为新段落提供了一个标记标签,当由 groff 格式化时,具有以下功能

  • 完成格式化并强制输出当前正在处理的任何挂起行

  • 通过 #PARSKIP 变量中的值,为后续段落创建垂直预留空间

  • 通过将至少 #ORPHANS 行保持在一起,来控制孤行

  • 将段落的首行临时缩进 #PARINDENT 变量中的值

文档文件中新段落宏的用法将如下所示
.<p>This is my new paragraph. Notice how groff
lets me create HTML-like tags.
.<p>
Here is my next paragraph...
虽然此示例是从最终实现中简化的,但它演示了我们如何导出从基本 groff 宏构建的用户界面,并为我们的文档创建结构化标记标签。另请注意,当将同一文档发布到 Web 时,另一个宏文件可以替代地定义 <p> 宏
.MACRO    <p>    __END__<p>
.__END__
宏名称可以是任何字符的任何字符串,并且 groff 区分大小写。在我们的示例中,命名为 <p>,尖括号没有特殊含义;它们只是我们设计的宏名称的一部分,用于模拟类似 HTML 的标签。

但是,我们应该扩展上面给出的宏的定义。回想一下,.MACRO 命令本身是我们给原始 groff 请求 .de 的别名。此命令接受两个参数:第一个是宏的名称(此处为 <p>);第二个是可选的终止标签(此处为 END)。任何任意字符串都可以用来标记宏定义的结束。我们在这些示例中使用 END,但也可以使用 <<< 或 *****,或任何其他有助于提高文件中宏命令集合可读性的约定。

macro 还演示了不同形式的注释。第一种形式 (.\“) 在第一列中使用句点,实际上充当 未定义的请求,其效果是整行被静默忽略。第二种形式 (\#) 是 GNU groff 扩展,忽略注释 包括换行符 之后行上的所有内容。第三种形式 (\") 可以与 groff 命令在同一行上使用,并忽略注释 不包括换行符 之后行上的所有内容。如果有人在单独一行上使用最后一种形式的注释 (\"),并且第一列没有句点,则 groff 会解释换行符,通常将其转换为输出中的空格或换行符(取决于填充模式)。意外的空格和空行可能是痛苦和苦恼的来源,特别是对于试图弄清楚为什么额外的空间会偷偷溜进文档的新手宏开发人员而言。通常,GNU 形式的注释对于单行注释是首选,而传统形式对于 groff 命令后面的同一行上的注释是必需的。

最后,您可能已经注意到,虽然 groff 命令语法要求第一列中有一个句点,但命令本身的名称可能会在同一行上缩进到任何级别。通过使用逻辑缩进的源代码,以及注释,您将大大提高您自己和未来几代 groff 用户代码的可读性。(前面的评论是计算机科学外科医生总会要求的公共服务公告,并且基于广泛的科学证据,表明此类约定将延长您的源代码的预期寿命。)

变量和命名空间

Groff 有一组大约 50 个预定义的变量,称为数字寄存器。这些是 groff 排版机制的内部仪表。在处理输入文件时,groff 使用页面编号、页面位置和磅值等变量的当前值来维护这些寄存器。数字寄存器与字符串和宏位于单独的命名空间中,并使用它们自己的别名命令进行别名化,如下所示

.ALIAS  ALIASNR aln.ALIASNR        _PTSIZE .s
.ALIASNR        _LEADING        .v

在此示例中,我们首先别名化用于别名化数字的命令,采用我们之前使用的方法。然后,我们别名化当前磅值和垂直行距的只读寄存器,选择使用传统的排版术语——“行距”——来表示后者。虽然不是必需的,但上面的示例还演示了我们遵循的特定约定,即以“_”(下划线字符)为系统变量的别名前缀。

当然,您可以在这些问题上遵循自己的意愿。但是,使用命名约定可能有助于区分变量本身与设置变量的命令的名称,例如

.ALIAS   PTSIZE   ps.ALIAS   LEADING  vs

这些可能会在宏中按如下方式使用

.MACRO    <fontsize:>    __END__.  PTSIZE  \\$1
.  IFELSE  "\\$2""  \{\
.      LEADING ( \\n[_PTSIZE]u * 120/100 )
.  \}
.  ELSE \\{\
.      LEADING  \\$2
.  \}
.__END__
在文档中使用
.<p>A message to the world:
.<p>
.<fontsize:>  18p
Is groff great or what?
宏的第一行将当前磅值设置为宏的第一个参数的值。第二行引入了一个复合 if/else 语句,使用 groff 的字符串比较语法进行逻辑测试。如果第二个参数为空,则通过取数字寄存器 _PTSIZE 中现在的磅值,并将其增加 20% 来设置行距。否则,行距设置为第二个参数提供的值。

数字表达式中的括号允许在表达式中使用空格。否则,在上面的示例中,我们将需要使用没有空格的较难辨认的形式

.LEADING  \\n[_PTSIZE]u*120/100

数字表达式只是从左到右求值,没有运算符优先级规则,并且需要括号来显式更改求值顺序。

所有算术运算和数字寄存器最终都是基于整数的。 Groff 在内部将所有尺寸测量值转换为机器单位(基于 PostScript 设备的每英寸 72,000 个单位),从而提供分数尺寸和磅值的函数式“错觉”。这使我们可以指定十进制项,例如 8.5i11.5p,实际上,它们分别评估为 612,000 和 11,500 个机器单位。数字值可以用表 1 中所示的任何单位指定。

表 1. Groff 单位

在实践中,groff 内部使用整数数学可能会对宏开发人员产生重大影响。考虑一下,如果上面的表达式改为这样表示会发生什么

.LEADING  (\\n[_PTSIZE]u * (120/100))

使用整数除法,120/100 的括号项将求值为 1,然后整个表达式将求值为当前磅值,而不是预期的 20% 更大。

事实证明,并非所有预定义的数字寄存器实际上都是数字的。例如,正在处理的当前文件的名称位于只读寄存器 .F

.ALIAS     MESSAGE  tm.ALIASNR   _LINE    .c
.ALIASNR   _FILE    .F
.MESSAGE  Currently processing file \n[_FILE], line \n[_LINE].

尽管两个变量都使用数字寄存器的语法进行求值,但 _FILE 将当前文件的名称作为字符串返回。尽管存在这种异常情况,但 groff 只允许在用户定义的数字寄存器中使用数字表达式。顺便说一句,这里的示例是在开发期间在宏文件中插入调试消息的一种方法。 .tm 请求——上面别名为 .MESSAGE——将任何后续文本发送到标准错误流。

宏和“复制”模式

细心的读者可能想知道为什么 <p> 宏内部评估数字寄存器的语法有两个反斜杠(例如,\\n[#PARSKIP]u),而不是像上面显示的单个反斜杠(例如,\n[_LINE])。区别是微妙但重要的。

在宏定义内部使用双反斜杠的原因是,我们通常不希望在首次读取宏时就评估宏内部的表达式。相反,我们希望每次回放宏时都评估该表达式。双反斜杠是 groff 的反斜杠字符本身的转义序列,提供了在输出中打印单个反斜杠的方法。当 groff 第一次读取宏时——在所谓的“复制模式”中——它像往常一样解释所有内容,包括转义序列。因此,当在宏定义中遇到双反斜杠时,groff 会将其转换为序列表示的单个反斜杠。然后,每当回放宏时,剩余的单个反斜杠都以通常的方式解释。

虽然我们可以使用单个反斜杠定义宏变量,例如

.MACRO   <p>.SKIP   \n[#PARSKIP]u
\# etcetera

此宏将始终使用首次读取宏时在变量 #PARSKIP 中指定的段落预留空间量执行。整个文档中您都将被卡在相同的 #PARSKIP 上。通过使用双反斜杠,就像我们在 <p> 的原始定义中所做的那样,我们可以动态更改文档中任何位置的 #PARSKIP 变量,并且可以根据需要经常更改,例如

\# user interface for setting parskip:.MACRO  <parskip:>    __END__
.    NUMBER  #PARSKIP  \\$1
.__END__
\#
\# tighten spacing between paragraphs:
.<parskip:>  0.4v
新设置现在将影响后续所有 <p> 宏实例的格式。

正如我们所期望的那样,groff 也在此领域提供了一个有用的扩展。“\E”序列表示一个转义字符,该字符在复制模式下 不会 被解释。因此,我们的 <p> 宏也可以轻松地编写为

.MACRO  <p>.SKIP   \En[#PARSKIP]u
\# etcetera

\E”序列将提供与“\\”双反斜杠序列相同的结果。

伪数组和真实循环

我们在上面展示了 groff 用于评估具有任意长度名称的数字寄存器的语法是

\n[anyname]

类似地,用于评估其他寄存器的语法是

\*[anyname]    string\f[anyname]    font
\[anyname]     special character
groff 只有标量变量,缺少复合结构或下标数组。但是,可以将定义和数值变量组合起来,以模拟复合数据类型的效果。在这里,我们将演示一个“伪数组”,它可能在您的宏技巧包中派上用场。

考虑以下一周中各天的字符串定义

.STRING  $DAYNAME1   Sunday.STRING  $DAYNAME2   Monday
\# etcetera
.STRING  $DAYNAME7   Saturday

groff 提供一个数字寄存器,表示当前星期几的数值,从 17,当然,我们再次别名化它以符合我们的方案

.ALIASNR  _DOW    dw
现在,我们可以使用我们上面定义的字符串伪数组来初始化一个包含当前日期名称的变量,如下所示
.STRING  $TODAY  \*[$DAYNAME\n[_DOW]]
任何时候我们需要宏或文档中的当前日期名称,我们只需要使用字符串变量 $TODAY
.<p>Thank goodness it's \*[$TODAY].
_DOW 变量求值为 6 时,人类对此消息的反应可能最有利。

groff 还具有一个扩展,可以使用户在宏中使用循环结构。与伪数组一起,此功能为您提供了比仅具有 if/else 分支逻辑的传统 troff 更多的能力和灵活性。按照上面的示例,如果您需要一个宏来为每个工作日(星期一到星期五)创建制表符分隔列列表的标题,则可以拼凑出类似以下内容的东西

.ALIAS  TABSET    ta.ALIAS  WHILE     while
.MACRO  <weekdays>    __END__
.  NUMBER  IX  1 1
.  NOFILL
.  TABSET  T  .75iC
.  WHILE  \\n[IX]<6  \{\
        \\*[$DAYNAME\\n+[IX]]\c
.  \}
.  FILL
.__END__

在上面的示例中,TABSET 命令利用了 groff 的“T”扩展,用于重复制表符,此处设置为每 3/4 英寸。循环测试变量“IX”演示了数字寄存器的自动递增语法(\\n+[IX] 表达式中的“+”号)。这具有预递增变量的效果,因此第一次通过循环时,IX 将评估为 2,从伪数组 $DAYNAME 打印“Monday”。最后,打印行以 \c 转义序列终止,以在当前行上继续输出,而无需插入否则将在非填充模式下插入的换行符。

groff 的“while 循环”实现包括 .break.continue 语句。这些为 groff 提供了更完整编程语言的流程控制工具。虽然您可能不会使用 groff 来解决多元回归问题,但 groff 的 while 循环确实使编写宏更容易,例如,在预切表格上打印地址标签列,而无需使用外部处理器。

页面布局和陷阱

groff 的基本页面布局模型与 groff 的极简主义保持一致。 groff 唯一需要的尺寸是页面的垂直长度、硬左边距和可用于可打印行的水平长度。这些尺寸中的每一个都使用我们下面别名化的请求来设置

.ALIAS    PGLENGTH    pl.ALIAS    PGOFFSET    po
.ALIAS    LNLENGTH    ll

但是,通常,我们需要文档具有其他布局参数,例如顶部和底部边距,可能带有运行页眉和/或页脚。所有这些都可以使用 groff 的 陷阱 机制结合我们设计的页面过渡的附加参数和宏来配置。

假设我们希望主文本的正文具有一英寸的顶部和底部边距,并且在每页底部半英寸处居中显示页码。此外,我们希望定义这些参数,使其无论我们使用 letter、legal 还是 A4 尺寸的纸张都能正常工作。第一步是定义我们自己的数字寄存器集来保存我们所有的布局参数

\# parameters with default settings:.NUMBER    #PAGELENGTH   11.0i
.NUMBER    #PAGEWIDTH     8.5i
.NUMBER    #LEFTMARGIN    1.0i
.NUMBER    #RIGHTMARGIN   1.0i
.NUMBER    #TOPMARGIN     1.0i
.NUMBER    #BOTMARGIN     1.0i
.NUMBER    #FOOTMARGIN    0.5i
\#
\# layout initialization macro:
.MACRO  SET_LAYOUT    __END__
.    PGLENGTH  \\n[#PAGELENGTH]u
.    PGOFFSET  \\n[#LEFTMARGIN]u
.    LNLENGTH \
\\n[#PAGEWIDTH]u-\\n[#LEFTMARGIN]u-\\n[#RIGHTMARGIN]u
.__END__
\#
\# initialize layout with defaults:
.SET_LAYOUT

下一步是编写我们的页面过渡宏,并使用陷阱机制将它们放置到位。以下代码片段演示了

\# some more aliases:.ALIAS  CENTER    ce
.ALIAS  RIGHT     rj
.ALIAS  NEWPAGE   bp
.ALIAS  SETTRAP   wh
\#
\# macro for header:
.MACRO  MYHEADER    __END__
'    SKIP  |\\n[#TOPMARGIN]u
.__END__
\#
\# macro for footer:
.MACRO  MYFOOTER    __END__
'    SKIP  |(\\n[#PAGELENGTH]u -
\\n[#FOOTMARGIN]u)
.    CENTER
\\n[_PAGE]
'    NEWPAGE
.__END__
\#
\# position to invoke header/footer:
.SETTRAP  0                  MYHEADER
.SETTRAP  0-\n[#BOTMARGIN]u  MYFOOTER
该示例显示了两个宏,MYHEADERMYFOOTER,它们定义了在页面顶部(位置 0)和底部边距(-1.0i)处执行的操作。这些宏中的语法显示了延迟中断控制字符,撇号 ',与原本会导致输出立即强制输出的 groff 命令一起使用。

页面布局参数使用默认值定义,但在这里我们将创建一个用户界面来更改纸张尺寸

.MACRO  <papersize:>    __END__.    IFELSE  "\\$1"letter"  \{\
.        NUMBER #PAGELENGTH  792p
.        NUMBER #PAGEWIDTH   612p
.    \}
.    ELSE .IFELSE  "\\$1"a4"  \{\
.        NUMBER #PAGELENGTH  842p
.        NUMBER #PAGEWIDTH   595p
.    \}
.    ELSE .IFELSE  "\\$1"legal"  \{\
.        NUMBER #PAGELENGTH  1008p
.        NUMBER #PAGEWIDTH    612p
.    \}
.    ELSE  \{\
.        MESSAGE \
Missing or unrecognized papersize,\
file \\n[_FILE], line \\n[_LINE]
.    \}
.    \" re-initialize layout:
.    SET_LAYOUT
.__END__

最终用户现在可以设置文档的纸张尺寸,这将相应地初始化边距。将我们目前看到的元素组合在一起,我们定义了一个 groff 接口,允许最终用户创建看起来像这样的文档

.<papersize:>  a4.<fontsize:>   11.5p
.<parskip:>     0.8v
.<p>
This document is typeset by groff!
结论

就像 vi 编辑器gcc 编译器 一样,groff 是标准 UNIX/Linux 环境中的经典支柱之一。我们已经看到了一些使用 groff 广泛的宏功能来定义标记和页面布局界面的方法,这些界面可以轻松地将纯文本文件转换为排版质量的打印件。

此处介绍的功能绝不是全部。例如,groff 还包括用于绘制线条、曲线、圆形、椭圆和具有阴影填充的多边形的本机工具。而且,这甚至还没有开始介绍 groff 的预处理器套件,用于图形 (grap)、图片 (pic)、公式 (eqn)、表格 (tbl) 和书目参考 (refer)。与 GNU 和 Linux 软件的惯例一样,groff 附带完整且高质量的文档。(有关更多信息,请参阅资源。)当然,还有活跃的邮件列表,用于保持与 groff 的同步并与其用户社区互动。

本文旨在创建短文档,但 groff 能够打印任何长度的作品。事实上,groff 很可能就是您最喜欢的 O'Reilly 图书的出版中使用的排版软件。对于 groff 在实际应用中的杰作示例,更不用说一些有史以来关于 UNIX 编程的最佳书籍,请参阅 W. Richard Stevens 的任何系列。(已故的 Stevens 博士在本篇文章的开头引用了他的 UNIX 网络编程,卷 2, Prentice-Hall PTR, 1999 的版权页。) 与诞生于同一时代的 C 编程语言非常相似,groff 具有持久而强大的极简主义,可以很好地用于各种尺寸的排版任务。如果您听到有关 groff 消亡的报告,请记住,有些人过去也对 UNIX 提出过类似的说法!

Wayne Marshall (guinix@yahoo.com) 是一位 UNIX 程序员和技术顾问,目前居住在西非几内亚。他喜欢旅行、远足、摄影、非洲、浓烈的红茶、爆米花和烘焙饼干。

空行宏

安装 PostScript 字体

为什么要使用 Groff?

资源

加载 Disqus 评论