# Boletín vecinal: noticia principal y breves

> Un boletín de huertos en columna y media: la noticia llena la columna ancha y sigue en la página 2; los saltos de columna llevan los breves a la estrecha.

- Versión HTML: https://postext.dev/es/cookbook/community-newsletter
- Receta N.º 042 · Página y retícula · Nivel 2 (Intermedio) · 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/community-newsletter/es/p01.webp?v=57dbd00b), [2](https://postext.dev/cookbook/community-newsletter/es/p02.webp?v=57dbd00b)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/community-newsletter.md)

## Lo que vas a componer

El número de primavera de *La Gaceta del Huerto*, boletín de una asociación de huertos inventada, impreso por las dos caras de una hoja A4. Una franja color paja lleva el nombre en Titan One, en dos líneas, una hilera de plantones al pie y una pestaña color tomate con número y temporada. Debajo, la página se divide en una columna de 113 mm y otra de 57 mm. La noticia principal, sobre tres cajones de compost, llena la columna ancha bajo un plano dibujado con código y se interrumpe con una etiqueta «Sigue en la página 2». La columna estrecha reúne cinco breves con etiquetas en forma de píldora. La página 2 retoma la noticia bajo un título de continuación y centra un gráfico de temperaturas. Más abajo, un recuadro a dos columnas pone el dibujo de un bancal junto a su texto. Ocho avisos llenan la columna estrecha.

**Esta receta responde a:**

- ¿Cómo pongo la noticia en una columna ancha y los breves en otra estrecha, y un texto junto a una imagen?
- ¿Puede Postext componer tres o más columnas de texto, o hacer que el texto rodee una imagen?
- ¿Cómo pongo dos columnas dentro de un recuadro (una de texto junto a otra con una figura)?
- ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?

## La respuesta corta

```js
// script.js, líneas 33–52
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
```

## Ingredientes

**Enseña**

- [Columna y media](https://postext.dev/es/docs/configuration.md#tipos-de-disposición): Una disposición asimétrica: una columna principal ancha y una lateral estrecha, a un lado fijo o en el borde exterior de cada página.
- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak): Llevan el bloque siguiente a una página nueva, a la siguiente página impar o par (con una página en blanco si hace falta) o a la columna siguiente.
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip): Cajas redondeadas alrededor de palabras que pasan de línea como una unidad y nunca se estiran: teclas, etiquetas, bancos de palabras, sílabas.

**También usa**

- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Filete de columna](https://postext.dev/es/docs/configuration.md#filete-de-columna)
- [Metadatos del documento](https://postext.dev/es/docs/document-format.md#frontmatter)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Líneas de fuente y crédito](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)

**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)

**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**

- Work Sans (OFL-1.1), Titan One (OFL-1.1), Courier Prime (OFL-1.1)

## Elaboración

### 1 · Deja que el Markdown llene cada columna

Los ajustes de la disposición están en [la respuesta corta](#la-respuesta-corta), más arriba. En una disposición `oneAndHalf` la columna estrecha lleva texto por defecto, y el texto baja por la columna ancha, sigue por la estrecha y pasa a la columna ancha de la página siguiente ([tipos de disposición](/es/docs/configuration#tipos-de-disposición)). Un `:::columnbreak` termina la columna donde está ([directivas](/es/docs/document-format#directivas)): tras la etiqueta «Sigue en la página 2» abre la columna estrecha para los breves, y tras el último breve, en la última columna de la página, abre la página 2. En este número las dos columnas de la página 1 están ajustadas para terminar en la misma línea de la rejilla base, a 275 mm del borde superior, así que quitar cualquiera de los dos saltos deja las páginas igual. Los saltos hacen falta cuando cambia el texto. Quita el segundo párrafo de la noticia y, sin el primer salto, «En breve» queda a 247 mm, en la columna ancha, bajo la etiqueta, y el primer breve empieza en la ancha y termina en la estrecha, donde su segunda mitad conserva la medida de 113 mm con la que empezó y la columna de 57 mm la recorta.

### 2 · Monta la mancheta con el frontmatter

```js
// script.js, líneas 56–80
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
    // area on this page, behind the masthead, however deep the masthead is.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
    // (gotcha: quote-frontmatter): an unquoted date prints nothing here.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };
```

El H1 lleva un estilo de título cuyo diseño dibuja la mancheta, y `span: 'page'` pone ese diseño sobre las dos columnas. `{titleText}`, a 60 pt, se parte en dos líneas en su caja de 178 mm. La pestaña une `{attr.issue}`, escrito en la línea del H1, y `{publishDate}`, del frontmatter, que tiene que ser una cadena entre comillas ([elementos de texto](/es/docs/configuration#elementos-de-texto)). Sin `minHeight`, la mancheta reservaría espacio hasta el pie de la franja, a 62 mm del borde superior, y el texto empezaría en la línea siguiente de la rejilla base, a 62,5 mm, pegado a la franja. Con un `minHeight` de diez líneas de la rejilla, empieza a 67 mm.

### 3 · Etiqueta los breves con chips

```js
// script.js, líneas 84–94
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];
```

Declarar `chipStyles` sustituye el chip azul pálido de serie, y un chip sin `style` toma el primero ([estilos de chip](/es/docs/configuration#estilos-de-chip)). Un radio mayor que la mitad de la altura de la caja dibuja una píldora. Un chip nunca se parte por dentro, así que la línea solo se corta en el espacio de antes o de después de un chip, y una fecha como «Sáb. 11 de abril» queda en una sola línea. Los estilos de chip no pueden poner el texto en mayúsculas; por eso las etiquetas se escriben así en el Markdown. El relleno vertical se pinta fuera de la línea, así que la rejilla base de 13,4 pt se mantiene.

### 4 · Pon el texto junto al dibujo dentro de un recuadro

```js
// script.js, líneas 98–109
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
//   :::callout{type="bed" title="Sixteen squares by the gate"}
//   :::columns{count=2 breaks="2"}
//   The demonstration bed by the gate is 1.2 metres square …   ← block 1
//   ::resource{id="bed"}                                        ← block 2
//   :::
//   :::                                   (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
  padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
  titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
  body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
```

Postext no contornea el texto alrededor de una imagen: una figura ocupa una franja entera de su columna. Dentro de un recuadro, en cambio, un grupo `:::columns` pone bloques uno al lado del otro ([`:::columns`](/es/docs/document-format#columns)). `breaks="2"` empieza la segunda columna, de 50 mm, en el dibujo, así que el párrafo queda entero en la primera. En este número el párrafo (61,2 mm) es más bajo que el dibujo con su pie (75,7 mm), y quitar `breaks="2"` no cambia nada. Sirve para un párrafo más largo: sin él, el grupo se equilibra, y con un párrafo de veintiuna líneas las dos últimas saltan a lo alto de la segunda columna, encima del dibujo.

### 5 · Centra un gráfico estrecho en su franja

```js
// script.js, líneas 113–125
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
```

`width: 0.62` da al gráfico 70,1 mm de los 113 mm de la columna, y `align: 'center'` lo centra con 21,5 mm de papel a cada lado ([colocación](/es/docs/document-format#colocación)). El texto de encima y de debajo conserva la medida entera y nunca entra en ese blanco. Los dibujos son de un tipo de recurso con `captionPrefix` vacío, así que cada pie empieza con sus propias palabras en negrita, sin «Figura 1» delante.

## 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/community-newsletter

### script.js

```js
// ═══ Postext Cookbook · Nº 042 · Community newsletter: lead story and briefs ═════
// https://postext.dev/en/cookbook/community-newsletter
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Work Sans, Titan One, Courier Prime (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: garden colours, every one linked by id
// ink: text · leaf: nameplate and headlines · tomato, the accent: tags, tab, column heads ·
// straw: the band · marigold: second tag · tint: the box · rule: hairlines · muted: notes
const palette = { ink: '#1d211c', leaf: '#2f6b3a', tomato: '#c43f2a', straw: '#f2d492',
  marigold: '#e2b33c', tint: '#eef4e8', rule: '#cfd3c6', muted: '#5f6659', paper: '#fbfaf5' };
// 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 leaf, never blue
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Work Sans', 'Titan One', 'Courier Prime'];
const SIDE = 16; // mm: the side margins of one A4 sheet, printed on both sides
const LEAD = 13.4; // body leading in pt: the baseline grid
const caps = (size, colour) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  textTransform: 'uppercase', letterSpacing: pt(size * 0.12), color: col(colour) });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a wide column for the lead, a narrow one for the briefs, one flow
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
// #endregion

// #region masthead: the H1 is the nameplate; the issue tab reads the frontmatter
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
    // area on this page, behind the masthead, however deep the masthead is.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
    // (gotcha: quote-frontmatter): an unquoted date prints nothing here.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };
// #endregion

// #region tags: four chip styles replace the built-in pale-blue chip
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];
// #endregion

// #region box: two columns inside a box set a paragraph beside its drawing
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
//   :::callout{type="bed" title="Sixteen squares by the gate"}
//   :::columns{count=2 breaks="2"}
//   The demonstration bed by the gate is 1.2 metres square …   ← block 1
//   ::resource{id="bed"}                                        ← block 2
//   :::
//   :::                                   (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
  padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
  titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
  body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
// #endregion

// #region band: a narrower figure still takes the whole band of its column
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
// #endregion

const head = (id, content, edge, x, y, look) => ({ kind: 'text', id, content, pages: 'body',
  align: edge.slice(4), placement: at('page', edge, x, y), ...look });
const folio = { elements: [ // page 2's head: title and date over a hairline, the folio right
  head('name', '{title} · {publishDate}', 'top-left', SIDE, 11, caps(7.5, 'leaf')),
  head('folio', '{pageNumber}', 'top-right', -SIDE, 9.6,
    { fontFamily: DISPLAY, fontSize: pt(12), color: col('tomato') }),
  { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), pages: 'body',
    placement: { ...at('page', 'top-left', SIDE, 15.5), size: { width: mm(210 - 2 * SIDE) } } },
] };

const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), ...look });
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes, chipStyles, layout, calloutStyles: [bedBox],
  page: { width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(20), bottom: mm(18), left: mm(SIDE), right: mm(SIDE) } },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('leaf'), // for a :ref label: the palette reaches bold but not this
    // colour, which would stay the default blue (gotcha: palette-skips-designs)
    // Ragged: no hyphens, no runt check (gotchas: ragged-no-hyphenation, ragged-runts)
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('leaf'), levels: [
    // Restated (gotcha: headings-drop-h1-break); span: 'page' sets the masthead over both columns
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginTop: pt(0),
      marginBottom: pt(LEAD / 2) },
    { level: 3, fontFamily: TEXT, fontWeight: 700, fontSize: pt(10.2), lineHeight: pt(LEAD),
      color: col('ink'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a brief's name
    { level: 4, ...caps(8.5, 'tomato'), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }, // a notice's name
  ] },
  headingStyles: [
    // marginBottom 0: the level's 0.5 em is added under minHeight and would drop the text a line
    { id: 'masthead', marginBottom: pt(0), advancedDesign: masthead },
    { id: 'rail', fontSize: pt(17), lineHeight: pt(2 * LEAD), color: col('tomato') },
    { id: 'jump', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD / 2) },
  ],
  paragraphStyles: [
    { id: 'standfirst', fontSize: pt(12), lineHeight: pt(16) },
    { id: 'byline', ...label(8, { color: col('muted'), marginBottom: pt(LEAD / 2) }) },
    { id: 'jump', ...label(8, { textAlign: 'right' }) }, // 'Continued on page 2'
    { id: 'from', ...label(8) }, // 'Continued from page 1'
    { id: 'colophon', ...label(7, { lineHeight: pt(9.4), color: col('muted'),
      marginTop: pt(LEAD) }) },
  ],
  captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), gap: mm(1.6),
    note: { fontFamily: LABEL, fontSize: pt(6.8), color: col('muted') } },
  header: folio,
  footer: { elements: [] }, // the folio is at the head of page 2; page 1 has the masthead
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "La Gaceta del Huerto"
subtitle: "Boletín de la Asociación Huertos del Soto"
publishDate: "Primavera 2026"
---

# La Gaceta del Huerto {style="masthead" issue="N.º 47"}

:chip[NOTICIAS]{style="tag"}

## Abren los cajones de compost compartidos

:::paragraphs{style="standfirst"}
Sesenta parcelas llenan ya tres cajones en vez de un montón cada una. El primero llegó a 63 °C en cinco días; su compost vuelve en septiembre.
:::

:::paragraphs{style="byline"}
Maribel Otero, secretaria de la asociación
:::

::resource{id="plan"}

El primer sábado de marzo, once socios montamos tres cajones con tablones de andamio recuperados junto a la puerta de arriba. Cada uno mide metro y medio de lado y un metro de alto, con tablas sueltas delante que se quitan para pasar el montón al cajón de al lado. Materiales Ruiz nos dio los tablones por lo que costó la furgoneta, y Ramón Ferrer trajo su taladro.

Hasta ahora cada parcela tenía su propio montón, y la mayoría no llegaba a calentarse. Un montón necesita cerca de un metro cúbico de material mezclado para guardar el calor, y pocos llenamos tanto en una temporada. En un solo cajón, con el estiércol que la hípica deja en la puerta, los mismos restos se calientan en pocos días.

:::paragraphs{style="jump"}
:chip[Sigue en la página 2]{style="jump"}
:::

:::columnbreak

## En breve {style="rail"}

### Limpieza de la acequia

:chip[JORNADA]{style="tag"} :chip[Dom. 12 de abril]{style="when"} Hay que sacar el barro y las cañas de la acequia antes de que empiece el riego. Julián Soria, que la limpia desde 1998, enseña a los nuevos. Hay azadas y guantes; quedamos en la caseta a las nueve.

### Intercambio de semillas

:chip[ACTIVIDAD]{style="tag"} :chip[Sáb. 11 de abril]{style="when"} Trae tus semillas sobrantes en sobres rotulados a la caseta, de diez a doce. Sirven las de variedades tradicionales; las de híbridos F1 no dan plantas iguales a la madre. Café y bizcocho, 1 €.

### Estiércol de caballo

:chip[GRATIS]{style="free"} La Hípica El Recreo deja un remolque en la puerta de arriba cada dos martes. Llévate lo que necesite tu parcela, pero llena antes el cajón uno.

### Tarros y una carpa

:chip[SE BUSCA]{style="tag"} Tarros limpios con tapa para el puesto de mermeladas y una carpa prestada: la feria de verano es el sábado 18 de julio. Deja los tarros en la caja que hay junto a la puerta.

### Vecinos nuevos

:chip[BIENVENIDA]{style="free"} Cuatro parcelas cambiaron de manos este invierno. Bienvenidos los Benali a la 9, Lucía y Tomás a la 23a, el señor Herrero a la 31 y el 5.º B del colegio San Blas, que cultiva la mitad de la 40.

:::columnbreak

:::paragraphs{style="from"}
:chip[Viene de la página 1]{style="jump"}
:::

## Qué va en los cajones {style="jump"}

El cajón uno se llenó el 7 de marzo. El día 12 marcaba 63 °C en el centro, y se mantuvo a 55 °C o más seis días, lo bastante para matar casi todas las semillas de malas hierbas. Las lecturas se apuntan con tiza en la pizarra de la puerta.

Llena un cajón cada vez, con verde y marrón en carretillas más o menos iguales: césped cortado, hierbas sin semilla, restos de cultivo y mondas con cartón roto, paja y estiércol. Trocea lo que sea más grueso que un pulgar. No eches raíces de coles con hernia, grama ni correhuela, ni comida cocinada, que atrae ratas.

::resource{id="heat"}

Al voltearlo entra aire en el centro y la temperatura vuelve a subir: el cajón uno, volteado el 18 de marzo, pasaba de 60 °C dos días después. Cada semana, dos parcelas se encargan de la horca según el turno de la caseta. En septiembre cada parcela se llevará dos carretillas de compost, y el resto irá al bancal de la puerta.

:::callout{type="bed" title="Dieciséis cuadros junto a la puerta"}
:::columns{count=2 breaks="2"}
El bancal de muestra de la puerta mide 1,2 metros de lado y tiene el mismo borde de tablones que los cajones. Este año lleva 15 centímetros de compost de la planta municipal; la próxima primavera llevará el nuestro. Unas cuerdas lo reparten en dieciséis cuadros de 30 centímetros, con una, cuatro, nueve o dieciséis plantas según el tamaño del cultivo. El 5.º B del colegio San Blas lo plantó el 20 de marzo y lleva su diario.

::resource{id="bed"}
:::
:::

:::columnbreak

## Avisos {style="rail"}

#### Cuotas

La cuota de 2026 es de 52 € por parcela y de 28 € por media, antes del 30 de abril. Se paga a Rafael Muñoz, en la 18, o por transferencia (datos en la caseta).

#### Riego

El pilón tiene agua de abril a octubre. La manguera es solo para llenar regaderas; nunca la dejes corriendo en un bancal.

#### Quemas

Prohibidas de abril a septiembre, y siempre que el viento sople hacia las casas del camino.

#### Casetas

Una caseta de más de dos metros de alto necesita el visto bueno de la junta. Manda un croquis con las medidas antes de comprarla.

#### Lista de espera

Hay treinta y una personas apuntadas. Este año saldrán unas ocho parcelas, casi todas medias.

#### Abejas

Carmen revisa las dos colmenas de la parcela 52 los sábados de sol de abril. Con su bandera puesta, no salgas del camino.

#### Perdida

Una regadera verde de diez litros, con alcachofa de latón, que se quedó junto al pilón. Si la tienes, devuélvela a la parcela 7.

#### Próximo número

Deja tus textos para el número de verano en el buzón de la caseta antes del 15 de junio.

:::paragraphs{style="colophon"}
La Gaceta del Huerto la escriben y maquetan socios de la Asociación Huertos del Soto. Compuesta en Work Sans, Titan One y Courier Prime (SIL Open Font License) · Texto original, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region art: the site plan, the heap's temperature and the square-metre bed
function mulberry32(seed) { // a seeded PRNG: the same plots 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
const f = (v) => +v.toFixed(2);
const P = palette;
const SOIL = mix(P.tomato, P.ink, 0.62);
const EARTH = mix(SOIL, P.straw, 0.28);
const GRASS = mix(P.tint, P.leaf, 0.3);
const DEEP = mix(P.leaf, P.ink, 0.35);
const svgOf = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`;
const dot = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`;
const rect = (x, y, w, h, fill, extra = '') => `<rect x="${f(x)}" y="${f(y)}" width="${f(w)}" `
  + `height="${f(h)}" fill="${fill}"${extra}/>`;
const line = (x1, y1, x2, y2, stroke, width) => `<path d="M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}" `
  + `stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"/>`;

// One plot seen from above: rows of crops across it, sometimes a shed or a greenhouse.
function plot(x, y, w, h, rand, extra) {
  let out = rect(x, y, w, h, EARTH);
  const strips = 2 + Math.floor(rand() * 3);
  let top = y + 0.8;
  for (let s = 0; s < strips; s++) {
    const room = y + h - 0.8 - top;
    if (room < 2) break;
    let sh = s === strips - 1 ? room : Math.max(4, (h - 1.6) * (0.2 + rand() * 0.3));
    if (room - sh < 4) sh = room; // no strip thinner than 4 mm
    const kind = Math.floor(rand() * 6);
    const [x0, x1] = [x + 0.9, x + w - 0.9];
    if (kind === 0) { // rows of lettuce and onions
      for (let yy = top + 1; yy < top + sh - 0.6; yy += 1.7) {
        for (let xx = x0 + 0.6; xx < x1; xx += 1.5) out += dot(xx, yy, 0.55, P.leaf);
      }
    } else if (kind === 1) { // peas and beans up canes
      for (let yy = top + 1.2; yy < top + sh - 0.8; yy += 2.6) out += line(x0, yy, x1, yy, DEEP, 1);
    } else if (kind === 2) { // brassicas under netting
      out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, mix(P.tint, P.paper, 0.4));
      for (let yy = top + 1.6; yy < top + sh - 1.2; yy += 3) {
        for (let xx = x0 + 1.4; xx < x1 - 0.8; xx += 3) {
          out += dot(xx, yy, 1.1, mix(P.leaf, P.rule, 0.45));
        }
      }
    } else if (kind === 3) { // rhubarb and squashes
      for (let xx = x0 + 2; xx < x1 - 1; xx += 4 + rand() * 2) {
        const yy = top + sh / 2 + (rand() - 0.5) * (sh - 3);
        out += dot(xx, yy, 1.8, P.leaf) + dot(xx + 0.6, yy - 0.5, 0.45, P.marigold);
      }
    } else if (kind === 4) { // dug over and raked
      for (let yy = top + 0.7; yy < top + sh - 0.3; yy += 0.9) {
        out += line(x0, yy, x1, yy, SOIL, 0.25);
      }
    } else { // straw round the strawberries
      out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, P.straw);
      for (let xx = x0 + 1; xx < x1; xx += 2.2) {
        out += dot(xx, top + sh / 2 - 0.2, 0.8, P.leaf)
          + dot(xx + 0.4, top + sh / 2 + 0.3, 0.3, P.tomato);
      }
    }
    top += sh;
  }
  if (extra === 'shed') { // a shed with its felt roof in tomato, and a water butt
    const sx = rand() < 0.5 ? x + 0.6 : x + w - 5.1;
    out += rect(sx, y + 0.6, 4.5, 3.4, P.tomato) + line(sx, y + 2.3, sx + 4.5, y + 2.3, SOIL, 0.3)
      + dot(sx + (sx > x + 1 ? -1.3 : 5.8), y + 1.6, 0.9, P.ink);
  } else if (extra === 'glass') { // a greenhouse: glass over the whole end of the plot
    out += rect(x + 0.8, y + h - 7.2, w - 1.6, 6.4, mix(P.paper, P.tint, 0.6))
      + [0.25, 0.5, 0.75].map((k) => line(x + 0.8 + k * (w - 1.6), y + h - 7.2,
        x + 0.8 + k * (w - 1.6), y + h - 0.8, P.rule, 0.3)).join('');
  }
  return out;
}

function planSvg(face) { // 114 × 56.5 mm: the top of the Wren Lane site, north up
  const rand = mulberry32(1921); // the year the society took the lease
  const [W, H, PATH] = [114, 56.5, 26.5];
  const label = (lx, ly, s, fill, anchor = 'start') => `<text x="${lx}" y="${ly}" `
    + `font-size="2.3" fill="${fill}" text-anchor="${anchor}">${s}</text>`;
  let out = rect(0, 0, W, H, GRASS) + rect(0, PATH, W, 4.5, P.straw); // the main path, gravel
  out += label(W - 2, PATH + 3, t({ en: 'MAIN PATH', es: 'CAMINO' }), SOIL, 'end');
  // The top gate at the end of the path, and the corner behind it.
  out += rect(0, PATH - 0.8, 1.2, 1.2, P.ink) + rect(0, PATH + 4.1, 1.2, 1.2, P.ink)
    + label(2.2, PATH + 3, t({ en: 'TOP GATE', es: 'PUERTA DE ARRIBA' }), SOIL);
  out += rect(2, 2.4, 11, 8, P.tomato) + rect(2, 6.4, 11, 4, mix(P.tomato, P.ink, 0.22)) // the hut
    + label(7.5, 7.2, t({ en: 'HUT', es: 'CASETA' }), P.paper, 'middle');
  out += rect(15.5, 3.2, 11.5, 3.6, mix(P.rule, P.ink, 0.3)) // the trough
    + rect(16.1, 3.8, 10.3, 2.4, mix(P.tint, P.rule, 0.4));
  for (let i = 0; i < 3; i++) { // the three bays in fresh boards: full, half full, empty
    const bx = 2 + i * 8.6;
    out += rect(bx, 13.5, 7.8, 7.8, P.marigold) + rect(bx + 0.8, 14.3, 6.2, 6.2, SOIL);
    for (let k = 0; k < 16 - i * 7; k++) {
      out += dot(bx + 1.5 + rand() * 4.8, 15 + rand() * 4.8, 0.5, i === 0 ? DEEP : EARTH);
    }
    out += label(bx + 3.9, 24.6, i + 1, P.ink, 'middle');
  }
  out += dot(25.5, 10.5, 3.6, DEEP); // the old pear
  // Plots: four above the path, five below; one split in halves, grass paths between.
  const row = (x0, x1, y, h, n, extras) => {
    const w = (x1 - x0 - (n - 1) * 1.4) / n;
    for (let i = 0; i < n; i++) {
      const px = x0 + i * (w + 1.4);
      if (extras[i] === 'halves') {
        const half = (h - 1.4) / 2;
        out += plot(px, y, w, half, rand) + plot(px, y + half + 1.4, w, half, rand, 'shed');
      } else out += plot(px, y, w, h, rand, extras[i]);
    }
  };
  row(31, W - 2, 2, PATH - 3.5, 4, ['shed', 'halves', '', 'shed']);
  row(2, W - 2, PATH + 6.5, H - PATH - 8.5, 5, ['', 'shed', 'glass', '', 'shed']);
  return svgOf(W, H, out, face);
}
// An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so the
// chart carries its label face inline, as a data URL of the Fontsource file.
async function inlineFace(family, weight) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}`
    + '-normal.woff2';
  const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return `<style>@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) `
    + `format('woff2')}text{font-family:L;font-weight:700}</style>`;
}
const HEAT_LOG = [12, 24, 38, 49, 57, 63, 64, 62, 59, 55, 51, 47, 56, 61, 62, 60, 57, 54, 51, 48,
  46, 44]; // °C at the heap's centre, 7 to 28 March; turned after the reading on the 18th
function heatSvg(face) { // 68 × 40 mm; the plot runs 7–28 March and 0–70 °C
  const [W, H, X0, X1, Y0, Y1] = [68, 40, 9, 66, 3, 33];
  const x = (day) => X0 + ((day - 7) / 21) * (X1 - X0);
  const y = (deg) => Y1 - (deg / 70) * (Y1 - Y0);
  const text = (tx, ty, s, fill, anchor = 'end', size = 2.5) => `<text x="${f(tx)}" y="${f(ty)}" `
    + `font-size="${size}" fill="${fill}" text-anchor="${anchor}">${s}</text>`;
  let out = rect(X0, y(65), X1 - X0, y(55) - y(65), P.straw); // where weed seeds die
  for (const deg of [20, 40, 60]) {
    out += line(X0, y(deg), X1, y(deg), P.rule, 0.2) + text(X0 - 1.2, y(deg) + 0.9, deg, P.muted);
  }
  out += text(X0 - 1.2, y(70) + 0.9, '°C', P.muted); // the unit, over the scale
  for (const day of [7, 14, 21, 28]) {
    out += line(x(day), Y1, x(day), Y1 + 1, P.ink, 0.25)
      + text(x(day), Y1 + 3.8, day === 28 ? t({ en: '28 March', es: '28 marzo' }) : day, P.muted,
        day === 28 ? 'end' : 'middle');
  }
  out += line(x(18.5), y(70), x(18.5), Y1, P.leaf, 0.35)
    + text(x(18.5) + 1, y(70) + 2.2, t({ en: 'turned', es: 'volteo' }), P.leaf, 'start');
  const pts = HEAT_LOG.map((deg, i) => [x(7 + i), y(deg)]);
  out += `<path d="M${pts.map(([px, py]) => `${f(px)} ${f(py)}`).join('L')}" fill="none" `
    + `stroke="${P.tomato}" stroke-width="0.55" stroke-linejoin="round"/>`;
  out += pts.map(([px, py]) => dot(px, py, 0.6, P.tomato)).join('');
  out += line(X0, Y1, X1, Y1, P.ink, 0.3);
  return svgOf(W, H, out, face);
}

// The square-metre bed: sixteen squares of 30 cm, one to sixteen plants in each.
const CROPS = { // plants per square, size and colour of each plant seen from above
  cabbage: [1, 4.2, mix(P.leaf, P.rule, 0.35)], lettuce: [4, 2.1, mix(P.leaf, P.straw, 0.35)],
  chard: [4, 1.9, mix(P.leaf, P.tomato, 0.3)], marigold: [4, 1.5, P.marigold],
  beetroot: [9, 1.1, mix(P.tomato, P.ink, 0.35)], beans: [9, 1.2, P.leaf],
  onion: [9, 0.8, mix(P.straw, P.paper, 0.3)], radish: [16, 0.6, P.tomato],
  carrot: [16, 0.55, mix(P.marigold, P.tomato, 0.35)],
};
const BED = [['cabbage', 'lettuce', 'lettuce', 'marigold'],
  ['beetroot', 'radish', 'carrot', 'onion'], ['chard', 'beans', 'beetroot', 'radish'],
  ['marigold', 'onion', 'carrot', 'lettuce']];
function bedSvg() { // 50 × 50 mm: 1.2 m of bed inside its scaffold boards
  const [S, BOARD] = [50, 2.6];
  const cell = (S - 2 * BOARD) / 4;
  let out = rect(0, 0, S, S, P.marigold) + rect(BOARD, BOARD, S - 2 * BOARD, S - 2 * BOARD, SOIL);
  BED.forEach((cropRow, r) => cropRow.forEach((crop, c) => {
    const [n, size, fill] = CROPS[crop];
    const k = Math.sqrt(n);
    for (let i = 0; i < n; i++) {
      const cx = BOARD + c * cell + ((i % k) + 0.5) * (cell / k);
      const cy = BOARD + r * cell + (Math.floor(i / k) + 0.5) * (cell / k);
      out += dot(cx, cy, size, fill);
      if (crop === 'marigold') out += dot(cx, cy, size * 0.4, P.tomato);
    }
  }));
  for (let i = 1; i < 4; i++) { // the strings
    const at = BOARD + i * cell;
    out += line(at, BOARD, at, S - BOARD, P.paper, 0.25)
      + line(BOARD, at, S - BOARD, at, P.paper, 0.25);
  }
  return svgOf(S, S, out);
}
// A drill of seedlings along the foot of the masthead band, 210 × 8 mm: some show only their
// seed leaves, the rest their first true leaves too.
function leaf(x, y, len, wid, deg, fill) { // a leaf from its stalk end, deg from the vertical
  const a = (deg * Math.PI) / 180;
  const [dx, dy] = [Math.sin(a), -Math.cos(a)];
  const [mx, my, px, py] = [x + (dx * len) / 2, y + (dy * len) / 2, -dy * wid, dx * wid];
  return `<path d="M${f(x)} ${f(y)}Q${f(mx + px)} ${f(my + py)} ${f(x + dx * len)} `
    + `${f(y + dy * len)}Q${f(mx - px)} ${f(my - py)} ${f(x)} ${f(y)}Z" fill="${fill}"/>`;
}
function drillSvg() {
  const rand = mulberry32(47); // the issue number
  const [W, H, RIDGE] = [210, 8, 1.3];
  const SEED = mix(P.leaf, P.straw, 0.3);
  let out = rect(0, H - RIDGE, W, RIDGE, SOIL);
  for (let x = 2.6; x < W - 1; x += 4.6 + rand() * 1.2) {
    const ground = H - RIDGE;
    const grown = rand() < 0.62;
    const stem = grown ? 2.6 + rand() * 1.2 : 1.2 + rand() * 0.8;
    const lean = (rand() - 0.5) * 10;
    out += line(x, ground, x, ground - stem, P.leaf, 0.45);
    const top = ground - stem;
    if (grown) {
      out += leaf(x, ground - stem * 0.45, 1.6, 0.55, -68 + lean, SEED)
        + leaf(x, ground - stem * 0.45, 1.6, 0.55, 68 + lean, SEED)
        + leaf(x, top, 2.6 + rand() * 0.5, 1, -32 + lean, P.leaf)
        + leaf(x, top, 2.6 + rand() * 0.5, 1, 32 + lean, P.leaf);
    } else {
      out += leaf(x, top, 1.7, 0.6, -58 + lean, SEED) + leaf(x, top, 1.7, 0.6, 58 + lean, SEED);
    }
  }
  return svgOf(W, H, out);
}

// What the drawings say under them, in the sample's language: caption, credit note, alt text.
const CAPTIONS = {
  plan: [t({
    en: '**The top of the Wren Lane site, from the air.** The three new bays stand between '
      + 'the hut and the top gate.',
    es: '**La parte alta del Soto, desde el aire.** Los tres cajones nuevos están entre la '
      + 'caseta y la puerta de arriba.' }),
  t({ en: 'Drawing: The Gazette, from the society’s site plan',
    es: 'Dibujo: La Gaceta, a partir del plano de la asociación' }),
  t({ en: 'Plan of allotment plots seen from above, with three compost bays by the gate.',
    es: 'Plano de parcelas de huerto vistas desde arriba, con tres cajones de compost junto a la '
      + 'puerta.' })],
  heat: [t({
    en: '**Bay one in March.** Temperature at the centre of the heap, read at nine each '
      + 'morning. In the shaded band, 55 to 65 °C, most weed seeds die.',
    es: '**El cajón uno en marzo.** Temperatura en el centro, a las nueve. En la franja, de '
      + '55 a 65 °C, mueren casi todas las semillas de malas hierbas.' }),
  t({ en: 'Readings: the compost rota', es: 'Lecturas: el turno del compost' }),
  t({ en: 'Line chart: the heap rises from 12 °C to 64 °C in six days, cools to 47 °C, and '
      + 'climbs back to 62 °C after it is turned on 18 March.',
    es: 'Gráfico de líneas: el montón sube de 12 °C a 64 °C en seis días, baja a 47 °C y vuelve '
      + 'a 62 °C después del volteo del 18 de marzo.' })],
  bed: [t({
    en: '**What goes where.** One cabbage to a square; four lettuces, chard or marigolds; '
      + 'nine beetroot, beans or onions; sixteen radishes or carrots.',
    es: '**Cómo se planta el bancal.** Una col por cuadro; cuatro lechugas, acelgas o '
      + 'caléndulas; nueve remolachas, judías o cebollas; dieciséis rábanos o zanahorias.' }),
  t({ en: 'Drawing: The Gazette', es: 'Dibujo: La Gaceta' }),
  t({ en: 'A square bed divided by strings into sixteen squares, each planted with one to '
      + 'sixteen plants.', es: 'Un bancal cuadrado dividido con cuerdas en dieciséis cuadros, '
      + 'cada uno con entre una y dieciséis plantas.' })],
};
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Text, display and label faces, loaded before the build (gotcha: fonts-first).
const FONTS = {
  'Work Sans': ['400', '700'], 'Titan One': ['400'], 'Courier Prime': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
const face = await inlineFace(LABEL, 700); // the label face, for the drawings' own lettering
await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)),
  loadSvg('bed.svg', bedSvg()), loadSvg('drill.svg', drillSvg())]);
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources: resources() }, config()), markdown);
showPages(doc, { title: t({ en: 'Community newsletter', es: 'Boletín vecinal' }) });

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

### Pon los breves a la izquierda

La columna ancha se sigue leyendo primero, así que el mismo Markdown pone la noticia a la derecha y los breves a la izquierda.

```diff
   layoutType: 'oneAndHalf',
+  sideColumnSide: 'left',
```

### Pon los breves a lo ancho de la página

El cuerpo tiene como mucho dos columnas de texto. La [portada de periódico](https://postext.dev/es/cookbook/newspaper-front-page.md) reparte cuatro breves a lo ancho del pie de la página en un recuadro con `:::columns{count=4}`.

## Errores frecuentes

- **Un párrafo conserva la medida de la columna en la que empieza.** En postext 1.4.1 un párrafo se corta en líneas una sola vez, con el ancho de la columna en la que empieza. En una página a columna y media con texto en las dos columnas, un párrafo que pasa de la columna ancha a la estrecha conserva sus líneas largas, que se recortan en el borde de la columna estrecha; uno que pasa de la columna estrecha a la ancha de la página siguiente conserva sus líneas cortas. Termina cada columna con un párrafo completo: ajusta el texto y pon un :::columnbreak después del último párrafo, para que el bloque siguiente abra la columna siguiente.
- **:::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.
- **Entrecomilla cada valor del frontmatter.** YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984".
- **En el texto en bandera no hay separación silábica.** La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida.
- **En el texto en bandera no se evitan las líneas cortas.** optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **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 texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **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.

- En esta disposición, el filete de columna de la página 1 empieza en lo alto de la caja de texto, detrás de la mancheta, reserve esta las líneas que reserve. La franja color paja se pinta encima; una mancheta sin franja de color dejaría ver el filete cruzando el nombre.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imágenes: El plano de las parcelas, el gráfico de temperaturas, el bancal de un metro cuadrado y la hilera de plantones bajo la mancheta, dibujados con código en la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Work Sans (OFL-1.1), Titan One (OFL-1.1), Courier Prime (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 001 · Libro de texto con columna al margen](https://postext.dev/es/cookbook/textbook-margin-column.md): Columna y media con la columna exterior solo para flotantes: en ella se apilan figuras y glosas con span 'side', y captionSide lleva allí los demás pies. · Nivel 3 (Avanzado) · Libros de texto
- [N.º 051 · Fanzine riso a dos tintas: ilustración a una tinta](https://postext.dev/es/cookbook/riso-zine-single-ink.md): Fanzine para risógrafo de dos tambores: los dibujos a todo color salen en tonos de rosa, recoloreados en el canvas por el código y en el PDF por renderToPdf. · Nivel 3 (Avanzado) · Revistas y fanzines
- [N.º 009 · Figuras que flotan hasta donde las citas](https://postext.dev/es/cookbook/figures-float-where-cited.md): Capítulo a dos columnas con siete figuras numeradas: seis flotan de su primer :ref al primer hueco que admite su colocación; una va donde la pone ::resource. · Nivel 3 (Avanzado) · Libros de texto
