用 groff 写中篇小说

Lobsters Hottest 新闻

摘要

一篇开发者博客文章,介绍了作者如何使用 groff 进行排版,创作了两部中篇小说,以及所使用的辅助工具链,包括 Vim、Git 和 Markdown 预处理。

<p><a href="https://lobste.rs/s/54xwm8/novellas_with_groff">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/10/31 21:28

# 用 groff 排版中篇小说 来源: https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html blog (https://movq.de/blog/)\-git (https://movq.de/git/)\-desktop (https://movq.de/desktop/)\-contact (https://movq.de/contact.html) --- 2026\-10\-31 我最近完成了我的第二部"中篇小说"。 pdfinfo-wc.png: 'pdfinfo' 和 'wc' 输出的截图:第一篇约 50 页 / 15000 词,第二篇约 120 页 / 40000 词。(https://movq.de/blog/postings/2026-10-31/0/pdfinfo-wc.png) 想都别想:我不会发表它。也不会私下发给你,那基本等于发表。 第一篇大约是 50 页 DIN A5 (https://en.wikipedia.org/w/index.php?title=DIN_A5) 尺寸(约 15000 词),第二篇大约 120 页(约 40000 词)。(两篇都勉强符合"中篇小说" (https://en.wikipedia.org/wiki/Novella) 的定义——第一篇有点太短,第二篇稍微有点太长。) 我能写出这些东西,感觉相当荒诞和超现实。在学校里,*任何*和"写作"沾边的东西对我来说都是纯粹的噩梦——除非是和数学、物理或其他"技术性"内容相关的。但事实就是如此。 花了多长时间?第一篇花了七个多月。但第二篇——更长的那篇?十天。 大约在去年某时,可能是在七月前后,我开始读一些在公寓里放了几十年的书。结果读起来远比想象中有趣。不知道为什么。又不是我以前没读过书,但读书对我来说一直是一件有点无聊的事情,而且需要花很长时间——除非是技术类书籍。我这里说的是虚构类小说和文学作品。大概一年最多读一本。然而去年我莫名其妙地上了瘾,自 2025 年 7 月以来已经读了大约 30 本小说。有的读得快(几天),有的读得慢(三个月)。 要尝试自己写一本,也不是什么太难跨越的一步。 这个博客本质上仍然是技术性的,所以我会介绍我使用的软件。 --- 目录: - groff (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#groff) - 基本格式 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#basic-formatting) - 页脚和页码 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#footers-and-page-numbers) - 页眉 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#header) - 深入了解陷阱 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#a-closer-look-at-traps) - 句间空格 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#inter-sentence-spacing) - 德语断词 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#german-hyphenation) - Markdown 和其他预处理 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#markdown-and-other-pre-processing) - Vim (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#vim) - Goyo (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#goyo) - 重排文本 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#reflowing-text) - Git (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#git) - demo\-book\.tar\.gz:将一切整合起来 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#demo-booktargz-putting-it-all-together) - 写作过程本身 (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#the-writing-process-itself) - 接下来打算做什么? (https://movq.de/blog/postings/2026-10-31/0/POSTING-en.html#where-to-go-from-here) --- ## groff 我的两本书(其实不算真正的"书",但为了方便我还是这么叫)都是用 groff (https://www.gnu.org/software/groff/) 排版的。你知道的,就是那个在 Linux 终端里通常用来渲染手册页的工具。不过 groff/troff 是一套完整的排版系统,可以用来制作真正的书籍 (https://www.troff.org/pubs.html),我之前在几篇博客文章 (https://movq.de/blog/postings/2025-08-30/0/POSTING-en.html) 中已经提到过。 troff-books-1.webp: 我收藏的《The C Programming Language》、《The UNIX Programming Environment》和《UNIX Network Programming》的照片。(https://movq.de/blog/postings/2026-10-31/0/troff-books-1.webp) troff-books-2.webp: 《The C Programming Language》的一页。(https://movq.de/blog/postings/2026-10-31/0/troff-books-2.webp) troff-books-3.webp: 《UNIX Network Programming》的一页,展示了一些图表。(https://movq.de/blog/postings/2026-10-31/0/troff-books-3.webp) 就我而言,groff 的吸引力在于它相对小巧且*快速*,但仍能生成高质量的输出。在我那台 13.5 年老的 PC 上,生成那个 50 页的文档大约需要 *700 毫秒*,120 页的那个需要 *1.6 秒*。这还包含了一些预处理步骤,后面会介绍。考虑到如今大多数现代软件用起来都像在泥地里走路一样,这速度令人耳目一新。 因为这样更有趣,我写了自己的一个小宏包。不过其实内容不多。非常小巧。一本简单的小说/中篇小说不需要太多东西——不需要目录、不需要交叉引用、不需要图表、不需要代码块、不需要列表……只需要流畅的文字,*偶尔*某个词需要强调一下。 免责说明:你会注意到下面文中有几个 TODO 项。groff 是一套完整的排版系统,这些东西很复杂。我还远没有掌握它的方方面面。 ### 基本格式 只有三个宏: ``` .\" 章节标题 / 章节名 .de SH . sp 1v . ps +5p . ce \f[HR]\$1\fP . ps . sp 4v .. .\" 普通段落,悬挂缩进。 .de PP . br . ti +2m .. .\" "长"段落,更大的段间距。 .de LP . sp 2v .. ``` (宏名 `SH` 是从手册页延续过来的。我写过不少手册页,这个名字不知不觉就用顺手了。用 `CH` 表示"chapter heading"或者 `CT` 表示"chapter title"可能更好。或者干脆用 `\.CHAPTER`。) 用 `\.de` 开始定义一个宏——直到 `\.​.` 为止的内容都属于这个宏。 - `\.SH`:向下移动一个 vee (https://www.gnu.org/software/groff/manual/groff.html.node/Measurements.html),增大字号,请求居中,用 `HR` 字体(Helvetica)打印宏的第一个参数,恢复之前的字号,再添加四个 vee 的垂直间距。 - `\.PP`:只断开当前行,然后为下一个输出行请求临时缩进。这样就形成了悬挂缩进,这在德国书籍中是常见的排版方式。 - `\.LP`:在段落之间添加一些空白。用于章节内时间跳跃之类的场景。 你可以在 `man 7 groff` 中查阅各个命令的详细说明。 其实这已经足够生成一个有点像书的文档了。 把上面的代码片段放进一个叫做 `macros\.groff` 的文件里。我为每一章建一个单独的文件。例如:`chapter01\.groff` (https://movq.de/blog/postings/2026-10-31/0/chapter01.groff)。然后就可以用类似下面的命令生成 PDF: ``` groff -Tpdf -dpaper=a5 -P-pa5 macros.groff chapter01.groff >book.pdf ``` A5 大致相当于"典型书籍"的页面尺寸。 ### 页脚和页码 你会注意到默认情况下没有页码。如果你只需要一个数字版 PDF,其实完全可以决定不要页码。它们对打印有用,但在文件里说实话并不是必需的。 不过:排版引擎在分页时会出问题。用上面的示例生成 PDF,你会看到: wrong-page-break.webp: PDF 查看器截图:最后一行文字超出了页面。(https://movq.de/blog/postings/2026-10-31/0/wrong-page-break.webp) 看起来最后一行文字的基线刚好超出了页面边界。不太确定原因——这和设置页面尺寸为 A5 有关。换句话说,我大概漏掉了*另一个*需要设置页面尺寸的地方。这会被下面的内容隐式地修复。 页码相关的设置由三部分组成:首先,页脚有多大(页码所在区域)?我在一个寄存器 (https://www.gnu.org/software/groff/manual/groff.html.node/Setting-Registers.html) 中定义了它(其实不是必须的,但我觉得这样"代码"更易读)——名字可以随意取: ``` .nr _FooterSize 5v ``` 然后我们在页面底部放置一个陷阱 (https://www.gnu.org/software/groff/manual/groff.html.node/Page-Location-Traps.html): ``` .wh -\n[_FooterSize]u _FooterTrap ``` `\n\[_FooterSize\]` 会"取出"名为 `_FooterSize` 的寄存器的值。前面有一个减号,它是负值且相对于页面底部。文档说 (https://www.gnu.org/software/groff/manual/groff.html.node/Default-Units.html): > 通用寄存器(用 nr 请求创建或更新的寄存器)默认是无量纲的,或者在测量上下文中被解释为基本单位。 我不完全确定为什么需要加上 `u`。我还没有读完手册的每一页。不用说,没有它就不行。 好的,陷阱被安装在页面末尾 5v 的位置,它会调用 `_FooterTrap` 宏。它的内容如下: ``` .de _FooterTrap .\" 将当前环境压入栈,切换到 `_FooterEnv`。 .\" 父环境会保留已部分收集的行。 . ev _FooterEnv .\" 从默认环境复制设置。 . evc 0 .\" 第 1 页和第 2 页不执行此操作,它们在实际"书"中是标题页。 . if \n[%]>2 \{\ .\" 设置垂直位置,下面的正文中有解释。 . sp |\n[.p]u-2v .\" 居中打印 % 寄存器。 . ce \fR\n[%]\fP \} .\" 回到之前的环境。 . ev .\" 请求分页。 ' 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:这并不完全对称。页眉的长度比页脚稍微大一点。又是页面尺寸的问题吗? ### 深入了解陷阱 让我们仔细看看陷阱。我对这一点*不完全*有把握——以下内容基于我的理解。 实际上,让我们从上面的例子中退一步。从这个开始: ``` 这是一小段摘自 Mary Shelly 的 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 ``` 效果如下: traps-1.webp (https://movq.de/blog/postings/2026-10-31/0/traps-1.webp) 在 10v 处安装一个陷阱。目前它是空的,什么也不做——把它插入文件的最顶部: ``` .de MyTrap .. .wh 10v MyTrap .\" ``` PDF 看起来和之前一样。 现在如果把陷阱宏改成这样会怎样? ``` .de MyTrap FOO .. ``` 结果是: traps-2.webp (https://movq.de/blog/postings/2026-10-31/0/traps-2.webp) 当输出到达"fishers"这个词时,陷阱被触发了。"FOO"出现在 PDF 中"fishers"的后面:"fishers"触发了陷阱,然后"FOO"被插入了。我觉得就这么简单,对吧? 假设我们想把"FOO"放在这行的开头,而且那一行上只有这个词。"fishers"应该被放到下一行。 据我理解,切换环境是关键:环境保存着当前收集的行(以及其他很多东西)。切换到新环境,我们就得到了一个新的缓冲区/行——目前是空的。然后我们可以冲刷/断行使其被包含在 PDF 中,然后切换回之前的环境: ``` .de MyTrap . ev MyTrapEnv FOO . br . ev .. ``` 效果如下: traps-3.webp (https://movq.de/blog/postings/2026-10-31/0/traps-3.webp) 到目前为止一切正常。现在我想在"FOO"的前后各加一个空行: ``` .de MyTrap . ev MyTrapEnv . sp FOO . sp . ev .. ``` 在新环境中,我们发出 `\.sp`:它会断行并插入间距。目前还没有收集任何内容,所以这里的断行不会打印任何东西。然后是 `FOO`——这不是直接打印的,只是被收集起来。下一个 `\.sp` 再次断行(从而打印出"FOO"),然后插入更多间距。 traps-4.webp (https://movq.de/blog/postings/2026-10-31/0/traps-4.webp) 好的。现在,假设在陷阱中我们只想打印"FOO"以及它前后的两个空行。然后我们希望正常文本从页面上的某个绝对位置继续,比如 17v 处。 那么,这样做如何?我们回到之前的环境,然后发出一个绝对位置请求: ``` .de MyTrap . ev MyTrapEnv . sp FOO . sp . ev . sp |17v .. ``` 出问题了: traps-5.webp (https://movq.de/blog/postings/2026-10-31/0/traps-5.webp) `\.sp \|17v` 断开了已经收集的行(那个只包含"fishers"的行),*然后*才插入间距。我们不想要那个断行。(还要注意"fishers"正好出现在陷阱中离开时的同一位置。) 这可以通过使用 `'` 而不是 `.` 来避免: ``` ' 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 .. ``` 结果: traps-6.webp (https://movq.de/blog/postings/2026-10-31/0/traps-6.webp) 文档 (https://www.gnu.org/software/groff/manual/groff.html.node/Page-Location-Traps.html) 中的例子没有切换环境。但我发现切换环境后更容易理解发生了什么。(而且希望我理解正确了。) 最后一点备注:环境不会自动继承设置。`\.ev foo` 会启动一个全新的环境(除非已经存在同名的环境),它不会克隆之前的环境。这就是我的宏包也做 `\.evc 0` 的原因。 作为一个小练习,看看当 `\.ev`

相似文章

我如何过度工程化我的书

Hacker News Top

一位开发者描述了使用 Git、Markdown、CI 管道和代码检查工具来过度工程化他们的书籍写作和发布过程,自动化检查和多种格式构建。

@elonmusk: Grok

X AI KOLs Following

埃隆·马斯克分享了一个工作流程,使用Grok 4.5进行各项开发任务,同时还有Fable 5和GPT-5.6 Sol,强调了一个实时研究和编码设置。

用 Gleam、Org-Mode 和 Pandoc 写博客

Hacker News Top

作者介绍了使用 Emacs/Org-mode 写作、Pandoc 转换、Gleam + Blogatto + Lustre 生成 HTML、Nix 固定构建环境、d2 绘制图示的个人博客发布管线,强调用类型化 Gleam 代码替代模板语言来生成站点页面。