# Títulos laterais, números pendurados e títulos em linha

> Edital de concurso com títulos de seção num canal na margem, sobre as linhas de base do texto, números de subseção na medianiz e títulos em linha em vermelho.

- Versão HTML: https://postext.dev/pt/cookbook/side-heads-hanging-numbers
- Receita Nº 045 · Títulos e aberturas · Nível 3 (Avançado) · Saídas: Canvas
- Gêneros: Relatórios
- Requer postext ≥ 1.19.1 · testada com 1.19.1 em 2026-10-06
- Páginas: [1](https://postext.dev/cookbook/side-heads-hanging-numbers/es/p01.webp?v=47014947), [2](https://postext.dev/cookbook/side-heads-hanging-numbers/es/p02.webp?v=47014947), [3](https://postext.dev/cookbook/side-heads-hanging-numbers/es/p03.webp?v=47014947), [4](https://postext.dev/cookbook/side-heads-hanging-numbers/es/p04.webp?v=47014947), [5](https://postext.dev/cookbook/side-heads-hanging-numbers/es/p05.webp?v=47014947)
- Abrir no Sandbox: https://postext.dev/pt/sandbox#recipe=side-heads-hanging-numbers&lang=es (.postext: https://postext.dev/cookbook/side-heads-hanging-numbers/es/side-heads-hanging-numbers.postext)
- Última atualização: 2026-10-06
- Outros idiomas: [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), [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)

## Em poucas palavras

O edital de uma prefeitura para escolher o projeto que vai transformar um antigo mercado de peixe numa biblioteca. Os títulos das seções ficam numa margem esquerda larga, na altura da primeira linha do texto ao lado.

## O que você vai compor

Cinco páginas A4 de um edital de concurso de arquitetura, em que a prefeitura de Puerto Lince, um porto espanhol fictício, convoca projetos para transformar o seu mercado de peixe de 1931 na biblioteca da cidade. A diagramação segue os relatórios municipais suíços. Um canal de 50 mm desce pela esquerda de cada página, e cada seção abre com um número vermelho e um fio preto que atravessa toda a mancha. O título da seção fica no canal, em Noto Serif Display, na altura da primeira linha do texto. Os números das subseções ficam pendurados na medianiz, em DM Mono vermelho, de modo que todo título de subseção mantém a borda esquerda do texto. Os critérios de julgamento da seção 4 começam com termos em negrito vermelho. A página 1 traz um título de 58 pt sobre uma planta de situação, uma planta do terreno fica no canal da página 2, e uma elevação da frente do cais abre a página 3.

**Esta receita responde a:**

- Como ponho títulos de seção num canal na margem, penduro os números e componho em linha os menores?
- Como monto uma diagramação em coluna e meia, com uma coluna de texto larga e uma coluna lateral estreita?
- Como decido onde vai uma figura: no alto da página, sobre as duas colunas, exatamente aqui ou na margem?
- Como dou cor aos termos-chave (em negrito ou itálico) no texto ou dentro dos boxes?

## A resposta curta

```js
// script.js, linhas 57–82
// 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) } };
```

## Ingredientes

**Ensina**

- [Notas na margem](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe): Boxes compostos na coluna lateral, na altura do parágrafo que comentam, em uma diagramação de coluna e meia com canal para flutuantes.
- [Aberturas desenhadas](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado): Um título desenhado como composição livre de textos, fios, caixas e imagens, que reserva a altura de que precisa acima do texto.
- [Coluna e meia](https://postext.dev/pt/docs/configuration.md#tipos-de-layout): Uma diagramação assimétrica: uma coluna principal larga e uma coluna lateral estreita, de um lado fixo ou na borda externa de cada página.

**Também usa**

- [Coluna de margem para flutuantes](https://postext.dev/pt/docs/configuration.md#diagramação)
- [Títulos numerados](https://postext.dev/pt/docs/configuration.md#sobrescritas-por-nível)
- [Atributos de título](https://postext.dev/pt/docs/document-format.md#atributos-de-título)
- [Textos, fios e caixas nos designs de página](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Ancoragem de elementos de design](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos)
- [Imagens nos designs de página](https://postext.dev/pt/docs/configuration.md#elementos-de-imagem)
- [Citações que posicionam as figuras](https://postext.dev/pt/docs/document-format.md#referência-em-linha-a-forma-principal)
- [Posicionamento de figuras](https://postext.dev/pt/docs/document-format.md#posicionamento)
- [Boxes](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)
- [Estilos de parágrafo](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo)
- [Negrito, itálico e suas cores](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)
- [Cabeços e fólios](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés)
- [Estilo de tabela](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela)
- [Estilo de legenda](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda)
- [Figura e Tabela no seu idioma](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Legendas laterais](https://postext.dev/pt/docs/document-format.md#posicionamento)
- [Figuras exatamente aqui](https://postext.dev/pt/docs/document-format.md#inserção-em-bloco-opcional-posicionamento-explícito-em-linha)
- [Equilíbrio de colunas](https://postext.dev/pt/docs/configuration.md#equilíbrio-de-colunas)
- [Faixa de capítulo em largura total](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)
- [Cor do papel](https://postext.dev/pt/docs/configuration.md#página)
- [Tipos de recurso personalizados](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso)
- [Figuras e tabelas como recursos](https://postext.dev/pt/docs/document-format.md#recursos)

**A configuração em resumo**

- [`bodyText`](https://postext.dev/pt/docs/configuration.md#texto-do-corpo), [`calloutStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe), [`captionStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-legenda), [`colorPalette`](https://postext.dev/pt/docs/configuration.md#paleta-de-cores), [`footer`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`header`](https://postext.dev/pt/docs/configuration.md#cabeços-e-rodapés), [`headings`](https://postext.dev/pt/docs/configuration.md#títulos), [`layout`](https://postext.dev/pt/docs/configuration.md#diagramação), [`page`](https://postext.dev/pt/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/pt/docs/configuration.md#estilos-de-parágrafo), [`resourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/pt/docs/configuration.md#estilo-de-tabela)

**API**

- [`buildDocument`](https://postext.dev/pt/docs/configuration.md#compilar-um-documento), [`clearMeasurementCache`](https://postext.dev/pt/docs/configuration.md#cache-de-medidas), [`defaultResourceTypes`](https://postext.dev/pt/docs/configuration.md#tipos-de-recurso), [`registerResourceImage`](https://postext.dev/pt/docs/architecture.md#superfície-da-api), [`renderPageToCanvas`](https://postext.dev/pt/docs/configuration.md#renderizar-uma-página-como-bitmap)

**Tipos**

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

## Preparo

### 1 · Imprima o título da seção a partir de um boxe no canal

O código é [a resposta curta](https://postext.dev/pt/cookbook/side-heads-hanging-numbers.md#a-resposta-curta) lá em cima. Um título reserva espaço até o elemento mais baixo do seu design, então um título desenhado no canal na altura da primeira linha da seção empurraria o texto uma linha para baixo, e duas no caso de um título de duas linhas. Por isso o design do título desenha só o número e o fio, e `sideHeads()` copia o texto de cada `## Title` para um `:::callout` aberto logo depois do título. Com `span="side"`, esse boxe sai do fluxo e fica no canal, na linha da grade onde o texto recomeça ([estilos de boxe](https://postext.dev/pt/docs/configuration.md#estilos-de-boxe)). O número e o fio definem `reserve: false`: do contrário, um boxe no canal mantém um espaço de flutuante, uma linha, embaixo dos elementos do título que estão ali, e o título cairia para a segunda linha do texto. Os boxes e o texto corrido põem a linha de base a 0,8 da altura da linha, então um boxe com a entrelinha de 14 pt do texto põe cada linha de um título de duas linhas sobre uma linha de base do parágrafo ao lado. O Markdown continua sendo um simples `## Programa de necesidades`. Como o design do título não imprime `{titleText}`, o Sandbox mostra o aviso *Título não referenciado*, embora o boxe imprima o título.

![Página 2: 2 El emplazamiento. Figura 1. 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.](https://postext.dev/cookbook/side-heads-hanging-numbers/es/p02.webp?v=47014947)

*Página 2: o número e o fio vêm do título, e o título vem do boxe no canal, na altura da primeira linha do texto. A planta do terreno se empilha embaixo dele.*

### 2 · Reserve o canal para títulos e figuras

```js
// script.js, linhas 47–53
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'` mantém o texto fora do canal ([tipos de diagramação](https://postext.dev/pt/docs/configuration.md#tipos-de-layout)). Depois da abertura, o canal guarda só os seis títulos de seção e a planta do terreno, os dois mandados para lá com `span: 'side'`, e a legenda da tabela 1. A coluna lateral fica por padrão em `'right'`, e um edital impresso só num lado da folha a mantém do mesmo lado em todas as páginas. `sideColumnSide: 'left'` a põe à esquerda, onde cada título de seção é lido antes do seu texto.

### 3 · Pendure os números e componha os títulos menores em linha

```js
// script.js, linhas 86–103
// 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: '**Accesibilidad.** Todo el edificio…' 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
```

O título da subseção começa na borda esquerda da coluna. O seu número é ancorado `'left-of'` do título, alinhado à direita numa caixa de 12 mm que termina 2 mm antes do texto, de modo que números como 2.1 e 3.3 ficam pendurados na medianiz e todo título se alinha com o texto embaixo dele ([posicionamento de elementos](https://postext.dev/pt/docs/configuration.md#posicionamento-de-elementos)). A caixa precisa de largura fixa. Uma largura `'auto'` é limitada pela coluna. Lá fora, na medianiz, isso não deixa largura nenhuma, e o número não é desenhado. O nível 2 imprime `'{2}'` e o nível 3, `'{2}.{3}'`, porque com `{1}` todo número começaria com o 1 do único H1 do edital. Os critérios da seção 4 são um quarto nível composto sem título. Cada parágrafo começa com um termo em negrito, e `bodyText.boldColor` o imprime em vermelho ([texto do corpo](https://postext.dev/pt/docs/configuration.md#texto-do-corpo)). Nenhum outro parágrafo tem negrito, e as células de tabela tiram a cor de `tableStyle`, então a linha Total em negrito da tabela 1 continua preta.

### 4 · Dê a cada figura e tabela o seu próprio posicionamento

```js
// script.js, linhas 327–355
// The plot plan stands in the channel; the elevation crosses channel and column at the
// head of the next page; the programme floats to the head of the text column, with its
// caption beside it in the channel; the calendar sits where ::resource puts it. The
// programme floats instead of sitting 'here' because an inline table that opens a page
// keeps a pending top figure off that page (gotcha: inline-table-skips-top-float).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resources = [
  { id: 'parcela', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'parcela.svg', width: PLAN_W * 10, height: 400 },
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: '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: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' },
  { id: 'fachada', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'fachada.svg', width: 1780, height: 300 },
    placement: { position: 'top', span: 'page' },
    caption: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.',
    altText: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' },
  { id: 'programa', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'top', captionSide: true },
    caption: 'Programa de superficies útiles por zonas.', table: programme },
  { id: 'calendario', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' }, caption: 'Calendario del concurso.', table: calendar },
  // No :ref cites the site plan: only the opener's image element draws it.
  { id: 'situacion', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'situacion.svg', width: MAP.w * 10, height: MAP.h * 10 },
    altText: 'Plano de situación del muelle de Poniente con la parcela de la lonja en rojo.' },
];
```

A planta do terreno vai para o canal com `span: 'side'` e se empilha sob o título lateral da seção 2 ([posicionamento](https://postext.dev/pt/docs/document-format.md#posicionamento)). `width: PLAN_W / SIDE` lhe dá 44 mm de largura, o que põe os seus 88 metros exatamente em 1:2000. A elevação da fachada é um flutuante `'top'` na largura da página. Ela é citada na página 2 e abre a página 3, já que um flutuante nunca é posicionado acima da sua referência. A tabela do programa, também citada na página 2, flutua para o alto da coluna de texto da página 3; composta inline, ela teria aberto a página 3 e mandado a elevação para a página 4. `captionSide: true` põe a legenda no canal, na altura do topo da tabela. O calendário fica onde `::resource` o coloca, no fim do edital.

### 5 · Abra o edital sobre a planta de situação

```js
// script.js, linhas 107–132
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 is an image element, which reserves no height (gotcha:
  // opener-image-no-reserve): minHeight carries the reserve down to its foot.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)), // on a grid line
  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) } } },
  ] } };
```

Com `span: 'page'`, o contêiner da abertura é a mancha inteira, canal incluído ([span e design avançado](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado)). O chapéu e o título ficam a 57 mm da borda, na borda esquerda da coluna de texto, e a planta atravessa canal e coluna. Sem isso, o contêiner é a coluna de texto, todos os elementos andam 57 mm para a direita, e o título, a linha fina e a planta passam da borda direita da página. Um elemento de imagem não reserva altura, então `minHeight` estende a reserva até o pé da planta. Arredondada para uma linha inteira da grade, ela deixa o fio da seção 1 tão longe do seu texto quanto o fio de todas as seções seguintes. O chapéu, a linha fina e a legenda do mapa são atributos da linha `# Bases del concurso`.

## A receita completa

Um único arquivo, composto a partir da pasta da receita com o texto de exemplo e o kit comum das Receitas já incluídos; ele monta a própria página. Para executá-lo, coloque-o em um `<script type="module">` de uma página vazia ou cole-o no painel JS de um pen novo do CodePen (como módulo). Ele importa o postext do esm.sh, então não há nada para instalar nem compilar.

- Pasta da receita: 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.19.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @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',
};
// The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs).
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: '**Accesibilidad.** Todo el edificio…' 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 is an image element, which reserves no height (gotcha:
  // opener-image-no-reserve): minHeight carries the reserve down to its foot.
  minHeight: mm(GRID * Math.ceil((MAP.y + MAP.h) / GRID)), // on a grid line
  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 = () => ({ // a factory: the engine caches resolved configs per object
  // Figura / Tabla, counted through the whole brief: 1, 2… (gotcha: resource-types-locale)
  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: [
    // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
    // 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: "Nueva Biblioteca Municipal"
subtitle: "Concurso de proyectos"
author: "Ayuntamiento de Puerto Lince"
---

# Bases del concurso {kicker="Expediente BML-2026/04" lead="Concurso abierto y anónimo, en dos fases, para convertir la lonja de 1931 en la biblioteca de la ciudad." map="Plano de situación a escala 1:3000, con la parcela de la lonja en rojo y la ciudad construida en gris."}

## Objeto del concurso

El Ayuntamiento de Puerto Lince convoca un concurso de proyectos con intervención de jurado para elegir la propuesta arquitectónica de la nueva Biblioteca Municipal, que ocupará la antigua lonja del pescado en el muelle de Poniente. La biblioteca actual funciona desde 1987 en la planta baja de la Casa de Cultura: 610 m² para una ciudad de 24.300 habitantes, sin posibilidad de ampliarse.

El concurso se rige por estas bases y por la Ley 9/2017, de Contratos del Sector Público. Pueden participar los arquitectos con título habilitante en un Estado de la Unión Europea, solos o al frente de un equipo.

### Alcance del encargo

El equipo ganador redactará el proyecto básico y el de ejecución, el estudio de seguridad y salud y el proyecto de actividad, y dirigirá la obra. Los honorarios, fijados en 412.000 € más IVA, incluyen también el diseño del mobiliario fijo y de la señalética.

### Presupuesto y plazo

El presupuesto máximo de ejecución material, que incluye la urbanización de la parcela, es de 4.850.000 € sin IVA. Quedan fuera del concurso las propuestas que lo superen en más de un 10 %. La obra durará unos veinte meses, y la biblioteca debería abrir en 2030.

## El emplazamiento

La lonja ocupa el frente de la ciudad hacia el puerto viejo, entre el barrio de pescadores y el ensanche de 1905. Es el único edificio del puerto viejo anterior a 1936 que sigue en pie, y el Ayuntamiento lo compró a la Autoridad Portuaria en 2022.

### La parcela

La parcela es un rectángulo de 70 por 45 metros, 3.150 m² (:ref{id="parcela" case="lower"}). Linda al sur con el paseo del Muelle, al este con la calle de la Aduana y al norte con la plaza de las Redes, que el planeamiento prevé hacer peatonal en 2028. Al oeste queda la caseta de básculas, de propiedad municipal, que puede demolerse o integrarse en el conjunto.

Descontada la nave, quedan 1.300 m² edificables en un máximo de dos plantas y 9,5 m de altura a cornisa. Ninguna construcción nueva puede adelantarse a la línea de la fachada del muelle.

### La lonja de 1931

La lonja es una nave de hormigón armado de 52 por 24 metros, cubierta por trece bóvedas de cañón de cuatro metros de luz que apoyan en pórticos. La construyó la Junta de Obras del Puerto, y en ella se subastó el pescado hasta 2009, cuando la subasta pasó al puerto nuevo.

El catálogo municipal la protege en grado 2. Deben conservarse la estructura, las bóvedas y la fachada al muelle con sus once arcos de medio punto (:ref{id="fachada" case="lower"}). La inspección de 2025 encontró armaduras corroídas en los cuatro pórticos del extremo este; su reparación entra en el presupuesto, y cada propuesta explicará cómo piensa hacerla sin desmontar las bóvedas.

### Accesos y arbolado

La entrada principal puede plantearse desde la plaza o desde el paseo, pero la carga y descarga se hará por la calle de la Aduana. Los seis tamarindos del borde norte, de unos cuarenta años, se conservarán.

## Programa de necesidades

Las superficies de la tabla son útiles (:ref{id="programa" case="lower"}). Cada zona admite un 10 % de más o de menos, siempre que el total no cambie. La nave alojará las salas de lectura y de encuentro; lo que necesite cerramientos, instalaciones o control del ruido puede ir en la edificación nueva.

### Áreas públicas

La sala general reunirá la colección de préstamo para adultos, unos 38.000 documentos, con noventa puestos de lectura. La hemeroteca y el fondo local compartirán una sala contigua, con doce puestos de consulta y un escáner de uso libre. La sala infantil tendrá entrada propia desde el exterior y un rincón de cuentacuentos para veinticinco niños. El espacio joven y la sala polivalente deben poder abrirse fuera del horario de la biblioteca, con el resto del edificio cerrado.

### Áreas internas

El proceso técnico, la dirección y el depósito cerrado suman 260 m². Estas áreas han de comunicarse con la zona de carga sin cruzar las salas públicas. El depósito tendrá estanterías compactas; su forjado se calculará con una sobrecarga de 12 kN/m² y quedará a nivel de la calle.

### Espacios exteriores

Entre la nave y la edificación nueva se pide un patio de lectura al aire libre de al menos 180 m², protegido del viento del norte, que en invierno sopla aquí con rachas de más de 60 km/h.

## Criterios de proyecto

El jurado valorará las propuestas con los criterios siguientes, ordenados de mayor a menor peso.

**Relación con la lonja.** Las intervenciones sobre la nave serán reconocibles y reversibles. No se admiten forjados que corten las bóvedas; sí altillos exentos que dejen ver los pórticos completos.

**Accesibilidad.** Todo el edificio será accesible sin ayuda en silla de ruedas, patio y altillos incluidos. Ninguna rampa superará el 6 % de pendiente.

**Energía y confort.** La propuesta dirá cómo se ventila la nave y cómo se protege del sol, porque sus bóvedas no admiten aislamiento por el interior. Se valorará que la climatización pueda regularse sala por sala.

**Mantenimiento.** La lonja está a 18 metros del agua y el salitre le llega todo el año. Los materiales nuevos se elegirán para ese ambiente, y la memoria incluirá un plan de limpieza de cubiertas y canalones.

**Coste.** El jurado comprobará que la estimación de coste se ajusta al máximo del apartado 1.2.

## Entrega de propuestas

El concurso se desarrolla en dos fases. Toda la documentación se entrega en papel en el registro general del Ayuntamiento, y en PDF en la plataforma de contratación, antes de las 14.00 h del día fijado.

### Primera fase

Cada equipo entregará dos paneles DIN A1 en vertical, sobre soporte rígido ligero, con la idea general, la planta baja a escala 1:200 y una sección por la nave. Los acompañará una memoria de cuatro páginas DIN A4 como máximo; el jurado no leerá las páginas que excedan ese límite. En esta fase no se admiten maquetas.

### Segunda fase

El jurado elegirá cinco propuestas, que desarrollarán su idea en tres paneles DIN A1, una memoria de diez páginas y una estimación de coste por capítulos. Cada equipo seleccionado recibirá 6.000 € al entregar la documentación completa.

### Anonimato

Los paneles y la memoria llevarán solo un lema de hasta cinco palabras, que se repetirá en el sobre cerrado con los datos del equipo. Se excluirá cualquier propuesta con nombres, logotipos o referencias que permitan reconocer a sus autores. Los sobres se abrirán en un acto público, después de que el jurado haya firmado el fallo.

## Jurado y plazos

El jurado se constituirá antes de que termine el plazo de consultas, y su composición se publicará en el perfil del contratante. Sus deliberaciones serán secretas, y el acta del fallo explicará por escrito las razones de cada premio y de cada accésit.

### Composición del jurado

El jurado tendrá siete miembros con voto: la concejala de Cultura, que lo presidirá; la arquitecta municipal; la directora de la biblioteca; tres arquitectos, dos de ellos designados por el colegio profesional, y una bibliotecaria del servicio regional de bibliotecas. Un técnico de contratación actuará como secretario, con voz y sin voto.

### Premios

El primer premio conlleva la adjudicación del contrato de redacción del proyecto y dirección de obra. El segundo premio recibirá 12.000 € y el tercero, 8.000 €. Hay además dos accésits de 4.000 €, que el jurado puede declarar desiertos.

### Calendario

Las fechas de la tabla no se moverán (:ref{id="calendario" case="lower"}). Las consultas se enviarán por escrito, y las respuestas se publicarán sin identificar a quien pregunta.

::resource{id="calendario"}

:::paragraphs{style="colophon"}
Documento de ejemplo: Puerto Lince, su lonja y este concurso son ficticios. Compuesto en Mona Sans, Noto Serif Display y DM Mono (SIL Open Font License). Texto original, 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 row = (cells, header = false) => cells.map((content, i) => ({ content,
  isHeader: header, align: i > 0 ? 'right' : 'left' }));
const programme = { model: { headerRowCount: 1, columnWidths: [3, 1], rows: [
  row(['Zona', 'm²'], true), row(['Acogida y préstamo', '120']), row(['Sala general', '560']),
  row(['Sala infantil', '240']), row(['Espacio joven', '140']),
  row(['Hemeroteca y fondo local', '150']), row(['Sala polivalente', '160']),
  row(['Aulas de formación (2)', '100']), row(['Proceso técnico y dirección', '140']),
  row(['Depósito cerrado', '120']), row(['Aseos, almacenes e instalaciones', '270']),
  row(['**Total**', '**2.000**']),
] } };
const calendar = { model: { headerRowCount: 1, columnWidths: [3, 2], rows: [
  row(['Hito', 'Fecha'], true),
  row(['Publicación del anuncio', '15 de octubre de 2026']),
  row(['Fin del plazo de consultas', '13 de noviembre de 2026']),
  row(['Entrega de la primera fase', '11 de enero de 2027, 14.00 h']),
  row(['Selección de cinco equipos', '5 de febrero de 2027']),
  row(['Entrega de la segunda fase', '22 de abril de 2027, 14.00 h']),
  row(['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 programme floats to the head of the text column, with its
// caption beside it in the channel; the calendar sits where ::resource puts it. The
// programme floats instead of sitting 'here' because an inline table that opens a page
// keeps a pending top figure off that page (gotcha: inline-table-skips-top-float).
const PLAN_W = 44; // mm: the plot plan, 88 m wide at 1:2000
const resources = [
  { id: 'parcela', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'parcela.svg', width: PLAN_W * 10, height: 400 },
    placement: { span: 'side', width: PLAN_W / SIDE }, // 44 mm of the channel's 50
    caption: '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: 'Planta de la parcela con la lonja, la caseta, seis árboles y tres accesos.' },
  { id: 'fachada', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'fachada.svg', width: 1780, height: 300 },
    placement: { position: 'top', span: 'page' },
    caption: 'Fachada al muelle, 1:300. En rojo, los cuatro pórticos del extremo este, con '
      + 'las armaduras corroídas.',
    altText: 'Alzado de la lonja: trece bóvedas, once arcos y cuatro pórticos en rojo.' },
  { id: 'programa', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'top', captionSide: true },
    caption: 'Programa de superficies útiles por zonas.', table: programme },
  { id: 'calendario', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' }, caption: 'Calendario del concurso.', table: calendar },
  // No :ref cites the site plan: only the opener's image element draws it.
  { id: 'situacion', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'situacion.svg', width: MAP.w * 10, height: MAP.h * 10 },
    altText: '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());
await loadFonts(FONTS, source);
const doc = await buildWithFonts(() => buildDocument({ markdown: source, resources }, config()),
  source);
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 v2 ── the same in every recipe · postext.dev/cookbook
// Postext measures with the loaded faces and caches the widths: load every face
// before the first build, from Fontsource, the files the PDF embeds too.

/** 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.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
  const jobs = [];
  let added = 0;
  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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

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

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

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

/** 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, '-'); }

/** 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 v1 ── 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. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

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

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

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

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

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

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

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

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

## Variações

### Ponha o fio só na coluna de texto

Ancore o fio na caixa do próprio título em vez do número, e ele vai da borda esquerda do texto até a borda direita da coluna, com só o número e o título no canal.

```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),
```

## Erros comuns

- **Um boxe lateral começa na altura do bloco que vem depois do seu delimitador.** No postext 1.4.1, um boxe com span: 'side' fica na coluna lateral na altura a que o texto chegou no seu delimitador, na linha seguinte da grade e abaixo dos boxes que já estiverem ali. Abra o delimitador de uma glosa logo antes do parágrafo que ela explica: se vier depois, a glosa começa ao lado do parágrafo seguinte. Um boxe que passaria do pé da coluna sobe até seu pé coincidir com o da coluna, se o boxe de cima permitir; se mesmo assim não couber, espera pela coluna lateral da página seguinte.
- **O excesso de texto de design é 'ellipsis-end' por padrão.** Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas.
- **Texto em bandeira nunca é verificado contra linhas curtas.** optimalLineBreaking, avoidRunts, runtPenalty e runtMinCharacters agem sobre o algoritmo de quebra de linha de Knuth–Plass, que o postext 1.4.1 só aplica a texto justificado. Um parágrafo em bandeira é quebrado linha a linha e pode terminar numa única palavra curta, digam o que disserem essas configurações. Revise as últimas linhas do texto em bandeira e reescreva o parágrafo que terminar numa linha curta.
- **As imagens de uma abertura nunca contam para a altura que ela reserva.** No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar.
- **Um flutuante 'top' nunca cai na página que o cita.** Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita.
- **Uma página que começa com uma tabela no texto pula um flutuante superior pendente.** No postext 1.4.1, quando uma tabela em linha (posição 'here', colocada com ::resource) passa para o alto da página seguinte, uma flutuante 'top' na largura da página citada na página anterior não ocupa o alto dessa página: espera a próxima. Faça a tabela flutuar também (posição 'top' ou 'auto', citada com :ref) ou cite a figura onde a página seguinte começa com texto.
- **O texto dentro de um SVG <img> não pode usar fontes web.** Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda.
- **O lineHeight de um texto de design é um múltiplo, nunca uma medida.** Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título.
- **Uma paleta trocada não chega aos elementos de design nem à cor das referências.** postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor.
- **Traduza Figura e Tabela com defaultResourceTypes(locale).** O locale da configuração define a hifenização, não as legendas: sem resourceTypes, os tipos embutidos dizem Figure e Table, em inglês. Passe resourceTypes: defaultResourceTypes('es') para o espanhol; para qualquer outro idioma, escreva você mesmo os nomes em resourceTypes.
- **Qualquer objeto headings desativa a quebra de página do H1.** Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração.
- **Aviso de diagramação: Texto do título não impresso** (`headingAdvancedWithoutTitleText`). Um design avançado não tem nenhum elemento que imprima {titleText}, então as palavras do próprio título nunca aparecem. Solução: Acrescente um elemento de texto com {titleText}, a menos que o título deva ser invisível. ([Documentação](https://postext.dev/pt/docs/configuration.md#largura-e-design-avançado))

- Uma figura lateral ocupa o alto do canal na página que a cita, e um título lateral aberto depois dela se empilha embaixo da figura, abaixo do início da sua própria seção. Cite as figuras laterais depois do título de seção da página delas, como a seção 2 cita a planta do terreno.
- No postext 1.4.1, um boxe lateral aberto entre um título e o seu primeiro parágrafo dá a esse parágrafo um recuo de primeira linha, mesmo com `indentAfterHeading: false`. Este edital não tem recuos, então nada aparece. Com uma mancha recuada, envolva o primeiro parágrafo de cada seção num contêiner `:::paragraphs` cujo estilo defina `firstLineIndent: 0`.
- No postext 1.4.1, o `spaceBetween` de um estilo de parágrafo também é somado depois do último parágrafo do contêiner, onde não se funde com o `marginTop` do título seguinte como acontece com o espaçamento entre parágrafos do corpo. Com os critérios num contêiner assim e uma linha de `spaceBetween`, a seção 5 ficava com três linhas em branco acima e todas as outras com duas, por isso este edital colore os termos em linha com `bodyText.boldColor`.

## Créditos

- Receita: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipos: Mona Sans (OFL-1.1), Noto Serif Display (OFL-1.1), DM Mono (OFL-1.1)
- Código: MIT · Conteúdo de exemplo: CC-BY-4.0

## Relacionadas

- [Nº 018 · Títulos de seção em sete níveis](https://postext.dev/pt/cookbook/section-heads-field-manual.md): Seções numeradas de 1.1 a 1.12 numa pílula âmbar que cresce com o número, mais quatro níveis abaixo delas e um sétimo feito com um estilo de título. · Nível 2 (Intermediário) · Manuais, guias e obras de referência
- [Nº 032 · Clássico anotado com glosas na margem](https://postext.dev/pt/cookbook/annotated-classic-glosses.md): O chá maluco de Alice em edição anotada: glosas verdes e vermelhas na margem externa, junto às linhas que explicam, cada uma chamada por uma letra da sua cor. · Nível 3 (Avançado) · Ficção, teatro e prosa literária
- [Nº 001 · Livro didático com coluna na margem](https://postext.dev/pt/cookbook/textbook-margin-column.md): Página em coluna e meia cuja coluna externa só recebe flutuantes: figuras e glosas com span 'side' se empilham ali, e captionSide leva as demais legendas. · Nível 3 (Avançado) · Livros didáticos
