用 groff 写的中篇小说
一位程序员用 groff 写了两部中篇小说,一部 50 页,一部 120 页,还讲了自己那套小小的宏包、页面陷阱、断词和 Vim 配置。
中文
复制

我最近完成了自己的第二部“中篇小说”。

想都别想。我不会发布它,也不会私下发给你,那跟发布没什么两样。
第一篇大约 50 个 A5 页(约 1.5 万词),第二篇约 120 页(约 4 万词)。(按“中篇小说”的定义来看,两篇都勉强够格,第一篇稍短了一点,第二篇又稍长了一点。)
我居然做成了这件事,感觉挺荒诞、挺不真实的。上学时,凡是跟“写作”沾边的东西对我来说都是纯粹的折磨,除非它跟数学、物理或其他“技术”类的东西有关。可如今我偏偏写出来了。
花了多久?第一篇花了七个多月。第二篇呢,更长的那篇?十天。
去年某段时间,大概是七月前后,我开始读一些在公寓里放了几十年的书。读起来比我预想的愉快得多。不知道为什么。倒不是说我从没读过书,只是读书一直有点无聊,而且特别费时间,除非是技术类的书。我这里说的只是虚构类小说。一年顶多一本。可去年我不知怎么就上瘾了,从 2025 年 7 月到现在已经读了大约 30 本小说。有的读得快(几天),有的读得慢(三个月)。
所以自己动手试试,也没那么遥不可及。
这个博客本质上还是技术向的,所以我来说说用了哪些软件。
groff
我这两本“书”(其实算不上真正的书,但为了省事我就这么叫了)都是用 groff 排版的。就是 Linux 终端里通常用来渲染 man page 的那个东西。不过 groff/troff 是一套完整的排版系统,可以用来制作真正的书,我在之前的博客文章里已经提过好几次了。



在我看来,groff 的吸引力在于它相对小巧、快,同时还能产出高质量的排版结果。在我那台用了 13.5 年的 PC 上,生成那份 50 页的文档大约需要 700 毫秒,120 页的那份则要 1.6 秒。这其中还包含一些预处理步骤,后面会讲到。如今大多数现代软件用起来都像在泥里走路,这个速度让人耳目一新。
因为这样更有意思,我自己写了一个小小的宏包。其实内容并不多,非常小。写一部简单的小说或中篇用不着多少东西,不需要目录、不需要交叉引用、不需要图表、不需要代码块、不需要列表……只要文字顺畅地流动,偶尔有个词被强调一下就够了。
先说清楚,你会注意到下面文本里有一些 TODO 项。groff 是一套完整的排版系统,这些东西很复杂。我离掌握它的每一个方面还差得远。
基本格式
只有三个宏:
.\" Section header / chapter title
.de SH
. sp 1v
. ps +5p
. ce
\f[HR]\\$1\fP
. ps
. sp 4v
..
.\" Normal paragraph with hanging indent.
.de PP
. br
. ti +2m
..
.\" "Long" paragraph, longer empty space.
.de LP
. sp 2v
..
(SH 这个名字是从 man page 沿用过来的。我写过不少 man page,这个名字不知怎么就留下来了。叫 CH(“章标题”)或 CT(“章题目”)也许更合适。或者,你知道的,.CHAPTER。)
定义宏以 .de 开头,直到 .. 之前的内容都属于这个宏。
.SH:向下移一个 vee, 增大字号,请求居中,用HR字体(Helvetica)打印宏的第一个参数,恢复之前的字号,再加四个 vee 的间距。.PP:只断开当前行,然后为下一行输出请求一个临时缩进。这样就形成了悬挂缩进,这在德国书籍中是惯例。.LP:只是段落之间的一些空白。用于章节内的时间跳跃之类的地方。
各个命令可以在 man 7 groff 里查到。
做到这里,已经足以产出一份多少有点像书的文档了。
把上面的片段放进一个名为 macros.groff 的文件。我每章一个文件。例如:
chapter01.groff。
然后就可以用类似这样的命令生成 PDF:
groff -Tpdf -dpaper=a5 -P-pa5 macros.groff chapter01.groff >book.pdf
A5 大致相当于一本“普通书”的页面尺寸。
页脚与页码
你会注意到,默认情况下没有页码。如果你只想要一份数字版 PDF,其实完全可以不要页码。页码在打印时有用,但说实话,放在文件里并不是真的需要。
不过,这东西在分页处会犯迷糊。用上面的例子生成 PDF,你就会看到:

最后一行输出的基线看起来刚好落在页面之外。不清楚这里发生了什么,这和把页面尺寸设为 A5 有关。换句话说,我大概又漏掉了另一处需要设置的地方。下面的改动会顺带把它修好。
页码这整套东西由三部分组成。首先,页脚(页码所在的区域)有多大?我在一个寄存器里定义了它(其实并非必要,只是这样写我觉得“代码”更好读),名字是随便起的。
.nr _FooterSize 5v
然后我们在页面底部埋一个陷阱:
.wh -\n[_FooterSize]u _FooterTrap
\n[_FooterSize] 会“取出”名为 _FooterSize 的寄存器的值。它前面有个负号,是负值,相对于页面底部。文档里说:
通用寄存器(用 nr 请求创建或更新的那种)本身是无量纲的;如果在测量的语境里理解,就按基本单位计算。
我不完全确定为什么需要加上 u。手册我还没逐页读完。不用说,不加它就跑不起来。
好,于是陷阱被安装在页面结束前 5v 处,它会调用 _FooterTrap 宏。长这样:
.de _FooterTrap
.\" Push current environment on stack and switch to `_FooterEnv`.
.\" Parent env will keep partially collected lines.
. ev _FooterEnv
.\" Copy settings from default environment.
. evc 0
.\" Don't do this for pages 1 and 2, they're title pages in my actual
.\" "book".
. if \\n[%]>2 \{\
.\" Set vertical position, explained in the text below.
. sp |\\n[.p]u-2v
.\" Print % register centered.
. ce
\fR\\n[%]\fP
\}
.\" Go back to previous environment.
. ev
.\" Request page break.
' bp
..
去掉行内注释后好读多了:
.de _FooterTrap
. ev _FooterEnv
. evc 0
. if \\n[%]>2 \{\
. sp |\\n[.p]u-2v
. ce
\fR\\n[%]\fP
\}
. ev
' bp
..
为什么是 'bp 而不是 .bp?这个后面再说。
有个地方我不确定。我把垂直位置设成了 |\\n[.p]u-2v。那是相对于页面顶部的,等于页面长度减去两个 vee。我本以为 |-2v-1v(算上文档里提到的“减一个 vee”)也应该能用,但那样会陷入死循环(大概是一次次又触发同一个陷阱)。为什么?
页眉
为了对称,我也想在页面顶部留一些空白:
.nr _HeaderSize 5v
.de _HeaderTrap
' sp |\\n[_HeaderSize]u
..
.wh 0v _HeaderTrap
还有一个 TODO,这并不完全对称。页眉的长度比页脚略大。又是页面尺寸的问题?
细看陷阱
我们来仔细看看陷阱。这一点我并非百分之百确定,以下是我目前的理解。
其实,我们先暂且放下上面的例子。从下面这个开始:
This is a little excerpt from Mary Shelly's Frankenstein.
These visions faded when I perused, for the first time, those poets
whose effusions entranced my soul and lifted it to heaven. I also became
a poet and for one year lived in a paradise of my own creation; I
imagined that I also might obtain a niche in the temple where the names
of Homer and Shakespeare are consecrated. You are well acquainted with
my failure and how heavily I bore the disappointment. But just at that
time I inherited the fortune of my cousin, and my thoughts were turned
into the channel of their earlier bent.
Six years have passed since I resolved on my present undertaking. I can,
even now, remember the hour from which I dedicated myself to this great
enterprise. I commenced by inuring my body to hardship. I accompanied
the whale-fishers on several expeditions to the North Sea; I voluntarily
endured cold, famine, thirst, and want of sleep; I often worked harder
than the common sailors during the day and devoted my nights to the
study of mathematics, the theory of medicine, and those branches of
physical science from which a naval adventurer might derive the greatest
practical advantage. Twice I actually hired myself as an under-mate in a
Greenland whaler, and acquitted myself to admiration. I must own I felt
a little proud when my captain offered me the second dignity in the
vessel and entreated me to remain with the greatest earnestness, so
valuable did he consider my services.
把它保存为 test.groff,然后生成 PDF:
groff -Tpdf test.groff >book.pdf
效果如下:

在 10v 处放一个陷阱。它目前是空的,什么也不做。把下面这段插到文件最顶端:
.de MyTrap
..
.wh 10v MyTrap
.\"
PDF 看起来没有变化。
现在把 trap 宏改成这样会怎样?
.de MyTrap
FOO
..
结果如下:

当输出到“fishers”这个词时,陷阱被触发了。在 PDF 里,“FOO”排在“fishers”后面。“fishers”触发了陷阱,随后“FOO”被插入。我想事情就是这么简单,对吧?
假设我们想把“FOO”放在行首,并且这一行除了这个词什么都不放。“fishers”则要放到下一行。
如果我没理解错,关键在于切换环境。环境保存着当前收集到的那一行(以及许多其他东西)。切换到一个新环境,就得到一个全新的缓冲区/行,此时还是空的。然后我们可以刷新/断开这一行,让它进入 PDF,再切回原来的环境:
.de MyTrap
. ev MyTrapEnv
FOO
. br
. ev
..
看起来是这样:

到目前为止一切正常。现在我希望“FOO”前后各有一个空行:
.de MyTrap
. ev MyTrapEnv
. sp
FOO
. sp
. ev
..
在新环境中,我们发出 .sp,它会断行并插入间距。此前没有收集到任何内容,因此此时这个断行不会打印出任何东西。接着是 FOO,它不会被直接打印,只是被收集起来。下一个 .sp 再次断行(于是打印出“FOO”),然后插入更多间距。

好。现在假设在 trap 里我们只想打印“FOO”,上下各带一行。接着我们希望正文从页面上某个绝对位置继续,比如 17v。
那么,下面这样如何?我们回到之前的环境,然后发出一个绝对定位请求:
.de MyTrap
. ev MyTrapEnv
. sp
FOO
. sp
. ev
. sp |17v
..
糟糕。

.sp |17v 把已经收集好的那一行(只有“fishers”的那行)断开了,然后才插入空格。我们不想要这个断行。(还要注意,“fishers”正好位于我们在 trap 中停下的同一位置。)
用 ' 代替 . 就能避免这个问题:
' The neutral apostrophe is the no-break control character, rec‐
ognized where the control character is. It suppresses the
(first) break implied by the bp, ce, cf, fi, fl, "in", nf, rj,
sp, ti, and trf requests. The requested operation takes ef‐
fect at the next break.
那就这样做:
.de MyTrap
. ev MyTrapEnv
. sp
FOO
. sp
. ev
' sp |17v
..
结果是这样。

文档里的示例并没有切换环境。不过我觉得,切换环境时更容易看清发生了什么。(希望我没弄错。)
最后一点,环境会携带设置。.ev foo 会开启一个全新的环境(除非同名环境已经存在),而不会克隆上一个环境。所以我的宏包也做了 .evc 0。
留个小练习:试试 'sp |12v、'sp |11.5v、'sp |11v,一直到 'sp |9v,看看会发生什么。
句间距
直到写这篇博客时,我才在文档(Basics)里看到下面这句话:
如果你不在新行开始一句话,就在上一句后面加两个空格。GNU troff 会据此识别出结束句子的标点,并相应地插入句间距。
我没这么做,因为德语里不兴这个。但现在我注意到了。有时 PDF 里会是这样:
Foo. Bar.
有时则会多出一点空隙:
Foo. Bar.
据我所知,德语书籍通常不这么做。我用下面这条请求把它关掉了,它把“最小词间距”保持在默认值 12,同时把“补充句间距”设为 0:
.ss 12 0
这不在我的宏包里,但它放在“index”文件(book.groff,见下面的演示包)的最前面。
德语断词
显然,就这么简单:
.hla den
.hpf hyphen.den
den 是“de(utsch) new”,包含 2006 年的新德语规则。
Markdown 与其他预处理
其实我那两个故事都是用纯 groff 写的,用的就是上面讲的宏。
Markdown的一大优势是“看着舒服”,它大体上就像给人读的文本。groff 则不然。我是说,它也没那么糟,如果写起来是种折磨,我也不会用 groff 写 170 多页。它还行。我在别的好几个地方也用 groff。但 Markdown 还是稍微好那么一点,我猜它能让我更专注于创作过程。
后来,我把第二篇故事转成了“Markdown”。第一篇就懒得弄了。加引号是因为它只用到很少几个元素,并不是完整的 Markdown:
- 文件开头的
# Chapter title。 - 段落之间空一行,
---表示“长空行”。 *foo*表示强调文本。
就这些。转换起来确实轻松。
整件事我本可以只用纯 Markdown 写,再生成一个 HTML 文件。工具我早就有了,我的博客就是这么做的。说不定还能顺便输出 PDF。但那样会缺不少东西,其中之一就是真正把文本两端对齐的引擎。HTML 里看到两端对齐的文字我就眼睛疼,那效果简直是一场灾难。groff 或 TeX 排出来的东西要好得多,好得不是一点半点。
既然都动手了,顺便还能做些基本的字符替换。我的键盘上直接就有德文排印引号 U+201E 和 U+201C,但书里更常用 Guillemets。
一开始我想用 Python 做这些,后来改成了一个简短的 Perl 脚本,因为用它读入整个文件、再做多行正则处理很方便。大概是这样:
perl -0pe '
# Guillemets are preferred in books.
s/... German typographic quotes, see demo package .../... Guillemets .../g;
# Markdown "h1" into ".SH".
s/^# ([^\n]+)\n/.SH "\1"\n/smg;
# Markdown "hr" into ".LP".
s/\n---\n/.LP/smg;
# Markdown paragraphs into ".PP".
s/\n\n/\n.PP\n/smg;
# Markdown emphasize into groff font selection:
s/\*([^*]+)\*/\\fI\1\\fP/smg;
'
输出仍然是我那套小宏包用的 groff。但现在我可以这样写:
# Kapitel 42: Die Antwort
Da war sie also. Die Antwort auf *alle* Fragen.
Und dann ging es weiter:
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum
hendrerit blandit suscipit. Maecenas sed nisi nec ante mattis
vulputate. Pellentesque maximus leo id purus vestibulum dictum.
Donec tincidunt mollis viverra. Curabitur at ligula in turpis
pellentesque vehicula.
Duis hendrerit commodo mauris, non posuere leo vulputate ut. Nulla
finibus urna eget vehicula auctor. Mauris in posuere nibh. Vivamus
ut metus vel nisi gravida molestie vitae et tellus. Integer mi odio,
vestibulum eget laoreet vel, auctor nec ligula. Quisque eu placerat
quam, eu tincidunt mauris. Nam vestibulum lectus non leo dignissim,
non sagittis dolor venenatis.
读起来舒服多了,Vim 里还有语法高亮。嗯,高亮的是 *emphasized* 那部分,其余的无所谓。

说回 Vim……
Vim
Goyo
有一个特别重要的 Vim 插件:
https://github.com/junegunn/goyo.vim
它几乎隐藏了所有 UI 元素。
除此之外,我把终端窗口设为全屏,把桌面也藏起来。
就这样。“无干扰写作”名副其实。也许再放点音乐,但除此之外什么都没有。没有浏览器,没有时钟,什么都没有。只有一台打字机,外加一个好用的撤销功能。

字体用的是 UW ttyp0 的 11x22 版本。
文本重排
纯文本文件里有一种很流行的排版方式:
This is one sentence.
This is another one.
And so on and so on.
One sentence per line, that's it.
Sentences can be short.
Or they can be pretty long, sometimes even exceeding the available width on your screen, thus requiring you to either scroll or have your editor soft-wrap it for you when displaying it.
前面在 groff 文档里已经说过,这种写法对 groff 来说有实际意义(能正确识别句子结尾),而且能让版本控制里的 diff 噪音更少。
另一种流行做法是每个段落只占一行。整段文字后面只有一个“物理”换行符,怎么正确显示就交给编辑器:
This is one sentence. This is another one. And so on and so on. One sentence per line, that's it. Sentences can be short. Or they can be pretty long, sometimes even exceeding the available width on your screen, thus requiring you to either scroll or have your editor soft-wrap it for you when displaying it.
九十年代末到两千年代初,我用第二种方式用了好几年,后来还是换成了硬换行。像这样:
This is one sentence. This is another one. And so on and so on. One
sentence per line, that's it. Sentences can be short. Or they can be
pretty long, sometimes even exceeding the available width on your
screen, thus requiring you to either scroll or have your editor
soft-wrap it for you when displaying it.
我就是觉得这样读起来更轻松,翻起来也更方便。而且它让我想起 DOS 的“美好”旧时光,那会儿我大量使用 T 编辑器,我所有的文件都是这个样子(当然是手动排的)。缺点确实有。和别人协作时不太友好,不过在我这儿无所谓。
另一个缺点是编辑时得顺手把段落重新排一遍。好在 Vim 让这件事很简单:
" In ~/.vimrc:
set nojs
map Q gq}
然后只要把光标放到段落开头:
[T]his is one sentence.
This is another one.
And so on and so on.
One sentence per line, that's it.
Sentences can be short.
按 Q,就变成这样:
This is one sentence. This is another one. And so on and so on. One
sentence per line, that's it. Sentences can be short.
[_]
光标会停在这一段之后。要重排整个文档,一直按住 Q 直到文件末尾就行。
补记:也可以用 :set formatoptions+=a 让段落边编辑边自动重排。我一般不想这样,我希望重排是一个明确的操作。不过写散文的时候,这大概挺有用。
nojs 这个选项(名字挺逗的吧?)我是刚知道。全名是 nojoinspaces,它阻止的恰恰是我应该做(但在德语里并不习惯)的事,也就是它不会在句号后插入两个空格。不加它,也就是用 set js,结果会是这样:
This is one sentence. This is another one. And so on and so on.
One sentence per line, that's it. Sentences can be short.
有人喜欢这样,我不喜欢。
Git
和我几乎所有的东西一样,这个也在 Git 上。
但也就到此为止了。我会经常提交当前状态,但其他事情基本不怎么在意。没有像样的提交信息,什么都没有。它只是一个方便做“备份”、在笔记本和台式机之间同步数据的工具。
demo-book.tar.gz:把所有东西拼起来
如我所说,我不会公开真正的“书”。但这里有一个“脚手架”的演示:
(这个我不会更新。它只是当前状态的一个快照。)
写作过程本身
这个我不多说。我完全是个新手。
我中途改过几次方向。两个故事,我都试过在动笔写正文之前先把整个东西草拟出来,但没太大帮助。第一个故事本来也不怎么样。它的设定挺有意思,但我当时并不在状态。这也是它花了那么久的原因。第二个故事的基本点子平淡得多,后来我才意识到它其实有多有意思。里面有不少转折,我很喜欢这一点。
把事实理清楚比我预想的要难得多,尤其是中途改方向之后。哪个角色在什么时候、因为什么、知道什么?动机是什么?一切都自洽、可信吗?要不停地质疑一切,她现在真的非得那么做吗,还是说有别的、更合理的出路?她一百页之前说的每句话,在结尾的大反转之后还站得住吗?诸如此类。这方面我得练得更好。下次大概会用个“角色清单”,给每个角色建一份档案之类的。
搭建一个有自己的规则、历史等等的世界,结果非常迷人。塑造角色也是。
故事的篇幅让校对难得离谱。我不可能几分钟读完 120 页。昨天我最后又通读了一遍第二个故事,花了……翻了翻记录……三个小时。哎。
接下来往哪走?
既然昨天又把那 120 页的东西读了一遍……我其实挺喜欢它。我对结果满意。显然还有很多要学的,整个领域对我来说完全是新的。我找出了不少讲故事不到位的地方,节奏不对,事情推进得太快。(目前我只有一口气读完的时候才注意得到。唉。)有时候措辞不行。细节不够。等等。但故事本身,是我自己会愿意读的。
再往 groff 深处钻这件事已经列在我的 TODO 上了,原因也很明显。
我真正喜欢的是,整个过程让我完全沉浸其中。写那 120 页东西花了十天,其中有一天我甚至根本没在敲键盘。当时我们在横穿全国,大把空闲时间,我没带笔记本电脑,但基本上“写”完了至少一章,然后(大部分)背了下来。来回琢磨,冒出新想法。完全投入。
我想要更多这样的状态。:-)
也许这些东西最终会发表(意思是,我可能会把它放到这个网站上),但不是现在。我做这个是为了自己。我对自己的作品被粗鲁的批评者撕碎连一丁点兴趣都没有。别来烦我。:-)
要是你想建议我用 LLM,那恭喜你,你完全没抓住重点。“Setzen, 6.”