# 架构：排版流水线

> 放置遍及其轮次：引擎怎样放置行、浮动体、注释和栏，并反复运行直到版面稳定

- HTML版本: https://postext.dev/zh/docs/architecture-layout-pipeline
- 最后更新: 2026-10-16
- 阅读时间: 19分钟
- 其他语言: [en](https://postext.dev/en/docs/architecture-layout-pipeline.md), [es](https://postext.dev/es/docs/architecture-layout-pipeline.md), [ca](https://postext.dev/ca/docs/architecture-layout-pipeline.md), [de](https://postext.dev/de/docs/architecture-layout-pipeline.md), [fr](https://postext.dev/fr/docs/architecture-layout-pipeline.md), [it](https://postext.dev/it/docs/architecture-layout-pipeline.md), [pt](https://postext.dev/pt/docs/architecture-layout-pipeline.md), [ja](https://postext.dev/ja/docs/architecture-layout-pipeline.md), [ar](https://postext.dev/ar/docs/architecture-layout-pipeline.md)

## 简单来说

本页写给想了解Postext怎样在页面上摆放内容的程序员。它从头到尾走一遍文字，把每一行、每张图、每个表格、每条注释和每个框放到各自的位置。如果后面的步骤改动了前面的内容，比如引用里的一个页码，它就从头再来一遍。等到没有任何东西再移动，它才停下。本页列出每一步、它决定什么，以及它最多能运行几次。

## 排版流水线

一个放置遍，版面需要几次就运行几次。这就是排版流水线的全部。

这一遍（`placeDocumentPass`，位于`packages/postext/src/pipeline/build.ts`）从第一个块到最后一个块遍历内容。对每个块，它做完排定这个块所需的一切：按块即将进入的那一栏的宽度测量它，看它是否放得下，按分页规则和保持同页规则拆分它或把它后移，给它引用的浮动体提供位置，再让文本流回到基线网格上（见[垂直韵律系统](https://postext.dev/zh/docs/architecture.md#垂直韵律系统)）。浮动体、孤行和网格都没有各自单独的一遍：它们在每个块落定时就已解决。

有些决定无法在一次遍历之内做出，因为它们取决于遍历最终的结果：目录印出的页码，通栏框切割上方各栏的高度，一个短栏还缺的行数。对于这些决定，`buildDocument`带着从前一遍结果中取得的*提示*重新运行这一遍。下面的五个阶段跟随一个块走完这一遍；[放置遍的驱动器](https://postext.dev/zh/docs/architecture-layout-pipeline.md#放置遍的驱动器)和[各栏齐底](https://postext.dev/zh/docs/architecture-layout-pipeline.md#各栏齐底)讲述重新运行。

### 第1阶段：内容结构化

- **输入**：原始的`PostextContent`
- **处理**：把Markdown解析成内容块，按ID在`resources[]`中解析资源引用，把脚注定义（`[^id]:`）移出文本流并按引用顺序给标记编号，给标题编号，并规划浮动体：哪些资源浮动，排在哪个块之后（不读取`notes[]`）
- **输出**：内容块的扁平列表（已确定类型并填入内容，但尚未分配页和栏）和浮动体规划
- **运行时机**：每个放置遍开始时。第一遍之后，解析结果从记忆化缓存中读出；其余部分重新计算

### 第2阶段：文本测量

- **输入**：一个含文本的内容块，以及它即将进入的那一栏的宽度
- **处理**：通过专门的**测量模块**（`packages/postext/src/measure/`）按这个宽度测量块的各行。该模块在Pretext之上叠加了断词、两端对齐、富文本行内片段和Knuth-Plass断行。结果是块的`VDTLine[]`和总高度
- **关键细节**：测量结果有缓存。`cachedMeasureBlock` / `cachedMeasureRichBlock`（位于`measure/cache.ts`）以文本、字体、宽度和所有影响版面的选项为键，重新测量一个未改动的段落只是一次映射查找
- **输出**：块的各行，带有精确的像素尺寸
- **运行时机**：在遍历到达这个块时，而不是提前进行，因为宽度取决于块落在哪里：侧栏旁边的栏，绕排图旁边的那些行，`:::columns`组的子栏。块转到另一种宽度的栏时，按那个宽度重新测量

该模块按职责清晰拆分：`plain.ts`测量纯文本片段，`rich.ts`测量粗体、斜体、数学公式混排的片段，`font.ts`构造字体字符串并管理缓存的生命周期，`canvas.ts`封装底层的Canvas文本宽度原语。有一个生命周期细节在实践中很重要：按后备字体测得的字形宽度，不能在真实字体到达之后继续留用。`canvas.ts`按字体字符串保存宽度，并记录一个测量代次；`evictFontFamilies`（构建发现某个字体族的字体有变化时调用，`watchFonts`、`prepareFonts`和`loadBundleFonts`也会调用）丢弃这些字体族的宽度并清空Pretext的缓存，在较早代次下填充的`MeasurementCache`则在下一次查找时丢弃用这些字体族排出的块。`clearMeasurementCache()`对所有字体族做同样的事。字体本身由`fonts/prepare.ts`（`prepareFonts`）在排版前加载，`fonts/faces.ts`读出已排好的文档用到了哪些字体，用于`fontFallback`警告。

中文、日文和韩文段落在同一模块中走另一条路径。CJK字符数多于词间空格数的段落交给**CJK排版器**（`cjkCompose.ts`）处理。它把文本切分成单元（一个字符、一串拉丁文、一个两字宽的破折号或省略号、行内标签或注释标记这样不可分割的盒子），每个单元只测量一次，然后按`cjkClasses.ts`的避头尾规则以首次适配的方式填充各行：遇到不能出现在下一行行首的标点时，本行先通过标点挤压（`cjkPunctuation.ts`）让出标点的空白把它容纳进来，然后才考虑把一个字符移到下一行。两端对齐的行再把余量分配到字符之间。输出是普通的`VDTLine`，其片段携带渲染器按测量结果精确绘制所需的一切：`tracking`（每个字符后的像素数，已计入片段宽度）、让出空白的标点的`inkOffset`、`hangs`、作为`autospace`片段的汉字与拉丁文之间的间距，以及中文注释的着重号、注音和割注行。在日文文档中，同一个排版器遵循JLReq：断行时从每个单元两端的字符读出日文级别的禁则类别，标点空白按JLReq的顺序挤压和让出，振假名由`rubyJis.ts`排布（1:2:1间距、伸到假名上、熟语），汉文训读记号则加宽其所属字符的单元。开销与段落长度成线性关系：排《紅樓夢》第1回（6,949个字符）时测量了1,298个字符，每个不同的字符只测一次；而如果测量段落的各个前缀，则需要测量636,948个字符。详见[中文排版](https://postext.dev/zh/docs/chinese-layout.md)。

在底层，这正是Pretext发挥作用的地方。`prepare()`调用开销大：它用Canvas字体引擎分析文本并缓存结果。而`layout()`调用只是纯算术，几乎没有开销。这种拆分是关键所在。文本一旦准备好，引擎再次给它断行的开销就可以忽略不计：换一个宽度，用于在更窄的栏中接续的段落；或者多排一行，用于被各栏齐底放松的段落。准备一次，需要排多少次就排多少次。

```typescript
// 简化示例：测量模块在内部如何使用pretext
const prepared = prepare(paragraphText, '16px/1.5 Inter');
const { height } = layout(prepared, columnWidth, 24); // 行距24px
// => “在320px栏宽下，这个段落高168px，即7行。”
```

### 第3阶段：页面与栏的放置

- **输入**：一个已测量的块
- **处理**：把块排入当前栏。随文本流的需要创建`VDTPage`和`VDTColumn`节点。记录每栏的`availableHeight`。某个块放不下时，拆分它（第5阶段），或者转到下一栏或下一页
- **策略**：贪心的首次适配放置。分栏和分页采用最简单的有效分配
- **输出**：这个块分配了`pageIndex`、`columnIndex`和`bbox`

到这一步，VDT才成为一份真正的文档。在这个阶段之前，块只是扁平列表中的一项，没有位置。放置把每个块分配到某一页的某一栏，就像往一排格子容器里倒水：先注满第1栏，溢出后流入第2栏，一页满了就开新页。

在往页面上放入任何内容块之前，这一遍先为结构性元素预留空间：书眉和页脚（按`config.header` / `config.footer`作为设计槽排出），以及已经等待放到新开页面上的浮动带。这些预留会减少每栏的`availableHeight`，因此内容块开始排入时，引擎已经确切知道还有多少空间。

**栏带与通栏栏。**`page.columns`是一个按阅读顺序排列的扁平数组，但一页并不总是只有一排栏。在多栏版面中，通栏的行内块会把页面切成上下堆叠的*栏带*。有四种东西算作这样的块：样式或围栏设置了`span: 'page'`的`:::callout`，正文中的`:::columns{span="page"}`组，放置规则解析为`{ position: 'here', span: 'page' }`的行内`::resource`，以及在书写处横跨页面排出的漫画条（`:::strip{span=page}`）。后三种在放置之前先被包进一个无边框的通栏框（`pipeline/flowColumns.ts`、`pipeline/spanEmbeds.ts`），所以四种都由同一个机制排出。在切割处，当前栏带的文本栏在切割线处封闭（高度被截断，`availableHeight`为零），该块获得自己的一个全宽栏，`kind: 'span'`，并在它下方追加一个新的文本栏带（`band + 1`，x和宽度相同，底边与被替代的栏带相同）。栏只会被追加，因此`columnIndex`始终对应`page.columns[i]`，渲染器也无需特殊绘制：每栏按自己的bbox裁剪（由`columnClipRect`放宽：字形墨迹留2pt，再加上该栏的设计覆盖层（如标题标签或标注框徽标）超出其两侧的距离，以及标题设计超出其顶部的距离；底边仍是栏的边缘；Canvas和PDF后端使用同一个矩形）。栏间分隔线按栏带在相邻文本栏之间绘制，从页首通栏标题所占栏带的下方开始（`columnRuleSegments`）；带样式的章节设置了分隔线时，使用该页自己的分隔线（`VDTPage.columnRule`，通过`pageColumnRule`读取）。各栏齐底会忽略通栏栏、侧栏和高度为零的栏带。通栏块在栏带齐平的位置（页首、紧接章首标题之后、另一个通栏块之后或顶部浮动带之后）直接切割；如果到达时栏带各栏不齐，它就提出一个*栏带上限*（`packages/postext/src/pipeline/bandCaps.ts`）：以某个内容块开头的栏带，其各栏被缩短到`ceil(Σ used / N / grid)`行。`buildDocument`带着这个上限重新运行放置遍（受限栏带溢出时每次增加一行，最多`MAX_BAND_CAP_RETRIES` = 3次，之后放弃这个上限，该块于是退回到下一页的顶部），于是文本在遵守所有放置规则的前提下填满缩短后的各栏，在切割处齐平结束，封闭的栏把剩余的空白保留为`availableHeight`，交给各栏齐底去吸收。同一机制也用于让章节末尾和文档末尾的*收尾*栏带齐平（`headings.balancing.trailing`）：遇到章首页、`:::part`、结束一章的`placement: 'fixed'`标注框或文档末尾时，如果当前栏带各栏不齐，就提出一个`kind: 'trailing'`上限，以该边界块为键。由于上限以开启其栏带的块为键，而只要前面某页吸收了额外的齐底行，这个块就会移动，因此收尾上限要在各栏齐底稳定*之后*、在冻结齐底提示的情况下求解，然后再进行一轮简短的润色，让各种调节手段补足切割留下的空缺。`placement: 'fixed'`的标注框脱离文本流：框锚定在页面内容区、成品框或出血框上，它覆盖的文本栏让出该区域（像浮动带一样从底部或顶部切掉，冲突时移到下一页），框体及其子元素进入`page.floats`。

**竖排页面**。设置`layout.writingMode: 'vertical-rl'`时，这一遍排出的是一个顺时针旋转了90度的横排页面。在这样的页面上，内容区、栏、块、行、浮动体和脚注区都使用**流坐标**，`VDTPage.flow`携带把它们变换到纸面上的旋转：流坐标中的点(x, y)落在(页宽 − y, x)。下游无需知道这一点：断行、浮动体、保持同页规则和各栏齐底都在流坐标系中照常工作，流中的一栏就是纸面上的一层，各后端在绘制时应用旋转（`flowToPage`和`pageToFlow`在两个方向上映射点）。书眉、页码、裁切标记和背景保持纸面坐标。图和表作为直立的块排版，在框内转回原方向；需要直立的字符在绘制时逐个转回。

### 第4阶段：资源放置

- **输入**：第1阶段的浮动体规划，以及到目前为止已放置的各页
- **处理**：把每个被引用的资源浮动到其首次引用之后的第一个空闲位置：引用所在栏的底部、下一个空栏的顶部或底部，或下一页的某个浮动带（`packages/postext/src/pipeline/floatPlacement.ts`规划浮动体，`pipeline/floatSlots.ts`枚举并测量各位置；放置遍在遍历过程中预留浮动带）
- **运行时机**：包含首次引用的块一落定就运行，在同一次遍历之内；此后放置的文本绕开浮动带排布
- **放置规则解析**：对每个资源，引擎依次解析`resource.placement` → 该类型的`resourceType.defaultPlacement` → 内置默认值`{ position: 'auto', span: 'column' }`

| 放置字段 | 行为 |
| --- | --- |
| `position: 'auto'` | 资源占据引用之后的第一个空闲位置，顶部或底部均可，这是默认值 |
| `position: 'top'` | 只用顶部位置：下一个空栏或下一页顶部的浮动带，把栏内容推到它下方 |
| `position: 'bottom'` | 只用底部位置：栏底或页底的浮动带，缩短其上方的栏 |
| `position: 'here'` | 不浮动：资源在其`::resource`指令处以行内方式嵌入，就在它出现在文本流中的位置 |
| `span: 'column'` | 浮动带只占一栏（在新开的页面上，引擎选择剩余空间最多的那一栏） |
| `span: 'page'` | 浮动带横跨所有栏，占满整个内容宽度，打断栏的文本流；全宽浮动带最先预留，因此单栏浮动体嵌套在剩余空间中 |
| `span: 'side'` | 资源排在一栏半版式的侧栏中，前提是该版式把侧栏留给浮动体（`layout.sideColumnRole: 'floats'`）；资源叠放在首次引用它的段落旁边。在没有这种侧栏的页面上，它的行为与`'column'`相同 |

除了这两个字段，`ResourcePlacement`还有`rotate`、`width`、`align`、`columns`、`captionSide`、`shrink`、`minScale`、`captionMeasure`、`wrap`、`wrapGap`和`citingPage`。各字段的作用见[配置 → 资源类型](https://postext.dev/zh/docs/configuration-resources.md#资源类型)和[文档格式 › 位置](https://postext.dev/zh/docs/document-format-resources.md#位置)。

- **延后放置**：在当前页找不到任何合适位置的浮动体会等到文本流打开的下一页。在章节边界处，它会被排到边界之前新开的页面上。图片保持原尺寸，除非`placement.shrink`（或作用于整个文档的`layout.floatShrink`）允许把它缩小到某个位置实有的空间。新页面也放不下的表格按行切开，在后续页面上接续，并重复表头行（`tableStyle.overflow: 'split'`，默认值）
- **输出**：资源定位在页面浮动带中（`page.floats`），受影响的栏高度相应减少，使文本绕开浮动带排布

资源放置是有意思的地方，因为浮动体不只是占据空间，还会重塑周围的空间。包含引用的块落定后，等待中的浮动体会依次尝试当前页该块之后的空闲位置：先是该栏底部，再是下一个空栏的顶部和底部（通栏浮动体则是页底，前提是每栏都还有空间）；哪里都放不下的，就等到下一页，在那里等待中的浮动体先占据各自的浮动带，然后才排入文本。各栏缩短以容纳在浮动带之间，文本不间断地流过变窄的栏，读者在提到图的位置附近（但不一定恰好在那里）看到它。这是专业排版的标准做法，图书中随处可见。

**放置规则**。除了放置分派之外，浮动体还遵循严格的编辑约束：

- **引用在先规则**。浮动体落在正文中首次引用它之后的第一个空闲位置，绝不会在引用之前。读者先读到引用，再看到资源。浮动体放不下时，向后延到更晚的位置或页面，绝不往前挪。
- **同一序列内保持引用顺序**。等待中的浮动体按首次引用的顺序依次尝试每个位置；某个浮动体哪里都放不下时，同一编号序列中排在它后面的浮动体也要等待：表3绝不会排在表4之后，图12绝不会排在图11之前。不同序列之间互不阻挡，一个等待中的表不会挡住后面的图。为了让长表不必等到新开一页，一个被分配到空栏顶部的长表会在该栏处截断，在下一个位置（旁边的栏或下一页的浮动带）接续，并重复表头行。
- **章节屏障**。浮动体绝不会越出自己所在的章。在章首页（设置了`breakBefore`或`span: 'page'`的标题级别）、`:::part`、带`floatBarrier: true`的标注框样式以及文档末尾，所有等待中的浮动体都会先被放下：先放在本页的空闲位置，再放到边界之前新开的页面上（每页至少强制放置一个浮动体），然后才执行边界本身的分页。在打开这样的页面之前，仍在等待的图或表会再尝试一次当前页的空闲位置，不论其`position`如何：在一章最后一页被引用的页首浮动体会占据该页底部、位于已齐底的各栏下方，而不是单独占一页（浮动的标注框保持自己的放置方式）。`:::pagebreak`会把等待中的浮动体送到它之后的那一页，排在为奇偶页补齐而插入的页面之后。
- **最少文本空间**。在新开的页面上，只有受影响的栏中仍能容纳至少3行正文时，才会预留浮动带。有一个例外：超大的浮动体可以被强制放进一个仍然全是文本的浮动带，以免一个占主导地位的图永远卡住队列。当前页上的位置必须能放进该栏的剩余高度；3行规则在那里只适用于紧邻另一个浮动带的情况。
- **留白**。浮动带与旁边的文本之间隔开一个正文行高。
- **基线网格对齐**。顶部浮动带*向上*取整到基线网格的整数倍（增大浮动体下方的间隙），使每一行被推开的文本仍然落在网格上。底部浮动体的锚定方式是让题注的最后一条基线落在网格上：题注与相邻各栏最后一行文本共用基线，各栏和对页的页面在同一高度结束。

**按类型、按首次引用编号。**资源*不*在Markdown中编号。`pipeline/resourceNumbering.ts`按阅读顺序，在每个资源首次被引用时为它分配编号，依据是资源的`ResourceType`：`numberingTemplate`把按类型计数的`{n}`与引用处生效的标题计数器`{h1}`..`{h6}`组合起来（例如`'{h1}.{n}'` → “1.7”），`resetOn`控制计数器何时重置（`'never'`或任意标题级别），`counterFormat`选择十进制、罗马数字或字母编号。内置的*图*和*表*类型来自`defaultResourceTypes(locale)`，按文档语言本地化。由于编号遵循首次引用的顺序，在文档中间插入一张新图时，其后的所有编号会自动更新，无需修改源文件。

### 第5阶段：分页与保持同页规则

排版引擎与只会堆砌文字的工具，区别就在这个阶段。它执行专业排版师几百年来手工遵循的编辑质量规则，而简单的文本渲染对这些规则完全不管。

这项工作由三种机制分担，各自在所需信息存在的地方起作用：断行器内部的**孤字惩罚值**，段落遇到栏末时的**计分拆分**，以及块之间的**结构性保持同页规则**。

#### 段首孤行、段末孤行和孤字

**段末孤行和段首孤行**是业余排版最明显的标志：

- **段首孤行**（widow）是段落在栏底单独留下的一行。段落在下一栏继续，但这孤零零的一行
  看起来被搁浅了（好像这一栏提前结束了）。
- **段末孤行**（orphan）是段落搁浅在栏顶的单独一行。段落的主体在上一栏，只有一行溢了过来
  （看起来与上下文脱节）。
- **孤字**是指段落最后一行只有一两个短词，从视觉上看太短，不像一行正常的文字。它在结构上不如段首孤行严重，但对细心的读者同样刺眼。

**孤字在断行器中解决**。孤字只与段落本身有关：无论段落落在哪里，它的最后一行都一样短。所以三条规则中只有这一条是Knuth-Plass断行器看得到的。如果一组断点会让段落的最后一行内容宽度小于`runtMinCharacters × normalSpaceWidth`（`runtMinCharacters`默认20：二十个词间空格宽的内容，大约8到12个字母），断行器就在平方缺陷值公式中把`runtPenalty`（默认1000）加到这一行的劣度上。这样它与行劣度（在10000处饱和）在同一尺度上竞争，而不会被后者淹没。这个惩罚值与通常的缺陷值（劣度、断词代价、适配等级不匹配）一起参与同一个全局优化，断行器几乎总能找到一组没有孤字的断点。找不到时，只要词间空格加上少量负字距能吸收这一差额，`bodyText.tightenRunts`就把段落少排一行。测量模块把惩罚值交给断行器（`measure/plain.ts`、`measure/rich.ts`），由`knuthPlass/breakpoints.ts`应用。

**段首孤行和段末孤行在栏末解决**。断行器按某个宽度给段落断行。它不知道段落会落在页面的什么位置，所以无从知道哪一行会结束一栏。放置遍知道。段落的各行无法全部放进这一栏剩下的空间时，`chooseParagraphSplit`（位于`pipeline/orphanWidow.ts`）对已经断好的各行尝试每一种拆分`k`，从0到放得下的行数：`k`行留下，其余的行开启下一栏。每个候选拆分这样计分：

- `slackWeight · slack²`，对应它在栏底留空的行，其中`slack`是放得下的行数减去`k`（`slackWeight`默认10）；
- 在栏底留下的行少于`widowMinLines`时，加上`widowPenalty`（默认1000）；
- 带到下一栏顶部的行少于`orphanMinLines`时，加上`orphanPenalty`（默认1000）。

得分最低的拆分胜出，`k = 0`表示整段后移。这些规则是软约束，软到什么程度可以从数字看出：1000的惩罚值相当于十个空行的余量代价（10 · 10² = 1000），所以在默认值下，只要栏内留空的行少于十行，同时满足两条规则的拆分就会胜出。超过这个限度，段首孤行或段末孤行就可能是代价更低的选择。拆分只在断行器已经断好的行中选择，不重新给段落断行。列表项通过`avoidOrphansInLists`和`avoidWidowsInLists`走同样的拆分，并通过`avoidRuntsInLists`接受孤字惩罚值（默认都为`true`）。

所有这些都可以在`BodyTextConfig`中调整，参见[配置 → 段首孤行、段末孤行、孤字与保持同页规则](https://postext.dev/zh/docs/configuration-page-flow.md#段首孤行段末孤行孤字与不拆分规则)。把任何`*Penalty`设为`0`，实际上就关闭了对应的规则。

#### 结构性保持同页规则

有些组合比单个段落更大：它们跨越相邻的块，仅靠断行无法处理。放置遍在每个块落定时执行这些规则，当这些组合会被分栏或分页拆开时，把整组向后移：

- **标题与其第一段**。如果标题所引出的段落会从下一栏开始，标题就绝不能出现在栏底。由`headings.keepWithNext`（默认`true`）执行：如果放不下标题*加上*下一个块的正文段首孤行最小行数（`bodyText.widowMinLines`，默认`2`；`avoidWidows`关闭时只算一行），标题就被推到后面，与其正文一起移动。
- **连续的标题**。多个标题相继出现时（例如h2之后是h3，再之后是段落），整组必须保持在一起。任何一个标题都不能被留在栏底而脱离它所引出的内容。
- **以冒号引出的列表**。段落以直接引出列表的冒号结尾时，带冒号的那一行必须与列表的开头保持在一起。由`bodyText.keepColonWithList`（默认`true`）执行：如果放下这个段落后没有空间让第一个列表项开始（当列表的段首孤行和段末孤行规则使它不可拆分时，需要容纳整个列表项；设置`bodyText.colonListRoom: 'line'`时只需一行，与postext 1.4及以前的行为相同），带冒号的最后一行（如果段落只有一行，则是整个段落）就与列表一起后移。每当这条规则需要推动整个段落，而该栏中紧挨在它前面有一串标题时，这些标题也会被一起拉走，以免悄悄违反`keepWithNext`；唯一的例外是，该栏中只有先前一次移动已经带到后面的标题，此时引擎让段落与标题待在一起，接受冒号与列表分离这一较轻的问题，以避免循环。
- **图与其题注**。图和题注是不可分割的整体，总是一起移动。

检测到违反保持同页规则时，引擎把整组推到下一栏或下一页。它离开的那一栏于是提前结束，短了这一组本该占用的行数。把它填满是[各栏齐底](https://postext.dev/zh/docs/architecture-layout-pipeline.md#各栏齐底)的工作。

这些修正做得好时是看不见的，读者不应察觉到它们。但只要细心阅读，一旦*缺少*这些修正就会立刻发现：栏顶那一行别扭的孤行，引擎放弃让文本排得下时留下的那些不均匀的空隙。专业出版社有整本的体例手册专门防止这些问题。Postext把它们自动化了。

### 放置遍的驱动器

> **图: 放置遍及其重新运行**
> 一个放置遍带着内容走过五个阶段：结构化、测量、放置、浮动体，以及分页与保持同页规则。buildDocument带着前一遍的提示把整个放置遍重新运行：栏带上限、齐底手段、收尾栏带的切割、已确定的页码标签或注释编号。
>
> *一遍放置整份文档。buildDocument带着提示重新运行它，直到每一轮稳定下来或达到上限。*

`buildDocument`是围绕放置遍的驱动器（`buildDocumentGen`和`buildDocumentBalanced`，位于`pipeline/build.ts`）。它先把这一遍运行一次。然后读取这一遍报告的情况，哪个决定需要再看一次，它就带着提示（`PassHints`）把整个放置遍重新运行：提示是下一遍要应用的上限、额外行数和编号。下表中的条件一个都没有出现时，第一遍就是整个构建。

重新运行分成若干轮，每一轮针对一类决定，顺序如下：

| 轮 | 运行条件 | 告诉下一遍的内容 | 上限 |
| --- | --- | --- | --- |
| **1. 目录** | 文档印出宿主没有提供的页码：内容中没有`outline`的`:::toc`或`:::index`，或者印出某个锚点所在页码的引用 | 带有上一次构建所得页码标签的大纲 | `MAX_TOC_ROUNDS`：第一次构建之外最多3次构建 |
| **2. 注释编号** | `footnotes.numbering`为`'page'`、`'column'`或`'spread'`，且注释排在栏底或跨页的底部，因此注释的编号取决于版面把它排在哪里 | 每条注释在上一次构建中得到的编号（`footnoteNumbers`） | `MAX_FOOTNOTE_ROUNDS`：第一次构建之外最多3次构建 |
| **3. 侧栏题注** | 侧栏中的框或浮动体压住了某幅图排在侧栏里的题注（`placement.captionSide`） | 这些题注改排在各自的图下方（`captionUnder`） | 3遍 |
| **4. 引用页上的浮动体** | 带`placement.citingPage`的浮动体落在了引用它的那一行之后的页或栏 | 该页（或该栏）以这个浮动体开头（`citingPageFloats`） | `MAX_CITING_PAGE_ROUNDS`：3轮提议；此后，某一遍没能留在那里的浮动体，每一批再运行一遍 |
| **5. 栏带上限** | 通栏块到达时，所在栏带的各栏不齐（见[第3阶段](https://postext.dev/zh/docs/architecture-layout-pipeline.md#第3阶段页面与栏的放置)） | 该栏带的切割高度（`bandCaps`） | `MAX_BAND_PASSES`：提议和重试共4遍。一个上限每次增加一行，最多3次（`MAX_BAND_CAP_RETRIES`），之后被放弃 |
| **6. 各栏齐底** | `headings.balancing.enabled`开启，且有可齐底的栏提前结束 | 每个块额外增加的网格行，以及要多排一行的段落（`balanceExtraPx`、`balanceLooseness`） | `MAX_BALANCING_PASSES`：每个分段10次尝试。`MAX_BALANCING_PASSES_PER_DOCUMENT`：每份文档30遍 |
| **7. 收尾栏带** | 齐底开启且设了`headings.balancing.trailing`，而某一章或整份文档结束于各栏不齐的栏带 | 该栏带的收尾上限，同时冻结齐底提示 | `MAX_BAND_PASSES`：提议和重试共4遍，每个上限各有3次重试 |
| **8. 润色** | 第7轮保留了某个收尾上限 | 在带上限的版面上再做一次各栏齐底，分段重新划分 | 每个分段10次尝试、共30遍，重新计数 |

第3至8轮在一次构建之内依次运行（`buildDocumentBalanced`）。第1、2轮在它之外：一轮目录或一轮注释编号是一次完整的构建，带有它自己的第3至8轮。整个构建没有总的上限，只有上述这些。

**每次重新运行都是一次完整的放置**。提示不会去修补前一遍的树。这一遍在提示生效的情况下把文档重新放置一次，所以，上方多了两行的标题，或者在高出六行处切割的栏带，都和其他内容一样经过同样的分页规则和保持同页规则。随后驱动器检查结果：框没有落在切割处的栏带上限会被加大或放弃，没有改善某个分段的齐底遍对该分段作废。驱动器返回的版面由某一遍放置出的页面组成：要么是某一遍的全部页面，要么在各栏齐底之后，每个分段的页面取自该分段最好的那一遍。

**文档报告什么**。`doc.iterationCount`统计返回的版面背后的放置遍数：第一遍，加上栏带上限、各栏齐底和收尾栏带各轮的那些遍。侧栏题注和引用页的各遍不计入，目录轮或注释编号轮中较早的构建也不计入；`BuildDocumentOptions.onPass`在每一遍都会被调用，并带有该遍的实际耗时。`doc.converged`只与各栏齐底有关。每个分段结束时，可齐底的栏中没有空行，或者已经没有手段可以提议，它就为`true`；有分段停在没有改善它的一遍上，或者用完了尝试次数，而空隙仍然存在，它就为`false`。齐底关闭时它为`true`。`converged: false`的文档是完整的：它保存着驱动器找到的最好版面，只是其中某一栏提前结束。

### 各栏齐底

- **运行时机**：作为驱动器的第6轮和第8轮（默认开启；`headings.balancing.enabled: false`可将其关闭，在竖排和字符网格上默认关闭，见[配置 → 各栏齐底](https://postext.dev/zh/docs/configuration-page-flow.md#各栏齐底)）
- **输入**：目前为止最好的版面，以及每个可齐底的栏底部的空网格行（`collectColumnGaps`，位于`pipeline/columnBalancing.ts`）
- **处理**：提议在原处填满每个短栏的手段，带着它们运行放置遍，分段的空隙分数下降时为该分段保留结果
- **输出**：结束于页面最后一个网格行的各栏，每个手段都记录在它所作用的块上（`VDTBlock.balancing`）

各栏不齐是一眼就能看出来的。分页规则随时都在留下短栏：为了与正文待在一起而后移的标题，为了避免段首孤行而整段后移的段落，一幅放不下的图。比旁边一栏早两行结束的栏看上去像没做完，仿佛排版进行到一半就放弃了。

修正它时没有任何块换栏。分栏的位置与那个放不下的元素绑定在一起：开启下一栏的块对当时剩下的空间来说太高，对更小的空间来说仍然太高。所以短栏可以从内部填满，填充量最多等于它的空隙，没有任何内容越入下一栏。这种调整是局部的，排字工手工做的也正是这件事：在眼睛能接受的地方加一行空。如果内容流过某一栏进入了同页后面的栏，这一栏就是可齐底的；对一页的最后一栏来说，条件是该页的文本流继续到下一页。一章的最后一页可以提前结束；它的收尾栏带改由收尾上限切齐（见[第3阶段](https://postext.dev/zh/docs/architecture-layout-pipeline.md#第3阶段页面与栏的放置)）。

对每个空隙，手段按编辑上的优先顺序提议，与[配置 → 各栏齐底](https://postext.dev/zh/docs/configuration-page-flow.md#各栏齐底)列出的顺序相同：

1. **收尾的框**：结束一栏的框把其底部下方的空余当作它上方的空白，使它的下边缘落在页面最后一个网格行上，与相邻栏的最后一行平齐。记录为`trailingCallout`。
2. **有安全区的图片**：设了安全区（`Resource.safeArea`）的图片，如果以行内方式排在这一栏里，或者作为只占这一栏的浮动体排在栏顶或栏底，就按整数个网格行增高，在安全区以内裁切。图片变高不会在页面上留下空洞，所以这一手段在增加任何空白之前执行。记录为`flexFigure`。
3. **标题**：在标题上方增加整数个网格行，各标题轮流每次加一行，最重要的标题（级别数最小的）排在最前，因此分得最多。位于栏顶的标题从不加空，因此各栏始终从页面顶部开始。一个标题这样只先加最初几行，然后轮到后面的手段；只有它们再也补不了时，标题才继续加行，直到它的上限。记录为`heading`。
4. **列表结尾**：在列表结束处增加一个网格行。记录为`listEnd`。
5. **公式、框和图的下方**：在它们下方增加一个网格行：独立公式或标注框之下、文字接续之处（`afterDisplay`），以及栏首的图或表与其下文字之间（`afterFloat`）。
6. **放松段落**：最后手段。栏内的段落从最长的开始，各自重新断行、多排一行（即TeX的`\looseness=+1`，通过Knuth-Plass断行器的`looseness`选项），只有每一行都保持在`bodyText.maxWordSpacing`以内时，放松后的方案才会被采用。单靠词间距无法多出这一行时，段落还可以采用恰好能多出这一行的最小正字距，字距有上限。记录为`looseParagraph`。

各项上限、开关和例外情况（字符网格、收尾各页、跨多栏的浮动体）都是`headings.balancing`的键。配置页列出了它们，并随它们保持更新；本页不再重复。

提议要靠放置来检验。应用这些手段的那一遍是一次完整的放置，偶尔某个手段终究还是让内容越过了分栏处：头部再也放不下的拆分段落，失去了位置的浮动体。那一点之后的页面排法变了，别处就出现空隙。驱动器从分数上看出这一点，对该分段作废这一遍，并把第一个发生变化的栏中新应用的手段列入黑名单，于是下一次提议保留有效的手段，去掉引起连锁反应的那一个。

**各栏齐底按分段收敛**。显式断点（章首页、`:::pagebreak`）之间的页面彼此独立排版：没有内容会跨越这样的断点流动，因此一段页面中的齐底手段不可能移动另一段中的任何一行。所以各栏齐底循环对每个这样的*分段*单独评判。分段的空隙分数是其可齐底的栏底部空网格行的数目。每一遍仍然放置整份文档，但每个分段按自己的空隙分数决定保留或拒绝自己那部分手段，把自己的连锁反应列入黑名单，自己判断进入平台期，并消耗自己的尝试预算；某个分段重试后变差时，会从它自己最好的那一遍取回页面，其他分段则继续前进。这样，一本三十章的书排出来的结果与逐章单独排版完全相同（全书PDF与同一章的单章PDF完全一致），而不会因为任何地方的一次连锁反应就让全书每一页多跑一遍。

这些上限是安全阀。有些栏无法填满：只有短段落的栏没有可用的手段；每个手段都会把某个块推过分栏处的栏，也没有一个手段留得住。引擎保留它找到的最好版面，让那一栏短着。由于手段记录在块上，校样或测试可以列出没有任何手段能补齐的栏（见[配置 → 哪个手段起了作用](https://postext.dev/zh/docs/configuration-page-flow.md#哪个手段起了作用)）。
