# Portada de periódico

> Portada y página 2 de un semanario local a dos columnas con filete, titulares a todo el ancho en recuadros y una franja de breves a cuatro columnas al pie.

- Versión HTML: https://postext.dev/es/cookbook/newspaper-front-page
- Receta N.º 027 · Página y retícula · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Periódicos y boletines
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/newspaper-front-page/en/p01.webp?v=12406580), [2](https://postext.dev/cookbook/newspaper-front-page/en/p02.webp?v=12406580)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/newspaper-front-page.md)

## Lo que vas a componer

Portada y página 2 de *The Elverdale Courier*, un semanario comarcal inventado, en páginas de 246 × 328 mm. La mancheta gótica va entre dos orejas, el tiempo y el precio, sobre dos filetes y la línea de fecha. La noticia principal arranca con un titular a dos líneas en Libre Franklin Black de 56 pt sobre las dos columnas, un subtítulo en cursiva y una foto en banda de 222 mm, y sigue en dos columnas justificadas con filete. Al pie, cuatro breves comparten una franja de fondo beis, uno por columna, cada uno encabezado por un chip rojo. La página 2 abre con un rótulo de sección rojo y lleva la noticia del puente con un dibujo hecho en código, el tiempo en cabeza de la columna derecha, un segundo titular a todo el ancho, las cartas y una tabla de votos con una barra en cada fila.

**Esta receta responde a:**

- ¿Puedo componer una página de periódico a más de dos columnas, o hacer que el texto rodee la foto?
- ¿Cómo pongo un recuadro que cruce las dos columnas a media página, como un panel de cifras en tres bloques?
- ¿Cómo decido dónde va una figura: en la cabeza de la página, a lo ancho de las dos columnas, justo aquí o al margen?
- ¿Cómo pongo el título de capítulo a todo el ancho mientras el texto sigue a dos columnas debajo?

## La respuesta corta

```js
// script.js, líneas 83–100
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the most a body can have
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// Three or more columns live only inside a box: a :::columns group in a :::callout. Its fence
// spans the page and floats the box to the foot, so the story fills the columns above it:
//   :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
//   :::columns{count=4 breaks="2,3,4"}   ← each brief opens a column (child 2, 3 and 4)
//   …four paragraphs…
//   :::
//   :::                                  (gotcha: callout-columns)
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
  background: col('tint'), // one device: a tint, no stripe or border
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  columnGap: mm(GUTTER), // the inner columns keep the page's gutter
  titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
  body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns
```

## Ingredientes

**Enseña**

- [Recuadros a todo el ancho](https://postext.dev/es/docs/configuration.md#el-contenedor-callout): Un recuadro que cruza todas las columnas a media página; las columnas de encima acaban a la misma altura y continúan debajo.
- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns): Dos o más columnas equilibradas dentro de un recuadro, como un texto junto a una figura o un panel de tres.
- [Recuadros flotantes](https://postext.dev/es/docs/configuration.md#el-contenedor-callout): Un recuadro que sale del flujo hacia la cabeza o el pie de una página mientras el texto sigue llenando la página que dejó.

**También usa**

- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Anclaje de elementos de diseño](https://postext.dev/es/docs/configuration.md#posicionamiento-de-elementos)
- [Textos, filetes y cajas en los diseños de página](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Filete de columna](https://postext.dev/es/docs/configuration.md#filete-de-columna)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Líneas de fuente y crédito](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Imágenes en las celdas](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`calloutStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-aviso), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)

**Tipografías**

- Grenze Gotisch (OFL-1.1), PT Serif (OFL-1.1), Libre Franklin (OFL-1.1)

## Elaboración

### 1 · Papel de periódico, tinta y un rojo

```js
// script.js, líneas 14–27
const palette = {
  ink: '#111315', // text and the heavy rules: a cold near-black
  paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
  flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
  tint: '#ebe6db', // the In brief strip
  rule: '#9a978f', // hairlines: the column rule, table rules
  muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
```

El papel es un blanco roto cálido (#f8f5ee) y la letra, un negro frío (#111315). El único rojo, #a6192e, va en los rótulos de sección, los antetítulos, los chips y las barras del sí. Cada color lleva su `paletteId` y su hexadecimal, porque la 1.4.1 pinta los elementos de diseño con el hexadecimal. `main-color` apunta a la tinta, de modo que las cursivas y todo lo que la configuración deja por defecto sale en negro. `bodyText.boldColor` también se fija en la tinta: de él toman su color las remisiones y la negrita de los recuadros, y la paleta no llega a ninguna de las dos. Sin él, las dos remisiones de la página 2 y las primeras palabras en negrita de los breves salen en el azul por defecto, #295AA3.

### 2 · La mancheta es el H1 de la portada

```js
// script.js, líneas 51–79
const NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
  lineHeight: 1.25 }; // a multiple of the size (gotcha: design-lineheight-multiple)
const nameplate = { enabled: true, slot: { elements: [
  depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
      { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
  text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
    fontWeight: 700, lineHeight: 1, align: 'center' },
  { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
  ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
    ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
  ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
    ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
//   day="…" price="…"}
```

`# The Elverdale Courier` lleva el estilo de título `front`, que compone la mancheta con el texto del título y siete de sus atributos (`issue`, `area`, `outlook`, `temps`, `since`, `day` y `price`); la fecha sale del `publishDate` del frontmatter. Una caja vacía de siete líneas exactas de la rejilla (31,1 mm) fija la altura de la apertura. Los filetes, la línea de fecha y el nombre cuelgan de su pie, y así la apertura termina justo en una línea de la rejilla. Con una caja de 6,6 líneas, en la 1.4.1 el filete de columna atraviesa el nombre y la línea de fecha.

### 3 · Un titular sobre las dos columnas es un recuadro

```js
// script.js, líneas 104–117
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
  marginTop: pt(0), // flush under the nameplate or the section flag
  marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
  body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
  marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below
```

Un título solo ocupa las dos columnas como apertura, y una apertura empieza página. Si pones `span: 'page'` en el H2 de la noticia de los barrios en lugar de en su recuadro, esa noticia se va a una tercera página. Un recuadro sin marco con `span="page"`, en cambio, puede empezar a media página. En la [página 2](https://postext.dev/cookbook/newspaper-front-page/en/p02.webp?v=12406580), las columnas quedan igualadas por encima de él y siguen debajo. Dentro van el H2, con uno de tres estilos de título (56, 30 o 22 pt), el subtítulo en cursiva y, en la portada, la foto. Su `marginBottom` de media línea hace que las columnas empiecen en la siguiente línea de la rejilla, 3,4 mm por debajo del subtítulo en la página 2. Con el margen por defecto quedan 7,8 mm y las cartas pasan a una tercera página.

### 4 · Más de dos columnas caben en un recuadro

El código de este paso es [la respuesta corta](#la-respuesta-corta) de arriba. El texto de una página admite como mucho dos columnas ([Tipos de disposición](/es/docs/configuration#tipos-de-disposición)), por lo que las cuatro o cinco columnas de un periódico tienen que ir en un recuadro, aquí uno a todo el ancho con un grupo [`:::columns`](/es/docs/document-format#columns) dentro. `breaks` cuenta los bloques del grupo, uno por breve, así que los breves 2, 3 y 4 abren las columnas 2, 3 y 4. Cada columna mide 49 mm, con el medianil de 6 mm de la página entre una y otra.

```markdown
:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
:::columns{count=4 breaks="2,3,4"}
:chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus …

:chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane …
…
:::
:::
```

`placement="bottom"` lleva el recuadro al pie de la [página 1](https://postext.dev/cookbook/newspaper-front-page/en/p01.webp?v=12406580), donde ocupa 36,9 mm, y la noticia llena los 83 mm de columna que quedan encima. La 1.4.1 tampoco hace que el texto rodee una imagen. Un flotante ocupa una banda entera de su columna o de la página; por eso la foto principal es una banda sobre las dos columnas dentro del recuadro del titular, y el dibujo del puente ocupa todo el ancho de la columna 1. Para poner texto junto a una imagen sirve el mismo tipo de recuadro: en `:::columns{count=2 breaks="2"}`, la línea `::resource` abre la primera columna y el texto, la segunda.

### 5 · Imágenes en línea, tablas flotantes

```js
// script.js, líneas 342–377
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Set inline in the page-span banner box: a 'top' float never lands above the line that
  // cites it (gotcha: top-float-next-page). A :::space gives it air (gotcha: box-embed-no-gap).
  // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
  resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
    height: 800 }, { placement: { position: 'here' },
    altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
    caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
      + 'Harrow Ridge would generate 2.3 megawatts.',
    note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
  // Drawn in code (the art region) and set inline, where ::resource puts it in column 1.
  resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
    { placement: { position: 'here' },
      altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
        + 'central pier, a red concrete footing.',
      caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
        + 'riverbed. Both arches were relaid with their own stones.',
      note: 'Drawing: The Courier' }),
  // A page-span 'bottom' float sits under both columns of the page that cites it. On the last
  // page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
  resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
    { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
      note: 'Red: yes. Grey: no. The black line marks half the vote. '
        + 'Source: Elverdale Town Council.' }),
  // A column 'top' float waits for the head of the next column with room.
  resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
    { placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
      note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];
```

La foto va en línea dentro del recuadro del titular porque un flotante `top` a todo el ancho nunca cae por encima de la línea que lo cita; como flotante, abriría la página 2 (véase Errores frecuentes). El dibujo del puente también va en línea, tras el párrafo sobre la pila. La previsión flota a la cabeza de una columna y cae en la de la derecha. La tabla de barrios flota bajo las dos columnas, donde se detiene el filete de columna. En la última página se coloca justo debajo de las columnas igualadas, y por eso el texto de encima está ajustado para que la tabla llegue al pie. Los dos tipos de recurso fijan `captionPrefix: ''`, y la 1.4.1 solo imprime la etiqueta del pie cuando el prefijo tiene texto, así que ningún pie empieza por «Figure 1».

### 6 · Una página interior abre con un rótulo de sección

```js
// script.js, líneas 121–139
const section = { enabled: true, slot: { elements: [
  depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
  at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4), size: { width: mm(TRIM.width
      - 2 * MARGIN.side) } } }] };
```

`# Town & Valley` es un H1 cuyo `breakBefore` lleva la paridad `'any'`, así que la sección abre la página 2 sin una página en blanco delante. Su diseño es un rótulo rojo sobre una barra de 3 pt, colgado de una caja vacía de dos líneas de la rejilla. La línea de folio es una cabecera anclada a 6 mm del borde superior de la página, con una versión para las pares y otra para las impares. El estilo de la portada vacía la cabecera, de modo que sobre la mancheta no se imprime línea de folio.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/newspaper-front-page

### script.js

```js
// ═══ Postext Cookbook · Nº 027 · Newspaper front page ═══════════════════════════
// https://postext.dev/en/cookbook/newspaper-front-page
// Code: MIT · Text: original (CC BY 4.0) · Photo: Jason Blackeye (CC0)
// Fonts: Grenze Gotisch, PT Serif, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'newspaper-front-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, ink and one red for the section flags
const palette = {
  ink: '#111315', // text and the heavy rules: a cold near-black
  paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
  flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
  tint: '#ebe6db', // the In brief strip
  rule: '#9a978f', // hairlines: the column rule, table rules
  muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 246, height: 328 }; // mm: a 3 : 4 compact, smaller than a tabloid
const MARGIN = { top: 14, bottom: 15, side: 12 }; // newspaper margins: narrow, not mirrored
const GUTTER = 6; // mm between the two body columns, and between the In brief columns
const LEAD = 12.6; // body leading in pt: the baseline grid
const franklin = (size, weight, look = {}) => ({ fontFamily: 'Libre Franklin',
  fontSize: pt(size), fontWeight: weight, color: col('ink'), ...look });
const caps = (size, weight, colour = 'ink') => franklin(size, weight, { color: col(colour),
  textTransform: 'uppercase', letterSpacing: pt(size * 0.16) }); // capitals tracked 0.16 em
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('ink'), ...look, placement }); // design text defaults to centred,
// black and ellipsized (gotcha: overflow-ellipsis-default)
// A rule across the slot whose bottom sits `gap` mm above the element it names.
const rule = (id, under, weight, gap = 0) => ({ kind: 'rule', id, thickness: pt(weight),
  color: col('ink'), placement: { ...at(under, 'above', 0, -gap), size: { width: 'fill' } } });
// An empty box exactly `lines` grid lines deep sets an opener's depth, and the rest hangs from
// its foot. Ending on a grid line leaves no sliver of column beside the opener, which 1.4.1
// would rule down through the title (gotcha: column-rule-through-opener).
const depth = (lines) => ({ kind: 'box', id: 'depth', style: {}, placement: {
  ...at('container', 'top-left'), size: { width: 'fill', height: pt(lines * LEAD) } } });

// #region nameplate: the front page's H1 is the paper's name, between two ears
const NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
  { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
    : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
  lineHeight: 1.25 }; // a multiple of the size (gotcha: design-lineheight-multiple)
const nameplate = { enabled: true, slot: { elements: [
  depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
  { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
      { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
  text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
    fontWeight: 700, lineHeight: 1, align: 'center' },
  { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
  ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
    ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
  ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
    ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
//   day="…" price="…"}
// #endregion

// #region answer: two body columns at most, so the four-up strip is a box with columns
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the most a body can have
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// Three or more columns live only inside a box: a :::columns group in a :::callout. Its fence
// spans the page and floats the box to the foot, so the story fills the columns above it:
//   :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
//   :::columns{count=4 breaks="2,3,4"}   ← each brief opens a column (child 2, 3 and 4)
//   …four paragraphs…
//   :::
//   :::                                  (gotcha: callout-columns)
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
  background: col('tint'), // one device: a tint, no stripe or border
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  columnGap: mm(GUTTER), // the inner columns keep the page's gutter
  titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
  body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns
// #endregion

// #region banner: a headline across both columns is a frameless page-span box
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
  marginTop: pt(0), // flush under the nameplate or the section flag
  marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
  body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
  marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below
// #endregion

// #region inside: an inside page opens with a section flag and carries a folio line
const section = { enabled: true, slot: { elements: [
  depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
  at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
  text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
    at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 600, 'muted'), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
  { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
    placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4), size: { width: mm(TRIM.width
      - 2 * MARGIN.side) } } }] };
// #endregion

const label = { fontFamily: 'Libre Franklin', fontSize: pt(8), firstLineIndent: pt(0) };
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes,
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.side), right: mm(MARGIN.side) } },
  layout,
  // Hyphenation stays at its defaults: on, in 'en-us'.
  bodyText: { fontFamily: 'PT Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    // boldColor is restated: :ref labels take it, because 1.4.1 never points referenceColor
    // at main-color (gotcha: palette-skips-designs), and bold in a box copies it before the
    // palette applies. Italics do follow main-color.
    boldColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.75, maxWordSpacing: 1.6, // tighter than the 0.6–2 defaults
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  headings: { fontFamily: 'Libre Franklin', fontWeight: 800, // in ink, through main-color
    marginBottom: pt(0), levels: [
      // Restated (gotcha: headings-drop-h1-break); 'any': a section opens the next page.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: section },
      { level: 2, fontSize: pt(17), lineHeight: pt(19) }, // Letters; banners restyle it
      { level: 3, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 700, marginTop: pt(LEAD) },
    ] },
  headingStyles: [
    { id: 'front', advancedDesign: nameplate, header: { elements: [] } }, // no folio line
    headline('lead', 56, { fontWeight: 900 }), headline('wide', 30), headline('second', 22),
  ],
  calloutStyles: [banner, story, briefs],
  chipStyles: [{ id: 'flag', background: col('flag'), color: col('paper'), borderWidth: pt(0),
    borderRadius: pt(0), fontFamily: 'Libre Franklin', fontSize: em(0.875), bold: true,
    paddingX: em(0.4), paddingY: em(0.15) }],
  paragraphStyles: [{ ...label, id: 'byline', textAlign: 'left', color: col('muted') },
    { id: 'flush', firstLineIndent: pt(0) }, // a story's first paragraph, the second letter
    { ...label, id: 'jump', textAlign: 'right' }, // "…: page 2", where a story turns
    { id: 'sign', textAlign: 'right', firstLineIndent: pt(0) }, // a letter's signature
    { id: 'imprint', fontFamily: 'Libre Franklin', fontSize: pt(6.8), lineHeight: pt(9),
      textAlign: 'left', firstLineIndent: pt(0), color: col('muted'), marginTop: pt(LEAD) }],
  captionStyle: { fontFamily: 'Libre Franklin', fontSize: pt(8), gap: mm(1.6),
    note: { fontSize: pt(6.8), color: col('muted') } }, // the credit line
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Libre Franklin',
    headerFontSize: pt(7.5), bodyFontFamily: 'Libre Franklin', bodyFontSize: pt(8.5),
    cellPadding: mm(1.3) },
  tableStyles: [{ id: 'results', cellPadding: mm(1) }, // tighter rows: the bars read as one chart
    { id: 'forecast', headerBackground: col('flag'), bodyFontSize: pt(8), // the weather box
      cellPadding: mm(1.2) }],
  header, footer: { elements: [] }, // newspapers put the folio at the head, if anywhere
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region art: the two tables as data, and the bridge and the ward bars drawn in code
const WARDS = [ // Tuesday's parish poll: yes, no (they add up to 3,904 and 2,393)
  ['Market', 942, 418], ['Riverside', 861, 402], ['St Oswald’s', 896, 471],
  ['Brook Lane', 736, 591], ['Harrow', 469, 511]];
const cell = (content, extra = {}) => ({ content, ...extra });
const head = (content, align = 'left') => cell(content, { isHeader: true, align });
const num = (n) => n.toLocaleString('en-GB');
const share = (yes, no) => `${Math.round((100 * yes) / (yes + no))}%`;
function barSvg(yes, no) { // 1000 × 34 units: yes in red from the left, no in grey, 50% marked
  const split = (1000 * yes) / (yes + no);
  return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="34" viewBox="0 0 1000 34">'
    + `<rect width="${split.toFixed(1)}" height="34" fill="${palette.flag}"/>`
    + `<rect x="${(split + 4).toFixed(1)}" width="${(996 - split).toFixed(1)}" height="34" `
    + `fill="${palette.rule}"/><rect x="499" y="-1" width="2" height="36" fill="${palette.ink}"/>`
    + '</svg>';
}
const TOWN = WARDS.reduce(([, y, n], [, yes, no]) => ['Whole town', y + yes, n + no], ['', 0, 0]);
const bars = [...WARDS, TOWN].map(([ward, yes, no], i) => ({ ward, yes, no,
  id: i < WARDS.length ? `bar-${i + 1}` : 'bar-town', svg: barSvg(yes, no) })); // one per row
function mulberry32(seed) { // a seeded PRNG: the same stones on every run
  return () => {
    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 channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16);
const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k)
  + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k
function bridgeSvg() { // 119 × 46 mm, 1 unit = 1 mm, about 2.6 mm to the metre
  const rand = mulberry32(1791); // the year the bridge was built, as far as the Courier knows
  const f = (v) => v.toFixed(2);
  const [W, H, TOP, DECK, WATER, BED] = [119, 46, 4.5, 8, 25, 30]; // heights from the top, mm
  const stone = mix(palette.tint, palette.muted, 0.35);
  const joint = mix(palette.tint, palette.ink, 0.55);
  const arches = [[16, 57], [62, 103]]; // two spans; the central pier stands between them
  const [SPRING, CROWN] = [WATER - 7, 13]; // the arches spring 7 mm above the water
  const CTRL = 2 * CROWN - SPRING; // the Bézier control point that puts the top at CROWN
  // The banks: road level at each end, down under the outer half of each arch to the water's
  // edge, 24 mm in, and on at the same slope to the bed.
  const EDGE = 24;
  const run = ((BED - WATER) * (EDGE - 10)) / (WATER - DECK);
  const bank = [[0, DECK], [10, DECK], [EDGE, WATER], [EDGE + run, BED]];
  const profile = [...bank, ...bank.map(([x, y]) => [W - x, y]).reverse()];
  const groundAt = (x) => { // the height of the ground at x, between two profile points
    const i = profile.findIndex(([px]) => px >= x);
    if (i <= 0) return profile[Math.max(i, 0)][1];
    const [[x0, y0], [x1, y1]] = [profile[i - 1], profile[i]];
    return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0);
  };
  const water = mix(palette.paper, palette.ink, 0.14);
  let out = `<rect x="0" y="${WATER}" width="${W}" height="${BED - WATER}" fill="${water}"/>`;
  out += `<path d="M${EDGE} ${WATER}H${W - EDGE}" stroke="${palette.muted}" stroke-width="0.3"/>`;
  for (let i = 0; i < 9; i++) { // ripples on the river, between the banks
    const [x, y] = [EDGE + 3 + rand() * (W - 2 * EDGE - 12),
      WATER + 1.2 + rand() * (BED - WATER - 2.4)];
    out += `<path d="M${f(x)} ${f(y)}h${f(3 + rand() * 5)}" stroke="${palette.paper}" `
      + 'stroke-width="0.4"/>';
  }
  // The masonry: parapet to riverbed, less the two arch openings (even-odd fill). The banks
  // are drawn over it, so only the stone above the ground shows.
  const opening = ([a, b]) => `M${a} ${BED}L${a} ${SPRING}Q${(a + b) / 2} ${CTRL} `
    + `${b} ${SPRING}L${b} ${BED}Z`;
  const outline = `M4 ${BED}L10 ${DECK}V${TOP}H${W - 10}V${DECK}L${W - 4} ${BED}`;
  out += `<path fill-rule="evenodd" fill="${stone}" `
    + `d="${outline}Z${arches.map(opening).join('')}"/>`;
  // Where the masonry is at height y: inside the sloping ends, outside the arch openings.
  const solid = (x, y) => {
    const end = y < DECK ? 10 : 10 - (6 * (y - DECK)) / (BED - DECK);
    if (x < end || x > W - end) return false;
    return !arches.some(([a, b]) => { // the opening's half width at y (a parabola)
      const c = (SPRING - y) / (2 * (SPRING - CTRL)); // t(1 − t) at height y
      const half = c <= 0 ? 0.5 : c >= 0.25 ? -1 : Math.sqrt(0.25 - c);
      return Math.abs(x - (a + b) / 2) <= half * (b - a);
    });
  };
  for (let y = TOP + 2.2; y < BED; y += 2.2) { // courses of stone, joints staggered
    for (let x = 4; x < W - 4; x += 0.5) {
      if (solid(x, y) && solid(x + 0.5, y)) {
        out += `<path d="M${f(x)} ${f(y)}h0.5" stroke="${joint}" stroke-width="0.2"/>`;
      }
    }
    for (let x = 4 + rand() * 4; x < W - 6; x += 3 + rand() * 4) {
      if (solid(x, y) && solid(x, y - 2.2)) {
        out += `<path d="M${f(x)} ${f(y - 2.2)}v2.2" stroke="${joint}" stroke-width="0.2"/>`;
      }
    }
  }
  for (const [a, b] of arches) { // the voussoirs: a ring of stones round each opening
    const cx = (a + b) / 2;
    for (let t = 0; t <= 1.0001; t += 1 / 14) {
      const x = a + (b - a) * t;
      const y = SPRING - 2 * t * (1 - t) * (SPRING - CTRL);
      const [dx, dy] = [x - cx, y - (WATER + 2)];
      const k = 3 / Math.hypot(dx, dy);
      out += `<path d="M${f(x)} ${f(y)}l${f(dx * k)} ${f(dy * k)}" stroke="${joint}" `
        + 'stroke-width="0.25"/>';
    }
    out += `<path d="${opening([a, b]).replace(/Z$/, '')}" fill="none" stroke="${palette.ink}" `
      + 'stroke-width="0.35"/>';
  }
  out += `<path d="${outline}" fill="none" stroke="${palette.ink}" stroke-width="0.4"/>`;
  // The ground in section: the banks and the riverbed, hatched below the profile.
  const edge = profile.map(([x, y]) => `${f(x)} ${f(y)}`).join('L');
  out += `<path d="M${edge}L${W} ${H}L0 ${H}Z" fill="${palette.paper}"/>`;
  for (let x0 = -H; x0 < W; x0 += 1.6) { // 45° hatching, cut where it leaves the ground
    let from = null;
    for (let t = 0; t <= H + 0.05; t += 0.1) {
      const [x, y] = [x0 + t, H - t];
      const inside = x >= 0 && x <= W && y > groundAt(x);
      if (inside && !from) from = [x, y];
      if (from && (!inside || t + 0.1 > H + 0.05)) {
        out += `<path d="M${f(from[0])} ${f(from[1])}L${f(x - 0.1)} ${f(y + 0.1)}" `
          + `stroke="${palette.rule}" stroke-width="0.25"/>`;
        from = null;
      }
    }
  }
  out += `<path d="M${edge}" fill="none" stroke="${palette.muted}" stroke-width="0.3"/>`
    + `<rect x="55.5" y="${BED}" width="8" height="15.6" fill="${palette.flag}"/>` // 6 m deep
    + `<path d="M9.4 ${TOP}H${W - 9.4}" stroke="${palette.ink}" stroke-width="1.2"/>`; // coping
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" `
    + `viewBox="0 0 ${W} ${H}">${out}</svg>`;
}
function resultsTable() {
  const row = (name, yes, no, bar, bold = '') => [cell(`${bold}${name}${bold}`),
    cell('', { image: { resourceId: bar } }), cell(`${bold}${num(yes)}${bold}`,
      { align: 'right' }), cell(`${bold}${num(no)}${bold}`, { align: 'right' }),
    cell(`${bold}${share(yes, no)}${bold}`, { align: 'right' })];
  return { headerRowCount: 1, columnWidths: [22, 110, 14, 14, 14], rows: [
    [head('Ward'), head('Share of the vote'), head('Yes', 'right'),
      head('No', 'right'), head('Yes share', 'right')],
    ...bars.map((b) => row(b.ward, b.yes, b.no, b.id, b.id === 'bar-town' ? '**' : ''))] };
}
function forecastTable() {
  const days = [['Thu', 'Bright spells', 16, 8, 'W 18'], ['Fri', 'Showers', 14, 9, 'W 22'],
    ['Sat', 'Heavy rain', 12, 9, 'SW 30'], ['Sun', 'Clearing', 13, 6, 'NW 20'],
    ['Mon', 'Sunny, cold start', 15, 4, 'N 8']];
  return { headerRowCount: 1, columnWidths: [10, 40, 12, 12, 16], rows: [
    [head('Day'), head('Outlook'), head('High', 'right'), head('Low', 'right'),
      head('Wind mph', 'right')],
    ...days.map(([day, sky, hi, lo, wind]) => [cell(`**${day}**`), cell(sky),
      cell(`${hi}°`, { align: 'right' }), cell(`${lo}°`, { align: 'right' }),
      cell(wind, { align: 'right' })])] };
}
// #endregion

// #region floats: the photo sits in its box, the results float to a foot, the forecast to a head
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
  numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
  unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
  createdAt: 0, updatedAt: 0, ...extra });
const resources = [
  // Set inline in the page-span banner box: a 'top' float never lands above the line that
  // cites it (gotcha: top-float-next-page). A :::space gives it air (gotcha: box-embed-no-gap).
  // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
  resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
    height: 800 }, { placement: { position: 'here' },
    altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
    caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
      + 'Harrow Ridge would generate 2.3 megawatts.',
    note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
  // Drawn in code (the art region) and set inline, where ::resource puts it in column 1.
  resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
    { placement: { position: 'here' },
      altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
        + 'central pier, a red concrete footing.',
      caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
        + 'riverbed. Both arches were relaid with their own stones.',
      note: 'Drawing: The Courier' }),
  // A page-span 'bottom' float sits under both columns of the page that cites it. On the last
  // page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
  resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
    { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
      note: 'Red: yes. Grey: no. The black line marks half the vote. '
        + 'Source: Elverdale Town Council.' }),
  // A column 'top' float waits for the head of the next column with room.
  resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
    { placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
      note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];
// #endregion
resources.push(...bars.map((b) => resource(b.id, 'panel', 'svg', // the bars the table cells draw
  { fileId: `${b.id}.svg`, width: 1000, height: 34 },
  { altText: `${b.ward}: ${num(b.yes)} yes, ${num(b.no)} no` })));
const markdown = String.raw`---
title: "The Elverdale Courier"
author: "The Elverdale Courier"
publishDate: "Thursday 24 September 2026"
---

# The Elverdale Courier {style="front" issue="Vol. 116 · No. 39" area="Elverdale, Harrow and the Upper Wend" outlook="Bright spells, breezy" temps="16° · 8°" since="Est. 1911" day="Every Thursday" price="90p"}

:::callout{type="banner" span="page"}
## Harrow Ridge turbines win the town’s vote {style="lead"}

*Elverdale backs its own wind farm by 62% on a 71% turnout. Now the cooperative must raise £9.8 million*

:::space{lines=0.5}

::resource{id="ridge"}
:::

:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
:::columns{count=4 breaks="2,3,4"}
:chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus will run every two hours on Sundays from 4 October, paid for by the county for a year. The first leaves the Market Place at 8.10.

:chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane Primary planted 24 apple and pear trees on Friday, all of them varieties grown in the valley before 1900.

:chip[ARTS]{style="flag"} **Book Weekend full.** Every ticket for the fourteen events of the Book Weekend, 9 to 11 October, has gone. The Saturday talks will be screened free in the Corn Exchange.

:chip[COUNCIL]{style="flag"} **Bin day moves.** Recycling north of the river goes out on Tuesdays instead of Mondays, starting in the week of 5 October. New calendars come with next week’s Courier.
:::
:::

:::paragraphs{style="byline"}
By **Martha Quayle** · Local Democracy Reporter
:::

:::paragraphs{style="flush"}
Elverdale will build a wind farm of its own. In the first town-wide poll since the 1974 vote to save the Corn Exchange, 3,904 residents backed plans for three turbines on Harrow Ridge, and 2,393 voted against.
:::

The count at the Corn Exchange finished shortly before midnight on Tuesday. Turnout was 71%, the highest the town clerk, Anjali Rees, can remember at any election. “People queued in the rain outside St Oswald’s,” she said. “We ran out of pencils at eleven.” The last ballot box, from Harrow, came in at twenty to ten.

The scheme belongs to Wend Valley Energy, a cooperative that residents formed in 2023 so that the wind on the ridge would pay the valley rather than a distant landlord. Its three turbines of 2.3 megawatts each would supply about 6,500 homes in an average year, far more than the 4,700 in the parish.

### Who pays and who gains

The cooperative must now raise £9.8 million. A share offer opens next Thursday, with a minimum stake of £50. It hopes to find £2.4 million in the valley and to borrow the rest from a community energy lender. Members would earn up to 4% a year, and £120,000 a year would go to a town fund, spent by a panel that residents elect.

“Two thousand households putting in £1,200 each would do it,” said Tom Ashby, who chairs the cooperative and farms at Low Harrow. “We had 312 pledges by Wednesday lunchtime.”

### Objectors turn to the planners

The Harrow Ridge Action Group, which campaigned against the scheme, says the turbines, 125 metres to the tip of the blade, will dominate the view from the Wend Way and disturb the curlews that nest on the moor.

“A poll is not a planning permission,” said its secretary, Clare Whitlock. “We will make our case to the district council, and we expect to be heard.”

The planning application is due in the spring, after a year of bird and wind surveys. If it succeeds, work could start in 2028, and the turbines would be turning by the autumn of 2029. The cooperative’s 640 founding members have paid £180,000 for the surveys.

:::paragraphs{style="jump"}
How each ward voted, and what happens next: **page 2**
:::

# Town & Valley

:::callout{type="banner" span="page" title="Transport"}
## Old Mill Bridge reopens after 14 months {style="wide"}

*Cars, buses and the Saturday market cross the Wend again. Lorries must wait for a load test next spring*
:::

:::paragraphs{style="byline"}
By **Owen Pryce**
:::

:::paragraphs{style="flush"}
The Old Mill Bridge reopened to traffic at seven o’clock on Monday morning, fourteen months after the flood of July 2025 scoured out the footing of its central pier and cracked both of its arches.
:::

The repair cost £1.9 million, most of it from the county council’s flood recovery fund. Engineers rebuilt the pier on a new concrete footing sunk six metres into the riverbed, and relaid the arches with 3,100 of their own stones, each one numbered as it came down and set back in the same place.

::resource{id="bridge"}

It reopened five days before the first storm of the autumn. Heavy rain is forecast for Saturday :ref{id="forecast" text="(Weather, right)"}, and the county’s engineers will be on the bridge to watch the river rise.

The first vehicle across was Keith Barlow’s dairy van, which has spent more than a year on the eleven-mile detour through Kirkby. “Forty minutes each way, six days a week,” he said. “I’d rather not add it up.”

The bridge is open to cars, vans and buses, and closed to goods vehicles over 7.5 tonnes. The county says heavy traffic had weakened the old structure long before the flood, and it will not let lorries back until the new pier has passed a load test next spring.

That has disappointed some traders. “It’s half a bridge for us,” said Priya Chandra, who runs the builders’ merchant on Mill Lane. “Our timber still comes round by Kirkby, eleven miles each way.”

For the market, it came just in time. Stallholders from the south bank have carried their stock over the footbridge for a year, and Saturday’s market will be the first in more than a year with every pitch taken.

:::callout{type="story" span="page" title="The wind farm vote"}
## Harrow was the one ward to say no {style="second"}
:::

:::paragraphs{style="flush"}
Every ward but one voted for the wind farm in Tuesday’s poll. Market gave it the biggest majority, with 69% voting yes, and Brook Lane the smallest, 736 votes to 591 :ref{id="wards" text="(Results, below)"}.
:::

Harrow, the ward beneath the ridge, voted against by 511 votes to 469, on a turnout of 76%, the highest of the five wards. “Most of the people who voted no can see the ridge from their front doors,” said its councillor, Ruth Okafor, who has asked the cooperative to meet Harrow residents before the application goes in. The first meeting is on 8 October.

Tom Ashby, the cooperative’s chair, said it had expected Harrow to be the hardest ward to win. He has offered to move the nearest turbine 200 metres further from the village, which would cost about 4% of its output, and will put both sites to Harrow residents at the October meeting.

The poll does not bind the district council, which expects to decide the planning application in the summer of 2027. Anyone may comment on it for 21 days after it is published.

:::columnbreak

## Letters

**Tea at the count.** I was one of 22 volunteers who sorted 6,297 ballots in the Corn Exchange on Tuesday night. When the urn ran dry at ten, the cricket club lent us theirs, and we finished at a quarter to twelve. Thank you to the Rotary Club for the sandwiches, and to whoever left a tin of flapjacks on the returning officer’s table.

:::paragraphs{style="sign"}
*Margaret Oyelaran, Brook Lane*
:::

:::paragraphs{style="flush"}
**The view belongs to all of us.** Those of us who walk the Wend Way every week were outvoted by people who have never climbed the ridge. I counted eleven pairs of curlews up there in May. I hope the district council will remember that the moor is not the town’s to sell, whatever the count says.
:::

:::paragraphs{style="sign"}
*David Hurst, Low Harrow*
:::

:::paragraphs{style="imprint"}
An imaginary weekly. Text CC BY 4.0. Set in Grenze Gotisch, PT Serif and Libre Franklin (SIL OFL).
:::
`; // content.en.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  'PT Serif': ['400', '400i', '700'], 'Grenze Gotisch': ['700'],
  'Libre Franklin': ['400', '600', '700', '800', '900'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await Promise.all([loadImage('ridge-2400.jpg', asset('ridge-2400.jpg')),
  loadSvg('bridge.svg', bridgeSvg()), ...bars.map((b) => loadSvg(`${b.id}.svg`, b.svg))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown);
showPages(doc, { title: 'Newspaper front page' });

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

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

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

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

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

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

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

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

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

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

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

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

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

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

## Variantes

### Deja los breves bajo la foto

Sin `placement="bottom"` en `content.en.md`, la franja se queda en su sitio del texto, justo debajo de la foto, y la noticia principal sigue por debajo hasta el pie de la página.

```diff
-:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
+:::callout{type="briefs" span="page" title="In brief"}
```

## Errores frecuentes

- **:::columns solo funciona dentro de un recuadro y no se parte.** :::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno.
- **El filete de columna puede atravesar una apertura a todo el ancho.** En postext 1.4.1 el filete de columna de una banda empieza en lo alto de su columna más alta. Un título a todo el ancho se queda en la primera columna, así que, si su apertura no termina justo en una línea de la rejilla base, el resquicio de segunda columna que queda a su lado hace que el filete arranque en lo alto de la caja de texto y cruce la apertura. Dale a la apertura una altura de líneas de rejilla enteras, con una caja vacía de esa altura o con minHeight si el diseño es más bajo: así termina en una línea de la rejilla y no deja resquicio.
- **Una tabla o una figura dentro de un recuadro no lleva aire alrededor.** En postext 1.4.1, una tabla o una figura que ::resource coloca con la posición 'here' dentro de un :::callout no recibe nada del aire que conserva en el texto corrido: toca el párrafo de encima y el de debajo. Pon un :::space antes de la línea ::resource, y otro después si sigue texto; una fracción de línea, como lines=0.33, deja un espacio pequeño.
- **Un flotante 'top' nunca cae en la página que lo cita.** Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita.
- **Los mapas de bits se miden en píxeles a la resolución del documento: declara el tamaño de impresión.** Un recurso de mapa de bits toma su tamaño del ancho y el alto que declara, en píxeles a la resolución del documento, no del archivo. Declara los píxeles del tamaño de impresión (unos 300 ppp al ancho impreso) para que la figura salga a su tamaño y nítida.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta.** En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta.
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

- Si le das a la foto `placement: { position: 'top', span: 'page' }`, sale de la página 1 y queda sola en la página 2. La noticia de la portada termina entonces 92 mm por encima de la franja de breves, y la sección pasa a la página 3.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: Aerogeneradores en una loma nevada (la foto de portada, recortada en banda): Jason Blackeye ([fuente](https://commons.wikimedia.org/wiki/File:Wind_Turbines_(Unsplash).jpg)), CC0-1.0
- Imágenes: El dibujo del puente y las barras de la tabla de barrios, dibujados en código con la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Grenze Gotisch (OFL-1.1), PT Serif (OFL-1.1), Libre Franklin (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 021 · Recuadros que se parten, flotan y se fijan](https://postext.dev/es/cookbook/boxes-split-float-pin.md): Una hoja de prácticas en la que el procedimiento se parte entre dos columnas, la hoja de datos encabeza la página siguiente y una insignia va fija al pie. · Nivel 3 (Avanzado) · Cuadernos y ejercicios, Libros de texto
- [N.º 023 · Portada de revista e índice por secciones](https://postext.dev/es/cookbook/magazine-cover-and-contents.md): Una portada cuyas llamadas cuelgan del nombre de la revista y un sumario generado con una fila de color por sección. Cada sección es una parte sin portadilla. · Nivel 3 (Avanzado) · Revistas y fanzines
- [N.º 059 · Póster científico en una sola página](https://postext.dev/es/cookbook/research-poster.md): Póster de congreso de 600 × 800 mm: una banda de título sobre un recuadro a todo el ancho con tres columnas de paneles, un mapa de calor y una cifra de 150 pt. · Nivel 3 (Avanzado) · Artículos y trabajos académicos, Hojas sueltas y efímeros
