# 枠から枠へ流し込む雑誌の特集記事

> 合成した見開きの二段を next でつなぎ、overflow.continueIn で追加ページへ流して、引用と切り抜きの写真を回り込ませます。

- HTML版: https://postext.dev/ja/cookbook/magazine-threaded-frames
- レシピ No. 160 · 完成した出版物 · 難易度 3 (上級) · 出力: Canvas, PDF
- ジャンル: 雑誌とジン
- 必要なもの postext ≥ 1.38.0, postext-pdf ≥ 1.38.0 · テスト環境 1.38.0, postext-pdf 1.38.0 ／テスト日 2026-10-17
- ページ: [46](https://postext.dev/cookbook/magazine-threaded-frames/es/p01.webp?v=08639e05), [47](https://postext.dev/cookbook/magazine-threaded-frames/es/p02.webp?v=08639e05), [48](https://postext.dev/cookbook/magazine-threaded-frames/es/p03.webp?v=08639e05), [49](https://postext.dev/cookbook/magazine-threaded-frames/es/p04.webp?v=08639e05)
- PDF: https://postext.dev/cookbook/magazine-threaded-frames/es/magazine-threaded-frames.pdf?v=08639e05
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=magazine-threaded-frames&lang=es (.postext: https://postext.dev/cookbook/magazine-threaded-frames/es/magazine-threaded-frames.postext)
- 最終更新: 2026-10-17
- 他の言語: [en](https://postext.dev/en/cookbook/magazine-threaded-frames.md), [es](https://postext.dev/es/cookbook/magazine-threaded-frames.md), [ca](https://postext.dev/ca/cookbook/magazine-threaded-frames.md), [de](https://postext.dev/de/cookbook/magazine-threaded-frames.md), [fr](https://postext.dev/fr/cookbook/magazine-threaded-frames.md), [it](https://postext.dev/it/cookbook/magazine-threaded-frames.md), [pt](https://postext.dev/pt/cookbook/magazine-threaded-frames.md), [zh](https://postext.dev/zh/cookbook/magazine-threaded-frames.md), [ar](https://postext.dev/ar/cookbook/magazine-threaded-frames.md)

## かんたんな説明

テンプレートから組む四ページの雑誌記事。文章はひとりでに次の段へ、さらに新しいページへ流れ、引用と写真の輪郭をよけて組まれます。

## できあがり

架空の都市生活季刊誌『Patio』の46〜49ページ。三十年勤めた印刷所の屋上で十四箱の蜜蜂を飼う、引退した植字工の特集です。巻頭見開きの左ページは写真で、見出しは屋上の砂利の上に、数字を並べた色付きの囲みは空に置きます。右ページにはリードと最初の二段。段間には引用がまたがり、背景を切り抜いた麦わらの巣箱が立っていて、二段目の行はそのドームの形に沿って組まれます。そのあと記事は必要なだけページを使い、一ページ三段で、終わるところで終わります。スペイン語、英語、ドイツ語の版は同じテンプレートに別の言葉を入れたもので、ドイツ語は長い複合語を60mmの段に通します。

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

- 雑誌の記事を、つないだ枠で何ページにもわたって流し込むには？
- 雑誌をセクションに分けて組み、各記事を見開きで始めて向かいのページにリード写真を置くには？
- Postextは3段以上の段組みや、画像の周りへの回り込みに対応していますか？

## 手短な答え

```js
// script.js, 行 61–89
const opening = {
  id: 'apertura',
  spread: true,
  header: false,
  frames: [
    { name: 'titulo', takes: 'h1', layer: 2, width: mm(150),
      placement: at('container', 'bottom-left', 4, -14) },
    { name: 'datos', style: 'datos', layer: 2, width: mm(64),
      placement: at('container', 'top-left', 4, 6) },
    { name: 'entradilla', width: mm(2 * COLUMN + GAP),
      placement: at('container', 'top-left', RECTO) },
    // The story names no slot, so it goes to the flow frame, then on down the chain.
    { name: 'col-1', flow: true, next: 'col-2', width: mm(COLUMN), height: 'fill',
      placement: at('#entradilla', 'below', 0, 7) },
    // What the last column cannot hold goes on to a page added after the spread.
    { name: 'col-2', width: mm(COLUMN), height: 'fill',
      placement: at('#col-1', 'right-of', GAP),
      overflow: { continueIn: 'template:seguido' } },
    ...pushers,
  ],
  decoration: { elements: [
    { kind: 'image', id: 'foto', resourceId: '{attr.foto}',
      fit: 'cover',
      placement: { ...at('bleed', 'top-left'), size: { width: mm(W), height: 'fill' } } },
    { kind: 'text', id: 'seccion', content: '{attr.seccion}', fontFamily: SANS, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2), textTransform: 'uppercase', color: col('paper'),
      placement: at('#titulo', 'above', 0, -3) },
  ] },
};
```

## 材料

**学べること**

- [連結したテキスト枠](https://postext.dev/ja/docs/composed-pages.md#枠をつなぐ): 枠の next は入りきらない分を同じページの別の枠へ送り、その枠の幅で改めて行を分けます。overflow.continueIn（'next-page' または 'template:<id>'）は残りを後ろに追加するページへ続きの印とともに運び、本文が終わるまで続けます。
- [テキストを押しのける枠（矩形または輪郭）](https://postext.dev/ja/docs/composed-pages.md#テキストを押しのける枠): exclude: 'push' の枠は、ほかの枠の行がぶつかる所でその行を短くするので、引用や写真を段の中に置けます。wrap: 'contour' を加えると、行は枠の中の図版の輪郭（アルファチャンネルか多角形）に沿います。
- [テンプレートページ](https://postext.dev/ja/docs/composed-pages.md#テンプレートページ): 設定に置いた名前付き枠のテンプレートから組むページ。::::page フェンスの中身が slot 名で各枠に流し込まれます。

**ほかに使うもの**

- [見開きの余白](https://postext.dev/ja/docs/configuration-page-layout.md#見開きの余白)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration-headers-footers.md#画像要素)
- [ハイフネーションと文書の言語](https://postext.dev/ja/docs/justification.md#対応するロケール)
- [柱とノンブル](https://postext.dev/ja/docs/configuration-headers-footers.md#柱とノンブル)
- [段落スタイル](https://postext.dev/ja/docs/configuration-styles.md#段落スタイル)
- [章扉のドロップキャップ](https://postext.dev/ja/docs/configuration-headers-footers.md#テキスト要素)
- [囲み](https://postext.dev/ja/docs/configuration-callouts.md#囲みスタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration-fonts-colors-viewers.md#カラーパレット)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format-resources.md#リソース)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration-programmatic-usage.md#ページをビットマップに描画する)
- [PDFの書き出し](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成)
- [本文段落のドロップキャップ](https://postext.dev/ja/docs/configuration-text.md#ドロップキャップ)
- [見開きのテンプレート](https://postext.dev/ja/docs/composed-pages.md#見開き)
- [次のページに続く枠](https://postext.dev/ja/docs/composed-pages.md#文章が追加ページに続くとき)
- [枠に固定した飾り](https://postext.dev/ja/docs/composed-pages.md#飾り柱レイヤー)
- [その場に置く図](https://postext.dev/ja/docs/document-format-resources.md#ブロック埋め込み任意本文中への明示的な配置)
- [PDFに埋め込むフォント](https://postext.dev/ja/docs/configuration-pdf-output.md#なぜフォントプロバイダーが必要か)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration-resources.md#リソースの種類)

**設定の一覧**

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

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#レイアウトの前にフォントを読み込む), [`decompressWoff2`](https://postext.dev/ja/docs/configuration-pdf-output.md#ブラウザー向けフォントプロバイダーfontsource--woff2), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration-programmatic-usage.md#ページをビットマップに描画する), [`renderToPdf`](https://postext.dev/ja/docs/configuration-pdf-output.md#pdfの生成), `traceResourceContour`, `withContourTrace`

**書体**

- Literata (OFL-1.1), Fraunces (OFL-1.1), Instrument Sans (OFL-1.1)

## 作り方

### 1 · 一つのテンプレートに一つの記事

コードは上の[手短な答え](https://postext.dev/ja/cookbook/magazine-threaded-frames.md#手短な答え)にあります。特集全体が一つの `::::page{template="apertura" spread}` フェンスです。見出しは `takes: 'h1'` の枠へ、数字とリードはそれぞれのスロットへ、スロットを指定しないそれ以外の内容はすべて流し込み枠 `col-1` へ入ります。`col-1` がいっぱいになると文章は `col-2` に続き、その幅で改めて行分けされます。`col-2` に収まらない分は、切れたところに *Continúa* の文字を置いてから、見開きの後ろに追加されるページへ続きます（`overflow.continueIn: 'template:seguido'`）。文章は段もページも指定しません。段落を一つ削れば、後ろの行がすべて前へ戻ります。

### 2 · 記事が追加するページ

```js
// script.js, 行 93–105
// The third column continues into this same template: the story goes on in the frame its
// chain starts in, so every added page fills all three columns.
const seguido = {
  id: 'seguido',
  frames: [
    { name: 'seguido-1', flow: true, next: 'seguido-2', height: 'fill', width: mm(60.3) },
    { name: 'seguido-2', next: 'seguido-3', height: 'fill', width: mm(60.3),
      placement: at('#seguido-1', 'right-of', 5) },
    { name: 'seguido-3', height: 'fill', width: mm(60.3),
      placement: at('#seguido-2', 'right-of', 5),
      overflow: { continueIn: 'template:seguido' } },
  ],
};
```

追加ページはどれも、幅60.3mmの段を三つつないだもので、テンプレートはすべてのページで一つです。三段目は同じテンプレートへ続き、記事は連鎖の先頭の枠 `seguido-1` から再開します。何ページ要っても、各ページで三段すべてが埋まります。文字サイズを固定する設定も要りません。別のページへ続ける枠は、文字を縮める前に続きへ送るので、五行あふれた記事は最後のページで92.5%に縮むのではなく、新しいページを得ます。

### 3 · 段を押しのける二つの枠

```js
// script.js, 行 50–57
const pushers = [
  // The pull quote straddles the gutter: each column loses the side it covers.
  { name: 'cita', style: 'cita', exclude: 'push', width: mm(62),
    placement: at('#entradilla', 'below', COLUMN - 27.5, 82) },
  // The skep's frame pushes with the shape of its picture, read from the PNG's alpha.
  { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
    placement: at('container', 'bottom-right', 9, -8) },
];
```

どちらの枠も記事の一部ではなく、重なった段の行を押しのけるだけです。引用はリードから82mm下に固定されて段間にまたがるので、一段目は右側を、二段目は左側を削られます。巣箱の枠には `wrap: 'contour'` が付いています。その横の行は、その高さでの絵の幅に2.5mmの間隔を足した分だけ短く組まれるので、二段目はドームに沿って流れ、底より下ではまた全幅に戻ります。

### 4 · 画像といっしょに運ばれる輪郭

```js
// script.js, 行 300–316
const photo = { id: 'azotea', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  caption: '', altText: t({
    es: 'Seis colmenas de colores en la azotea de una fábrica, con la ciudad al amanecer',
    en: 'Six painted hives on a factory roof, with the city behind them at sunrise',
    de: 'Sechs bunte Bienenkästen auf einem Fabrikdach, dahinter die Stadt im Morgenlicht' }),
  bitmap: { fileId: 'rooftop-1400.jpg', format: 'jpeg', width: 1400, height: 1848 } };
let skep = { id: 'cesto', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  caption: '', altText: t({ es: 'Un cesto de paja para abejas', en: 'A straw bee skep',
    de: 'Ein Bienenkorb aus Stroh' }),
  contour: { threshold: 0.5, margin: mm(2.5) },
  bitmap: { fileId: 'skep-720.png', format: 'png', width: 720, height: 828 } };
await loadImage('rooftop-1400.jpg', asset('rooftop-1400.jpg'));
await loadImage('skep-720.png', asset('skep-720.png'));
// The layout reads pictures by size, never by pixels: the outline travels as a trace.
const trace = await traceResourceContour(skep, imageBytes('skep-720.png'), 'image/png');
skep = withContourTrace(skep, trace);
const resources = [photo, skep];
```

レイアウトは画像を大きさで読み、ピクセルは読みません。そのため輪郭は `traceResourceContour` でPNGのアルファチャンネルから一度だけ取り出し、`contour.traced` としてリソースに持たせます。巣箱は720px、200色、背景透明のPNG（225KB）です。左ページの写真は飾りの画像 `resourceId: '{attr.foto}'` で、どの写真にするかはフェンスが、どこに置くかはテンプレートが決めます。 `fit: 'cover'` を付けてあるので、写真の縦横比にかかわらず左ページを埋め、枠からはみ出した部分は切り取られます。

### 5 · フェンスから読む柱

```js
// script.js, 行 109–120
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: at('page', edge, x, 11), ...extra,
});
const folio = { fontWeight: 700, color: col('honey') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUT, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUT + 9),
  head('recto-title', '{attr.seccion}', 'odd', 'top-right', -(OUT + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUT, folio),
] };
```

巻頭の見開きには柱を出しません（`header: false`）。追加ページでは左ページに誌名、右ページにセクション名が入ります。セクション名はフェンスの `seccion="Ciudad"` から来ていて、`{attr.seccion}` は記事が追加したページでも見開きと同じ値を読みます。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: https://github.com/drnachio/postext/tree/main/cookbook/magazine-threaded-frames

### script.js

```js
// ═══ Postext Cookbook · Nº 160 · A magazine feature threaded through frames ═════
// https://postext.dev/en/cookbook/magazine-threaded-frames
// Code: MIT · Text: original (CC BY 4.0) · Pictures: Generated With Diffusion Models
// Fonts: Literata, Fraunces, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.38.0
//
// A six-page feature of an invented city magazine, set as one composed page: an opening
// spread whose frames are drawn in the config, and pages the text adds as it runs on.
// Every word, the photo's id and the section name are in content.<lang>.md.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
  traceResourceContour, withContourTrace,
} 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' | 'de')
const RECIPE = 'magazine-threaded-frames';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: honey on warm paper, every colour linked by id
const palette = {
  ink: '#211c18', // text
  honey: '#a65a12', // the accent: kickers, rules, the drop cap, the pull quote
  tint: '#f5ecdc', // the fact box
  rule: '#d8c6a6', // hairlines
  muted: '#6b6158', // running heads, credits
  paper: '#ffffff',
  shade: '#1a1612', // the plate under the headline on the photo
};
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' } })),
  { id: 'main-color', name: 'honey (defaults)', value: { hex: palette.honey, model: 'hex' } },
];
// #endregion

const SERIF = 'Literata';
const DISPLAY = 'Fraunces';
const SANS = 'Instrument Sans';
const W = 225; // trim width in mm; the spread's sheet is twice as wide
const IN = 16; // inner margin
const OUT = 18; // outer margin
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
// The spread's content area runs from the verso's outer margin to the recto's: a frame
// on the recto starts RECTO mm in from its left edge.
const RECTO = W - OUT + IN;
const COLUMN = 92; // the two columns of the recto
const GAP = 7;

// #region push: two frames that push the columns' lines aside, by box and by outline
const pushers = [
  // The pull quote straddles the gutter: each column loses the side it covers.
  { name: 'cita', style: 'cita', exclude: 'push', width: mm(62),
    placement: at('#entradilla', 'below', COLUMN - 27.5, 82) },
  // The skep's frame pushes with the shape of its picture, read from the PNG's alpha.
  { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
    placement: at('container', 'bottom-right', 9, -8) },
];
// #endregion

// #region answer: the opening spread: a story in two chained columns that goes on
const opening = {
  id: 'apertura',
  spread: true,
  header: false,
  frames: [
    { name: 'titulo', takes: 'h1', layer: 2, width: mm(150),
      placement: at('container', 'bottom-left', 4, -14) },
    { name: 'datos', style: 'datos', layer: 2, width: mm(64),
      placement: at('container', 'top-left', 4, 6) },
    { name: 'entradilla', width: mm(2 * COLUMN + GAP),
      placement: at('container', 'top-left', RECTO) },
    // The story names no slot, so it goes to the flow frame, then on down the chain.
    { name: 'col-1', flow: true, next: 'col-2', width: mm(COLUMN), height: 'fill',
      placement: at('#entradilla', 'below', 0, 7) },
    // What the last column cannot hold goes on to a page added after the spread.
    { name: 'col-2', width: mm(COLUMN), height: 'fill',
      placement: at('#col-1', 'right-of', GAP),
      overflow: { continueIn: 'template:seguido' } },
    ...pushers,
  ],
  decoration: { elements: [
    { kind: 'image', id: 'foto', resourceId: '{attr.foto}',
      fit: 'cover',
      placement: { ...at('bleed', 'top-left'), size: { width: mm(W), height: 'fill' } } },
    { kind: 'text', id: 'seccion', content: '{attr.seccion}', fontFamily: SANS, fontSize: pt(9),
      fontWeight: 700, letterSpacing: pt(2), textTransform: 'uppercase', color: col('paper'),
      placement: at('#titulo', 'above', 0, -3) },
  ] },
};
// #endregion

// #region pages-after: the page the story adds, three chained columns, as often as needed
// The third column continues into this same template: the story goes on in the frame its
// chain starts in, so every added page fills all three columns.
const seguido = {
  id: 'seguido',
  frames: [
    { name: 'seguido-1', flow: true, next: 'seguido-2', height: 'fill', width: mm(60.3) },
    { name: 'seguido-2', next: 'seguido-3', height: 'fill', width: mm(60.3),
      placement: at('#seguido-1', 'right-of', 5) },
    { name: 'seguido-3', height: 'fill', width: mm(60.3),
      placement: at('#seguido-2', 'right-of', 5),
      overflow: { continueIn: 'template:seguido' } },
  ],
};
// #endregion

// #region running-heads: magazine name on the verso, section on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, fontFamily: SANS, fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'),
  placement: at('page', edge, x, 11), ...extra,
});
const folio = { fontWeight: 700, color: col('honey') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUT, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUT + 9),
  head('recto-title', '{attr.seccion}', 'odd', 'top-right', -(OUT + 9)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUT, folio),
] };
// #endregion

const body = { fontFamily: SERIF, fontSize: pt(9.8), lineHeight: pt(13.6), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(4) };
const config = {
  locale: t({ es: 'es', en: 'en-gb', de: 'de' }),
  // Pictures with no number and no caption label: a magazine credits them in the text.
  resourceTypes: [{ id: 'foto', name: t({ es: 'Foto', en: 'Photo', de: 'Foto' }), shortLabel: '',
    numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '',
    defaultPlacement: { position: 'here' } }],
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(W), height: mm(297), dpi: 150,
    margins: { top: mm(20), bottom: mm(22), left: mm(IN), right: mm(OUT), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    ...body, referenceColor: col('ink'), textAlign: 'justify', indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true, maxJustifyTracking: 12,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, color: col('ink'), fontWeight: 600,
    levels: [
      { level: 1, fontSize: pt(58), lineHeight: pt(56), color: col('paper'), marginBottom: pt(0),
        breakBefore: { enabled: false } },
      { level: 2, fontFamily: SANS, fontSize: pt(8.5), lineHeight: pt(13.6), fontWeight: 700,
        letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('honey'),
        marginTop: pt(13.6), marginBottom: pt(0) },
    ],
  },
  paragraphStyles: [
    { id: 'entradilla', fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(20), italic: true,
      textAlign: 'left', firstLineIndent: pt(0), color: col('ink') },
    // The first paragraph of the story opens with an initial in a frame, as it would in the flow.
    { id: 'inicio', firstLineIndent: pt(0),
      dropCap: { lines: 3, fontFamily: DISPLAY, fontWeight: 600, color: col('honey'),
        leadIn: { words: 3 } } },
    { id: 'firma', fontFamily: SANS, fontSize: pt(8), lineHeight: pt(12), fontWeight: 600,
      textTransform: 'uppercase', textAlign: 'left',
      firstLineIndent: pt(0), color: col('honey'), marginTop: pt(6) },
    { id: 'colofon', fontFamily: SANS, fontSize: pt(6.8), lineHeight: pt(9.5), color: col('muted'),
      textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(8) },
  ],
  calloutStyles: [
    { id: 'cita', backgroundEnabled: false, border: { enabled: false },
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('honey') },
      padding: { top: mm(3), right: mm(0), bottom: mm(3), left: mm(0) },
      body: { fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(18.5), italic: true,
        color: col('honey'), textAlign: 'left', firstLineIndent: pt(0) } },
    { id: 'datos', backgroundEnabled: true, background: col('tint'), border: { enabled: false },
      padding: { top: mm(3.5), right: mm(4), bottom: mm(3.5), left: mm(4) },
      titleStyle: { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700,
        textTransform: 'uppercase', letterSpacing: pt(1.2), color: col('honey') },
      body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(11.6), color: col('ink'),
        boldColor: col('honey'), firstLineIndent: pt(0), textAlign: 'left' } },
  ],
  captionStyle: { fontFamily: SANS, fontSize: pt(7.5), color: col('muted'), labelBold: true,
    labelColor: col('honey'), note: { color: col('muted') } },
  header,
  footer: { elements: [] },
  pageTemplates: [opening, seguido],
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Patio · Nº 31"
---

::::page{template="apertura" foto="azotea" seccion="Ciudad"}
# Las abejas del tejado

:::frame{slot="datos" title="La azotea en cifras"}
**14** colmenas en la azotea

**23 m** de altura sobre la calle

**380 kg** de miel en la cosecha de julio

**3 km** de radio de vuelo, más o menos
:::

:::frame{slot="entradilla"}
:::paragraphs{style="entradilla"}
Carmen Aldaba compuso libros a mano durante treinta años en el segundo piso de una imprenta. Cuando la imprenta cerró, subió al tejado. Hoy cuida allí catorce colmenas y medio millón de abejas que liban los tilos de media ciudad.
:::

:::paragraphs{style="firma"}
Texto: Lucía Ferrán
:::
:::

:::frame{slot="cita"}
«Una colmena es como una caja de tipos: si sabes dónde está cada cosa, no tienes que mirar.»
:::

::resource{id="cesto" slot="recorte"}

:::paragraphs{style="inicio"}
A las seis y media de la mañana la azotea todavía guarda el frío de la noche. Carmen Aldaba sube por la escalera de hierro con el ahumador encendido en una mano y una caja de cuadros vacíos en la otra, y no enciende la linterna: se sabe de memoria los veintidós peldaños. Arriba, en fila sobre la grava, esperan catorce cajas de madera pintadas de verde, ocre y azul, cada una con una letra escrita a tiza en la tapa.
:::

—La A es la más brava —dice mientras levanta la tapa con la palanca—. La M es la más trabajadora. Las demás van y vienen.

Durante treinta años, Aldaba trabajó dos pisos más abajo, en la sección de composición de Gráficas Lezcano, una imprenta familiar que imprimió catálogos de ferretería, programas de fiestas, libros de poemas pagados por sus autores y, durante una temporada larga, los horarios de los autobuses urbanos. Entró de aprendiza con diecisiete años, cuando todavía se componía a mano con tipos de plomo, y salió en 2011, el día que la familia vendió las máquinas a un chatarrero de Calatayud. El edificio pasó a una cooperativa de talleres: hoy hay en él un luthier, dos estudios de diseño, una escuela de cerámica y una panadería que cuece de madrugada en el antiguo almacén de papel.

## Una azotea sin uso

Nadie quería la azotea. Tenía goteras, una barandilla oxidada y la chimenea de la antigua caldera, que la cooperativa no se atrevía a derribar. Aldaba propuso en una asamblea poner allí dos colmenas «para probar», y la asamblea votó que sí con una condición: que nadie de los talleres tuviera que acercarse a ellas. Aquello fue en la primavera de 2013. Las dos colmenas son ahora catorce, y la miel de la azotea se vende en la panadería, en tarros de un cuarto de kilo con una etiqueta que ella misma compone en la vieja minerva de pedal que se quedó en el patio.

Aprendió el oficio de su abuelo, que tenía colmenas de corcho y de paja en un pueblo de Teruel. Uno de aquellos cestos de paja trenzada, con la boca en forma de arco, está ahora en un rincón de la azotea, vacío, encima de un bloque de hormigón. No lo usa: las colmenas modernas se abren por arriba y se revisan cuadro a cuadro, y un cesto sólo se puede vaciar rompiendo los panales. Pero lo trajo del pueblo cuando vendieron la casa y no quiso dejarlo en un trastero.

## Lo que comen las abejas de una ciudad

La pregunta que más le hacen es de qué viven unas abejas en medio del asfalto. La respuesta está en las calles. Una abeja obrera vuela sin esfuerzo hasta tres kilómetros desde su colmena, y en un círculo de ese tamaño alrededor de la imprenta hay miles de árboles de alineación: tilos, acacias de flor blanca, sóforas, almeces, plátanos de sombra que no dan néctar pero sí resina. Hay también parques con romero y lavanda, solares abandonados llenos de jaramagos y cientos de balcones con geranios, albahaca y tomateras.

—En el campo, una colmena depende de lo que se siembre ese año —explica—. Si el vecino planta girasol, tienes girasol. Aquí hay de todo, y florece algo casi todos los meses.

Por eso la miel de la azotea cambia de color a lo largo del año. La de junio, cuando florecen los tilos, es clara y huele a menta. La de finales de verano, después de las sóforas, es ámbar oscuro y algo amarga. Aldaba no la mezcla: cada cosecha va a sus tarros, con la fecha y el nombre del árbol que más había en flor aquellas semanas. En julio sacó trescientos ochenta kilos, el mejor año desde que empezó.

## Los vecinos

Al principio hubo quejas. Una vecina del bloque de enfrente, que tiende la ropa en una terraza a la misma altura, escribió al ayuntamiento porque las abejas se posaban en las sábanas. Aldaba fue a verla con un tarro de miel y una propuesta: cambiar la orientación de las piqueras para que las abejas salieran hacia el patio interior y no hacia su terraza. Funcionó. La vecina, que se llama Rosario y tiene ochenta y un años, compra ahora dos tarros cada otoño.

El susto más grande llegó una mañana de mayo de 2024. Una colmena enjambró, como hacen las colonias fuertes en primavera: la reina vieja salió con la mitad de las obreras a buscar casa nueva, y el enjambre, una bola de unas quince mil abejas, se colgó de la marquesina de una parada de autobús a dos calles. Alguien llamó a los bomberos, los bomberos llamaron a la policía local y la policía local, que ya tenía su número, llamó a Aldaba. Llegó en diez minutos con una caja de cartón y una sábana, sacudió la rama de abejas dentro de la caja y se la llevó a la azotea.

—Cuando enjambran no pican. Van llenas de miel y no tienen nada que defender —explica Aldaba—. Pero claro, eso no lo sabe la gente que espera el autobús.

## El calor y el ácaro

Una azotea no es un sitio fácil para una colmena. En agosto la grava pasa de los cincuenta grados y la madera de las cajas se calienta como una plancha. Las abejas refrigeran la colmena trayendo agua y abanicándola con las alas, y una colonia grande puede gastar más de un litro al día. Aldaba les deja bebederos de corcho flotante y ha tendido una red de sombreo sobre la fila de cajas, sujeta a la chimenea y a la barandilla. En verano sube dos veces al día, al amanecer y al caer la tarde, nunca a mediodía.

El otro enemigo es la varroa, un ácaro llegado de Asia que se alimenta de las larvas y transmite virus. No hay colmena en Europa que esté libre de él. Aldaba cuenta los ácaros que caen en una bandeja bajo cada caja y trata en invierno con ácido oxálico, cuando la colonia no tiene cría y los ácaros no pueden esconderse en las celdas. Lleva un cuaderno por colmena, con la letra en la portada, y apunta cada revisión: el peso, el número de cuadros con cría, el carácter de las abejas, el tiempo que hacía.

## Una colonia por dentro

Abrir una colmena con ella es asistir a una clase sin pizarra. Saca un cuadro, lo sostiene contra el sol bajo y va señalando con el dedo enguantado: aquí la cría operculada, bajo una tapa de cera color galleta; aquí las larvas, blancas y enroscadas en el fondo de cada celda; arriba, en el borde, la miel; entre las dos, una franja de polen de colores, naranja de las caléndulas, gris de los almeces, casi negro de las amapolas. En julio, una colonia fuerte reúne cincuenta mil obreras, unos cientos de zánganos y una sola reina, que pone hasta dos mil huevos al día y vive cuatro o cinco años si nadie la sustituye.

—La reina no manda —aclara—. Pone huevos. Las que deciden son las obreras: cuándo se cría una reina nueva, cuándo se enjambra, a qué flor se va esta semana.

Encontrarla entre la multitud es un ejercicio de paciencia. Es más larga que las demás, con el abdomen afilado y las alas cortas, y a su alrededor las obreras forman un corro, de cara a ella. Aldaba marca cada reina con un punto de pintura en el tórax, de un color distinto según el año en que nació, siguiendo un código internacional que cualquier apicultor entiende: blanco, amarillo, rojo, verde y azul, y vuelta a empezar. Este año toca el blanco.

## Los sábados de octubre

Desde hace cuatro años da un curso de iniciación los sábados de octubre, para ocho personas, en la sala de composición. Hay lista de espera. Se apuntan sobre todo jubilados y profesores, algún estudiante de Biología y, el año pasado, un conductor de autobús que había visto el enjambre de la marquesina desde su asiento y quería saber qué había pasado. Las normas del curso caben en una tarjeta: no llevar perfume, no vestir de negro, no hacer movimientos bruscos, no soplar sobre los cuadros. Al final de la cuarta sesión cada alumno abre una colmena él solo, con ella al lado y las manos en los bolsillos.

—Lo difícil no es la técnica —dice—. Lo difícil es estarse quieto cuando tienes cuatro abejas paseando por la careta.

## Lo que cuesta un tarro

La miel de la azotea se vende a siete euros el tarro de un cuarto de kilo, y el año pasado se vendieron unos mil doscientos. Las cuentas, que Aldaba lleva en otro cuaderno, no dejan beneficio. Con lo que entra paga los tarros, las etiquetas, el azúcar con que alimenta a las colonias débiles en otoño, los tratamientos contra la varroa, la cera estampada para los cuadros nuevos y un seguro de responsabilidad civil que la cooperativa le pidió desde el primer año. Por cada tarro vendido, un euro va al fondo común de los talleres, «en concepto de alquiler del tejado». Con ese dinero arreglaron el año pasado las goteras.

Lo que queda, cuando queda algo, lo gasta en libros. Tiene una pequeña biblioteca de apicultura en el antiguo despacho del regente, encuadernada en parte por ella misma, con tratados del siglo XIX comprados en mercadillos y revistas de asociaciones de media Europa.

## Otras azoteas

La de Gráficas Lezcano ya no es la única. Desde 2019 han aparecido colmenas en el tejado de un instituto, en el de un hotel del centro y en la terraza de una residencia de mayores, donde las cuida un grupo de internos con la ayuda de un apicultor del barrio. Aldaba ha asesorado a casi todos, y a todos les dice lo mismo: que no pongan más colmenas de las que el barrio puede alimentar.

—Si en un kilómetro cuadrado metes cien colmenas, las abejas se quitan la comida unas a otras, y también a los abejorros y a las abejas solitarias, que son las que de verdad polinizan muchas plantas de la ciudad —dice—. Catorce ya son bastantes para esta calle.

Por eso este año no ha dividido ninguna colonia, aunque tenía tres lo bastante fuertes para hacerlo. Prefiere que el excedente de abejas salga en enjambre en primavera y que lo recoja algún vecino con colmenas en el campo. Tiene una lista de teléfonos pegada en la puerta de la escalera, junto al calendario de la cooperativa, y la policía local tiene una copia.

## La cosecha en la sala de composición

La extracción se hace donde ella trabajó tantos años. La sala de composición está vacía desde 2011, salvo dos chibaletes de madera con sus cajas de tipos, que la cooperativa conservó «por si acaso». Ahí, sobre una mesa larga, Aldaba desopercula los cuadros con un cuchillo caliente y los mete en un extractor de cuatro cuadros que gira a mano. La miel cae a un bidón con un filtro doble y reposa una semana antes de envasarse.

Los niños de la escuela de cerámica bajan a mirar. Algunos se llevan un trozo de panal en un plato; otros prefieren las cajas de tipos, y Aldaba les deja componer su nombre en un componedor y estamparlo en la minerva, al lado de la etiqueta de la miel.

—Una colmena es como una caja de tipos —les dice—. Cada cosa tiene su sitio. Si sabes dónde está, no tienes que mirar.

A finales de octubre la azotea se queda quieta. Las abejas de invierno, más longevas que las de verano, se apiñan en el centro de cada caja alrededor de la reina, y Aldaba sólo sube para comprobar que no ha entrado agua. Deja las colmenas con miel suficiente para pasar el invierno y no vuelve a abrirlas hasta marzo, cuando florecen los almendros del parque y las primeras obreras salen a buscar polen. Para entonces ya sabrá qué colmenas han aguantado el frío, y si la A sigue siendo la más brava.

:::paragraphs{style="colofon"}
Patio es una revista inventada para este recetario; Carmen Aldaba, Gráficas Lezcano y su azotea también. Fotografías generadas con modelos de difusión. Compuesto en Literata, Fraunces e Instrument Sans (SIL OFL) · Texto: CC BY 4.0
:::
::::
`; // content.<lang>.md, inlined by the Cookbook

// #region pictures: the photo, and a cut-out whose alpha channel is the wrap's outline
const photo = { id: 'azotea', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  caption: '', altText: t({
    es: 'Seis colmenas de colores en la azotea de una fábrica, con la ciudad al amanecer',
    en: 'Six painted hives on a factory roof, with the city behind them at sunrise',
    de: 'Sechs bunte Bienenkästen auf einem Fabrikdach, dahinter die Stadt im Morgenlicht' }),
  bitmap: { fileId: 'rooftop-1400.jpg', format: 'jpeg', width: 1400, height: 1848 } };
let skep = { id: 'cesto', typeId: 'foto', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  caption: '', altText: t({ es: 'Un cesto de paja para abejas', en: 'A straw bee skep',
    de: 'Ein Bienenkorb aus Stroh' }),
  contour: { threshold: 0.5, margin: mm(2.5) },
  bitmap: { fileId: 'skep-720.png', format: 'png', width: 720, height: 828 } };
await loadImage('rooftop-1400.jpg', asset('rooftop-1400.jpg'));
await loadImage('skep-720.png', asset('skep-720.png'));
// The layout reads pictures by size, never by pixels: the outline travels as a trace.
const trace = await traceResourceContour(skep, imageBytes('skep-720.png'), 'image/png');
skep = withContourTrace(skep, trace);
const resources = [photo, skep];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  Literata: ['400', '400i', '700'],
  Fraunces: ['600', '600i'],
  'Instrument Sans': ['600', '700'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// Page 1 is the verso numbered 46: the feature opens on a spread inside the issue.
const continuation = { pageIndexOffset: 45, pageNumbering: { startAt: 46 } };
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config,
  kitFonts(FONTS));
showPages(doc, { title: t({ es: 'Las abejas del tejado', en: 'The bees on the roof',
  de: 'Die Bienen auf dem Dach' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

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

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

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

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

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

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

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

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

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

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

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

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

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

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

// ─── Kit · pdf v3 ── the same in every recipe that exports a PDF
/** The Fontsource files the screen used, as TrueType, with their range and
 *  place: the nearest weight the family ships, upright with no italic;
 *  latin, then what its letters need (kitSubsetsFor). */
async function fontsourceProvider(family, weight, style, request) {
  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 text = String.fromCodePoint(...(request?.codePoints ?? []));
  const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => {
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`);
    return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())),
      { unicodeRange: kitRanges()[subset], order });
  }));
  return files.length === 1 ? files[0] : files;
}

/** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame
 *  shows no 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 v3 ── recipes with pictures · postext.dev/cookbook
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

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

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

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

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

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

## アレンジ

### 巣箱を矩形で回り込ませる

`wrap: 'contour'` を外すと枠は矩形で押しのけ、ドームの横の行はまっすぐな縁で止まります。

```diff
-  { name: 'recorte', exclude: 'push', wrap: 'contour', layer: 2, width: mm(58),
+  { name: 'recorte', exclude: 'push', layer: 2, width: mm(58),
```

### 継続マーカーを外す

`mark: false` にすると、切れた部分は *Continúa* を置かずに最後の行で終わり、その段に一行多く入ります。

```diff
-      overflow: { continueIn: 'template:seguido' } },
+      overflow: { continueIn: 'template:seguido', mark: false } },
```

## よくあるつまずき

- テンプレートが独立した枠へ送る見出しは、テキストの枠ではドロップキャップを開きません。特集の最初の段落は `:::paragraphs{style="inicio"}` で包み、`dropCap` はそのスタイルに持たせます。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- 画像: Painted hives on a factory roof at sunrise (the opening spread): Generated With Diffusion Models, オリジナル
- 画像: A straw bee skep on a transparent ground (the cut-out the text runs round): Generated With Diffusion Models, オリジナル
- 書体: Literata (OFL-1.1), Fraunces (OFL-1.1), Instrument Sans (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 023 · 雑誌の表紙とセクション別の目次](https://postext.dev/ja/cookbook/magazine-cover-and-contents.md): 写真の表紙では表紙コピーが題字から吊り下がり、目次はセクションごとに色の行を立てて生成されます。各セクションは、ページを開かない部です。 · 難易度 3 (上級) · 雑誌とジン
- [No. 135 · 3段組みのグリッドで組む雑誌の特集](https://postext.dev/ja/cookbook/magazine-three-column-feature.md): layoutType 'multiple'でページを3段に分け、placement.columnsで写真とフロートするプルクオートを2段、または3段すべてにまたがらせます。 · 難易度 3 (上級) · 雑誌とジン
- [No. 004 · 雑誌の特集：写真の扉からエンドマークまで](https://postext.dev/ja/cookbook/magazine-feature-opener.md): 1つのadvancedDesignの扉が、裁ち落としの写真と、見出しのキッカー、見出し、リード、署名を組みます。続いて2つのフロートの囲みとチップのエンドマーク。 · 難易度 3 (上級) · 雑誌とジン
