# 长高以填满短栏的照片

> 每张照片标出自己的安全区；栏平衡可以裁去安全区以外的部分把照片放高，让被标题或浮动图留短的栏排到底。

- HTML版本: https://postext.dev/zh/cookbook/photos-fill-short-columns
- 食谱 No. 116 · 图与图片 · 难度 2 (中级) · 输出: Canvas, PDF
- 体裁: 杂志与小志
- 需要 postext ≥ 1.16.1, postext-pdf ≥ 1.16.1 · 已用 1.16.1, postext-pdf 1.16.1 测试，日期 2026-10-05
- 页面: [1](https://postext.dev/cookbook/photos-fill-short-columns/es/p01.webp?v=6ec2b71b), [2](https://postext.dev/cookbook/photos-fill-short-columns/es/p02.webp?v=6ec2b71b), [3](https://postext.dev/cookbook/photos-fill-short-columns/es/p03.webp?v=6ec2b71b), [2](https://postext.dev/cookbook/photos-fill-short-columns/es/p05.webp?v=6ec2b71b), [3](https://postext.dev/cookbook/photos-fill-short-columns/es/p06.webp?v=6ec2b71b)
- PDF: https://postext.dev/cookbook/photos-fill-short-columns/es/photos-fill-short-columns.pdf?v=6ec2b71b
- 在沙盒中打开: https://postext.dev/zh/sandbox#recipe=photos-fill-short-columns&lang=es (.postext: https://postext.dev/cookbook/photos-fill-short-columns/es/photos-fill-short-columns.postext)
- 最后更新: 2026-10-05
- 其他语言: [en](https://postext.dev/en/cookbook/photos-fill-short-columns.md), [es](https://postext.dev/es/cookbook/photos-fill-short-columns.md), [ca](https://postext.dev/ca/cookbook/photos-fill-short-columns.md), [ja](https://postext.dev/ja/cookbook/photos-fill-short-columns.md), [ar](https://postext.dev/ar/cookbook/photos-fill-short-columns.md)

## 简单来说

告诉排版每张照片哪一部分必须始终可见。某一栏差几行没排满时，照片会变高，而不是留下空白。

## 成品一览

一本海岸杂志的三页：一篇写河口小镇各行手艺的报道，开篇有一张出血大图，栏内另有五张照片。示例用同一段文字把报道排了两遍。第一遍里照片保持原文件的形状，[第2页](https://postext.dev/cookbook/photos-fill-short-columns/es/p02.webp?v=6ec2b71b)的两栏都比网格少一行：左栏停在最后一段之下，右栏停在栏底照片之上。第二遍里每张照片都说明画面哪一部分必须始终可见，引擎就裁去这部分以外的地方：在[同一页](https://postext.dev/cookbook/photos-fill-short-columns/es/p05.webp?v=6ec2b71b)上，拾贝的妇人和补网的妇人各长高一行，两栏都排到网格的最后一行。裁切位置不需要任何人来挑。

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

- 怎样让照片裁切后填满偏短的栏，同时不丢掉画面主体？
- 怎样让各栏底部齐平，最后一页也排得平衡（垂直两端对齐）？
- 怎样控制图的位置：页面顶部、横跨两栏、就放在这里，还是放在边栏里？

## 简短回答

```js
// script.js, 行 47–63
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
  mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
  redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
  carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
  faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
  pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
  stretchAfterFloats: false, looseParagraphs: false };
```

## 用料

**讲解**

- [图片安全区](https://postext.dev/zh/docs/document-format.md#安全区): 图片中始终显示的部分；排版可以裁去其外的部分，把图放高或放矮，以填满偏短的栏，或让行内图留在其文字旁。
- [各栏齐底](https://postext.dev/zh/docs/configuration.md#各栏齐底): 按以下顺序调整，使每栏都与页面底部平齐：标题、列表和浮动体周围的间距，然后是段落松紧和字距；也会把一章的最后一页排平。
- [图的放置](https://postext.dev/zh/docs/document-format.md#位置): 顶部、底部、自动或此处，排在一栏内或横跨页面，占宽度的某个比例，可按资源或按类型设置；同一序列的浮动体从不互相超越。

**还用到**

- [作为资源的图和表](https://postext.dev/zh/docs/document-format.md#资源)
- [决定图位置的引用](https://postext.dev/zh/docs/document-format.md#行内引用主要写法)
- [图就放在这里](https://postext.dev/zh/docs/document-format.md#块级嵌入可选显式的行内放置)
- [题注样式](https://postext.dev/zh/docs/configuration.md#题注样式)
- [自定义资源类型](https://postext.dev/zh/docs/configuration.md#资源类型)
- [设计过的章首页](https://postext.dev/zh/docs/configuration.md#通栏与高级设计)
- [页面设计中的图片](https://postext.dev/zh/docs/configuration.md#图像元素)
- [标题属性](https://postext.dev/zh/docs/document-format.md#标题属性)
- [单栏或双栏](https://postext.dev/zh/docs/configuration.md#版式类型)
- [对称页边距](https://postext.dev/zh/docs/configuration.md#镜像页边距)
- [书眉与页码](https://postext.dev/zh/docs/configuration.md#页眉与页脚)
- [按页面角色显示书眉](https://postext.dev/zh/docs/configuration.md#文本元素)
- [语义调色板](https://postext.dev/zh/docs/configuration.md#调色板)
- [段落样式](https://postext.dev/zh/docs/configuration.md#段落样式)
- [在Canvas上绘制页面](https://postext.dev/zh/docs/configuration.md#把页面渲染为位图)
- [导出PDF](https://postext.dev/zh/docs/configuration.md#生成pdf)
- [用你的语言显示“图”和“表”](https://postext.dev/zh/docs/configuration.md#资源类型)
- [通栏章首色带](https://postext.dev/zh/docs/configuration.md#通栏与高级设计)
- [嵌入PDF的字体](https://postext.dev/zh/docs/configuration.md#为什么需要字体提供函数)

**配置一览**

- [`bodyText`](https://postext.dev/zh/docs/configuration.md#正文), [`captionStyle`](https://postext.dev/zh/docs/configuration.md#题注样式), [`colorPalette`](https://postext.dev/zh/docs/configuration.md#调色板), [`footer`](https://postext.dev/zh/docs/configuration.md#页眉与页脚), [`header`](https://postext.dev/zh/docs/configuration.md#页眉与页脚), [`headings`](https://postext.dev/zh/docs/configuration.md#标题), [`layout`](https://postext.dev/zh/docs/configuration.md#版式), [`locale`](https://postext.dev/zh/docs/configuration.md#断词), [`page`](https://postext.dev/zh/docs/configuration.md#页面), [`paragraphStyles`](https://postext.dev/zh/docs/configuration.md#段落样式), [`resourceTypes`](https://postext.dev/zh/docs/configuration.md#资源类型)

**API**

- [`buildDocument`](https://postext.dev/zh/docs/configuration.md#构建文档), [`clearMeasurementCache`](https://postext.dev/zh/docs/configuration.md#测量缓存), [`decompressWoff2`](https://postext.dev/zh/docs/configuration.md#浏览器字体提供函数fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/zh/docs/configuration.md#资源类型), [`registerResourceImage`](https://postext.dev/zh/docs/architecture.md#api接口), [`renderPageToCanvas`](https://postext.dev/zh/docs/configuration.md#把页面渲染为位图), [`renderToPdf`](https://postext.dev/zh/docs/configuration.md#生成pdf)

**字体**

- Newsreader (OFL-1.1), Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)

## 做法

### 1 · 只标出必须保留的部分

代码就是上面的[简短回答](#简短回答)。安全区是图片的四个比例值，从左上角量起。引擎可以把照片显示成从整张文件到这个矩形之间的任何形状，宽度始终等于所在的栏：裁掉两侧就更高，裁掉上下就更矮。安全区以外的部分按两侧余白的比例裁去，所以码头上偏右的罗莎，在两侧被裁后仍然偏右。

### 2 · 让栏平衡只通过照片取得余量

同一段代码还设定了栏平衡。平衡通常的办法都是加空白：标题上方加一行、照片下方留空、让一段文字多排一行。这里把它们都关掉，好让没有安全区的版本如实显示每一个短栏。带安全区的照片会先于这些办法尝试，因为照片变高不会在文字里留下空洞。

### 3 · 安全区属于资源本身

```js
// script.js, 行 211–245
const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067
// Caption and alt text of each photo, one block per photo: content.figures.<lang>.md.
const figureTexts = String.raw`mariscadora
Carmen Lago separa berberechos en su parcela de la ría, en bajamar.
Una mujer con botas verdes, de rodillas en la arena mojada junto a un rastro y una cesta.

redeira
Rosa Doval remienda un paño de cerco en el muelle.
Una mujer sentada en un taburete cose una red verde; detrás, barcos de pesca.

carpintero
Manuel Barreiro cepilla el forro de la gamela.
Un hombre mayor cepilla el costado de una barca de madera a medio construir.

faro
La punta de Arnela: el faro y el camino de la costa.
Un faro blanco sobre rocas de granito; abajo, una caminante con mochila roja.

pulpeira
Lucía Fraga saca un pulpo del caldero de cobre.
Una mujer con mandil saca un pulpo cocido de un caldero de cobre humeante.
`;
const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, alt]) => [id, [caption, alt]]));
const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
const photo = (id, safe) => {
  const [w, h] = FILES[id] ?? [1600, 1067];
  const [caption, alt] = CAPTIONS[id] ?? [];
  return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
    ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }),
    ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) };
};
const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe));
```

`safeArea`和文件、题注、位置一起写在资源上，所以照片不论浮动到哪里、用在哪个文档里，都带着它。示例要么传入、要么省略它，这就是两次排版之间唯一的区别。开篇那张河口照片由标题样式绘制，不是浮动图，所以不需要安全区。

### 4 · 两次都排，再对照

```js
// script.js, 行 256–258
const build = (safe) => buildWithFonts(() =>
  buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
```

两份文档出自同一份配置和同一段Markdown。只有还要接排下去的页面才会排到页底：[第3页](https://postext.dev/cookbook/photos-fill-short-columns/es/p03.webp?v=6ec2b71b)是报道的最后一页，各栏只需要在同一高度结束。没有安全区时第二栏比第一栏早一行结束；有了安全区，章鱼那张照片长高这一行，两栏齐平。PDF按钮输出当前桌面上的那一版，PDF里每张照片也按同样方式裁到框内。

## 完整食谱

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

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

### script.js

```js
// ═══ Postext Cookbook · Nº 116 · Photos that grow to fill a short column ═══════════
// https://postext.dev/en/cookbook/photos-fill-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models
// Fonts: Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.16.1
//
// A magazine feature set twice. Without safe areas some columns end short; with them the
// engine crops each photo outside its area to set it taller, and the photos fill those lines.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: six named colours, the accents taken from the photos: sea slate, net green
const palette = {
  ink: '#1d2326', // text: a cold near-black
  sea: '#2f5d73', // the accent: kicker, caption labels, folios, references
  net: '#3f6b55', // the second colour: the opener's rule
  rule: '#c9d1d3', // hairlines
  muted: '#5f6a6e', // running heads, credits, the 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': pointed at the accent, no default blue shows.
  { id: 'main-color', name: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Newsreader', 'Archivo', 'Archivo Narrow'];
const LEAD = 13.6; // body leading in pt: the grid the photos grow by
const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [225, 297, 22, 22, 18, 16, 7]; // mm
const PHOTO_H = 150; // the opener's bleed photo: 225 × 150 mm, the shape of the file
const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', overflow: 'wrap', ...extra }); // wrap, not '…' (gotcha: overflow-ellipsis-default)
const caps = (size) => ({ fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) });

// #region answer: a safe area per photo, and balancing that may only grow pictures
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
  mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
  redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
  carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
  faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
  pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
  stretchAfterFloats: false, looseParagraphs: false };
// #endregion

// #region opener: the estuary across the head of the page, then kicker, title and standfirst
const opener = {
  enabled: true,
  // Images reserve no height (gotcha: opener-image-no-reserve): minHeight keeps the text
  // under the photo, the kicker, the title and the standfirst.
  minHeight: mm(188),
  slot: { elements: [
    { kind: 'image', id: 'photo', resourceId: 'estuario', // bleeds off the top and both sides
      placement: at('page', 'top-left', 0, 0, { width: mm(PAGE_W), height: mm(PHOTO_H) }) },
    text('kicker', '{attr.kicker}', LABEL, 8.5, 'sea', at('container', 'top-left', 0,
      PHOTO_H - TOP + 9), caps(8.5)),
    text('title', '{titleText}', DISPLAY, 34, 'ink', at('#kicker', 'below', 0, 2.5,
      { width: mm(150), height: 'auto' }), { fontWeight: 800, lineHeight: 1.04 }),
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(2), color: col('net'),
      placement: at('#title', 'below', 0, 4, { width: mm(16) }) },
    text('lead', '{attr.lead}', TEXT, 11.2, 'ink', at('#rule', 'below', 0, 4,
      { width: mm(150), height: 'auto' }), { italic: true, lineHeight: 1.36, hyphenate: true }),
  ] },
};
// #endregion

// #region furniture: magazine and issue on the verso, the feature's title on the recto
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), fontWeight: 600, parity, pages: 'body',
    overflow: 'ellipsis', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'sea',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
] };
const footer = { elements: [] }; // the opener carries no folio: its photo bleeds off the head
// #endregion

const types = () => defaultResourceTypes(LANG).map((type) => ({ ...type,
  numberingTemplate: '{n}', resetOn: 'never' })); // 'Figura 3', not '1.3', in a one-article issue

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: types(), // "Figura" in Spanish (gotcha: resource-types-locale)
  colorPalette,
  page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('sea'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(34), span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), color: col('ink'), gap: mm(2),
    labelBold: true, labelColor: col('sea'), descriptionItalic: false,
    note: { color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Mareas"
subtitle: "Revista de la costa · Número 14"
---

# Gente de la bajamar {kicker="Oficios del mar · Ría de Arnela" lead="Dos veces al día el agua se retira de la ría y deja al descubierto un kilómetro de arena. En ese rato trabajan las mariscadoras; en el muelle, las redeiras cosen lo que el mar rompió la noche anterior. Pasamos una semana de octubre con cinco oficios que siguen el reloj de la marea."}

Arnela tiene mil cuatrocientos vecinos, un puerto con once barcos de bajura y una ría que en bajamar se queda casi seca. Desde la carretera de la costa se ve el pueblo entero: las casas blancas apretadas en la ladera, la iglesia arriba y, abajo, la lonja con su tejado de uralita. Lo que no se ve desde arriba es el horario. En Arnela nadie queda a las cinco o a las seis; se queda «a media marea» o «cuando baje», y la tabla de mareas que la cofradía pega cada mes junto a la puerta de la lonja manda más que cualquier reloj.

Esta semana la bajamar cae a las siete y cuarto de la mañana y a las siete y media de la tarde. Una hora antes, el arenal ya está lleno de gente.

## A pie de marea

Carmen Lago empezó a mariscar a los catorce años, con su madre, y tiene sesenta y uno. Trabaja con un rastro de mango largo, un balde y una cesta de malla, siempre en la misma parcela de la ría, la que la cofradía asigna cada temporada a su grupo de nueve mujeres (:ref{id="mariscadora" case="lower"}). Rastrilla la arena a contrapelo de la corriente, se agacha, aparta con la mano los berberechos que no llegan a la talla mínima y los devuelve al agujero del que salieron.

—El berberecho chico se queda —dice—. Si lo coges hoy, en marzo no hay.

Cada mariscadora puede sacar al día un cupo fijo por especie, que la cofradía revisa según el estado del banco. En octubre el de Carmen son tres kilos de almeja fina y ocho de berberecho. Lo que pasa del cupo vuelve a la arena. A las nueve, cuando el agua empieza a cubrir las primeras charcas, el grupo sube la cuesta hasta la lonja con los baldes. Allí se lava el marisco, se pesa por mariscadora y se clasifica por calibre antes de la subasta de las once.

El trabajo no termina en el arenal. Dos días a la semana el grupo «siembra»: traslada almeja pequeña de las zonas donde se amontona a las que se han quedado vacías, y limpia la arena de algas y de las estrellas de mar que se comen la cría. Nadie cobra por esas horas. «Es como regar la huerta», explica Carmen, que todavía se acuerda de los inviernos en que no había nada que sacar.

## La subasta de las once

La lonja de Arnela es una nave alargada con el suelo de hormigón siempre mojado. A las once menos cuarto ya están alineadas sobre la cinta las cajas de la mañana: el marisco de las mariscadoras, separado por especie y calibre, y el pescado de los barcos de bajura, merluza de pincho, jurel, algún rodaballo. Cada caja lleva una etiqueta con el peso, la zona y el nombre de quien la trajo.

La subasta es a la baja. Un panel electrónico empieza por un precio alto y va bajando de diez en diez céntimos; el primer comprador que pulsa su mando se queda la caja a ese precio. Compran tres mayoristas de la comarca, dos restaurantes del pueblo y un cocedero que envasa almeja para toda la provincia. En octubre el kilo de almeja fina ronda los veintiséis euros y el de berberecho, los siete. Una mañana buena se subastan dos toneladas en cuarenta minutos.

Marisa Couto lleva el registro de la lonja desde hace diecinueve años. Anota en una hoja cada venta, con el comprador y el precio, y a final de mes reparte lo que corresponde a cada mariscadora y a cada barco, descontada la comisión de la cofradía. «Antes se gritaba», recuerda. «Ahora solo se oye el pitido del panel, y a la una ya está todo fregado.»

## Las redeiras del muelle

En el extremo del muelle, junto a las cajas apiladas de la lonja, trabaja Rosa Doval sobre un taburete bajo, con una red verde extendida a su alrededor como una alfombra (:ref{id="redeira" case="lower"}). Los barcos de cerco entraron de madrugada con la sardina y con tres paños rotos; uno de ellos lo partió una roca y tiene un agujero en el que cabe una persona.

Rosa cose con una aguja de plástico del largo de una mano, cargada de hilo, y una tablilla que fija el tamaño de cada malla. Corta los bordes deshilachados hasta dejar mallas enteras, cuenta las que faltan y teje el remiendo nudo a nudo. Un agujero grande le lleva una mañana. Un paño nuevo, entre doscientos y trescientos metros de red, casi un mes de trabajo de cuatro mujeres.

En Arnela quedan siete redeiras. Se organizaron hace doce años, cuando los armadores empezaron a mandar las redes a talleres del interior, y ahora cobran por hora y no por remiendo. La más joven tiene veintiocho años y aprendió en un curso de la cofradía; la mayor, ochenta y uno, y ya solo cose los días de buen tiempo. Rosa prefiere el muelle a la nave que les cedió el ayuntamiento: «Con la red al sol se ven mejor los agujeros, y además me entero de lo que trae cada barco».

## La última carpintería de ribera

Al fondo de la ensenada, donde el camino de la costa se convierte en pista de tierra, una nave de madera con las puertas abiertas huele a resina y a pino recién cortado. Es el astillero de Manuel Barreiro, el último de los cinco que llegó a tener la ría. Manuel tiene setenta y cuatro años y trabaja con su sobrino en una gamela de seis metros, una barca de fondo plano que se usaba para el marisqueo a flote (:ref{id="carpintero" case="lower"}).

El casco está montado sobre la grada desde agosto. Primero se pone la quilla, después las cuadernas de roble, curvadas al fuego, y por último las tablas de pino que forman el forro, clavadas una a una con clavos de cobre. Manuel no usa planos. Las formas las saca de unas plantillas de tablero que heredó de su padre y que cuelgan de la pared del fondo, cada una con el nombre de la barca para la que se hizo escrito a lápiz.

En los años ochenta el astillero botaba quince barcos al año. Ahora hace dos o tres, casi siempre para clubes de remo o para familias que quieren recuperar la barca del abuelo. Esta gamela la encargó la asociación de vecinos, que quiere sacarla en las regatas de verano con los colores del pueblo. Manuel calcula que estará en el agua en abril, si el invierno deja trabajar con las puertas abiertas.

## El faro y el camino

Desde el astillero, el camino sube entre tojos hasta la punta de Arnela, donde un faro blanco con la linterna roja marca la entrada de la ría (:ref{id="faro" case="lower"}). Lo automatizaron en 1993, y desde entonces nadie vive en la casa del torrero, pero Xosé Pazos, que lo atendió durante veintidós años, sigue subiendo dos veces por semana a revisar las baterías y a anotar en un cuaderno el viento y el estado de la mar.

El sendero que lleva hasta allí forma parte de una ruta de once kilómetros que rodea toda la ría, de playa en playa, y que la mancomunidad señalizó hace tres años con postes de madera y flechas amarillas. En octubre se cruzan pocos caminantes: algún grupo de jubilados, parejas con mochila y, los fines de semana, corredores que bajan a la carrera hasta el puerto. Pazos les indica, si preguntan, por dónde se baja a la cala del Cantal sin resbalar en la roca mojada.

Con mal tiempo la ola rompe contra la punta y sube por las rocas hasta la base de la torre. Pazos guarda una foto de 1987 en la que el agua llega a la puerta. Aquel invierno se quedó tres días sin poder bajar al pueblo, con la radio, una caja de latas y el cuaderno.

## El caldero de los domingos

El domingo, el mercado de abastos abre a las nueve y a las diez ya hay cola delante del puesto de Lucía Fraga. Lucía cuece el pulpo como lo cocía su madre, en un caldero de cobre de ochenta litros sobre un fuego de leña: lo «asusta» metiéndolo y sacándolo del agua hirviendo tres veces para que la piel no se despegue, lo deja cocer unos veinte minutos y lo saca con un gancho cuando la punta del tentáculo cede al pincharla.

::resource{id="pulpeira"}

Lo corta con tijera sobre platos de madera, le echa sal gruesa, pimentón dulce y picante a partes iguales y un chorro de aceite, y lo sirve con cachelos, patatas cocidas en la misma agua. Una ración cuesta doce euros. Los domingos de verano Lucía cuece treinta pulpos; en octubre, catorce o quince, que compra el viernes en la lonja a los barcos de nasa del propio puerto.

A las dos de la tarde la marea vuelve a estar baja. En el arenal ya se distinguen los primeros baldes y, en el muelle, una red tendida al sol.

:::paragraphs{style="colophon"}
Arnela y sus vecinos son imaginarios · Compuesto en Newsreader, Archivo y Archivo Narrow (SIL Open Font License) · Texto: CC BY 4.0 · Fotografías: modelos de difusión
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region photos: one resource per file; the safe area is the only difference between builds
const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067
// Caption and alt text of each photo, one block per photo: content.figures.<lang>.md.
const figureTexts = String.raw`mariscadora
Carmen Lago separa berberechos en su parcela de la ría, en bajamar.
Una mujer con botas verdes, de rodillas en la arena mojada junto a un rastro y una cesta.

redeira
Rosa Doval remienda un paño de cerco en el muelle.
Una mujer sentada en un taburete cose una red verde; detrás, barcos de pesca.

carpintero
Manuel Barreiro cepilla el forro de la gamela.
Un hombre mayor cepilla el costado de una barca de madera a medio construir.

faro
La punta de Arnela: el faro y el camino de la costa.
Un faro blanco sobre rocas de granito; abajo, una caminante con mochila roja.

pulpeira
Lucía Fraga saca un pulpo del caldero de cobre.
Una mujer con mandil saca un pulpo cocido de un caldero de cobre humeante.
`;
const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, alt]) => [id, [caption, alt]]));
const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
const photo = (id, safe) => {
  const [w, h] = FILES[id] ?? [1600, 1067];
  const [caption, alt] = CAPTIONS[id] ?? [];
  return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
    ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }),
    ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) };
};
const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe));
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  Newsreader: ['400', '400i', '600'], Archivo: ['800'], 'Archivo Narrow': ['600', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all(resources(false).map((r) => loadImage(r.bitmap.fileId, asset(r.bitmap.fileId))));
// #region builds: the same text, config and photos, without and then with the safe areas
const build = (safe) => buildWithFonts(() =>
  buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };
// #endregion
const title = t({ en: 'Photos that grow to fill a short column',
  es: 'Fotos que crecen hasta llenar la columna' });
// Two buttons put either build on the desk; the PDF follows the one shown.
const LABELS = { plain: t({ en: 'Without safe areas', es: 'Sin zona segura' }),
  safe: t({ en: 'With safe areas', es: 'Con zona segura' }) };
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);
  document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove());
  offerPdf(() => renderToPdf(docs[key], { fontProvider: fontsourceProvider,
    resourceBytes: imageBytes }), `${RECIPE}-${key}.pdf`);
};
for (const b of switches) b.addEventListener('click', () => show(b.value));
show('safe');
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 v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    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',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/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().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
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;
}

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  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, { 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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

// ─── Kit · images v1 ── 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 (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** 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 ───────────────────────────────────────────────────────────────────────
```

## 变化

### 让行内照片留在文字旁

设为`position: 'here'`并带安全区的照片，如果比所在栏剩下的空间稍高，会从上下裁掉一些，而不是移到下一栏。

```diff
-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };
```

### 把其他平衡手段还给栏平衡

用默认设置时照片仍然最先尝试，安全区够不到的部分再交给标题和浮动图。

```diff
-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
-  stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };
```

## 常见问题

- **传入任何headings对象都会关掉H1换页.** 默认情况下，H1换页到右页（always-odd），但只要传入headings对象，这个默认值就会被重置，于是各章接排，span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。
- **章首页的图片从不计入它预留的高度.** 在postext 1.4.1中，高级设计标题计算预留高度时不算其中的图片：文字、线条和框都计入，即使它们锚定在页面上；但图片（例如出血铺满页面顶部的图）不预留任何空间，所以正文可能排到它上面。把minHeight设为正文应当开始的位置。
- **设计文本的overflow默认为'ellipsis-end'.** 宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题，设置overflow: 'wrap'。
- **用defaultResourceTypes(locale)本地化Figure/Table.** 配置的locale决定断词，不决定题注：没有resourceTypes时，内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es')；其他语言请在resourceTypes中自己写出名称。
- **只有8种语言区域能断词，且须代码完全一致.** 断词支持en-us、es、fr、de、it、pt、ca和nl，须完全匹配：'es-ES'或其他任何语言都会悄悄退回美式英语。
- **排版前加载所有字体.** 排版用浏览器已加载的字体测量文字，并缓存宽度，所以首次构建之后才到的字体会造成断行错误，PDF也不再与屏幕一致。先加载所有字重和样式；有字体迟到时，重新构建前调用clearMeasurementCache()。

- 覆盖整张图片的安全区，或有一边小于图片2%的安全区，会被忽略：照片照常完整显示。
- 只有栏内浮动图和行内照片会长高。横跨两栏的浮动照片保持原形，因为它一长高就会同时推动下面的所有栏。

## 致谢

- 食谱: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 文字: The feature, the captions and the colophon, in Spanish and English; Arnela and its people are imaginary: Postext Cookbook, CC-BY-4.0
- 图片: The estuary at low water at dawn, with shellfish gatherers: Generated With Diffusion Models, 原创
- 图片: A shellfish gatherer kneeling on the wet sand: Generated With Diffusion Models, 原创
- 图片: A net mender on the quay: Generated With Diffusion Models, 原创
- 图片: A shipwright planing a wooden boat in his shed: Generated With Diffusion Models, 原创
- 图片: A lighthouse on a granite point and a walker below it: Generated With Diffusion Models, 原创
- 图片: A cook lifting an octopus from a copper cauldron at a market: Generated With Diffusion Models, 原创
- 字体: Newsreader (OFL-1.1), Archivo (OFL-1.1), Archivo Narrow (OFL-1.1)
- 代码: MIT · 示例内容: CC-BY-4.0

## 相关食谱

- [No. 009 · 浮动到引用处的图](https://postext.dev/zh/cookbook/figures-float-where-cited.md): 一个双栏章节，含七幅编号的图：六幅从首次:ref处浮动到其位置设置允许的第一个空位，一幅放在::resource指定的地方。 · 难度 3 (高级) · 教科书
- [No. 051 · 双色孔版小志：插图只用一种专色](https://postext.dev/zh/cookbook/riso-zine-single-ink.md): 一本为双滚筒孔版印刷机准备的四页小志：全彩插图印成粉色的深浅网调，Canvas上由CodePen示例重新着色，PDF里由renderToPdf重新着色。 · 难度 3 (高级) · 杂志与小志
- [No. 011 · 同一源文件，印刷版与屏幕版](https://postext.dev/zh/cookbook/print-and-screen-editions.md): 两个版本出自同一份配置：htmlViewer.overrides存放深色的屏幕版面设计，applyHtmlViewerOverrides在每次构建HTML之前把它合并进来。 · 难度 3 (高级) · 杂志与小志
