# Recuadros de libro de texto con código de colores

> Seis tipos de recuadro en tres colores, que se distinguen por una franja con icono, un distintivo, una pestaña numerada, pictogramas o una marca lateral.

- Versión HTML: https://postext.dev/es/cookbook/textbook-box-family
- Receta N.º 008 · Recuadros y notas · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Libros de texto
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [27](https://postext.dev/cookbook/textbook-box-family/es/p01.webp?v=b1177702), [28](https://postext.dev/cookbook/textbook-box-family/es/p02.webp?v=b1177702), [29](https://postext.dev/cookbook/textbook-box-family/es/p03.webp?v=b1177702)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/textbook-box-family.md)

## Lo que vas a componer

El capítulo 2 de *Materia viva*, libro de biología de bachillerato en página de 210 × 280 mm, se abre bajo una célula que se sale por la esquina superior de la página, con el número del capítulo dentro. Siguen seis tipos de recuadro en tres colores, cada uno con un elemento gráfico propio, para que el alumno distinga un objetivo de una advertencia antes de leerlos. En magenta van los objetivos, sobre una franja con una diana, y la autoevaluación, con una casilla marcada fuera del marco. En verde, el «Para recordar», junto a un filete fino rematado por una bombilla, y la práctica, con tres pictogramas de seguridad. La advertencia es ámbar y lleva un distintivo de alerta en la esquina exterior. El RECUADRO 2.1 es lavanda, bajo una pestaña negra y un matraz. Las negritas toman el color del recuadro, magenta en el RECUADRO 2.1.

**Esta receta responde a:**

- ¿Cómo hago recuadros de objetivos, consejos y advertencias con icono, franja, esquinas redondeadas o pestaña?
- ¿Cómo añado una pestaña numerada («RECUADRO 1-1»), un icono de esquina o un icono al margen con un filete?
- ¿Cómo doy color a los términos clave (en negrita o cursiva) en el texto o dentro de los recuadros?

## La respuesta corta

```js
// script.js, líneas 40–61
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
```

## Ingredientes

**Enseña**

- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Estilos de recuadro con nombre para notas, consejos y advertencias: fondo, borde, radio, franja, título y tipografía propia de texto y listas.
- [Pestañas numeradas en los recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Una pestaña en el borde del recuadro («RECUADRO 1-1») tomada del atributo label de la valla.
- [Iconos y distintivos de esquina](https://postext.dev/es/docs/configuration.md#estilos-de-aviso): Un glifo o una imagen junto al contenido de un recuadro, colgado de su esquina superior o como tira ancha de pictogramas.

**También usa**

- [Columna de marca junto al recuadro](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns)
- [Recuadros a todo el ancho](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Recuadros flotantes](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Negrita, cursiva y sus colores](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [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)
- [Imágenes en los diseños de página](https://postext.dev/es/docs/configuration.md#elementos-de-imagen)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Saltos de línea en los títulos](https://postext.dev/es/docs/document-format.md#saltos-de-línea-en-los-títulos)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration.md#márgenes-simétricos-espejo)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [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)

**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), [`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), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration.md#listas-ordenadas), [`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), [`unorderedLists`](https://postext.dev/es/docs/configuration.md#listas-no-ordenadas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`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**

- Noto Serif (OFL-1.1), Lexend (OFL-1.1), Barlow Semi Condensed (OFL-1.1)

## Elaboración

### 1 · Dale un color a cada tipo de recuadro

```js
// script.js, líneas 13–21
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

Seis tipos de recuadro se reparten tres tonos: magenta para los objetivos y la autoevaluación, verde para el «Para recordar» y la práctica, y ámbar para la advertencia. El RECUADRO 2.1 tiene fondo lavanda y usa el magenta en sus términos clave. Cada tono tiene un tinte claro para los fondos, y todos los ajustes de color enlazan con una entrada de la paleta, así que para cambiar un tono basta con editar un valor. Con un cuerpo tan pequeño, el texto necesita un contraste de 4,5:1. El magenta sobre el lavanda llega a 4,9:1, pero el ámbar sobre el fondo de la advertencia se queda en 2,2:1; por eso la advertencia reserva el ámbar para el marco y el distintivo, y compone el título y los términos clave en `warnInk`, un ámbar más oscuro que llega a 5,2:1.

### 2 · Comparte todo lo demás

```js
// script.js, líneas 27–36
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
```

`box()` reúne los ajustes comunes a todos los recuadros: los márgenes, el relleno, la letra del título y del cuerpo, y el espaciado de las listas. Su argumento `hue` colorea el título, las viñetas y números y las negritas. `body.boldColor` pone en magenta el **modelo de mosaico fluido** de los objetivos y en ocre la **pared celular** de la advertencia; las negritas del texto corrido siguen en tinta ([tipografía dentro de un recuadro](/es/docs/configuration#tipografía-dentro-de-un-recuadro)). Si declaras `calloutStyles`, sustituyes el único estilo que trae el motor, un recuadro gris llamado `note`. Un recuadro cuyo `type` falta o está mal escrito toma el primer estilo, así que la lista empieza por los objetivos, que aparecen en todos los capítulos.

### 3 · Franja, pestaña y distintivo

El código es [la respuesta corta](#la-respuesta-corta) de arriba. La franja de los objetivos mide 7,5 mm y su diana 5 mm, así que el icono queda dentro del color con aire a los dos lados. El `offset` de la pestaña es igual a su altura (`height`), de modo que la pestaña se apoya en el borde superior del recuadro, sin tocar el título. `'top-left'` es la esquina exterior de esta página par; el matraz va a continuación de la pestaña, y el filete recorre el borde superior desde el matraz hasta la esquina opuesta. `position: 'corner'` cuelga el distintivo de la advertencia con la mitad de su ancho fuera del borde, y `cornerSide: 'outer'` lo lleva al lado exterior, el izquierdo en esta página par. En ese lado el motor sangra el título para que no choque con el distintivo, salvo que el relleno ya cubra medio distintivo más el espacio del título; por eso la advertencia lleva `BADGE / 2 + GAP` de relleno a los dos lados, y el título queda alineado con el texto. Solo este recuadro lleva `borderRadius`. En uno con franja, la franja se pinta aparte, como una barra rectangular, y las esquinas de ese lado seguirían rectas ([estilos de aviso](/es/docs/configuration#estilos-de-aviso)).

### 4 · Tres elementos gráficos más

```js
// script.js, líneas 65–78
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
```

La franja del «Para recordar» mide 2,5 pt, frente a los 6,4 mm de su distintivo, de modo que se ve como un filete fino con la bombilla encima de su extremo superior; un relleno izquierdo de medio distintivo más 2 mm aparta el texto de la bombilla. `icon.width` encaja la imagen en una caja ancha en vez de en un cuadrado, así que los tres pictogramas de seguridad, dibujados como una sola imagen de 24,5 mm, forman la columna de iconos del recuadro de seguridad, centrada respecto a su texto. La marca de la autoevaluación va fuera del marco, en este orden: marca, filete, hueco y caja. Su casilla cuadrada sobre un filete de 0,75 pt la distingue del «Para recordar», que lleva un distintivo redondo. La valla añade `span="page" placement="bottom"` para que la autoevaluación flote al pie de la [página 29](https://postext.dev/cookbook/textbook-box-family/es/p03.webp?v=b1177702).

### 5 · Los iconos también son recursos

```js
// script.js, líneas 240–265
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
```

Todos los iconos, el matraz de la pestaña y la célula de la apertura son recursos SVG declarados junto al contenido y registrados por su identificador de archivo. Los estilos los nombran por su id de recurso y, como el texto no los cita, ninguno se convierte en una figura numerada. Glifos como ✓ o ☞ serían más cómodos de escribir, pero no están en los archivos latin de Fontsource que cargan las páginas: el lienzo los tomaría de una fuente del sistema y un PDF los perdería. Los dibujos tampoco llevan texto, porque un SVG pintado como imagen no puede usar las fuentes de la página; los colores se nombran en los pies.

### 6 · Abre el capítulo con una célula

```js
// script.js, líneas 82–101
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
```

La célula es un elemento de imagen anclado a la esquina superior derecha de la página y desplazado más allá de ella, de modo que asoma sobre todo su cuarto inferior izquierdo, con el número dentro. Ambos caben en los 66 mm que `minHeight` reserva para la apertura. El antetítulo y la entradilla salen de la línea del título, `# Células y \\ membranas {unit="La célula" lead="…"}`, y el `\\` parte el título solo en la apertura.

## 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/textbook-box-family

### script.js

```js
// ═══ Postext Cookbook · Nº 008 · A family of textbook boxes ═══════════════════════
// https://postext.dev/en/cookbook/textbook-box-family
// Code: MIT · Text: original (CC BY 4.0) · Drawings and icons: generated in code (CC BY 4.0)
// Fonts: Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: three hues for six kinds of box; everything links to an entry
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
  band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
  tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
  warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
  mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
// Label face (box text and titles, tabs, captions, heads); leading in pt; outer margin in mm
const LABEL = 'Barlow Semi Condensed', LEAD = 13.4, OUTER = 16, GAP = 1.5; // GAP: title gap, mm

// #region base: what every box shares; each kind brings its colour and its one device
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
  padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
  body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
    boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
  ...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
// #endregion

// #region answer: three devices: an icon on a wide stripe, a numbered tab, an outer badge
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
  // The first style is also the one a missing or misspelt type falls back to.
  box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
    background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
    icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
  box('feature', 'band', { background: col('mist'),
    titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
    // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
    label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
      height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
      icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
      rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
  box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
    border: { enabled: true, color: col('warn'), width: pt(0.75) },
    // The badge hangs half past the outer corner: sides of half badge + GAP align the title.
    padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
      left: mm(BADGE / 2 + GAP) },
    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
// #endregion

// #region more: a badge threaded on a thin rule, a strip of pictograms, a marker outside
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
  box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
    backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
    // A stripe narrower than its icon: the badge sits on it at the top and hides it there.
    padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
    icon: icon('bulb', BULB) }),
  box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
    icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
  box('check', 'band', { background: col('tintBand'),
    // Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
    marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
      rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
// #endregion

// #region opener: a cell cut by the corner of the page, the chapter number inside it
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
  { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
  text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
    edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
    { fontWeight: 800, lineHeight: 1, align: 'right' }),
  text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(3) } },
    { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
  text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
    { fontWeight: 700, lineHeight: 1.04 }),
  text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
    { italic: true, lineHeight: 1.42 }),
] } };
// #endregion

// Heads 12.8 mm below the trim (the larger folio 0.4 mm higher), titles 8.5 mm in from folios.
const head = (id, content, parity, edge, x, extra, y = 12.8) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.8), fontWeight: 600,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } } });
const folio = { fontFamily: 'Lexend', fontSize: pt(9), fontWeight: 700, color: col('band') };
const header = { elements: [ // folios on the fore-edge, never on the opener
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio, 12.4),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8.5),
  head('recto-title', `${chapter} · {chapterTitle}`, 'odd', 'top-right', -(OUTER + 8.5)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio, 12.4),
] }; // the opener's folio drops to the foot:
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio, -12),
  pages: 'opener' }] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales)
  resourceTypes: defaultResourceTypes(LANG), // "Figura" (gotcha: resource-types-locale)
  colorPalette, header, footer,
  page: { width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(24), bottom: mm(22),
    left: mm(20), right: mm(OUTER), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'double', gutterWidth: mm(8) },
  bodyText: { fontFamily: 'Noto Serif', fontSize: pt(9.4), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // hyphens: default
  headings: { fontFamily: 'Lexend', color: col('band'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
      marginTop: pt(LEAD * 1.4), marginBottom: pt(LEAD * 0.6) }, // 3 lines, close to its text
    { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('tip'),
      marginTop: pt(LEAD), marginBottom: pt(0) }, // a line clear of the text, on its box
  ] },
  // In 1.4.1 a box's lists.color reaches its numbers only if it differs from these bullets'.
  unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: 'Lexend', color: col('tip'), marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [...calloutStyles, ...moreStyles],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.8), color: col('ink'),
    labelColor: col('band'), gap: mm(1.8) },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.6), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Materia viva"
subtitle: "Biología para bachillerato"
---

# Células y \\ membranas {unit="La célula" lead="Toda célula viva está envuelta en una película diez mil veces más fina que una hoja de papel. El oxígeno la cruza directamente; las sales y los azúcares solo pasan por donde una proteína les abre paso."}

Una célula es un taller abarrotado y lleno de agua. Sus enzimas necesitan sales, azúcares y piezas a mano, y los desechos deben salir antes de estorbar. Entre ese taller y el mundo se interpone la **membrana plasmática**, una capa de unos ocho nanómetros de grosor. En este capítulo verás de qué está hecha y por qué se comporta más como un líquido que como un muro. Algunas sustancias la cruzan sin que la célula gaste nada; otras, solo si la célula gasta energía.

:::callout{type="objectives"}
- Usar el **modelo de mosaico fluido** para describir la membrana plasmática.
- Explicar cómo la **difusión** y la **ósmosis** mueven sustancias sin gastar energía.
- Predecir cómo cambia una célula en una disolución hipertónica, isotónica o hipotónica.
- Comparar el **transporte activo** con el pasivo.
:::

## Una frontera con puertas

Las membranas están hechas sobre todo de **fosfolípidos**. Cada molécula tiene una cabeza afín al agua y dos colas grasas que la rehúyen. En el agua, los fosfolípidos se ordenan de modo que las colas queden ocultas: forman una doble capa, la **bicapa lipídica**, con las cabezas vueltas hacia el medio acuoso de ambos lados y las colas enfrentadas en el centro (:ref{id="mosaic"}).

Como su interior es oleoso, la bicapa deja que las moléculas pequeñas y sin carga, como el oxígeno y el dióxido de carbono, la crucen sin más. Los iones y las moléculas polares más grandes, como la glucosa, se quedan fuera. Esa propiedad de dejar pasar unas sustancias y otras no se llama **permeabilidad selectiva**. Gracias a ella, la célula conserva un interior distinto de su entorno; a cambio, los iones y la glucosa necesitan proteínas de la membrana para cruzarla.

:::callout{type="tip"}
Lee el nombre del modelo como una descripción. **Fluido** quiere decir que los lípidos se desplazan de lado e intercambian su sitio con sus vecinos millones de veces por segundo, y **mosaico** significa que las proteínas están incrustadas en la capa como las teselas de un suelo.
:::

## El mosaico fluido

En 1972, S. Jonathan Singer y Garth Nicolson propusieron el modelo que seguimos usando hoy. La bicapa es la base fluida y en ella flotan las proteínas: unas atraviesan la membrana de lado a lado y otras se apoyan en una de sus caras. Las **proteínas de canal** forman poros llenos de agua para iones concretos, y las **proteínas transportadoras** cambian de forma para llevar una molécula de un lado al otro. Hacia fuera asoman cadenas de azúcares, unidas a proteínas y lípidos, que otras células reconocen como etiquetas de identidad.

El colesterol, encajado entre las colas de los fosfolípidos, actúa como amortiguador. Con calor sujeta las colas y evita que la membrana se vuelva demasiado líquida; con frío impide que se apiñen. Los seres vivos ajustan sus membranas a la temperatura: las bacterias que crecen en frío fabrican lípidos con colas más dobladas para que su capa siga fluida, y muchas plantas hacen lo mismo al llegar el invierno, igual que los peces de las aguas heladas.

:::callout{type="feature" label="RECUADRO 2.1" title="Proteínas a la deriva" span="page"}
:::columns{count=2 breaks="2"}
::resource{id="fusion"}

En 1970, Larry Frye y Michael Edidin fusionaron una célula de ratón con una humana. Habían marcado las proteínas de membrana del ratón con un colorante fluorescente verde y las humanas con uno rojo. Al principio, cada color se quedaba en su mitad de la célula híbrida (:ref{id="fusion"}), pero a los cuarenta minutos, a 37 °C, los colores estaban mezclados por completo. En frío, los híbridos tardaban mucho más en mezclarse. Las proteínas iban solas **a la deriva** por una capa fluida que el frío volvía más rígida.
:::
:::

:::callout{type="warning" title="¿Membrana o pared?"}
Las plantas, los hongos y la mayoría de las bacterias tienen además una **pared celular** por fuera de la membrana. La pared da forma a la célula y evita que estalle, pero deja pasar casi todo. Solo la membrana tiene permeabilidad selectiva.
:::

Los lípidos y muchas de las proteínas de la membrana se mueven sin parar, y aun así la capa no se deshace, porque las colas de cada lípido siguen a resguardo del agua vaya donde vaya. Por la misma razón, un agujero pequeño en una bicapa se cierra solo.

## Atravesar la membrana

Las partículas de una disolución no dejan de moverse nunca. La **difusión** es su movimiento neto desde donde están más concentradas hacia donde lo están menos, hasta repartirse por igual. No le cuesta energía a la célula, porque el propio movimiento de las partículas hace el trabajo: el oxígeno entra por difusión en una célula que lo consume sin parar, y el dióxido de carbono sale del mismo modo.

La **ósmosis** es la difusión del agua a través de una membrana con permeabilidad selectiva. Si la disolución que rodea a una célula tiene más partículas disueltas que el citoplasma, es **hipertónica**, y la célula pierde agua y se encoge; si tiene menos, es **hipotónica**, y el agua entra a raudales; si las dos están igualadas, es **isotónica**, y la célula conserva su tamaño (:ref{id="osmosis"}). Un glóbulo rojo en agua pura se hincha hasta reventar. Las células vegetales no revientan: la pared las mantiene firmes, y por eso una planta regada se sostiene erguida y una seca se marchita.

La difusión es rápida en distancias cortas y lentísima en las largas: una molécula de oxígeno cruza una célula en centésimas de segundo, pero tardaría unas siete horas en recorrer un centímetro de agua. Esa es una de las razones de que las células sean pequeñas y de que los animales grandes necesiten pulmones, branquias y sangre.

El paso a favor de gradiente a través de un canal o de una proteína transportadora se llama **difusión facilitada** y sigue siendo pasivo: la proteína abre el camino, pero no gasta energía. Para mover una sustancia cuesta arriba, de la concentración baja a la alta, la célula tiene que gastar energía, normalmente en forma de ATP; ese paso se llama **transporte activo**. La bomba de sodio y potasio de tus neuronas saca tres iones de sodio y mete dos de potasio por cada ATP, y bombas como ella consumen buena parte de la energía de una célula en reposo.

:::callout{type="check" title="Comprueba lo que sabes" span="page" placement="bottom"}
:::columns{count=2 breaks="4"}
1. ¿Por qué el oxígeno cruza la bicapa y la glucosa no?
2. ¿Qué significa **fluido** en el modelo de mosaico fluido?
3. Dejas una rodaja de pepino en agua con sal. Usa la **ósmosis** para predecir qué les pasa a sus células.
4. ¿Por qué el trabajo de la bomba de sodio y potasio es **transporte activo** y no difusión?
5. ¿Podría una célula de membrana rígida usar la **endocitosis** para tragarse una bacteria? Explícalo.
:::
:::

## Cargas a granel

Hay cargas demasiado grandes para cualquier canal o transportadora. Un glóbulo blanco se traga una bacteria entera: la envuelve con su membrana y la encierra en una burbuja, una **vesícula**, que se desprende hacia el citoplasma. Es la **endocitosis**. Las células también beben así: en la pinocitosis, la membrana se repliega alrededor de una gotita del líquido exterior, con todo lo que lleve disuelto.

El proceso inverso, la **exocitosis**, saca materiales al exterior: las vesículas cargadas de hormonas o de enzimas digestivas se funden con la membrana plasmática y vacían fuera su contenido. Casi todas las señales que una neurona pasa a la siguiente salen del mismo modo: las vesículas liberan unas moléculas mensajeras, los neurotransmisores, en el estrecho espacio que separa las dos células. Ambos procesos gastan energía y solo funcionan porque la bicapa es lo bastante fluida para romperse y cerrarse sin fugas.

Las membranas no se quedan en la superficie: dentro de la célula, la misma bicapa envuelve el núcleo, las mitocondrias y un laberinto de compartimentos. En el capítulo 3 seguirás a una proteína desde que se fabrica hasta que sale de la célula.

### Experimenta: ósmosis en una patata

:::callout{type="safety" title="Seguridad"}
Ponte gafas y guantes de protección, y corta alejando el filo de los dedos.
:::

1. Corta dos tiras iguales de patata, de unos cinco centímetros, y pésalas.
2. Deja una en agua del grifo y otra en agua muy salada durante media hora.
3. Sécalas, vuelve a pesarlas e intenta doblarlas.
4. Explica con la ósmosis por qué han cambiado su masa y su rigidez.

:::paragraphs{style="colophon"}
**Materia viva** es un libro de texto inventado para el Recetario de Postext, con texto e ilustraciones originales (CC BY 4.0). Compuesto en Noto Serif, Lexend y Barlow Semi Condensed (SIL OFL).
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region icons: icons are resources too: declared with the content, registered by file id
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
  // Uncited, so never placed as figures: the box styles and the opener use them by id.
  ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
  svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
  svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
    en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
      + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
    es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
      + 'glucosa, colesterol gris, azúcares ámbar.' }),
    altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
  svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
    en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
      + 'hybrid just after fusion and the same hybrid 40 minutes later.',
    es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
      + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
    altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
  svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
    en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
      + 'net flow of water.',
    es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
      + 'indican el flujo neto de agua.' }),
    altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
// #endregion

// #region art: the icons and the drawings, in the palette's colours (seeded)
// No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
function rng(seed) { // Mulberry32: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let x = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
    return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
  };
}
const n = (v) => +v.toFixed(2);
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" `
  + `fill="${fill}"${extra}/>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`;
const capsule = (x, y, w, h, fill, turn = 0, extra = '') => `<rect x="${n(x - w / 2)}" `
  + `y="${n(y - h / 2)}" width="${n(w)}" height="${n(h)}" rx="${n(Math.min(w, h) / 2)}" `
  + `fill="${fill}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})"${extra}/>`;
const arrow = (x, y, len, turn, fill) => shape(`M${x} ${y - 0.9}h${len - 4}v-1.9l4 2.8-4 2.8`
  + `v-1.9H${x}Z`, fill, ` transform="rotate(${turn} ${x} ${y})"`); // a path, never a marker

function target() { // white rings on the magenta stripe
  return svg(24, 24, `<g fill="none" stroke="${palette.paper}" stroke-width="2.3">`
    + `<circle cx="12" cy="12" r="9.8"/><circle cx="12" cy="12" r="5.3"/></g>`
    + dot(12, 12, 1.9, palette.paper));
}
function bulb() {
  return svg(24, 24, dot(12, 12, 12, palette.tip) + shape('M12 4.4a5.6 5.6 0 0 0-3.3 10.1c.7.5 1 '
    + '1.1 1 1.9v.6h4.6v-.6c0-.8.3-1.4 1-1.9A5.6 5.6 0 0 0 12 4.4Z', palette.paper)
    + capsule(12, 18.6, 4.6, 1.3, palette.paper) + capsule(12, 20.3, 3, 1.2, palette.paper));
}
function flask() {
  const body = 'M8 2v7L2.4 19.4A2.1 2.1 0 0 0 4.3 22.5h11.4a2.1 2.1 0 0 0 1.9-3.1L12 9V2';
  return svg(20, 24, shape(`${body}Z`, palette.paper) + shape('M5.3 14h9.4l3 5.4a1.1 1.1 0 0 1-1 '
    + '1.6H3.3a1.1 1.1 0 0 1-1-1.6Z', palette.band) + dot(8.6, 17.4, 1.1, palette.paper)
    + dot(11.8, 18.7, 0.8, palette.paper) + line(body, palette.ink, 1.7)
    + line('M6.4 2h7.2', palette.ink, 1.7));
}
function caution() { // ink on amber: white on amber would fail contrast
  return svg(24, 24, dot(12, 12, 11, palette.warn, ` stroke="${palette.paper}" stroke-width="2"`)
    + capsule(12, 10, 2.8, 9.2, palette.ink) + dot(12, 17.6, 1.7, palette.ink));
}
function check() { // a ticked tile: square where the study tip's badge is round
  return svg(24, 24, `<rect width="24" height="24" rx="5.5" fill="${palette.band}"/>`
    + line('M6.3 12.6l3.8 3.8 7.6-8', palette.paper, 2.9));
}
function safety() { // goggles, a glove and a blade: three mandatory-action discs
  const goggles = `<g fill="none" stroke="${palette.paper}" stroke-width="1.5">`
    + '<rect x="4.3" y="9" width="6.6" height="5.6" rx="2.4"/>'
    + '<rect x="13.1" y="9" width="6.6" height="5.6" rx="2.4"/></g>'
    + line('M10.9 11.4q1.1-1 2.2 0M2.6 11.6h1.7M19.7 11.6h1.7', palette.paper, 1.4);
  const glove = shape('M32.4 20.5v-6.6l-2.3-2.7a1.2 1.2 0 0 1 1.8-1.6l1.4 1.5V6.2a1.1 1.1 0 0 1 '
    + '2.2 0v5.2V4.9a1.1 1.1 0 0 1 2.2 0v6.5V5.5a1.1 1.1 0 0 1 2.2 0v6.2V7a1.1 1.1 0 0 1 2.2 0v8.6'
    + 'l-1.2 4.9Z', palette.paper);
  const blade = `<g transform="rotate(-45 64 12)">${capsule(64, 16.3, 3.6, 9, palette.paper)}`
    + shape('M62.2 11.4V6.3L64 3.2l1.8 3.1v5.1Z', palette.paper) + '</g>';
  return svg(76, 24, [12, 38, 64].map((x) => dot(x, 12, 11.4, palette.ink)).join('')
    + goggles + glove + blade);
}
function blob(cx, cy, rx, ry, r, fill, extra = '', square = 2.6) { // a soft, uneven shape
  const pts = Array.from({ length: 16 }, (_, i) => {
    const [c, sn] = [Math.cos((i * Math.PI) / 8), Math.sin((i * Math.PI) / 8)];
    const k = 1 + (r() - 0.5) * 0.12;
    const f = (v) => Math.sign(v) * Math.abs(v) ** (2 / square); // squarer than an ellipse
    return [cx + f(c) * rx * k, cy + f(sn) * ry * k];
  });
  const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2].map(n);
  const d = pts.map((p, i) => `Q${p.map(n)} ${mid(p, pts[(i + 1) % 16])}`).join('');
  return shape(`M${mid(pts[15], pts[0])}${d}Z`, fill, extra);
}
const shine = (cx, cy, rx, ry, r) => blob(cx - rx * 0.28, cy - ry * 0.34, rx * 0.46, ry * 0.4,
  r, palette.paper, ' fill-opacity=".22"');
const pale = ` stroke="${palette.tip}" stroke-width="1.6"`; // a light green protein, outlined
const sugars = (x, y, r, count = 5) => { // a chain of sugars, branched once
  const sugar = (sx, sy) => dot(sx, sy, 2.1, palette.warn,
    ` stroke="${palette.paper}" stroke-width=".7"`);
  let out = '';
  for (let k = 0; k < count; k++, y -= 4.3, x += (r() - 0.5) * 4) {
    out += sugar(x, y) + (k === 2 ? sugar(x + 4.3, y - 1.6) : '');
  }
  return out;
};
function cell() { // a cell drawn as a disc of bilayer: mostly its lower-left quarter shows
  let out = dot(100, 100, 71, palette.band);
  for (let a = 0; a < 360; a += 3.05) {
    const [c, s] = [Math.cos((a * Math.PI) / 180), Math.sin((a * Math.PI) / 180)];
    const at = (d) => [100 + c * d, 100 + s * d];
    const tail = (d0, d1) => line(`M${at(d0).map(n)}L${at(d1).map(n)}`, palette.rule, 0.8);
    out += tail(90.5, 84.8) + tail(75.8, 81.2) + dot(...at(92.6), 2.6, palette.band)
      + dot(...at(73.6), 2.5, palette.paper);
  }
  [106, 133, 157, 184].forEach((a, k) => { // proteins across the visible arc
    const rad = (a * Math.PI) / 180;
    const fill = k % 2 ? palette.warn : palette.tip;
    out += capsule(100 + Math.cos(rad) * 83, 100 + Math.sin(rad) * 83, 26, 7, fill, a);
  });
  return svg(200, 200, out);
}
function mosaic() { // the membrane in section, 174 × 62 mm; the outside of the cell on top
  const r = rng(5);
  const W = 348;
  const yc = (x) => 70 + 3.4 * Math.sin(x / 44);
  const proteins = [[56, 34, palette.tip, 'channel'], [148, 38, palette.warn, 'carrier'],
    [234, 27, palette.tip], [306, 32, palette.tintTip, 'pale']];
  const clear = (x) => proteins.every(([px, w]) => Math.abs(x - px) > w / 2 + 1.8);
  let out = `<rect width="${W}" height="70" fill="${palette.tintBand}"/>`
    + `<rect y="70" width="${W}" height="54" fill="${palette.mist}"/>`;
  for (let x = 4; x < W; x += 5.9) { // phospholipids: heads out, two tails in
    if (!clear(x)) continue;
    for (const s of [-1, 1]) {
      const y = yc(x) + s * 13;
      for (const dx of [-1, 1]) {
        out += line(`M${n(x + dx)} ${n(y - s * 2.4)}l${n(r() - 0.5)} ${-s * 4}`
          + `l${n(r() - 0.5)} ${-s * 4.6}`, palette.band, 0.75, ' stroke-opacity=".4"');
      }
      out += dot(x, y, 2.75, palette.band);
      if (r() < 0.13 && clear(x + 3)) { // cholesterol among the tails: the only grey rods
        out += capsule(x + 3, y - s * 7.4, 2.1, 7.2, palette.ink, 0, ' fill-opacity=".62"');
      }
    }
  }
  out += sugars(196, yc(196) - 16.5, r, 3); // a glycolipid
  for (const [px, w, fill, kind] of proteins) { // proteins across the bilayer
    const y = yc(px);
    const halves = kind === 'channel' ? [px - w / 4 - 1.3, px + w / 4 + 1.3] : [px];
    for (const x of halves) {
      const rx = kind === 'channel' ? w / 4 : w / 2;
      out += blob(x, y, rx, 21, r, fill, kind === 'pale' ? pale : '') + shine(x, y, rx, 21, r);
    }
    if (kind === 'channel') {
      out += dot(px, y - 7, 2, palette.warnInk) + dot(px, y + 4, 2, palette.warnInk);
    }
    if (kind === 'carrier') { // a glucose held in the carrier's open mouth
      out += shape(`M${px - 7} ${y - 24}L${px} ${y - 12}L${px + 7} ${y - 24}Z`, palette.tintBand)
        + shape(`M${px - 3.6} ${y - 19.5}l1.8-3.1h3.6l1.8 3.1-1.8 3.1h-3.6Z`, palette.paper,
          ` stroke="${palette.ink}" stroke-width=".8"`);
    } else out += sugars(px + (r() - 0.5) * 5, y - 22.5, r);
  }
  out += blob(100, yc(100) + 22.5, 11, 5.5, r, palette.tintTip, pale) // proteins on one face only
    + blob(270, yc(270) - 22, 8.5, 5, r, palette.tip);
  for (let x = 6; x < W; x += 3.2) { // the cytoskeleton under the membrane
    out += dot(x, 111 + 2.2 * Math.sin(x / 7), 1.4, palette.muted, ' fill-opacity=".45"');
  }
  for (let k = 0; k < 9; k++) { // oxygen outside the cell
    const [x, y] = [14 + r() * 320, 8 + r() * 26];
    for (const dx of [0, 2.6]) out += dot(x + dx, y, 1.5, palette.ink, ' fill-opacity=".5"');
  }
  return svg(W, 124, out);
}
function fusion() { // two cells, the hybrid just after fusion, the hybrid 40 minutes later
  const r = rng(3);
  const cellAt = (cx, rx, fill, nuclei, pick) => {
    let out = `<ellipse cx="${cx}" cy="25" rx="${rx}" ry="${Math.min(rx, 15)}" fill="${fill}" `
      + `stroke="${palette.ink}" stroke-width=".8"/>`
      + nuclei.map((x) => dot(x, 25, 4.2, palette.rule)).join('');
    for (let a = 0; a < 360; a += 12) {
      const rad = (a * Math.PI) / 180;
      out += dot(cx + Math.cos(rad) * rx, 25 + Math.sin(rad) * Math.min(rx, 15), 1.7,
        pick(Math.cos(rad), r()));
    }
    return out;
  };
  return svg(180, 50, cellAt(16, 13, palette.tintTip, [16], () => palette.tip)
    + cellAt(45, 13, palette.tintBand, [45], () => palette.band)
    + arrow(65, 25, 12, 0, palette.muted)
    + cellAt(104, 22, palette.paper, [96, 112], (c) => (c < 0 ? palette.tip : palette.band))
    + arrow(132, 25, 12, 0, palette.muted)
    + cellAt(163, 15.5, palette.paper, [158, 168], (c, k) => (k < 0.5 ? palette.tip
      : palette.band)));
}
function osmosis() { // three solutions: water leaves, stays even, floods in
  const r = rng(9);
  const panel = (x, solutes, body, flow) => {
    let out = `<rect x="${x}" width="108" height="86" rx="4" fill="${palette.mist}"/>`;
    for (let k = 0; k < solutes; k++) {
      const [px, py] = [x + 5 + r() * 98, 5 + r() * 76];
      if (Math.hypot(px - x - 54, py - 43) > 30) out += dot(px, py, 1.5, palette.muted);
    }
    for (let k = 0; k < 6; k++) { // arrows: the net flow of water, out (+1) or in (-1)
      const rad = (k * Math.PI) / 3 + 0.5;
      if (flow === 0 && k % 3) continue;
      const d = flow < 0 || (flow === 0 && k) ? 40 : 27;
      out += arrow(x + 54 + Math.cos(rad) * d, 43 + Math.sin(rad) * d, 10,
        (rad * 180) / Math.PI + (d > 30 ? 180 : 0), palette.tip);
    }
    return out + body;
  };
  const at = (k, d) => [54 + Math.cos((k * Math.PI) / 11) * d, 43 + Math.sin((k * Math.PI) / 11)
    * d].map(n);
  const crenated = `M${at(0, 13)}${Array.from({ length: 22 }, (_, k) => (k % 2 ? ''
    : `Q${at(k + 1, 19)} ${at(k + 2, 13)}`)).join('')}Z`; // bumps: control points outside
  const disc = (x, rr) => dot(x + 54, 43, rr, palette.band)
    + dot(x + 54, 43, rr * 0.45, palette.tintBand);
  return svg(348, 86, panel(0, 70, shape(crenated, palette.band), 1)
    + panel(120, 34, disc(120, 17), 0) + panel(240, 8, disc(240, 23), -1));
}
const drawings = { target, bulb, flask, caution, safety, check, cell, mosaic, fusion, osmosis };
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Noto Serif': ['400', '400i', '700'], Lexend: ['700', '800'], // text, display,
  'Barlow Semi Condensed': ['400', '600', '700'] }; // labels (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await Promise.all([loadFonts(FONTS, markdown),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);
// Folio 27 is odd like page 1, always a recto: parity follows the page (gotcha: parity-page1-recto)
const continuation = { pageNumbering: { startAt: 27 },
  headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Living Matter, chapter 2', es: 'Materia viva, capítulo 2' }) });

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

### Extiende un recuadro sobre las dos columnas

`span="page"` en la valla extiende cualquier recuadro sobre las dos columnas; el RECUADRO 2.1 anida además un grupo `:::columns` para poner la figura junto al texto.

```diff
-:::callout{type="warning" title="¿Membrana o pared?"}
+:::callout{type="warning" title="¿Membrana o pared?" span="page"}
```

### Cuelga el distintivo del lado del lomo

`'inner'` lleva el distintivo al lado del lomo, el izquierdo en la página impar y el derecho en la par; deja sitio en ese lado para la mitad que sobresale del recuadro.

```diff
-    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
+    icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'inner' }) }),
```

### Pon la pestaña en la esquina derecha

`'top-right'`, el valor por defecto, refleja toda la etiqueta: la pestaña pasa a la esquina derecha, el matraz a su izquierda y el filete llega desde la izquierda.

```diff
-      position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
+      position: 'top-right',
```

## Errores frecuentes

- **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.
- **El texto en bandera puede dejar sola la puntuación junto a una negrita o un :ref.** En postext 1.4.1, el texto que no va justificado (cuerpos de recuadro, párrafos en bandera) puede partir la línea entre una negrita, una cursiva o un :ref y el signo de puntuación pegado a ellos: un punto puede abrir la línea siguiente y el «(» de una remisión puede cerrar la anterior. El texto justificado nunca se parte ahí. Revisa los recuadros de cada edición y reescribe la frase afectada para que ese tramo quede en mitad de la línea.
- **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.
- **Los archivos latin de Fontsource solo traen glifos del rango latino.** El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin.
- **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.
- **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.
- **:::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.
- **Traduce Figura y Tabla con defaultResourceTypes(locale).** El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes.
- **Solo 8 idiomas tienen separación silábica, con el código exacto.** La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano.
- **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.
- **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.
- **La página 1 es impar: planifica con números físicos.** La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Aviso de maquetación: Tipo de recuadro desconocido** (`unknownCalloutType`). Un :::callout nombra un tipo que calloutStyles no define, así que toma sin avisar el primer estilo. Solución: Define el estilo o corrige el atributo type. ([Documentación](https://postext.dev/es/docs/configuration.md#el-contenedor-callout))

- La pestaña solo se imprime si la valla lleva el atributo `label="RECUADRO 2.1"` y el estilo tiene un objeto `label`. Un estilo sin él ignora el atributo.
- Postext no numera los recuadros. La etiqueta es el texto literal de la valla, así que cada número, RECUADRO 2.1, RECUADRO 2.2, se escribe a mano.
- Un distintivo de esquina sobresale la mitad de su ancho por el lado del recuadro, hacia el margen en el lado exterior de la página. En la otra columna de esa misma página sobresale hacia el medianil, así que deja los recuadros con distintivo en la columna exterior o dales sitio.
- Un recuadro solo cambia el color de sus viñetas y números si su `lists.color` es distinto de `unorderedLists.color`. Si los dos son el mismo magenta, una lista numerada dentro del recuadro toma `orderedLists.color`; por eso este documento pone sus propias viñetas en tinta.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Noto Serif (OFL-1.1), Lexend (OFL-1.1), Barlow Semi Condensed (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 050 · Manual de producto con avisos de seguridad](https://postext.dev/es/cookbook/product-manual-warnings.md): Manual de un hervidor en alemán: los recuadros WARNUNG y VORSICHT llevan el triángulo en una franja del color de aviso, y los pies dicen Abbildung y Tabelle. · Nivel 2 (Intermedio) · Manuales, guías y obras de consulta
- [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.º 022 · Ficha con cajas de respuesta y banco de palabras](https://postext.dev/es/cookbook/worksheet-answer-boxes.md): Una ficha de ciencias de cuatro páginas: cajas de respuesta blancas en tarjetas verde claro, a 2 mm de cada pregunta, y chips para bancos de palabras y huecos. · Nivel 2 (Intermedio) · Cuadernos y ejercicios
