Florilegium:让 LaTeX 的每一页长出一座花园

一个用 Lua 现算的 LaTeX 宏包,按页面剩下的空间长出代尔夫特蓝的花草,每一页都不一样。

中文
复制
一页 LaTeX 文档,左侧页边距里长满德尔夫特蓝的花草藤蔓,有叶片、花朵、卷须,页面上是单栏正文和章节标题

缘起

Reddit 帖子问的是如何在 LaTeX 里做出这样的花纹。原图是怎么做的,我并不清楚,这个项目也没打算复刻它。它只是其中一种做法,也是我的做法,手工编写图元,参数随机化,用 Lua 绘制,再由一套程序化布局摆放位置;其中“填满空白区域”这一步借用了 1998 年装饰纹样文献里的一个思路。最终效果与原图相近。

这是什么

为 LaTeX 程序化生成的代尔夫特蓝花卉页边。不用图片,不导入路径,不依赖外部资源。每一片花瓣、每一片叶子、每一根茎,都是页面写入过程中由 Lua 计算出的参数曲线,以水彩的方式绘制,直接写进 PDF。每一页都有自己的花园。

六颗种子

同一生长构图的六个种子。代码相同,种子不同,文字留出多少空间,花园就填满多少。

两份文档共用一个包,另外两份(showcase.tex、spread.tex)展示了它还能做什么。

florilegium.texflorilegium-grow.tex
构图手工编排,茎、叶、花逐一放置,每个参数都经过采样生长而成,一株植物由贪心式填空循环生长,直到填满页面
适应对象无(按 A4、68 mm 页边距调好)页面布局,以及末页正文下方的空白区域

两份 PDF 都自带说明,正文讲的就是页边距是怎么画出来的。

已生成文档的最后一页
成长文档的最后一页,文本提前结束,因此分支向下方空间拱起。
详情

300 dpi 裁切图。斑驳的晕染,其间提亮出白色条纹;具裂片的叶子带着白色叶脉,勿忘我的聚伞花序,茎干后面一朵浅淡的幽灵花。

看它生长

种子 6 生长中
随着它不断增长
种子 6 的生长过程,标出它找到的空白圆盘
用它填充的空白空间

种子 6,从空白页边到成稿页面。茎先立起来,叶子在尖端越过节点时展开,每根茎的末端都开出一朵花。接着贪心循环接管。右侧每个虚线圆都是它找到的最大空白圆盘,就在一条枝干伸向它之前。大洞先出现,来得慢;小洞最后出现,来得快。蝴蝶和蜜蜂飞进来,鸟滑落到枝头,蜗牛沿着茎向上爬,直到最后一帧,那正是这个种子每次都会给出的页面。

生长过程只记录一次,就在页面排版的时候。每根茎还会以逐渐加长的前缀形式绘制,每个图案也会在几个较小的尺度上绘制,重放同样的随机抽取。然后每一帧按原本的绘制顺序,把时间已到的片段写出来。make grow-6 用这种方式渲染一个种子(build/grow-6.gif 和 .mp4,约 45 秒);加上 EXPLAIN=1 可显示虚线圆。

页边读取文本

showcase.tex 的三个页面

showcase.tex,“页边的一年”,第一页是春,然后是夏,最后一页是秋。每一节描述它所展示的那个特性。

长成的页边能对周围的文档作出回应。

  • 首字下沉。 \florilegiuminitial[5]{E} 把段落的首字母排进一个五行高的方块里。字母的轮廓从 OpenType 字体中读取(默认是 Latin Modern Bold),像花瓣一样绘制出来;一条藤蔓依次从笔画上方和下方穿过,能放叶子的地方长叶子,空隙里开花,花常常开在字母的封闭空间里。这个字母同时还会被隐形地排版一遍,所以复制段落时得到的仍然是“Every”。
  • 标题旁的花。 加上选项 sections,每个带编号的章节和小节都会在标题旁的页边得到一朵花,用强调色所用的铜色绘制,就像从前用红色标出标题那样。
  • 文字要求处开花。 \florilegiumbloom 会在它所在的那一行旁边放一朵花;\florilegiumbloom[lily] 则要求花园里的任意一种图案。这些标记在页面输出时于页面盒中查找,因此一次运行即可生效。
  • 四季。 加上选项 aging,花园会从第一页的春天(大多是花苞、小而浅的叶子)渐渐走到最后一页的秋天(种穗、飘落的花瓣、几片铜色的叶子)。这需要运行两次,因为要先知道页数;\florilegiumage{0.8} 则改为手动设定一个季节。
四个首字母
一颗种子走过四季

春、初夏、晚夏、秋各一粒种子。四季改变的是长什么,不是长在哪。

访客

一只鸟和一只蜗牛的细节

大多数页面都有访客。开阔处有一两只蝴蝶和蜜蜂,一只蜗牛沿着某根主茎往上爬,大约每五页里有一页,还会有一只鸟停在自己的细枝上。它们来自各自独立的随机流,所以没有这些访客的页面,和原本的页面一模一样;novisitors 选项会把它们略去。

任意墨水

六种墨水中的一粒种子

整套配色都出自同一个颜色 ink,代尔夫特蓝、棕褐、绿、绯红、紫罗兰、青绿。从最深的笔触到最淡的渲染,这一整条色阶都在 OKLCH 中由该色相算出,因此浅色一端永远不会发灰。

主题

种子 6 的三种形态,植物、几何、数学
种子 6 的三次生长,按它一贯的方式、使用 theme=geometric,以及使用 theme=math。

生长循环并不知道自己在画什么。它读取一张图案表(每个图案占据的圆盘大小、权重、绘制函数),然后按名字调用每一个图元。一个主题就是一个 Lua 文件 theme-<name>.lua,它替换掉其中一些图元,也可以往表里添加行,或者自带一套构图。

theme=geometric 是用平面形状剪出来的同一株植物(theme-geometric.lua)。它的生长方式和那些花完全一样,变的只是绘制。茎在节点之间走直线,叶子是风筝形和带齿的多边形,折成明暗两个面,叶脉是笔直的白色线条,花是菱形拼成的花结或层叠的星形多边形,卷须是方形螺旋,鸟和蝴蝶是低多边形。颜色是墨的平涂色调,没有渲染;晶体(柏拉图立体,像切割宝石那样平面着色)在花丛间生长。一页的大小大约是花卉主题页面的十分之一。

一个以几何为主题的页面
几何主题的基本元素

theme=math 根本不是植物(theme-math.lua,取自 figures.lua)。数学沿着页边倾泻而下,横贯页面底部,彼此略微重叠,融为一体。角落处、即栏与底边相接的地方,放着一个大图形(Apollonian 圆堆、深一层的 Koch 雪花、Vogel 圆盘、一个多面体)。沿栏向下排布着一两个族,图形在行进中不断变化,由小小的铜色箭头相连,Koch 雪花多出一级,多边形在通往圆的路上增加边数,立方体截角成八面体,弦 k → mk mod n 从心脏线变为肾形线,Vogel 圆盘只在黄金角下才均匀铺满。成立的等式(e^{iπ} + 1 = 0、V − E + F = 2、……)沿底部延伸,并在最后一页钻到文字下方;其余的一切,从最大的空洞开始,都是图形(柏拉图立体、纽结、Hilbert 曲线、Lissajous 曲线、内摆线、Maurer 玫瑰、Petersen 图、……)、由 TeX 以多种字号并略微倾斜排出的字母、数字和符号,以及点。它填满页面留下的任何空间,就像花园一样;density 决定填多少。

一个以数学为主题的页面
\usepackage[grown,theme=geometric]{florilegium}   % or theme=math; the whole document
\florilegiumtheme{math}                            % or from the page this stands on

不带主题的文档,输出结果与主题功能出现之前逐字节相同。

页边更少

种子 6 在密度 1、0.5、0.2、0.05 和强度 0.5 下的样子
种子 6,密度=1、0.5、0.2 和 0.05,强度=0.5。

一整座花园放在公式旁边,实在太多了。有两种办法可以把它调小,任何主题都适用。

  • density=0.4 让图案长得更少,循环在更大的孔洞处就停下,每个图案周围保留更多纸面,垂下的叶子和填充物也更少。把旋钮调到最低,植物本身就会变稀疏,先是两根主茎,然后只剩一根,页脚也没有了;调到 0.05 时,只剩一根藤蔓带着它的花、几片叶子和一两簇花枝。配合数学主题,就是东西更少。
  • intensity=0.5 把整条页边都涂得更淡,和花园背后那些幽灵花是同一种画法。它只改颜色,不改细节。
\usepackage[grown,density=0.4,intensity=0.6]{florilegium}

在默认值下(1 和 1),每一页都和之前完全一样。

正文里的一处装饰

\florilegiumornament

也可以完全不留边距。\florilegiumornament{leaf} 在行内放置一个图元,装进一个字母大小的盒子里,比如用来开启一个段落。文档里的每个装饰图形都有自己的形状,并且从一次构建到下一次构建都保持不变;seed=random 每次生成 PDF 时都会重新绘制一个,seed=7 则绘制一个固定的。

\usepackage[nomargin]{florilegium}       % ornaments only, no garden in the margin
\florilegiumornament{leaf} A paragraph that opens with a leaf.
\florilegiumornament[size=2.4em, ink=5B2A1F, angle=30, seed=random]{flower}

任何图元都可以使用(leaf、leaflet、flower、blossom、bud、bell、lily、cyme、umbel、butterfly、bee、bird、hedera、sparkle……),当前主题的图元也可以(crystal 配 geometric;knot、polyhedron、koch……配 math)。

页面的两侧

spread.tex 的第 2 页和第 3 页

spread.tex,第 2、3 页,双面文档,外侧页边距较宽。花园在左手页上沿左侧生长,镜像后则在右手页上沿右侧生长。最后一页还会切换到 geometric 主题。

花园生长在较宽的那一侧页边距上,因此无需任何设置。在双面文档中(twoside、outer 的页边距较宽),它会随页面换边。单面文档若右侧页边距较宽,则每页的花园都位于右侧。镜像页面的排版如同从镜中看去,再经一次反射绘制而成,每个点本来就要经过这最后一次变换。它恰好就是同一颗种子在左侧长出的花园的镜像。

在 PostScript 中

postscript/florilegium.ps 的两页,种子 7

postscript/florilegium.ps,纯 PostScript 绘制的生长边距,一个自包含文件。Ghostscript 或打印机在解释页面时计算每一片花瓣,两页文字说明了其中的原理。

绘图核心、每个图元以及生长循环都从 ink.lua、flora.lua 和 grow.lua 逐个过程移植而来。它不需要 TeX,也不需要其他文件。

gs -dALLOWPSTRANSPARENCY -sDEVICE=pdfwrite -dSeed=7 -o garden.pdf postscript/florilegium.ps
make postscript             # the same -> build/postscript-7.pdf; SEED=12 for another garden

-dSeed=N 选择花园;不指定时由时钟挑选,因此每次打印都是新的。-dAge=0 到 1 设定季节,-dNoVisitors 则省去蝴蝶、蜜蜂、蜗牛和鸟。两页大约耗时 2.5 秒。Lua 中的大部分逻辑可以直接对应。PostScript 自带的变换矩阵负责放置每个图元(translate、rotate、scale),路径和贝塞尔曲线是原生支持的,运行时构建的过程则替代了 Lua 的闭包(花瓣的半宽就是一个传给轮廓构建器的过程)。有四处无法直接对应。

  • 图层。 花园按一种顺序生长,却要从后往前绘制,因此每次填充和描边都用 pathforall 捕获到按图层划分的列表中,最后再回放这些列表。
  • 透明度。 PostScript 没有透明度。Ghostscript 通过 -dALLOWPSTRANSPARENCY 提供。没有这个标志,或者在打印机上,每个半透明填充都会与它大概率会落在其上的颜色混合,然后以不透明方式绘制。结果更扁平,但仍是同一座花园。
  • 随机性。 生成器是 32 位整数运算的 Park-Miller,而 PostScript 的实数只有单精度,所以这里的种子 7 与 LaTeX 中的种子 7 得到的是不同的花园。
  • 文字。 文件用 Times 自行排版它的两页内容。它知道标题在哪里、正文到哪里结束,因此标题处的铜色花朵和最后一行下方的花园都能像在 LaTeX 中一样工作。它没有首字下沉、动画、主题、镜像页面、密度或强度、正文中的装饰,也没有人工编排的构图。
florilegium.ps 的两幅打印作品,两个不同的花园

同一个文件两次发送到 Epson 喷墨打印机,使用 lp postscript/florilegium.ps,未指定种子。计算机的打印系统会为每个打印任务重新运行程序,因此每次打印都从时钟取种子,长出不同的花园。打印机拿到的是不透明版本,因为它没有透明度。

构建

LuaLaTeX,一遍编译,每页约一秒(showcase.tex 需要两遍,因为它的季节依赖页数)。

make                        # the four PDFs
make seed-7                 # one page from seed 7 -> build/seed-7.pdf
make gallery                # gallery/seeds.png + seeds.gif, inks.png, seasons.png, themes.png, dials.png (python3, Pillow, pdftoppm)
make grow-6                 # growth animation of seed 6 -> build/grow-6.gif + .mp4 (ffmpeg)
make grow-6 EXPLAIN=1       # the same with the free discs marked -> build/grow-6-explain.*

没有安装 TeX?构建一次镜像,然后在其中运行所有内容。

docker build -t florilegium-tex tools
make RUN=tools/in-docker

或者将 florilegium.sty、九个 .lua 文件以及其中一个 .tex 文件粘贴到 Overleaf 项目中,并将编译器设为 LuaLaTeX。

在自己的文档中使用。

\usepackage[grown]{florilegium}      % or plain \usepackage{florilegium} for the authored one
% options for the grown one: sections (flowers at the headings), aging (two runs),
% novisitors (no butterflies, bees, snails or birds); for both: theme=geometric or theme=math,
% density=0.4 (grown only), intensity=0.6
% two-sided? nothing to set: the garden grows in the wider side margin
\definecolor{ink}{HTML}{5B2A1F}      % optional: the hue of the whole palette (this is sepia)
\definecolor{accent}{HTML}{B4662F}   % optional: the copper of the hairlines
...
\florilegiuminitial[4]{T}his paragraph starts with a decorated T.
... a flower in the margin beside this line\florilegiumbloom ...
\florilegiumage{0.8}                 % grown only: autumn from here on
\florilegiumtheme{geometric}         % another theme, from the page this stands on
\florilegiumornament{leaf}           % one leaf in the text, the size of a letter
\florilegiumtextend                  % grown only: at the very end of the text

用固定种子代替页码:lualatex "\def\florilegiumseed{7}\input{florilegium-grow.tex}"。

任何图元都可以在 TikZ 图片中单独绘制,遵循当前作用域的平移、旋转和缩放。

\begin{tikzpicture}
  \begin{scope}[shift={(2,2)},rotate=20]\florilegiumdraw{flower}{1.3}\end{scope}
  \begin{scope}[shift={(5,0)}]\florilegiumdraw{leaf}{3.0, {depth=0.6}}\end{scope}
  \begin{scope}[shift={(8,1)}]\florilegiumdraw{ghost}{"lily", {s=1.4}}\end{scope}
  \begin{scope}[shift={(11,1)}]\florilegiumdraw{butterfly}{1.5}\end{scope}
\end{tikzpicture}

使用主题时,其图元也可以按名称绘制:\florilegiumdraw{crystal}{1.0} 在 theme=geometric 下(参见 specimen-geometric.tex),\florilegiumdraw{knot}{1.2} 在 theme=math 下。

工作原理

绘制(ink.lua)。所有几何图形都在 Lua 中计算,并作为 PDF 路径操作符写入每页的一个字面节点;TeX 不做任何算术运算。图元在仿射变换下的局部坐标系中绘制,当图元在作用域内被调用时,该变换从 TikZ 读取。颜色来自基于唯一文档颜色 ink 推导出的色调渐变,在 ink 的色相上从藏青到雾色,在 OKLCH 中插值,使浅色端保持蓝色而不变成灰色。渐细的笔画是两条偏移的贝塞尔曲线,而非采样的带状。

图元(flora.lua)。按照水彩画家的顺序绘制。一层平涂,上面叠着更深的斑块,颜料在边缘积聚,用白色提亮出条纹,几根细脉,几乎没有轮廓线。宽大的花瓣和花朵,倾斜视角的星形百合,花朵和小花,花蕾和风铃草;浅裂和全缘的叶子;像蝎子尾巴一样卷曲的勿忘我聚伞花序,分枝的小枝,伞形花序,卷须,种子头;蝴蝶、蜜蜂、蜗牛和一只鸟;铜色闪光、玫瑰花饰,以及茎变成页眉线的常春藤叶花饰。其中任何一个都可以画成淡色的幽灵。

手工构图(authored.lua)。一株植物从左下角的根部生长出来。三条茎穿过手工挑选的控制点,第四条沿页面底部延伸,作为页脚;叶子按选定比例挂在茎上;其余每个图案都长在一根枝条上,枝条从茎上切向离开,再沿图案自身的轴线抵达,因此每朵花都能一路追溯到根部。

生成构图(grow.lua),思路取自 Wong、Zongker 与 Salesin 的 Computer-Generated Floral Ornament (SIGGRAPH 1998)。茎沿着栏向上摆动,形态是随高度变化的平滑噪声;叶挂在茎的节点上,前提是那里有空间。接下来反复执行。在茎的触及范围内取最大的空白圆盘,选一个能放进去的图案,从最近的茎上一点长出一根枝条通向它;这根枝条随即也成为茎。先占大空洞,所以花朵和百合出现得早,小花枝和叶子填充中段,循环收尾时把小叶、花苞和小花塞到茎上。同一遍里用 \pagetotal 量出文本的结尾,因此最后一页偏短时,枝条能一次编译就长进它下方的空间。

读取页面(florilegium.lua)。标题或 \florilegiumbloom 会在文本中留下一个不可见的标记节点。页面输出时,逐节点遍历它的盒子,按排版时的实际值累加高度、深度和胶,求出每个标记在页面上的高度;花园先给这些行配上花,然后才挂叶。年龄会重新调整图案的权重,春天是花苞和成串的花苞,秋天是种子头,此时有些叶子用由 accent 而非墨色构建的渐变来上色。

首字下沉(initial.lua)。字母的轮廓由 luaotfload 的 OpenType 读取器以贝塞尔段给出。展平成多边形后,得到一个奇偶内外测试;再用一张到字母和边框的距离网格找出最大的空隙,用来放花。藤蔓是一条横跨图块的 Catmull-Rom 曲线;与字母相交处依次从上方、下方穿过,从上方穿过的股线再画一遍,裁剪到字母形状,并带一圈薄薄的纸色光晕。

程序化 ≠ 随机。 种子由页码推导而来,因此第 n 页永远对应第 n 个花园。正是这种可复现性,让文档稳定到足以排版。

代码结构、可调参数以及尚未实现的想法,见 CLAUDE.md,写法类似交接文档。

致谢

  • 提出这个问题的 Reddit 帖子(见缘起)。
  • 生长循环沿用 Wong、Zongker 与 Salesin 的论文 Computer-Generated Floral Ornament(SIGGRAPH 1998)。
  • 色调渐变在 Oklab 中计算,这是 Björn Ottosson 提出的感知色彩空间;ink.lua 中的转换矩阵出自他手,已进入公有领域。
  • 随机数来自 mulberry32,即 Tommy Ettinger 的公有领域生成器。
  • \florilegiumornament、它的 ink 与 seed=random 键,以及 density 选项对应的回答,来自 rubyFeedback 的 issue #4。
  • 数学主题中的图形保留了各自来源人物的名字,Maurer 玫瑰线、Vogel 向日葵模型、Temple Fay 蝴蝶曲线、Petersen 图、Koch 雪花、Hilbert 曲线、Lissajous 图形、Apollonius 圆堆。

许可证

MIT,见 LICENSE。Copyright (c) 2026 Kevin Raymond。

来源: GitHub← 返回首页