# 补满被寡行规则留短的一栏

> 齐左排的杂志栏落在网格上：fillShortColumns 把短栏里的一段多排或少排一行，而不是在栏底留下空白。

- HTML版本: https://postext.dev/zh/cookbook/filling-short-columns
- 食谱 No. 177 · 页面与网格 · 难度 2 (中级) · 输出: Canvas
- 体裁: 杂志与小志
- 需要 postext ≥ 1.65.0 · 已用 1.65.0 测试，日期 2026-10-31
- 页面: [1](https://postext.dev/cookbook/filling-short-columns/en/p01.webp?v=76529999), [1](https://postext.dev/cookbook/filling-short-columns/en/p04.webp?v=76529999), [2](https://postext.dev/cookbook/filling-short-columns/en/p05.webp?v=76529999), [3](https://postext.dev/cookbook/filling-short-columns/en/p06.webp?v=76529999)
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=filling-short-columns&lang=en (.postext: https://postext.dev/cookbook/filling-short-columns/en/filling-short-columns.postext)
- 最后更新: 2026-10-31
- 其他语言: [en](https://postext.dev/en/cookbook/filling-short-columns.md), [es](https://postext.dev/es/cookbook/filling-short-columns.md), [ca](https://postext.dev/ca/cookbook/filling-short-columns.md), [de](https://postext.dev/de/cookbook/filling-short-columns.md), [fr](https://postext.dev/fr/cookbook/filling-short-columns.md), [it](https://postext.dev/it/cookbook/filling-short-columns.md), [pt](https://postext.dev/pt/cookbook/filling-short-columns.md), [ja](https://postext.dev/ja/cookbook/filling-short-columns.md), [ar](https://postext.dev/ar/cookbook/filling-short-columns.md)

## 简单来说

当一栏因为某段文字不能在那里断开而停在页脚上方一行时，这个设置会重新断开该栏中某一段的行，让这一栏排到底。

## 成品一览

海岸杂志《Littoral》的三页：一篇关于菲涅耳透镜的长文，开篇是一幅画，画的是灯室里点亮的透镜。正文用 Public Sans 排成两栏齐左，段与段之间空一条网格线；寡行和孤行规则要求每段在断开处两侧至少各留两行。示例把这篇文章排了两遍。不开启设置时，在[开篇页](https://postext.dev/cookbook/filling-short-columns/en/p01.webp?v=76529999)上，左栏比右栏早结束一行：下一段不能只在栏底放一行。开启后，在[同一页](https://postext.dev/cookbook/filling-short-columns/en/p04.webp?v=76529999)上，第一段多排了一行，两栏都结束在网格的最后一行。翻到下一页，这个设置还收回了平衡在两个小标题上方多加的空白。

**这份食谱回答的问题:**

- 为什么齐左排的一栏会在页脚上方空出一行？怎样在遵守寡行孤行规则的同时补满它？
- 怎样让各栏底部齐平，最后一页也排得平衡（垂直两端对齐）？
- 怎样避免段末孤行、段首孤行和只有一个词的末行（孤字），并让标题与正文不分开？

## 简短回答

```js
// script.js, 行 37–55
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
```

## 用料

**讲解**

- [补满短栏](https://postext.dev/zh/docs/configuration-page-flow.md#各栏齐底): 当寡行和孤行规则让一栏在页脚上方空出一两行时，齐底功能把空白分摊到该栏的标题、列表和段落之间，使它与页脚平齐（headings.balancing.fillShortColumns）。

**还用到**

- [各栏齐底](https://postext.dev/zh/docs/configuration-page-flow.md#各栏齐底)
- [段末孤行、段首孤行与孤字](https://postext.dev/zh/docs/configuration-page-flow.md#段首孤行段末孤行孤字与不拆分规则)
- [最优断行（Knuth–Plass）](https://postext.dev/zh/docs/justification.md#knuth-plass通观整个段落)
- [设计过的章首页](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [页面设计中的图片](https://postext.dev/zh/docs/configuration-headers-footers.md#图像元素)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [书眉与页码](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration-headers-footers.md#文本元素)
- [语义调色板](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板)
- [段落样式](https://postext.dev/zh/docs/configuration-styles.md#段落样式)
- [通栏章首色带](https://postext.dev/zh/docs/configuration-headings.md#通栏与高级设计)
- [作为资源的图和表](https://postext.dev/zh/docs/document-format-resources.md#资源)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration-text.md#正文), [`colorPalette`](https://postext.dev/zh/docs/configuration-fonts-colors-viewers.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration-headers-footers.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration-headings.md#标题), [`layout`](https://postext.dev/zh/docs/configuration-page-layout.md#版式), [`locale`](https://postext.dev/zh/docs/configuration-language.md#断词), [`page`](https://postext.dev/zh/docs/configuration-page-layout.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration-styles.md#段落样式)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#排版前加载字体), [`registerResourceImage`](https://postext.dev/zh/docs/architecture.md#api接口), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration-programmatic-usage.md#把页面渲染为位图)

**字体**

- Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)

## 做法

### 1 · 给齐左排的栏一个自己的手段

```js
// script.js, 行 37–55
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
```

栏平衡补满短栏的办法是：在标题上方、列表之后、方框或插图下方加网格线，最后才把两端对齐的段落放松一行。齐左排的正文栏在小标题之间没有这些位置，寡行和孤行规则留下的那一行就空着。`fillShortColumns` 让这一栏里某个齐左的段落按自身的词距多排一行；如果没有段落能做到，就把某段少排一行，让断开处后面的段落上移补满这一行。只有当短栏变少时引擎才保留这一改动，所以开启它不会让任何一页变差。`maxLinesBeforeLoose: 0` 先试段落再试标题，于是每个小标题上方都只保留一条网格线的空白。

### 2 · 排两遍再比较

```js
// script.js, 行 204–208
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };
```

两份文档出自同一份配置和同一段 Markdown。第二遍直接使用配置；第一遍使用关闭了该设置的副本，因此在 Sandbox 中打开的文件包带有 `fillShortColumns: true`。桌面上方的两个按钮在两遍之间切换。只有文字继续流向下一页的页面才会排到栏底：第 3 页是文章的结尾，两遍中它的各栏都被截成一样高。

### 3 · 用画作开篇

```js
// script.js, 行 59–85
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};
```

这幅画是锚定在出血线上的图像元素，高 150 mm，所以三边都延伸到裁切线，浮动元素做不到这一点。眉题、标题、导语和署名都来自 `#` 行的属性。署名下方有 6 mm 的内边距，计入开篇的高度，所以正文栏从它下面的第一条网格线开始。

### 4 · 开篇页不放书眉

```js
// script.js, 行 89–106
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio
```

左页书眉从 frontmatter 取杂志名和期号，右页取文章标题，`pages: 'body'` 让两者都不出现在开篇页上，开篇页只在页脚放页码。标签比页码低 0.35 mm，让两种字号共用一条基线。下方的细线长 197 mm，等于版心宽度。

## 完整食谱

一个文件，由食谱文件夹合成，示例文本和排版食谱的公共工具包都已内联；它自己排出页面。要运行它，把它放进空白页面的`<script type="module">`，或粘贴到新建CodePen的JS面板里（设为模块）。它从esm.sh导入postext，不需要安装或构建。

- 食谱文件夹: https://github.com/drnachio/postext/tree/main/cookbook/filling-short-columns

### script.js

```js
// ═══ Postext Cookbook · Nº 177 · Filling the column the widow rule left short ═════
// https://postext.dev/en/cookbook/filling-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Painting: diffusion model
// Fonts: Public Sans, Libre Caslon Display, Libre Caslon Text (OFL) · Needs postext ≥ 1.65.0
//
// A magazine long read in two ragged columns, set twice. Without fillShortColumns a column
// whose paragraph would split into a widow ends a line above the foot; with it, a paragraph
// of that column is set a line longer or shorter, and the column ends on the last grid line.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'filling-short-columns';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#1b2026', // text: a slate near-black
  brass: '#875a14', // the accent: kicker and crossheads (6:1 on paper)
  sea: '#2c4a5e', // the standfirst
  rule: '#c9ccd0', // hairlines
  muted: '#5d656e', // running heads, credit, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'brass (defaults)', value: { hex: palette.brass, model: 'hex' } },
];
const TOP = 22; // top margin in mm: the opener's container starts here
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.2; // body leading in pt: the baseline grid every column ends on

// #region answer: ragged columns that end on the grid: fillShortColumns
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
// #endregion

// #region opener: the painting bled across the top, then kicker, headline and standfirst
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio
// #endregion

const config = {
  locale: t({ en: 'en-gb', es: 'es' }), // each edition's hyphenation patterns
  colorPalette,
  page: { width: mm(225), height: mm(297), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(21), left: mm(16), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // a 94 mm measure, about 62 characters
  bodyText,
  headings: {
    fontFamily: 'Libre Caslon Text', fontWeight: 400, color: col('ink'),
    balancing, // the first build turns it off (see builds)
    levels: [
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), italic: true, color: col('brass'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
    ],
  },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Public Sans', fontSize: pt(6.8),
    lineHeight: pt(9.6), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(LEAD) }],
  header, footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const resources = [
  { id: 'lens', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // only the opener draws it, cropped to its 225 × 150 mm box
    bitmap: { fileId: 'lens-1400.jpg', format: 'jpeg', width: 1400, height: 933 },
    altText: t({ en: 'A lit Fresnel lens of brass and glass rings in a lighthouse lantern at '
      + 'dusk, with the sea and a sunset behind the diagonal panes.',
    es: 'Una lente de Fresnel encendida, de anillos de vidrio y latón, en la linterna de un faro '
      + 'al anochecer, con el mar y la puesta de sol tras los cristales en rombo.' }) },
];
const markdown = String.raw`---
title: "Littoral"
subtitle: "Autumn 2026"
---

# The Lens That Lit the Coast {kicker="History · Lights" standfirst="Two hundred years ago a road engineer in poor health took the mirrors out of a lighthouse and put rings of glass in their place. His lenses still turn on a few headlands, fewer every winter." byline="Words by Marta Quiroga" credit="Painting made for this issue"}

The tower of Cordouan stands on a reef at the mouth of the Gironde, six kilometres from either shore, and at high tide it seems to stand on the sea itself. The masons of Louis de Foix began it in 1584 and finished it in 1611, a lighthouse dressed as a palace, with a royal chapel on its third floor. For two centuries its light was a fire of oak and pitch, and later of whale oil, burning in an open brazier under the lantern. Sailors coming in from the Bay of Biscay often saw the tower before they saw its fire. On a hazy night they saw neither, and more than one ship found the reef of Cordouan before it found the channel into the river and the port of Bordeaux beyond it.

In the summer of 1823 a new apparatus went up the stairs of Cordouan in crates. It was a cage of brass, taller than a man, holding panels of glass cut into steps, and in the middle of it a single oil lamp with several concentric wicks. On the first night it was lit, the light reached the pilots at Royan more clearly than the old reflectors ever had. The engineer who designed it, Augustin Fresnel, was thirty-five and already ill with the tuberculosis that would kill him four years later.

Fresnel was not a lighthouse man. He trained at the École polytechnique and spent his first years building roads in the Drôme for the Corps des Ponts et Chaussées, and he did his real work at night, on the nature of light. His memoir on diffraction won the prize of the Academy of Sciences in 1819, against judges who expected light to be a stream of particles. That same year the Commission des Phares, the body in charge of France's coastal lights, asked him to look at its lamps. He found a waste of light that offended him as a physicist.

The lamp came first. With the physicist François Arago, Fresnel built a burner with several round wicks set one inside another, fed with oil from below by a small pump so that the flames never ran dry. It gave a broad, steady flame, much brighter than one wick, but a brighter flame was no use if most of it lit the inside of the lantern. The question was how to send it out to sea.

## A lamp in a ring of prisms

Before Fresnel, the best lighthouses used reflectors. An Argand lamp burned at the focus of a polished copper bowl, silvered on the inside, and the bowl threw the light forward in a beam. A tower needed a dozen or more of these lamps and mirrors, each with its own wick to trim. The silver tarnished, the bowls were never quite parabolic, and much of the light that fell on them was absorbed or scattered. Keepers spent their days polishing, and the beam was still weak.

A lens could do better, in theory, but a lens large enough for a lighthouse would have been a block of glass thick at the centre, heavy enough to crack its own frame, and too cloudy to pass much light. Buffon had suggested a century earlier that a burning glass could be cut into steps, keeping only the curved surface that does the bending. Fresnel took the idea further. He built his lens from separate rings, each ground to its own curve and all bringing the light to the same point, so that no piece of glass was thicker than a few centimetres.

Around the central belt of rings he added prisms above and below. These catch the light that a lens would let escape towards the sky or the sea and turn it back towards the horizon, by refraction and then, in the outer rows, by reflection inside the glass itself. Fresnel worked out these reflecting prisms in the last years of his life, writing to his brother Léonor about their angles. He did not live to see a light built with them from top to bottom: Léonor, who took over his post at the Commission, saw the first ones lit in the 1840s.

The result was a lamp in a ring of prisms that sent most of its light where a ship could see it. A single wick in a large Fresnel lens did the work of the twenty or so lamps and mirrors it replaced, on a fraction of the oil. On a clear night the light of a coast station could be seen from more than twenty nautical miles, limited less by the lamp than by the curve of the Earth.

The glass itself came in pieces. Each ring of a large lens was ground on a turning table against a cast-iron tool of the right curve, then polished with rouge for days, and the finished pieces were bedded in their brass frame with putty. The lens arrived at the lighthouse as a kit of several hundred parts, and the engineer who put it together had to align every ring with the flame to within a millimetre or two, by eye, at night, moving a lamp and watching where its light fell on the rocks below.

## Seven orders of glass

The Commission soon needed lenses of several sizes, and Fresnel's successors sorted them into orders by focal distance, the space between the flame and the inner face of the glass. A first-order lens, for the great coastal lights, had a focal distance of 920 millimetres and stood about two and a half metres high. A sixth-order lens, for a harbour pier or a river mouth, had 150 millimetres and could be carried by two people. Between them came the second, third and fourth orders, with a three-and-a-half added later for the middle distances of estuaries.

Each lens had to say which light it was. A sailor who sees a light on a dark coast needs to know whether it is Cordouan, the Pointe de Grave or a fishing village, and the answer is in its rhythm. Fixed lights burned steadily. Flashing lights used panels of rings like bull's eyes, set round the lamp and turned by clockwork, so that each panel swept a beam across the sea as it passed. The number of panels and the speed of the turn gave each tower its own count of flashes and dark intervals, printed in the lists of lights that every navigator carried.

The clockwork had to be wound by hand. A heavy weight hung in a tube running down the centre of the tower, and the keeper on watch cranked it back up every few hours through the night. If he fell asleep, the lens slowed and stopped, and the light lost its character: to a ship offshore it became another light, or no light at all. In the 1890s French engineers floated the rotating lens on a bath of mercury, so that a small push turned several tonnes of brass and glass. The lens could turn faster, and the flashes grew shorter and brighter.

Paris made most of the lenses. The workshops of Henry-Lepaute, Sautter and Barbier ground and polished the rings by hand and shipped them in numbered crates to coasts all over the world, with drawings for assembling them on the spot. Chance Brothers, near Birmingham, made glass for the British lights and later lenses of their own, including the largest of all, the hyperradiant lenses of the 1880s, with a focal distance of 1,330 millimetres. In the United States, the Lighthouse Board set up in 1852 replaced the old reflectors with French lenses within a decade.

## When the keepers left

A lens could last for more than a century. The glass did not wear out, and the brass needed only cleaning. What changed around the lenses was the lamp. Oil gave way to paraffin vapour burners with incandescent mantles at the end of the nineteenth century, then to electric lamps, at first in the towers that had a supply nearby and later almost everywhere. The light grew brighter, but the lens around it stayed the same, because it already put nearly all of that light on the horizon.

For the keepers, the routine hardly changed in a hundred years. At sunset they lit the lamp and wound the clockwork; through the night they took turns on watch in the service room under the lantern, writing the weather and the state of the light in the station's log every few hours; at dawn they put the light out, drew linen curtains round the lens so that the sun would not set fire to anything through it, and began cleaning. Brass was polished, glass wiped with spirit and a soft cloth, never anything harder.

Electricity also made the keepers unnecessary. A lamp that did not need its wick trimmed and a motor that did not need winding could be watched from a distance. Through the second half of the twentieth century, lighthouse after lighthouse was automated, its family moved ashore and its door locked. The last keepers of the British lights left North Foreland, in Kent, in November 1998. In France the keepers of the offshore towers, the ones the service called hells, were among the last to go.

The lenses went next. A modern beacon of light-emitting diodes weighs a few kilograms, uses less power than a kettle and needs no mercury, which has its own risks for those who climb the tower. Some lenses were taken down and sold, some broke in storage, and some were cut into pieces for collectors. The ones that survive have mostly gone to museums, where they are lit by a bulb inside and look like what they are, a machine for sending light a long way.

A few still work. At Cordouan a first-order lens still turns in the lantern, and the reef that holds the tower became a World Heritage site in 2021. Visitors who make the crossing at low tide can climb the stairs past the chapel to the lantern gallery and stand beside the rings of glass. The light still shows its own rhythm to the pilots of the Gironde, white over the channel and coloured over the shoals, as the ships coming in from Biscay have read it for two hundred years.

:::paragraphs{style="colophon"}
Set in Public Sans, Libre Caslon Display and Libre Caslon Text (SIL Open Font License) · Text: Postext Cookbook, CC BY 4.0 · Painting made for this issue with a diffusion model · The magazine and the writer are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  'Public Sans': ['400', '400i', '500', '600', '700'], 'Libre Caslon Display': ['400'],
  'Libre Caslon Text': ['400', '400i'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('lens-1400.jpg', asset('lens-1400.jpg'));
// #region builds: the same text and design, without and then with fillShortColumns
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };
// #endregion
const title = t({ en: 'Filling the column the widow rule left short',
  es: 'La columna que la regla de viudas dejaba corta' });
// Two buttons put either build on the desk.
const LABELS = { off: 'fillShortColumns: false', on: 'fillShortColumns: true' };
const switches = Object.keys(docs).map((key) => Object.assign(document.createElement('button'),
  { type: 'button', value: key, textContent: LABELS[key] }));
const show = (key) => {
  showPages(docs[key], { title });
  for (const b of switches) b.ariaPressed = String(b.value === key);
};
for (const b of switches) b.addEventListener('click', () => show(b.value));
show('on');
document.getElementById('pt-actions').prepend(...switches);
document.head.insertAdjacentHTML('beforeend',
  '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta && (await fontsourceWeights(family, style)).includes(weight))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this face (fonts.check() says yes to any family). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

// ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { ...render, scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## 变化

### 让小标题先吸收空白

按默认顺序，标题在段落被拉长之前最多可取两条网格线，下一页的小标题上方又会变宽。

```diff
-  maxLinesBeforeLoose: 0,
+  maxLinesBeforeLoose: 2,
```

### 改为两端对齐

两端对齐的段落在不开启设置时就能在 `maxWordSpacing` 以内多排一行。开启 `fillShortColumns` 后，它也可以少排一行，而且不会比它自己最松的一行更松。

```diff
-  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+  textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
```

## 常见问题

- 这个设置只补一行的空缺。缺两行的栏，比如在段间空行之后只剩放下一段一行的位置，可能仍然偏短：增删一句文字来调整。
- 如果某段重排后会以过短的末行结束，就不会采用它，所以只有短段落的一栏可能仍然偏短。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 图片: A lit Fresnel lens in a lighthouse lantern at dusk: Generated With Diffusion Models, 原创
- 字体: Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 116 · 长高以填满短栏的照片](https://postext.dev/zh/cookbook/photos-fill-short-columns.md): 每张照片标出自己的安全区；各栏齐底可以裁去安全区以外的部分把照片放高，让被标题或浮动图留短的栏排到底。 · 难度 2 (中级) · 杂志与小志
- [No. 037 · 各栏齐底的年度报告](https://postext.dev/zh/cookbook/annual-report-flush-columns.md): 一家能源合作社的年度报告，两栏两端对齐的正文都止于同一条网格线，一个关键数据框横跨页面，末页各栏齐平收尾。 · 难度 3 (高级) · 报告
- [No. 004 · 杂志专题：从照片首页到结束符](https://postext.dev/zh/cookbook/magazine-feature-opener.md): 一个advancedDesign首页设计排出出血照片，以及标题的眉题、主标题、导语和署名；后面是两个浮动框和一个用行内标签做的结束符。 · 难度 3 (高级) · 杂志与小志
