# 短い段を埋めるように伸びる写真

> 写真ごとにセーフエリアを指定すると、段末そろえはその外側を切り落として写真を高く組めます。見出しやフロートのせいで短くなった段も、グリッドの最終行で終わります。

- HTML版: https://postext.dev/ja/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
- Sandboxで開く: https://postext.dev/ja/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), [zh](https://postext.dev/zh/cookbook/photos-fill-short-columns.md), [ar](https://postext.dev/ar/cookbook/photos-fill-short-columns.md)

## かんたんな説明

写真の中で必ず見せたい部分をレイアウトに伝えておきます。段が数行足りなくなったとき、すき間を残す代わりに写真が縦に伸びます。

## できあがり

沿岸地域の雑誌の3ページです。河口の町の仕事を取り上げた特集で、扉には裁ち落としの写真を1枚、段の中には写真を5枚置きます。CodePenのサンプルは同じテキストから特集を2回組みます。1回目は写真がファイルの形のままで、[2ページ](https://postext.dev/cookbook/photos-fill-short-columns/es/p02.webp?v=6ec2b71b)では両方の段がグリッドの1行手前で終わります。左の段は最後の段落の下で、右の段は地に置いた写真の上で終わります。2回目は写真ごとに、常に見せる必要のある部分を指定し、エンジンはその外側を切り落とします。[同じページ](https://postext.dev/cookbook/photos-fill-short-columns/es/p05.webp?v=6ec2b71b)では、貝を採る人と網を繕う人の写真がそれぞれ1行ぶん高くなり、両方の段がグリッドの最終行で終わります。どのトリミングも、人が選ぶ必要はありませんでした。

**このレシピが答える問い:**

- 被写体を失わずに、短い段を埋めるよう写真をトリミングさせるには？
- 段の下端をそろえ、最終ページの段の長さを均等にするには（垂直方向の両端そろえ）？
- 図の位置を制御するには（ページの上端、2段をまたぐ、まさにこの位置、余白）？

## 手短な答え

```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/ja/docs/document-format.md#セーフエリア): 画像のうち常に表示される部分です。レイアウトは図を高くしたり低くしたりするために、その外側をトリミングすることがあります。短い段を埋めたり、インラインの画像を本文と同じページに保ったりするためです。
- [段末そろえ](https://postext.dev/ja/docs/configuration.md#段末そろえ): すべての段の末尾をページの下端にそろえます。調整は、見出し・リスト・フロートまわりのアキ、段落のゆるさとトラッキングの順に行います。章の最終ページの段の高さもそろえます。
- [図の配置](https://postext.dev/ja/docs/document-format.md#配置): 上、下、自動、その場のいずれかを、段の中かページ幅で、幅の割合も指定して、リソースごとまたは種類ごとに選びます。同じ番号系列のフロートが互いを追い越すことはありません。

**ほかに使うもの**

- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [図を配置する参照](https://postext.dev/ja/docs/document-format.md#インライン参照基本の形)
- [その場に置く図](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [1段組みと2段組み](https://postext.dev/ja/docs/configuration.md#レイアウトの種類)
- [見開きの余白](https://postext.dev/ja/docs/configuration.md#見開きの余白)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration.md#pdfの生成)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration.md#なぜフォントプロバイダーが必要か)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`locale`](https://postext.dev/ja/docs/configuration.md#ハイフネーション), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`decompressWoff2`](https://postext.dev/ja/docs/configuration.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration.md#pdfの生成)

**書体**

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

## 作り方

### 1 · 残すべき部分だけを指定する

コードは前の[手短な答え](#手短な答え)にあります。セーフエリアは、画像の左上の角から測る4つの割合です。エンジンは写真を、ファイル全体からその長方形までの間の任意の形で、段の幅いっぱいに表示できます。左右を切れば縦長に、天地を切れば横長になります。セーフエリアの外側は両側の余白に比例して切り落とされるので、岸壁で中央より右に立つRosaは、左右を切っても中央より右に残ります。

### 2 · 段末そろえが余りを吸収するのは写真だけにする

同じコードで段末そろえも設定しています。通常の調整手段は白を足すものです。見出しの上に1行加える、写真の下にアキを入れる、段落を1行長く組む、といった方法です。ここではそれらをオフにしているので、セーフエリアのない組版では短い段がそのまま見えます。セーフエリアのある写真は、オフにしていなくても、それらの調整手段より先に試されます。写真が高くなってもテキストに穴は残らないからです。

### 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`はリソースの上で、ファイル、キャプション、配置と並んで指定します。そのため写真はどこにフロートしても、それを使うどの文書でもセーフエリアを保ちます。サンプルはこれを渡すか省くかだけで、2つの組版の違いはそれだけです。扉の河口の写真は見出しスタイルが描くもので、フロートではないので、セーフエリアは要りません。

### 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) };
```

2つの文書は1つの設定と1つのMarkdown文字列から作ります。地まで埋めるのは、次のページへ続くページだけです。[3ページ](https://postext.dev/cookbook/photos-fill-short-columns/es/p03.webp?v=6ec2b71b)は特集の最後のページで、最後のページでは段の終わりの高さを互いにそろえるだけです。ここではすでにそろっているので、そのページで高くなる写真はありません。「PDF」ボタンは表示している方の組版を書き出し、PDFでも各写真は同じように枠でクリップされます。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenの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'を設定してください。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **ハイフネーションできるのは8つのロケールだけで、コードは完全一致.** ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 画像全体を覆うセーフエリアや、辺が画像の2%未満のセーフエリアは無視されます。写真はセーフエリアがないときと同じく、全体が表示されます。
- 高くなるのは段のフロートとインラインの写真だけです。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/ja/cookbook/figures-float-where-cited.md): 番号付きの図が7点ある2段組みの章。6点は最初の:refから、配置の指定が許す最初の場所へフロートし、1点は::resourceを置いた位置に入ります。 · 難易度 3 (上級) · 教科書
- [No. 051 · 2色リソのZine：図版を1色の特色で](https://postext.dev/ja/cookbook/riso-zine-single-ink.md): 2ドラムのリソグラフ用の4ページのZineです。フルカラーの線画はピンクの濃淡で印刷され、CanvasではCodePenのサンプルが、PDFではrenderToPdfが色を置き換えます。 · 難易度 3 (上級) · 雑誌とジン
- [No. 011 · 1つの原稿から印刷版と画面版を作る](https://postext.dev/ja/cookbook/print-and-screen-editions.md): 2つの版は1つの設定から生まれます。htmlViewer.overridesが画面用のダークなデザインを持ち、applyHtmlViewerOverridesがHTMLを生成するたびにそれをマージします。 · 難易度 3 (上級) · 雑誌とジン
