# Titres en marge, numéros suspendus et titres en ligne

> Un règlement de concours aux titres de section en colonne marginale, sur les lignes de base du texte, numéros pendus dans la gouttière, titres en ligne rouges.

- Version HTML: https://postext.dev/fr/cookbook/side-heads-hanging-numbers
- Recette Nº 045 · Titres et ouvertures · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Rapports
- Nécessite postext ≥ 1.28.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/side-heads-hanging-numbers/en/p01.webp?v=4c3afd27), [2](https://postext.dev/cookbook/side-heads-hanging-numbers/en/p02.webp?v=4c3afd27), [3](https://postext.dev/cookbook/side-heads-hanging-numbers/en/p03.webp?v=4c3afd27), [4](https://postext.dev/cookbook/side-heads-hanging-numbers/en/p04.webp?v=4c3afd27), [5](https://postext.dev/cookbook/side-heads-hanging-numbers/en/p05.webp?v=4c3afd27)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=side-heads-hanging-numbers&lang=en (.postext: https://postext.dev/cookbook/side-heads-hanging-numbers/en/side-heads-hanging-numbers.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/side-heads-hanging-numbers.md), [es](https://postext.dev/es/cookbook/side-heads-hanging-numbers.md), [ca](https://postext.dev/ca/cookbook/side-heads-hanging-numbers.md), [pt](https://postext.dev/pt/cookbook/side-heads-hanging-numbers.md), [zh](https://postext.dev/zh/cookbook/side-heads-hanging-numbers.md), [ja](https://postext.dev/ja/cookbook/side-heads-hanging-numbers.md), [ar](https://postext.dev/ar/cookbook/side-heads-hanging-numbers.md)

## En bref

L’appel à projets d’une mairie pour transformer une ancienne halle aux poissons en bibliothèque. Les titres de section se placent dans une large marge gauche, au niveau de la première ligne du texte voisin.

## Ce que vous allez composer

Cinq pages A4 du règlement d’un concours d’architecture, dans lequel la municipalité de Puerto Lince, un port espagnol imaginaire, appelle à des projets pour transformer sa halle aux poissons de 1931 en bibliothèque municipale. La mise en page suit les rapports municipaux suisses. Une colonne marginale de 50 mm descend à gauche de chaque page, et chaque section s’ouvre sur un numéro rouge et un filet noir qui traverse tout le bloc de texte. Le titre de la section se tient dans la colonne marginale en Noto Serif Display, au niveau de la première ligne du texte. Les numéros de sous-section sont suspendus dans la gouttière en DM Mono rouge, si bien que chaque titre de sous-section garde le bord gauche du texte. Les critères de jugement de la section 4 s’ouvrent sur des termes en gras rouge. La page 1 compose un titre de 58 pt au-dessus d’un plan de situation, un plan de la parcelle se tient dans la colonne marginale en page 2, et une élévation de la façade sur le quai ouvre la page 3.

**Cette recette répond à:**

- Comment mettre les titres de section en marge, suspendre les numéros et composer en ligne les petits titres ?
- Comment faire une mise en page en colonne et demie, avec une colonne de texte large et une colonne latérale étroite ?
- Comment choisir où va une figure : en haut de page, sur les deux colonnes, exactement ici ou dans la marge ?
- Comment colorer les termes clés (en gras ou en italique) dans le texte ou dans les encadrés ?

## La réponse courte

```js
// script.js, lignes 56–81
// The heading stays in the flow and keeps its number, but its design has no {titleText}:
// it prints the number in the channel and a rule from there to the column's right edge,
// both reserve: false, or the box below would keep a float gap under them (a line lower).
const [NUMBER, RULE] = [8.5, 0.75]; // pt: the DM Mono number, whose figures are 0.7 em tall
// Centre the rule on the figures: their baseline is 0.8 of the line down, less 0.35 em.
const RULE_Y = pt(NUMBER * (0.8 - 0.7 / 2) - RULE / 2);
const h2 = { level: 2, numberingTemplate: '{2}', lineHeight: pt(LEAD), // one grid line
  marginTop: pt(2 * LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: {
    elements: [
      { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
        fontSize: pt(NUMBER), lineHeight: 1, color: col('signal'), reserve: false,
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE), reserve: false,
        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
          size: { width: 'fill' } } },
    ] } } };
// The title goes in a box fenced right after the heading: span="side" stands it in the
// channel on the grid line where the text resumes (gotcha: side-box-starts-at-fence).
// Attributes at the end of the line stay with the heading.
const sideHeads = (md) => md.replace(/^## (.+?)(\s*\{[^}]*\})?$/gm,
  (line, title) => `${line}\n\n:::callout{type="sidehead" span="side"}\n${title}\n:::`);
// The box has no background and no padding, and it takes the text's leading, so each
// line of the title sits on a baseline of the text beside it.
const sidehead = { id: 'sidehead', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: DISPLAY, fontSize: pt(13.5), lineHeight: pt(LEAD) } };
```

## Ingrédients

**Enseigne**

- [Notes marginales](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré): Des encadrés composés dans la colonne latérale à la hauteur du paragraphe qu’ils glosent, dans une mise en page en colonne et demie avec un canal pour les flottants.
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée): Un titre dessiné comme une composition libre de texte, de filets, de boîtes et d’images, qui réserve au-dessus du texte courant la hauteur dont il a besoin.
- [Colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page): Une mise en page asymétrique : une colonne principale large et une colonne latérale étroite, d’un côté fixe ou sur le bord extérieur de chaque page.

**Utilise aussi**

- [Colonne marginale pour les flottants](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page)
- [Titres numérotés](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Texte, filets et boîtes dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Gras, italique et leurs couleurs](https://postext.dev/fr/docs/configuration-text.md#texte-courant)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Figure et Tableau dans votre langue](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Légendes latérales](https://postext.dev/fr/docs/document-format.md#placement)
- [Des figures exactement ici](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte)
- [Équilibrage des colonnes](https://postext.dev/fr/docs/configuration-text.md#équilibrage-des-colonnes)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Couleur du papier](https://postext.dev/fr/docs/configuration-page-layout.md#page)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`defaultResourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

- Mona Sans (OFL-1.1), Noto Serif Display (OFL-1.1), DM Mono (OFL-1.1)

## Préparation

### 1 · Imprimer le titre de section depuis une boîte dans la colonne marginale

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/side-heads-hanging-numbers.md#la-réponse-courte) plus haut. Un titre réserve de la place jusqu’au plus bas élément de sa maquette : un titre dessiné dans la colonne marginale au niveau de la première ligne de la section repousserait donc le texte d’une ligne, et de deux pour un titre sur deux lignes. La maquette du titre ne dessine donc que le numéro et le filet, et `sideHeads()` copie le texte de chaque `## Title` dans un `:::callout` placé juste après le titre. Avec `span="side"`, cette boîte quitte le flux et se tient dans la colonne marginale sur la ligne de grille où le texte reprend ([styles d’encadré](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)). Le numéro et le filet règlent `reserve: false` : sans cela, une boîte dans la colonne marginale garde un écart de flottant, une ligne, sous les éléments de titre qui s’y trouvent, et le titre descendrait à la deuxième ligne du texte. Les boîtes comme le texte courant placent la ligne de base d’une ligne à 0,8 de sa hauteur : une boîte sur l’interlignage de 14 pt du texte pose donc chaque ligne d’un titre sur deux lignes sur une ligne de base du paragraphe voisin. Le Markdown reste un simple `## Schedule of accommodation`. Comme la maquette du titre n’imprime pas de `{titleText}`, le Sandbox affiche l’avertissement *Texte du titre non référencé*, bien que la boîte imprime le titre.

![Page 2: 2 The site. Figure 1. The plot, 1:2000. Market hall and weighbridge hut in grey; six tamarisks along the north edge. Red arrows: possible entrances; grey arrow: service.](https://postext.dev/cookbook/side-heads-hanging-numbers/en/p02.webp?v=4c3afd27)

*Page 2 : le numéro et le filet viennent du titre, le titre de la boîte dans la colonne marginale, au niveau de la première ligne du texte. Le plan de la parcelle s’empile dessous.*

### 2 · Réserver la colonne marginale aux titres et aux figures

```js
// script.js, lignes 46–52
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 50 of the 178 mm between the margins
  sideColumnSide: 'left', // on every page: the brief is printed on one side of the sheet
  sideColumnRole: 'floats', // no text in the channel: what span: 'side' sends, side captions
  gutterWidth: mm(GUTTER),
};
```

`sideColumnRole: 'floats'` tient le texte hors de la colonne marginale ([types de mise en page](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page)). Après l’ouverture, la colonne ne contient que les six titres de section et le plan de la parcelle, tous deux envoyés là avec `span: 'side'`, et les légendes des tableaux 1 et 2. La colonne latérale est à `'right'` par défaut, et un règlement imprimé au recto seulement la garde du même côté sur chaque page. `sideColumnSide: 'left'` la place à gauche, où chaque titre de section se lit avant son texte.

### 3 · Suspendre les numéros et mettre les plus petits titres en ligne

```js
// script.js, lignes 85–102
// The title keeps the text's left edge; the number, right-aligned in a 12 mm box, ends
// 2 mm short of it. The box needs that fixed width: an 'auto' width is clamped to the
// column and would shrink to nothing out here.
const H3 = { size: 10.5, number: 9 }; // pt; two boxes one grid line tall share a baseline
const h3 = { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(H3.size),
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0),
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(H3.size), lineHeight: LEAD / H3.size, color: col('ink'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
      fontSize: pt(H3.number), lineHeight: LEAD / H3.number, color: col('signal'),
      align: 'right', placement: { ...at('#title', 'left-of', mm(-2)),
        size: { width: mm(12) } } },
  ] } } };
// Level 4 is not a heading: '**Accessibility.** A wheelchair user…' opens its paragraph.
// No other paragraph has bold; table cells keep tableStyle's ink, so Total stays black.
const runIn = { boldColor: col('signal') }; // spread into bodyText
```

Le titre de sous-section commence au bord gauche de la colonne. Son numéro est ancré `'left-of'` le titre, aligné à droite dans une boîte de 12 mm qui s’arrête 2 mm avant le texte : des numéros comme 2.1 et 3.3 pendent donc dans la gouttière, et chaque titre s’aligne sur le texte qui le suit ([placement des éléments](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)). La boîte a besoin d’une largeur fixe. Une largeur `'auto'` est limitée à la colonne. Dans la gouttière, cela ne laisse aucune largeur, et le numéro n’est pas dessiné. Le niveau 2 imprime `'{2}'` et le niveau 3 `'{2}.{3}'`, car avec `{1}` chaque numéro commencerait par le 1 de l’unique H1 du règlement. Les critères de la section 4 forment un quatrième niveau composé sans titre. Chaque paragraphe s’ouvre sur un terme en gras, que `bodyText.boldColor` imprime en rouge ([texte courant](https://postext.dev/fr/docs/configuration-text.md#texte-courant)). Aucun autre paragraphe n’a de gras, et les cellules de tableau prennent leur couleur dans `tableStyle` : la ligne de total en gras du tableau 1 reste donc noire.

### 4 · Donner à chaque figure et à chaque tableau son propre placement

```js
// script.js, lignes 333–367
// The plot plan stands in the channel; the elevation crosses channel and column at the
// head of the next page; the table of areas floats to the head of the text column, with
// its caption beside it in the channel; the timetable sits where ::resource puts it, and
// its caption takes the channel too (captionSide serves an inline embed from 1.28).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resource = (id, typeId, rest) => ({ id, typeId, createdAt: 0, updatedAt: 0, ...rest });
const drawing = (id, width, height, rest) => resource(id, 'figure', { kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, ...rest });
const resources = [
  drawing('parcela', PLAN_W * 10, 400, {
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: t({ en: 'The plot, 1:2000. Market hall and weighbridge hut in grey; six tamarisks '
      + 'along the north edge. Red arrows: possible entrances; grey arrow: service.',
    es: 'La parcela, 1:2000. Lonja y caseta de básculas, en gris; seis tamarindos en el '
      + 'borde norte. Flechas rojas: accesos posibles; flecha gris: servicio.' }),
    altText: t({ en: 'Plan of the plot with the hall, the hut, six trees and three entrances.',
      es: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' }) }),
  drawing('fachada', 1780, 300, { placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Quay front, 1:300. In red, the four frames at the east end, where the '
      + 'reinforcement has corroded.',
    es: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.' }),
    altText: t({ en: 'Elevation of the hall: thirteen vaults, eleven arches, four frames in red.',
      es: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' }) }),
  resource('programa', 'table', { kind: 'table', table: programme,
    placement: { position: 'top', captionSide: true }, caption: t({
      en: 'Schedule of net floor areas.', es: 'Programa de superficies útiles por zonas.' }) }),
  resource('calendario', 'table', { kind: 'table', table: calendar,
    placement: { position: 'here', captionSide: true },
    caption: t({ en: 'Competition timetable.', es: 'Calendario del concurso.' }) }),
  // No :ref cites the site plan: only the opener's image element draws it.
  drawing('situacion', MAP.w * 10, MAP.h * 10, { altText: t({
    en: 'Site plan of the Poniente quay with the market hall’s plot in red.',
    es: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' }) }),
];
```

Le plan de la parcelle va dans la colonne marginale avec `span: 'side'` et s’empile sous le titre marginal de la section 2 ([placement](https://postext.dev/fr/docs/document-format.md#placement)). `width: PLAN_W / SIDE` lui donne 44 mm de large, ce qui met ses 88 mètres exactement au 1:2000. L’élévation de la façade est un flottant `'top'` sur toute la largeur de la page. Elle est citée en page 2 et ouvre la page 3, car un flottant ne se place jamais au-dessus de son renvoi. Le tableau des surfaces, cité lui aussi en page 2, flotte en tête de la colonne de texte de la page 3, sous l’élévation. `captionSide: true` place sa légende dans la colonne marginale, au niveau du haut du tableau. Le calendrier se trouve là où le place `::resource`, à la fin du règlement, et `captionSide: true` place aussi sa légende dans la colonne marginale, à côté de la section 6.3.

### 5 · Ouvrir le règlement sur le plan de situation

```js
// script.js, lignes 106–131
const MAP = { y: 68.5, w: CONTENT, h: 52 }; // mm: from the top of the text block
const display = { fontFamily: DISPLAY, fontWeight: 300, color: col('ink'), align: 'left',
  overflow: 'wrap' };
const opener = { enabled: true,
  // The plan's foot falls between two grid lines. A floor on the next one leaves the rule
  // of section 1 as far from its text as the rule of every later section.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('signal'),
      placement: at('container', 'top-left', mm(HANG)) },
    { kind: 'text', id: 'series', content: '{subtitle}', ...label,
      placement: { ...at('container', 'top-left'), size: { width: mm(SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(58),
      lineHeight: 0.96, placement: { ...at('#kicker', 'below', mm(0), mm(4.5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...display, italic: true,
      fontSize: pt(14), lineHeight: 1.3, placement: { ...at('#title', 'below', mm(0), mm(5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'legend', content: '{attr.map}', fontFamily: TEXT, fontSize: pt(7.5),
      lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at('#lead', 'align-top', mm(-HANG), pt(2)),
        size: { width: mm(SIDE - 6) } } },
    { kind: 'image', id: 'map', resourceId: 'situacion', // across the channel and the column
      placement: { ...at('container', 'top-left', mm(0), mm(MAP.y)),
        size: { width: mm(MAP.w) } } },
  ] } };
```

Avec `span: 'page'`, le conteneur de l’ouverture est tout le bloc de texte, colonne marginale comprise ([largeur et maquette avancée](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)). Le surtitre et le titre sont placés à 57 mm du bord, sur le bord gauche de la colonne de texte, et le plan traverse colonne marginale et colonne de texte. Sans cela, le conteneur est la colonne de texte, chaque élément se décale de 57 mm vers la droite, et le titre, le chapeau et le plan dépassent le bord droit de la page. La maquette réserve la place jusqu’au pied du plan, qui tombe entre deux lignes de la grille. Un `minHeight` arrondi à la ligne de grille suivante laisse le filet de la section 1 aussi loin de son texte que le filet de chaque section suivante. Le surtitre, le chapeau et la légende du plan sont des attributs de la ligne `# Competition brief`.

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/side-heads-hanging-numbers

### script.js

```js
// ═══ Postext Cookbook · Nº 045 · Side heads, hanging numbers and run-in heads ═══════
// https://postext.dev/en/cookbook/side-heads-hanging-numbers
// Code: MIT · Text: original (CC BY 4.0) · Drawings: made in code (MIT)
// Fonts: Mona Sans, Noto Serif Display, DM Mono (SIL OFL 1.1) · Needs postext ≥ 1.28.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'side-heads-hanging-numbers';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // a Swiss municipal report: black, white and one signal red
  ink: '#17171a', // text and rules
  signal: '#d42a1f', // section numbers, run-in heads, the plot on the plans (5.1:1)
  tint: '#f9dcd7', // the plot's ground on the plans
  sea: '#d8e2e8', // water on the plans
  stone: '#cacad0', // built ground on the plans
  rule: '#c3c3ca', // hairlines under the running head and in tables
  muted: '#5e5e67', // running heads, legends, the colophon (6.4:1)
  paper: '#ffffff',
};
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Defaults this config does not restate link to 'main-color', so it points at the accent.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.signal })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Mona Sans', 'Noto Serif Display', 'DM Mono'];
const TRIM = { width: 210, height: 297 }; // mm: A4, like every document of the competition
const [TOP, LEFT, RIGHT] = [26, 16, 16]; // mm; not mirrored: the brief prints one-sided
const LEAD = 14; // pt: the body leading, the pitch of the baseline grid
const LINES = 50; // grid lines in the text block
const PT = 25.4 / 72; // mm in a point
const GRID = LEAD * PT; // mm: one line of the grid
const CONTENT = TRIM.width - LEFT - RIGHT; // 178 mm
const [SIDE, GUTTER] = [50, 7]; // mm: the margin channel on the left, and the gap after it
const MAIN = CONTENT - SIDE - GUTTER; // 121 mm: the text column
const HANG = SIDE + GUTTER; // mm from the channel's left edge to the text's left edge
const at = (to, edge, x = mm(0), y = mm(0)) => ({ anchor: { to, edge }, offset: { x, y } });
// Kickers, running heads and folios: tracked capitals in the mono.
const LABEL_PT = 7.5;
const label = { fontFamily: LABEL, fontWeight: 500, fontSize: pt(LABEL_PT), lineHeight: 1.35,
  letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'), align: 'left' };

// #region channel: one text column, and on its left a channel for boxes and figures only
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 50 of the 178 mm between the margins
  sideColumnSide: 'left', // on every page: the brief is printed on one side of the sheet
  sideColumnRole: 'floats', // no text in the channel: what span: 'side' sends, side captions
  gutterWidth: mm(GUTTER),
};
// #endregion

// #region answer: side heads: the heading draws a number and a rule, a box prints the title
// The heading stays in the flow and keeps its number, but its design has no {titleText}:
// it prints the number in the channel and a rule from there to the column's right edge,
// both reserve: false, or the box below would keep a float gap under them (a line lower).
const [NUMBER, RULE] = [8.5, 0.75]; // pt: the DM Mono number, whose figures are 0.7 em tall
// Centre the rule on the figures: their baseline is 0.8 of the line down, less 0.35 em.
const RULE_Y = pt(NUMBER * (0.8 - 0.7 / 2) - RULE / 2);
const h2 = { level: 2, numberingTemplate: '{2}', lineHeight: pt(LEAD), // one grid line
  marginTop: pt(2 * LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: {
    elements: [
      { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
        fontSize: pt(NUMBER), lineHeight: 1, color: col('signal'), reserve: false,
        placement: at('container', 'top-left', mm(-HANG)) },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(RULE), reserve: false,
        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
          size: { width: 'fill' } } },
    ] } } };
// The title goes in a box fenced right after the heading: span="side" stands it in the
// channel on the grid line where the text resumes (gotcha: side-box-starts-at-fence).
// Attributes at the end of the line stay with the heading.
const sideHeads = (md) => md.replace(/^## (.+?)(\s*\{[^}]*\})?$/gm,
  (line, title) => `${line}\n\n:::callout{type="sidehead" span="side"}\n${title}\n:::`);
// The box has no background and no padding, and it takes the text's leading, so each
// line of the title sits on a baseline of the text beside it.
const sidehead = { id: 'sidehead', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: DISPLAY, fontSize: pt(13.5), lineHeight: pt(LEAD) } };
// #endregion

// #region lower: level 3 hangs its number in the gutter; level 4 runs into its paragraph
// The title keeps the text's left edge; the number, right-aligned in a 12 mm box, ends
// 2 mm short of it. The box needs that fixed width: an 'auto' width is clamped to the
// column and would shrink to nothing out here.
const H3 = { size: 10.5, number: 9 }; // pt; two boxes one grid line tall share a baseline
const h3 = { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(H3.size),
  lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0),
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(H3.size), lineHeight: LEAD / H3.size, color: col('ink'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: LABEL, fontWeight: 500,
      fontSize: pt(H3.number), lineHeight: LEAD / H3.number, color: col('signal'),
      align: 'right', placement: { ...at('#title', 'left-of', mm(-2)),
        size: { width: mm(12) } } },
  ] } } };
// Level 4 is not a heading: '**Accessibility.** A wheelchair user…' opens its paragraph.
// No other paragraph has bold; table cells keep tableStyle's ink, so Total stays black.
const runIn = { boldColor: col('signal') }; // spread into bodyText
// #endregion

// #region opener: the brief's first page: kicker, title, lead and the site plan
const MAP = { y: 68.5, w: CONTENT, h: 52 }; // mm: from the top of the text block
const display = { fontFamily: DISPLAY, fontWeight: 300, color: col('ink'), align: 'left',
  overflow: 'wrap' };
const opener = { enabled: true,
  // The plan's foot falls between two grid lines. A floor on the next one leaves the rule
  // of section 1 as far from its text as the rule of every later section.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, color: col('signal'),
      placement: at('container', 'top-left', mm(HANG)) },
    { kind: 'text', id: 'series', content: '{subtitle}', ...label,
      placement: { ...at('container', 'top-left'), size: { width: mm(SIDE) } } },
    { kind: 'text', id: 'title', content: '{titleText}', ...display, fontSize: pt(58),
      lineHeight: 0.96, placement: { ...at('#kicker', 'below', mm(0), mm(4.5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', ...display, italic: true,
      fontSize: pt(14), lineHeight: 1.3, placement: { ...at('#title', 'below', mm(0), mm(5)),
        size: { width: mm(MAIN) } } },
    { kind: 'text', id: 'legend', content: '{attr.map}', fontFamily: TEXT, fontSize: pt(7.5),
      lineHeight: 1.4, color: col('muted'), align: 'left', overflow: 'wrap',
      placement: { ...at('#lead', 'align-top', mm(-HANG), pt(2)),
        size: { width: mm(SIDE - 6) } } },
    { kind: 'image', id: 'map', resourceId: 'situacion', // across the channel and the column
      placement: { ...at('container', 'top-left', mm(0), mm(MAP.y)),
        size: { width: mm(MAP.w) } } },
  ] } };
// #endregion

// #region furniture: letterhead and folio, the same on every page of a one-sided brief
const [HEAD_Y, FOOT_Y] = [13, 11]; // mm from the top and from the foot of the sheet
const FILE_NO = 'BML-2026/04'; // the council's file number, also in the opener's kicker
const header = { elements: [
  // The council's name in two lines, the second on the running head's baseline.
  { kind: 'text', id: 'city', content: '{author}', ...label, color: col('ink'),
    overflow: 'wrap', placement: { ...at('page', 'top-left', mm(LEFT),
      mm(HEAD_Y - LABEL_PT * label.lineHeight * PT)), size: { width: mm(40) } } },
  { kind: 'text', id: 'book', content: '{title}', ...label,
    placement: at('page', 'top-left', mm(LEFT + HANG), mm(HEAD_Y)) },
  { kind: 'text', id: 'folio', content: t({ en: 'Page {pageNumber} of {totalPages}',
    es: 'Página {pageNumber} de {totalPages}' }), ...label,
  placement: at('page', 'top-right', mm(-RIGHT), mm(HEAD_Y)) },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    placement: { ...at('page', 'top-left', mm(LEFT), mm(HEAD_Y + 5)),
      size: { width: mm(CONTENT) } } },
] };
const footer = { elements: [
  { kind: 'text', id: 'file', content: `{chapterTitle} · ${FILE_NO}`, ...label,
    placement: at('page', 'bottom-left', mm(LEFT + HANG), mm(-FOOT_Y)) },
] };
// #endregion

const config = () => ({
  locale: t({ en: 'en-gb', es: 'es' }), // each edition is hyphenated in its own language
  // Figure / Table, Figura / Tabla, counted through the whole brief: 1, 2…
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}' })),
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * GRID), left: mm(LEFT),
      right: mm(RIGHT), mirror: false } },
  layout,
  bodyText: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    ...runIn, boldFontWeight: 600, referenceColor: col('ink'), referenceBold: false,
    italicColor: col('ink'), // an italic would otherwise take main-color, the red
    // Report texture: ragged right, no indent, a blank line between paragraphs.
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // The hidden text of levels 2 and 3 asks for this weight, and 600 is loaded already.
  headings: { fontFamily: TEXT, fontWeight: 600,
    // Balancing would add lines above heads to fill short pages: three blank lines over
    // some side heads instead of two. Off, the white above every head is the same.
    balancing: { enabled: false }, levels: [
    // parity 'any': a brief opens the next page, whichever side it falls on.
    // span: 'page' gives the opener the whole text block, channel included, as container.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener },
    h2, h3,
  ] },
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10.5),
    color: col('muted') }],
  calloutStyles: [sidehead],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(8),
    bodyFontSize: pt(8.6), cellPadding: mm(1.4) },
  captionStyle: { fontSize: pt(8), labelColor: col('signal'), gap: mm(2) },
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "New Municipal Library"
subtitle: "Design competition"
author: "Puerto Lince Town Council"
---

# Competition brief {kicker="File BML-2026/04" lead="An open, anonymous competition in two stages to turn the fish market of 1931 into the town’s library." map="Site plan at 1:3000, with the fish market’s plot in red and the built town in grey."}

## The competition

Puerto Lince Town Council is holding a design competition, judged by a jury, to select the architectural proposal for the new Municipal Library, which will occupy the former fish market on the Poniente quay. The present library has operated since 1987 on the ground floor of the Casa de Cultura: 610 m² for a town of 24,300 inhabitants, with no room to grow.

The competition is governed by this brief and by Spain’s Law 9/2017 on Public Sector Contracts. It is open to architects qualified to practise in a Member State of the European Union, entering alone or as the lead of a team.

### Scope of the commission

The winning team will prepare the outline and detailed designs, the health and safety study and the activity licence application, and will supervise the works. The fee, set at €412,000 plus VAT, also covers the design of the fixed furniture and the signage.

### Budget and timescale

The maximum construction budget, which includes the landscaping of the plot, is €4,850,000 excluding VAT. Entries that exceed it by more than 10% will be disqualified. The works will take about twenty months, and the library should open in 2030.

## The site

The fish market stands on the town’s front to the old harbour, between the fishermen’s quarter and the extension of 1905. It is the only building in the old harbour from before 1936 that is still standing, and the Council bought it from the Port Authority in 2022.

### The plot

The plot is a rectangle of 70 by 45 metres, 3,150 m² (:ref{id="parcela" case="lower"}). It is bounded to the south by the Paseo del Muelle, to the east by Calle de la Aduana and to the north by the Plaza de las Redes, which the local plan intends to pedestrianise in 2028. To the west stands the weighbridge hut, owned by the Council, which may be demolished or brought into the scheme.

Outside the hall, 1,300 m² may be built on, in no more than two storeys and 9.5 m to the cornice. No new building may project beyond the line of the quay front.

### The 1931 fish market

The fish market is a reinforced-concrete hall of 52 by 24 metres, roofed by thirteen barrel vaults spanning four metres that rest on portal frames. It was built by the Harbour Works Board, and fish was auctioned in it until 2009, when the auction moved to the new port.

The municipal heritage list protects it at grade 2. The structure, the vaults and the quay front with its eleven round arches must be kept (:ref{id="fachada" case="lower"}). The structural survey of 2025 found corroded reinforcement in the four frames at the east end; their repair is included in the budget, and each entry will explain how it proposes to carry it out without dismantling any of the vaults.

### Access and trees

The main entrance may be from the square or from the promenade, but deliveries will be made from Calle de la Aduana. The six tamarisks along the north edge, some forty years old, are to be kept.

## Schedule of accommodation

The areas in the table are net internal areas (:ref{id="programa" style="full" case="lower"}). Each zone may vary by 10% either way, provided the total does not change. The hall will house the reading and meeting rooms; anything that needs enclosure, services or noise control may go in the new building.

### Public areas

The main reading room will hold the adult lending collection, some 38,000 items, with ninety reader seats. Periodicals and the local history collection will share an adjoining room, with twelve study places and a self-service scanner. The children’s library will have its own entrance from outside and a storytelling corner for twenty-five children. The young people’s room and the multi-purpose hall must be able to open outside library hours, with the rest of the building closed.

### Staff areas

Cataloguing, the offices and the closed stack add up to 260 m². These areas must connect with the loading bay without crossing the public rooms. The stack will have mobile shelving; its floor slab will be designed for an imposed load of 12 kN/m² and will be at street level.

### Outdoor spaces

Between the hall and the new building, an open-air reading courtyard of at least 180 m² is required, sheltered from the north wind, which in winter gusts here at over 60 km/h.

## Design criteria

The jury will assess the entries against the following criteria, listed in descending order of weight.

**The market hall.** Work on the hall must be recognisable as new and reversible. Floors that cut across the vaults will not be accepted; mezzanines that stand clear and leave the frames in full view will.

**Accessibility.** A wheelchair user must reach every part of the building unaided, courtyard and mezzanines included. No ramp may exceed 6%.

**Energy and comfort.** Entries will say how the hall is ventilated and shaded from the sun, since its vaults cannot be insulated on the inside. Credit will be given for heating and cooling controlled room by room.

**Maintenance.** The fish market is 18 metres from the water, and salt spray reaches it all year. New materials will be chosen for those conditions, and the report will include a plan for cleaning roofs and gutters.

**Cost.** The jury will check that the cost estimate keeps within the maximum set in section 1.2.

## Submission of entries

The competition is held in two stages. All documents are to be delivered on paper to the Council’s general registry, and as PDF files to the procurement platform, before 14:00 on the date set.

### Stage one

Each team will submit two A1 boards in portrait format, mounted on lightweight rigid board, showing the general idea, the ground-floor plan at 1:200 and a section through the hall. They will be accompanied by a report of no more than four A4 pages; the jury will not read any pages beyond that limit. Models are not accepted at this stage.

### Stage two

The jury will shortlist five entries, whose authors will develop their idea on three A1 boards, with a ten-page report and a cost estimate broken down by element. Each shortlisted team will receive €6,000 on delivery of the complete documents.

### Anonymity

The boards and the report will carry only a motto of up to five words, repeated on the sealed envelope that holds the team’s details. Any entry bearing names, logos or references by which its authors could be identified will be excluded. The envelopes will be opened at a public session, once the jury has signed its decision.

## Jury and timetable

The jury will be constituted before the deadline for questions, and its membership will be published on the Council’s buyer profile. Its deliberations will be confidential, and the record of the decision will give written reasons for each prize and each commendation.

### Members of the jury

The jury will have seven voting members: the councillor for Culture, who will chair it; the municipal architect; the director of the library; three architects, two of them nominated by the professional association; and a librarian from the regional library service. A procurement officer will act as secretary, with the right to speak but not to vote.

### Prizes

The first prize carries the award of the contract for the design and the supervision of the works. The second prize will receive €12,000 and the third, €8,000. There are also two commendations of €4,000 each, which the jury may decide not to award.

### Timetable

The dates in the table will not change (:ref{id="calendario" style="full" case="lower"}). Questions are to be sent in writing, and the answers will be published without naming whoever asked.

::resource{id="calendario"}

:::paragraphs{style="colophon"}
Sample document: Puerto Lince, its fish market and this competition are fictional. Set in Mona Sans, Noto Serif Display and DM Mono (SIL Open Font License). Original text, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// The two tables: every column after the first is right-aligned, its header too.
const table = (columnWidths, rows) => ({ model: { headerRowCount: 1, columnWidths,
  rows: rows.map((cells, r) => cells.map((content, i) => ({ content, isHeader: r === 0,
    align: i > 0 ? 'right' : 'left' }))) } });
const AREAS = [120, 560, 240, 140, 150, 160, 100, 140, 120, 270]; // m²: 2,000 in all
const programme = table([3, 1], [[t({ en: 'Area', es: 'Zona' }), 'm²'], ...t({
  en: ['Reception and lending desk', 'Main reading room', 'Children’s library',
    'Young people’s room', 'Periodicals and local history', 'Multi-purpose hall',
    'Training rooms (2)', 'Cataloguing and offices', 'Closed stack', 'Toilets, stores and plant'],
  es: ['Acogida y préstamo', 'Sala general', 'Sala infantil', 'Espacio joven',
    'Hemeroteca y fondo local', 'Sala polivalente', 'Aulas de formación (2)',
    'Proceso técnico y dirección', 'Depósito cerrado', 'Aseos, almacenes e instalaciones'],
}).map((zone, i) => [zone, `${AREAS[i]}`]),
['**Total**', t({ en: '**2,000**', es: '**2.000**' })]]);
const calendar = table([3, 2], t({
  en: [['Milestone', 'Date'], ['Notice published', '15 October 2026'],
    ['Questions close', '13 November 2026'], ['Stage one submission', '11 January 2027, 14:00'],
    ['Five teams shortlisted', '5 February 2027'], ['Stage two submission', '22 April 2027, 14:00'],
    ['Jury’s decision', '20 May 2027']],
  es: [['Hito', 'Fecha'], ['Publicación del anuncio', '15 de octubre de 2026'],
    ['Fin del plazo de consultas', '13 de noviembre de 2026'],
    ['Entrega de la primera fase', '11 de enero de 2027, 14.00 h'],
    ['Selección de cinco equipos', '5 de febrero de 2027'],
    ['Entrega de la segunda fase', '22 de abril de 2027, 14.00 h'],
    ['Fallo del jurado', '20 de mayo de 2027']],
}));

// #region resources: four placements: the channel, across the page, a column top, here
// The plot plan stands in the channel; the elevation crosses channel and column at the
// head of the next page; the table of areas floats to the head of the text column, with
// its caption beside it in the channel; the timetable sits where ::resource puts it, and
// its caption takes the channel too (captionSide serves an inline embed from 1.28).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resource = (id, typeId, rest) => ({ id, typeId, createdAt: 0, updatedAt: 0, ...rest });
const drawing = (id, width, height, rest) => resource(id, 'figure', { kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, ...rest });
const resources = [
  drawing('parcela', PLAN_W * 10, 400, {
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: t({ en: 'The plot, 1:2000. Market hall and weighbridge hut in grey; six tamarisks '
      + 'along the north edge. Red arrows: possible entrances; grey arrow: service.',
    es: 'La parcela, 1:2000. Lonja y caseta de básculas, en gris; seis tamarindos en el '
      + 'borde norte. Flechas rojas: accesos posibles; flecha gris: servicio.' }),
    altText: t({ en: 'Plan of the plot with the hall, the hut, six trees and three entrances.',
      es: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' }) }),
  drawing('fachada', 1780, 300, { placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Quay front, 1:300. In red, the four frames at the east end, where the '
      + 'reinforcement has corroded.',
    es: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.' }),
    altText: t({ en: 'Elevation of the hall: thirteen vaults, eleven arches, four frames in red.',
      es: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' }) }),
  resource('programa', 'table', { kind: 'table', table: programme,
    placement: { position: 'top', captionSide: true }, caption: t({
      en: 'Schedule of net floor areas.', es: 'Programa de superficies útiles por zonas.' }) }),
  resource('calendario', 'table', { kind: 'table', table: calendar,
    placement: { position: 'here', captionSide: true },
    caption: t({ en: 'Competition timetable.', es: 'Calendario del concurso.' }) }),
  // No :ref cites the site plan: only the opener's image element draws it.
  drawing('situacion', MAP.w * 10, MAP.h * 10, { altText: t({
    en: 'Site plan of the Poniente quay with the market hall’s plot in red.',
    es: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' }) }),
];
// #endregion

// #region art: the three drawings, made in code with a seeded PRNG
const rng = (seed) => () => { // Mulberry32: the same town on every run
  seed = (seed + 0x6d2b79f5) | 0;
  let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => (+v).toFixed(2);
const svg = (w, h, body, view = `0 0 ${w} ${h}`) => '<svg xmlns="http://www.w3.org/2000/svg" '
  + `width="${w * 10}" height="${h * 10}" viewBox="${view}">${body}</svg>`;
const rect = (x, y, w, h, fill, extra = '') =>
  `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" height="${n(h)}" fill="${fill}" ${extra}/>`;
const circle = (x, y, r, fill, extra = '') =>
  `<circle cx="${n(x)}" cy="${n(y)}" r="${r}" fill="${fill}" ${extra}/>`;
const path = (d, extra) => `<path d="${d}" ${extra}/>`;
const stroke = (color, width) => `fill="none" stroke="${color}" stroke-width="${width}"`;
const through = (points) => `M${points.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`;
const arrow = (x, y, angle, color) => path('M0 -4 L0 3 M-1.6 1.2 L0 3.6 L1.6 1.2 Z',
  `transform="translate(${x} ${y}) rotate(${angle}) scale(1.3)" fill="${color}" `
  + `stroke="${color}" stroke-width="0.7"`);
const north = (x, y, s) => path(`M${x} ${y} L${x + 0.3 * s} ${y + s} L${x} ${y + 0.8 * s} `
  + `L${x - 0.3 * s} ${y + s} Z`, `fill="${palette.ink}"`);

// Site plan, 1:3000 (1 mm = 3 m): the old quarter, the 1905 ensanche, the plaza, the plot.
function situacionSvg() {
  const { w, h } = MAP;
  const P = palette;
  const rand = rng(45);
  const m = (metres) => metres / 3; // mm on the plan
  const QUAY = h - 10; // the water's edge
  const PLOT = { x: 94, y: QUAY - m(18) - m(45), w: m(70), h: m(45) }; // behind the promenade
  const EAST = PLOT.x + PLOT.w; // the calle de la Aduana starts here
  // The sea: a beach on the west, then the quay of the old harbour and the fishing pier.
  let body = path(`M0 ${h - 5} C14 ${h - 5.5} 30 ${QUAY + 1} 42 ${QUAY} L${w} ${QUAY} V${h} H0 Z`,
    `fill="${P.sea}"`);
  body += rect(150, QUAY - 1, 7, h - QUAY + 1, P.stone);
  body += path(`M${w} ${h - 3.5} L161 ${h - 2.5}`, stroke(P.stone, 2.2));
  for (let i = 0; i < 7; i++) { // boats moored along the quay
    body += rect(90 + i * 8 + rand() * 3, QUAY + 1.2, 1.3, 3 + rand(), P.rule, 'rx="0.6"');
  }
  // The old quarter: one built mass cut by lanes that wander, and the old road to the port.
  const lane = (points, width) => path(through(points),
    `${stroke(P.paper, width)} stroke-linejoin="round" stroke-linecap="round"`);
  body += rect(-1, -1, EAST + 1, QUAY - m(18) + 1, P.stone);
  for (let x = 2; x < EAST; x += 7 + rand() * 5) { // lanes down to the sea
    const points = [];
    for (let y = -2; y <= QUAY - 5; y += 6) points.push([x + (rand() - 0.5) * 3.2, y]);
    body += lane(points, rand() < 0.25 ? 1.9 : 1);
  }
  for (let y = 4; y < QUAY - 10; y += 6 + rand() * 3.5) { // lanes along the coast
    const points = [];
    for (let x = -2; x <= EAST + 2; x += 8) points.push([x, y + (rand() - 0.5) * 2.4]);
    body += lane(points, rand() < 0.2 ? 1.7 : 0.9);
  }
  body += lane([[6, -2], [34, 12], [58, QUAY - 7]], 2.6);
  body += rect(PLOT.x - 4, 8, PLOT.w + 4, QUAY - m(18) - 8, P.paper); // the plaza de las Redes
  // The ensanche: chamfered blocks built round a courtyard.
  const [c, bw, bh] = [2, 13, 9];
  for (let x = EAST + 5; x < w; x += bw + 2.8) {
    for (let y = -4; y < QUAY - 16; y += bh + 2.6) {
      body += path(`${through([[x + c, y], [x + bw - c, y], [x + bw, y + c], [x + bw, y + bh - c],
        [x + bw - c, y + bh], [x + c, y + bh], [x, y + bh - c], [x, y + c]])} Z`,
      `fill="${P.stone}"`) + rect(x + 3.2, y + 3, bw - 6.4, bh - 6, P.paper, 'fill-opacity="0.55"');
    }
  }
  for (let x = 6; x < w - 4; x += 8.6) body += circle(x, QUAY - m(9), 0.9, P.rule); // promenade
  for (let i = 0; i < 10; i++) {
    body += circle(PLOT.x + (i % 5) * 5, 11 + Math.floor(i / 5) * 4.2, 0.9, P.rule);
  }
  // The plot, the lonja along its south edge and the weighbridge hut: as in figure 1.
  body += rect(PLOT.x, PLOT.y, PLOT.w, PLOT.h, P.tint, `stroke="${P.signal}" stroke-width="0.5"`);
  body += rect(PLOT.x + m(9), PLOT.y + m(21), m(52), m(24), P.signal)
    + rect(PLOT.x + m(1.5), PLOT.y + m(29), m(5.5), m(8), P.stone);
  body += north(86, h - 7, 5.5);
  body += rect(44, h - 3.4, m(100), 0.8, P.ink) + rect(44, h - 3.4, m(50), 0.8, P.paper,
    `stroke="${P.ink}" stroke-width="0.2"`); // 100 m
  return svg(w, h, body);
}

// The plot, 1:2000 (1 mm = 2 m), drawn in metres: 88 m across the figure's 44 mm.
function parcelaSvg() {
  const P = palette;
  let body = rect(-9, 63, 88, 3, P.sea); // the harbour, past the 18 m promenade
  body += rect(0, 0, 70, 45, P.tint, `stroke="${P.signal}" stroke-width="0.9"`);
  body += rect(9, 21, 52, 24, P.stone); // the lonja
  for (let x = 13; x < 61; x += 4) body += path(`M${x} 21 V45`, stroke(P.paper, 0.35));
  body += rect(1.5, 29, 5.5, 8, P.stone); // the weighbridge hut
  for (let i = 0; i < 6; i++) { // the six tamarinds
    body += circle(9 + i * 10.4, 6.5, 3.1, 'none', `stroke="${P.muted}" stroke-width="0.5"`)
      + circle(9 + i * 10.4, 6.5, 0.6, P.muted);
  }
  body += arrow(35, -7, 0, P.signal) + arrow(35, 52.5, 180, P.signal) // the entrances
    + arrow(75.5, 14, 90, P.muted); // service
  body += north(-6, -12, 6);
  return svg(PLAN_W, 40, body, '-9 -14 88 80');
}

// The quay front, 1:300 (1 m = 3.33 mm), drawn in metres: 13 vaults on 14 porticos.
function fachadaSvg() {
  const P = palette;
  const [VIEW_W, VIEW_H, SKY] = [CONTENT * 0.3, 9, 7.4]; // m; SKY: ground to the top edge
  const X = (x) => n(x + (VIEW_W - 52) / 2);
  const Y = (y) => n(SKY - y); // y up from the ground
  const CORNICE = 6.2;
  const R = (4 + 0.9 * 0.9) / (2 * 0.9); // radius of a 4 m vault that rises 0.9 m
  let roof = `M${X(0)} ${Y(0)} V${Y(CORNICE)}`;
  for (let i = 1; i <= 13; i++) roof += ` A${R} ${R} 0 0 1 ${X(4 * i)} ${Y(CORNICE)}`;
  let body = path(`${roof} V${Y(0)} Z`, `fill="${P.paper}" stroke="${P.ink}" stroke-width="0.08"`);
  body += path(`M${X(0)} ${Y(CORNICE - 0.35)} H${X(52)} M${X(0)} ${Y(0.5)} H${X(52)}`,
    stroke(P.ink, 0.04));
  for (let i = 0; i < 13; i++) { // eleven arches; a square door in each end bay
    const cx = 4 * i + 2;
    body += i === 0 || i === 12 ? rect(+X(cx - 1.1), +Y(3.6), 2.2, 3.6, P.stone)
      : path(`M${X(cx - 1.2)} ${Y(0.5)} V${Y(3.4)} A1.2 1.2 0 0 1 ${X(cx + 1.2)} ${Y(3.4)} `
        + `V${Y(0.5)} Z`, `fill="${P.stone}"`);
  }
  for (let i = 0; i <= 13; i++) { // the porticos' pilasters; the four eastern ones in red
    const x = Math.min(Math.max(4 * i - 0.25, 0), 51.5);
    body += rect(+X(x), +Y(CORNICE - 0.35), 0.5, CORNICE - 0.35, i >= 10 ? P.signal : P.paper,
      `stroke="${P.ink}" stroke-width="0.04"`);
  }
  body += path(`M0 ${Y(0)} H${VIEW_W}`, stroke(P.ink, 0.12)); // the quay
  body += rect(+X(0), +Y(-0.9), 5, 0.22, P.ink) // 10 m
    + rect(+X(5), +Y(-0.9), 5, 0.22, P.paper, `stroke="${P.ink}" stroke-width="0.04"`);
  return svg(CONTENT, VIEW_H / 0.3, body, `0 0 ${VIEW_W} ${VIEW_H}`);
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Mona Sans': ['400', '600'],
  'Noto Serif Display': ['300', '300i', '400'],
  'DM Mono': ['500'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const source = sideHeads(markdown);
await loadSvg('situacion.svg', situacionSvg());
await loadSvg('parcela.svg', parcelaSvg());
await loadSvg('fachada.svg', fachadaSvg());
const doc = await buildDocumentWithFonts({ markdown: source, resources }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Competition brief', es: 'Bases del concurso' }) });

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

## Variantes

### Fileter la seule colonne de texte

Ancrez le filet à la boîte du titre lui-même plutôt qu’au numéro : il va alors du bord gauche du texte au bord droit de la colonne, et seuls le numéro et le titre restent dans la colonne marginale.

```diff
-        color: col('ink'), placement: { ...at('#number', 'right-of', mm(2), RULE_Y),
+        color: col('ink'), placement: { ...at('container', 'top-left', mm(0), RULE_Y),
```

## Erreurs fréquentes

- **Un encadré latéral commence au niveau du bloc qui suit sa clôture.** Dans postext 1.4.1, un encadré span: 'side' se place dans la colonne latérale à la hauteur atteinte par le texte à sa clôture, sur la ligne de grille suivante, et sous tout encadré déjà présent. Placez une glose juste avant le paragraphe qu’elle commente : placée après, elle commence en face du paragraphe suivant. Un encadré qui dépasserait le pied de la colonne remonte jusqu’à ce que son pied repose sur celui de la colonne, autant que le permet l’encadré au-dessus ; s’il ne tient toujours pas, il attend la colonne latérale de la page suivante.
- **Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite.** Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite.
- **Avertissement de mise en page: Texte du titre non référencé** (`headingAdvancedWithoutTitleText`). Une maquette avancée n’a aucun élément qui imprime {titleText} : les mots du titre lui-même n’apparaissent jamais. Solution: Ajoutez un élément de texte avec {titleText}, sauf si le titre doit rester invisible. ([Documentation](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée))

- Une figure latérale prend la tête de la colonne marginale sur la page qui la cite, et un titre marginal placé après elle s’empile dessous, plus bas que le début de sa propre section. Citez les figures latérales après le titre de section de leur page, comme la section 2 cite le plan de la parcelle.
- Une ressource intégrée au fil du texte (`position: 'here'`) prend en compte `captionSide` depuis postext 1.28 : la légende du calendrier se tient dans la colonne marginale en page 5, comme celle du tableau des surfaces en page 3. La légende a besoin que la colonne marginale soit libre à côté du tableau. Là où un titre marginal ou une figure latérale s’y trouve déjà, elle reste sous le tableau.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Mona Sans (OFL-1.1), Noto Serif Display (OFL-1.1), DM Mono (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 018 · Des titres de section sur sept niveaux](https://postext.dev/fr/cookbook/section-heads-field-manual.md): Sections numérotées de 1.1 à 1.12 dans une pastille ambre qui s’élargit avec le numéro, quatre niveaux de plus dessous, et un septième fait d’un style de titre. · Niveau 2 (Intermédiaire) · Manuels, guides et ouvrages de référence
- [Nº 032 · Un classique annoté avec gloses marginales](https://postext.dev/fr/cookbook/annotated-classic-glosses.md): Le thé chez les fous d’Alice en édition annotée : gloses vertes et rouges dans la marge extérieure, face aux lignes expliquées, appelées par une lettre. · Niveau 3 (Avancé) · Fiction, théâtre et prose littéraire
- [Nº 001 · Manuel avec une colonne marginale](https://postext.dev/fr/cookbook/textbook-margin-column.md): Une page à colonne et demie dont la colonne extérieure ne prend que des flottants : figures span 'side' et gloses s’y empilent, captionSide y met les légendes. · Niveau 3 (Avancé) · Manuels scolaires
