# Recuadros que se parten, flotan y se fijan

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

- Versión HTML: https://postext.dev/es/cookbook/boxes-split-float-pin
- Receta N.º 021 · Recuadros y notas · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Cuadernos y ejercicios, Libros de texto
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [41](https://postext.dev/cookbook/boxes-split-float-pin/es/p01.webp?v=b181456a), [42](https://postext.dev/cookbook/boxes-split-float-pin/es/p02.webp?v=b181456a), [43](https://postext.dev/cookbook/boxes-split-float-pin/es/p03.webp?v=b181456a), [44](https://postext.dev/cookbook/boxes-split-float-pin/es/p04.webp?v=b181456a)
- Última actualización: 2026-09-25
- Otros idiomas: [en](https://postext.dev/en/cookbook/boxes-split-float-pin.md)

## Lo que vas a componer

Cuatro páginas de *Taller de ciencias*, un cuaderno de prácticas escolar: la Práctica 4, «Construye un reloj de sol». En la página 42, el aviso de seguridad queda entero, y el procedimiento de catorce pasos, con fondo amarillo y un compás en la esquina, empieza al pie de la columna izquierda y continúa en la cabeza de la derecha, a la misma medida y sin título. La hoja de datos de Madrid sale del flujo en esa página y encabeza la 43, sobre el texto. Al pie de la última página va fija una insignia antracita, «Autoevaluación · 10 min», con una mano que la señala desde el margen. En la apertura, la banda amarillo sol lleva las sombras de una varilla entre el mediodía y las cinco de la tarde, calculadas para la latitud de Madrid, y un panel a todo el ancho reparte los materiales en tres columnas.

**Esta receta responde a:**

- ¿Cómo dejo que un recuadro largo se parta, hago flotar otro a la página siguiente y fijo una insignia al pie?
- ¿Cómo llevo un recuadro a la cabeza o al pie de la página sin que el texto deje de llenarla?
- ¿Cómo fijo una insignia o una pegatina en una posición concreta de la página?
- ¿Cómo pongo un recuadro que cruce las dos columnas a media página, como un panel de cifras en tres bloques?

## La respuesta corta

```js
// script.js, líneas 55–85
const HAND = 6, GAP = 2, RULE = 0.75 * PT; // mm: the badge's hand, its gap, the hand's rule
const calloutStyles = [
  // Kept whole: keepTogether defaults to true, so a box that does not fit the rest of a
  // column moves on in one piece; only a box taller than a whole column splits anyway.
  box('seguridad', bar('charcoal', 'aviso')),
  // Split: the procedure fits a column, so it takes keepTogether: false to break where it
  // falls instead of moving on whole: between steps, or inside one (splitMinLines, default
  // 2, counts the box's lines on each side of the cut, not the step's: see Pitfalls). The
  // rest goes on in the next column or page without the title or the icon; a corner icon
  // takes no room from the text, so both parts keep one measure.
  box('pasos', { keepTogether: false, background: col('light'),
    icon: icon('compas', 7, { position: 'corner', cornerSide: 'outer' }) }),
  // Floated: its fence adds placement="top", so the box leaves the flow where the fence
  // stands and heads the next page, while the text after it fills this one.
  box('datos', { span: 'page', background: col('tint') }),
  // Pinned: 'fixed' sets the box on the page where its fence falls, at the bottom-left corner
  // of the text block unless fixed.anchor says otherwise, and the column text keeps out of it.
  // width: 'auto' shrink-wraps the title, so an empty fence prints a badge.
  box('autoevaluacion', { placement: 'fixed', width: 'auto',
    // Hang the hand and its rule in the margin (the outer one on this verso), so the badge
    // itself lines up with the text: [hand][rule][GAP][badge].
    fixed: { offset: { x: mm(-(HAND + RULE + GAP)) } },
    marker: { ...icon('mano', HAND), gap: mm(GAP),
      rule: { enabled: true, color: col('charcoal'), width: mm(RULE) } },
    background: col('charcoal'), borderRadius: mm(3.2),
    padding: { top: mm(1.6), right: mm(3.4), bottom: mm(1.6), left: mm(3.4) },
    titleStyle: { ...TITLE, color: col('paper') } }),
  // Across the page, in the flow: the text above it is cut level and resumes under it.
  box('resumen', { span: 'page', backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('charcoal') } }),
];
```

## Ingredientes

**Enseña**

- [Recuadros que se parten o no](https://postext.dev/es/docs/configuration.md#el-contenedor-callout): Los recuadros largos se parten entre columnas y páginas, conservan el marco y pierden el título; los cortos se mueven enteros; el que no cabe en ninguna parte da calloutOverflow.
- [Recuadros flotantes](https://postext.dev/es/docs/configuration.md#el-contenedor-callout): Un recuadro que sale del flujo hacia la cabeza o el pie de una página mientras el texto sigue llenando la página que dejó.
- [Recuadros fijos e insignias](https://postext.dev/es/docs/configuration.md#el-contenedor-callout): Un recuadro fijado en un punto exacto de su página, ajustado a su título si se quiere; las columnas de texto que pisa se acortan para dejarle sitio.

**También usa**

- [Columna de marca junto al recuadro](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Recuadros a todo el ancho](https://postext.dev/es/docs/configuration.md#el-contenedor-callout)
- [Columnas dentro de un recuadro](https://postext.dev/es/docs/document-format.md#columns)
- [Iconos y distintivos de esquina](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Recuadros](https://postext.dev/es/docs/configuration.md#estilos-de-aviso)
- [Espacio vertical explícito](https://postext.dev/es/docs/document-format.md#space)
- [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)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)
- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Estilos de tabla con nombre](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Títulos numerados](https://postext.dev/es/docs/configuration.md#configuración-por-nivel)
- [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)
- [Avisos y diagnóstico](https://postext.dev/es/docs/configuration.md#depuración)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [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)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)

**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), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas), [`tableStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-tabla-con-nombre), [`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**

- Host Grotesk (OFL-1.1), Commit Mono (OFL-1.1)

## Elaboración

### 1 · Una base común para todos los recuadros

```js
// script.js, líneas 39–51
const TITLE = { fontFamily: LABEL, fontSize: pt(8), color: col('charcoal'),
  textTransform: 'uppercase', letterSpacing: pt(1.2) }; // bold by default
const BOX_TYPE = { fontSize: pt(8.8), lineHeight: pt(12.4) }; // colours inherit bodyText
const box = (id, device) => ({ id, // margins: the defaults, snapped to whole grid lines
  padding: { top: mm(3), right: mm(3.6), bottom: mm(3.4), left: mm(3.6) },
  titleStyle: { ...TITLE, gap: mm(2) }, body: BOX_TYPE,
  lists: { gap: mm(2), itemSpacing: pt(3) }, ...device });
const icon = (id, size, extra) => ({ kind: 'resource', resourceId: id, size: mm(size),
  ...extra });
// A side bar with its icon centred on it, 1.4 mm narrower than the bar.
const bar = (hue, id, width = 5.6) => ({ backgroundEnabled: false,
  stripe: { enabled: true, side: 'left', width: mm(width), color: col(hue) },
  icon: icon(id, width - 1.4) });
```

Todos los estilos parten de `box()`: el título en monoespaciada, un cuerpo de 8,8 pt (el texto va a 9,4) y el relleno interior. Después, cada uno añade un solo rasgo propio: el aviso, una franja antracita; el procedimiento, un fondo amarillo; la hoja de datos, un fondo crema; el resumen, un filete superior, y la insignia, una píldora antracita. Así, el alumno distingue cada recuadro antes de leer su título. En el procedimiento, el fondo marca además la continuación: la parte que sigue en la cabeza de la columna derecha no lleva título, y solo el amarillo la une a los pasos 1 a 4, al pie de la izquierda.

### 2 · Entero, partido, flotante y fijo

El código es [la respuesta corta](#la-respuesta-corta) de arriba. El procedimiento mide 192 mm y en una columna caben 211, así que, si se mantuviera entero, saltaría a la cabeza de la columna derecha y dejaría 43 mm vacíos al pie de la izquierda. Con `keepTogether: false` empieza donde cae y sigue en la columna siguiente, o en la página siguiente, como muestra la segunda variante ([el contenedor `:::callout`](/es/docs/configuration#el-contenedor-callout)). La continuación pierde el título y el icono. Con un icono en línea, además, devolvería al texto la columna del icono y el resto se compondría más ancho; por eso el compás va en la esquina exterior del recuadro, donde no resta anchura y las dos partes conservan la misma medida. La valla de la hoja de datos lleva `placement="top"`, así que el recuadro sale del flujo en la [página 42](https://postext.dev/cookbook/boxes-split-float-pin/es/p02.webp?v=b181456a) y encabeza la siguiente. La insignia es `fixed`, y un `offset.x` negativo lleva la mano y su filete al margen exterior para que el borde de la insignia quede alineado con el texto.

### 3 · Cruza la página con un panel de tres columnas

```js
// script.js, líneas 89–94
const materials = box('material', { span: 'page', background: col('sun'),
  marginBottom: pt(LEAD), // one more grid line of air before the text resumes
  padding: { top: mm(3.6), right: mm(4.4), bottom: mm(3.8), left: mm(4.4) },
  columnGap: mm(GUTTER), // the page's gutter: the panel's columns sit as far apart as the text's
  body: { ...BOX_TYPE, paragraphSpacing: false },
  lists: { color: col('ink'), gap: mm(1.8), itemSpacing: pt(1) } });
```

Con `span: 'page'`, el recuadro de materiales ocupa las dos columnas. La introducción queda cortada a la misma altura por encima y el texto sigue a dos columnas por debajo ([página 41](https://postext.dev/cookbook/boxes-split-float-pin/es/p01.webp?v=b181456a)). Dentro de la valla, `:::columns{count=3 breaks="5,9"}` fija dónde empieza cada columna en lugar de equilibrarlas: `breaks` cuenta bloques hijos, y cada elemento de lista es uno, así que la segunda y la tercera columna empiezan en el quinto y el noveno, las etiquetas en negrita. `columnGap` usa el medianil de 7 mm de la página, y las columnas del panel quedan tan separadas como las del texto.

```markdown
:::callout{type="material" title="Necesitarás"}
:::columns{count=3 breaks="5,9"}
**Para la esfera**

- Cartón pluma de 20 × 30 cm
- La plantilla de la esfera
- Pegamento y rotulador

**Para el gnomon y la base**
…
:::
:::
```

### 4 · Un amarillo para los campos, otro más claro para el recuadro largo

```js
// script.js, líneas 16–29
const palette = {
  ink: '#1b2430', // text
  charcoal: '#2b2d42', // the safety bar, the summary's rule, the table head, the badge, titles
  sun: '#f2b705', // the opener band and the materials panel: fields, never type
  light: '#fad65a', // the procedure
  tint: '#fff6d6', // the data sheet
  rule: '#d7dde3', // hairlines
  muted: '#5d6b78', // running heads, notes
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at charcoal, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.charcoal })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

El amarillo sol se reserva para los fondos grandes, la banda y el panel; sobre él, el antracita y la tinta dan un contraste de 7,4:1 y 8,6:1. Como color de letra sobre blanco se quedaría en 1,8:1, así que nunca se usa para texto. El procedimiento lleva `light`, un amarillo más claro: sobre él la tinta llega a 11:1, y el corte se sigue viendo en la miniatura de la galería. La hoja de datos lleva `tint`, un crema casi blanco, para que no se confunda con el procedimiento, el otro recuadro con fondo del pliego. `main-color` apunta al antracita, de modo que los valores por defecto que la configuración no cambia salen en antracita y no en el azul del motor.

### 5 · Abre la práctica con una banda amarilla

```js
// script.js, líneas 98–121
const BAND = 100; // mm from the trim to the foot of the band
const ART_W = 80; // mm: the width of the band's drawing, at the fore-edge
const AIR = 8; // mm between the band and the first line of text
const [TITLE_W, LEAD_W] = [104, 88]; // mm: the title's and the lead's measure
// Wrapped, not cut with the default ellipsis (gotcha: overflow-ellipsis-default).
const onBand = { color: col('charcoal'), align: 'left', overflow: 'wrap' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const opener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sun') },
    placement: { anchor: { to: 'page', edge: 'top-left' }, size: { height: mm(BAND) } } },
  { kind: 'image', id: 'art', resourceId: 'sombras', placement: { anchor: { to: 'page',
    edge: 'top-right' }, size: { width: mm(ART_W), height: mm(BAND) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL,
    fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.7), textTransform: 'uppercase',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(4) } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT,
    fontSize: pt(44), fontWeight: 800, lineHeight: 0.98, placement: below('kicker', 3, TITLE_W) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT,
    fontSize: pt(10.5), lineHeight: 1.4, placement: below('title', 5, LEAD_W) },
  { kind: 'text', id: 'meta', content: '{attr.meta}', ...onBand, fontFamily: LABEL,
    fontSize: pt(7.4), fontWeight: 700, letterSpacing: pt(1.1), textTransform: 'uppercase',
    placement: below('lead', 4, LEAD_W) },
] } };
```

La banda es un elemento de caja anclado a la página; el dibujo, un elemento de imagen junto al corte delantero. El antetítulo, la entradilla y la línea de sesiones salen de atributos de la línea del título. Como la banda está anclada a la página, la apertura ya reserva altura hasta su pie, y el texto empezaría 3,6 mm por debajo. `minHeight: BAND - TOP + AIR` pide 8 mm; la reserva se ajusta a la siguiente línea de la rejilla base y el texto empieza 8,4 mm bajo la banda.

## 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/boxes-split-float-pin

### script.js

```js
// ═══ Postext Cookbook · Nº 021 · Boxes that split, float and pin ══════════════════
// https://postext.dev/en/cookbook/boxes-split-float-pin
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Host Grotesk, Commit Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Four pages of a school lab workbook in Spanish, and five ways a box can sit on them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'boxes-split-float-pin';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one yellow for fields, paler ones for two boxes, near-blacks for type and bars
const palette = {
  ink: '#1b2430', // text
  charcoal: '#2b2d42', // the safety bar, the summary's rule, the table head, the badge, titles
  sun: '#f2b705', // the opener band and the materials panel: fields, never type
  light: '#fad65a', // the procedure
  tint: '#fff6d6', // the data sheet
  rule: '#d7dde3', // hairlines
  muted: '#5d6b78', // running heads, notes
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at charcoal, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.charcoal })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Host Grotesk'; // text and display
const LABEL = 'Commit Mono'; // labels: kickers, box titles, step numbers, heads, the table
const LEAD = 13.6; // pt: the body leading, the pitch of the baseline grid
const LINES = 44; // grid lines in a full column
const [TRIM_W, TRIM_H, TOP, INNER, OUTER, GUTTER] = [195, 255, 22, 18, 14, 7]; // mm
const PT = 25.4 / 72; // mm in a point

// #region look: the base of every box (a mono title, smaller type), then one device each
const TITLE = { fontFamily: LABEL, fontSize: pt(8), color: col('charcoal'),
  textTransform: 'uppercase', letterSpacing: pt(1.2) }; // bold by default
const BOX_TYPE = { fontSize: pt(8.8), lineHeight: pt(12.4) }; // colours inherit bodyText
const box = (id, device) => ({ id, // margins: the defaults, snapped to whole grid lines
  padding: { top: mm(3), right: mm(3.6), bottom: mm(3.4), left: mm(3.6) },
  titleStyle: { ...TITLE, gap: mm(2) }, body: BOX_TYPE,
  lists: { gap: mm(2), itemSpacing: pt(3) }, ...device });
const icon = (id, size, extra) => ({ kind: 'resource', resourceId: id, size: mm(size),
  ...extra });
// A side bar with its icon centred on it, 1.4 mm narrower than the bar.
const bar = (hue, id, width = 5.6) => ({ backgroundEnabled: false,
  stripe: { enabled: true, side: 'left', width: mm(width), color: col(hue) },
  icon: icon(id, width - 1.4) });
// #endregion

// #region answer: one style per behaviour: kept whole, split, floated, pinned, page-wide
const HAND = 6, GAP = 2, RULE = 0.75 * PT; // mm: the badge's hand, its gap, the hand's rule
const calloutStyles = [
  // Kept whole: keepTogether defaults to true, so a box that does not fit the rest of a
  // column moves on in one piece; only a box taller than a whole column splits anyway.
  box('seguridad', bar('charcoal', 'aviso')),
  // Split: the procedure fits a column, so it takes keepTogether: false to break where it
  // falls instead of moving on whole: between steps, or inside one (splitMinLines, default
  // 2, counts the box's lines on each side of the cut, not the step's: see Pitfalls). The
  // rest goes on in the next column or page without the title or the icon; a corner icon
  // takes no room from the text, so both parts keep one measure.
  box('pasos', { keepTogether: false, background: col('light'),
    icon: icon('compas', 7, { position: 'corner', cornerSide: 'outer' }) }),
  // Floated: its fence adds placement="top", so the box leaves the flow where the fence
  // stands and heads the next page, while the text after it fills this one.
  box('datos', { span: 'page', background: col('tint') }),
  // Pinned: 'fixed' sets the box on the page where its fence falls, at the bottom-left corner
  // of the text block unless fixed.anchor says otherwise, and the column text keeps out of it.
  // width: 'auto' shrink-wraps the title, so an empty fence prints a badge.
  box('autoevaluacion', { placement: 'fixed', width: 'auto',
    // Hang the hand and its rule in the margin (the outer one on this verso), so the badge
    // itself lines up with the text: [hand][rule][GAP][badge].
    fixed: { offset: { x: mm(-(HAND + RULE + GAP)) } },
    marker: { ...icon('mano', HAND), gap: mm(GAP),
      rule: { enabled: true, color: col('charcoal'), width: mm(RULE) } },
    background: col('charcoal'), borderRadius: mm(3.2),
    padding: { top: mm(1.6), right: mm(3.4), bottom: mm(1.6), left: mm(3.4) },
    titleStyle: { ...TITLE, color: col('paper') } }),
  // Across the page, in the flow: the text above it is cut level and resumes under it.
  box('resumen', { span: 'page', backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('charcoal') } }),
];
// #endregion

// #region panel: a yellow panel across both columns with three columns of its own
const materials = box('material', { span: 'page', background: col('sun'),
  marginBottom: pt(LEAD), // one more grid line of air before the text resumes
  padding: { top: mm(3.6), right: mm(4.4), bottom: mm(3.8), left: mm(4.4) },
  columnGap: mm(GUTTER), // the page's gutter: the panel's columns sit as far apart as the text's
  body: { ...BOX_TYPE, paragraphSpacing: false },
  lists: { color: col('ink'), gap: mm(1.8), itemSpacing: pt(1) } });
// #endregion

// #region opener: a sun-yellow band, a shadow chart, texts from the heading line
const BAND = 100; // mm from the trim to the foot of the band
const ART_W = 80; // mm: the width of the band's drawing, at the fore-edge
const AIR = 8; // mm between the band and the first line of text
const [TITLE_W, LEAD_W] = [104, 88]; // mm: the title's and the lead's measure
// Wrapped, not cut with the default ellipsis (gotcha: overflow-ellipsis-default).
const onBand = { color: col('charcoal'), align: 'left', overflow: 'wrap' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, size: { width: mm(width) } });
const opener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('sun') },
    placement: { anchor: { to: 'page', edge: 'top-left' }, size: { height: mm(BAND) } } },
  { kind: 'image', id: 'art', resourceId: 'sombras', placement: { anchor: { to: 'page',
    edge: 'top-right' }, size: { width: mm(ART_W), height: mm(BAND) } } },
  { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL,
    fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.7), textTransform: 'uppercase',
    placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(4) } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT,
    fontSize: pt(44), fontWeight: 800, lineHeight: 0.98, placement: below('kicker', 3, TITLE_W) },
  { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT,
    fontSize: pt(10.5), lineHeight: 1.4, placement: below('title', 5, LEAD_W) },
  { kind: 'text', id: 'meta', content: '{attr.meta}', ...onBand, fontFamily: LABEL,
    fontSize: pt(7.4), fontWeight: 700, letterSpacing: pt(1.1), textTransform: 'uppercase',
    placement: below('lead', 4, LEAD_W) },
] } };
// #endregion

// Running heads in the label face, the folio in bold on the outer edge.
const HEAD_Y = 13; // mm from the trim to the heads' baseline area
const RUN_X = OUTER + 9; // mm from the fore-edge to the running title, clear of the folio
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(7.4), letterSpacing: pt(1.1),
  textTransform: 'uppercase', color: col('muted'), ...extra,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } } });
const folio = { fontWeight: 700, fontSize: pt(8.5), color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', RUN_X),
  head('recto-title', 'Práctica {chapterNumber} · {chapterTitle}', 'odd', 'top-right', -RUN_X),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio),
  pages: 'opener', // the drop folio, 11 mm above the foot of the opener
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-11) } } }] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  // The document's language; ragged text is never hyphenated (gotcha: ragged-no-hyphenation).
  locale: 'es',
  resourceTypes: defaultResourceTypes(LANG), // "Figura", "Tabla" (gotcha: resource-types-locale)
  colorPalette, header, footer,
  page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP),
    bottom: mm(TRIM_H - TOP - LINES * LEAD * PT), left: mm(INNER), right: mm(OUTER),
    mirror: true } }, // a text block of LINES whole lines; left is the inner margin on a recto
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // references follow the bold colour
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  headings: { fontFamily: TEXT, fontWeight: 800, color: col('ink'),
    // No extra line under a top float: on the closing page it keeps the layout from settling
    // (gotcha: float-stretch-closing-page).
    balancing: { stretchAfterFloats: false }, 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(13), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}',
      marginTop: pt(LEAD), marginBottom: pt(0) },
  ] },
  unorderedLists: { color: col('charcoal'), marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: LABEL, fontWeight: 700, color: col('charcoal') }, // step numbers
  calloutStyles: [...calloutStyles, materials],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('charcoal'), headerColor: col('paper'), headerFontFamily: LABEL,
    headerFontSize: pt(7.4), bodyFontFamily: LABEL, bodyFontSize: pt(7.4),
    bodyColor: col('ink'), cellPadding: mm(1.1) },
  tableStyles: [{ id: 'registro', cellPadding: mm(2.2), bodyFontSize: pt(8.4) }],
  captionStyle: { fontSize: pt(8), color: col('ink'), labelColor: col('charcoal'), gap: mm(2) },
  paragraphStyles: [{ id: 'colofon', fontFamily: LABEL, fontSize: pt(6.6), lineHeight: pt(9.4),
    color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Taller de ciencias · Cuaderno de prácticas"
---

# Construye un reloj de sol {kicker="Práctica 4 · El Sol y la hora" meta="2 sesiones · por parejas · un día de sol" lead="Con una brocheta y un disco de cartón pluma construirás un reloj de sol ecuatorial. Una vez orientado al norte, casi nunca marcará la misma hora que tu móvil, y en esta práctica verás por qué."}

Los egipcios ya medían las horas con sombras hace más de 3.000 años. Un reloj de sol funciona sin engranajes porque la Tierra gira sobre su eje a un ritmo casi constante, 360° en 24 horas, es decir, 15° cada hora. La sombra de una varilla bien orientada recorre entonces la esfera como la aguja de un reloj, siempre al mismo paso.

Vas a construir el modelo más fácil de trazar, el reloj ecuatorial. Su varilla, el **gnomon**, apunta al polo norte celeste, muy cerca de la estrella Polar, y queda paralela al eje de la Tierra. La esfera es perpendicular a ella y, por tanto, paralela al ecuador; por eso sus líneas horarias se separan 15°, como los radios de una rueda.

:::callout{type="material" title="Necesitarás"}
:::columns{count=3 breaks="5,9"}
**Para la esfera**

- Cartón pluma de 20 × 30 cm
- La plantilla de la esfera
- Pegamento y rotulador

**Para el gnomon y la base**

- Brocheta de 15 cm
- Cartón pluma para la base
- Plastilina

**Herramientas**

- Regla metálica y cúter
- Transportador y compás
- Brújula o la del móvil
:::
:::

## La inclinación es tu latitud

Para que el gnomon quede paralelo al eje terrestre, debe formar con el suelo un ángulo igual a la latitud del lugar: unos 40° en Madrid, 43° en Oviedo, 37° en Sevilla o 28° en Las Palmas de Gran Canaria. Cuanto más al norte vivas, más empinado quedará. Búscala en un atlas o en el mapa del móvil y redondéala al grado; un error de uno o dos grados apenas se nota en la lectura. Anótala: la necesitarás en los pasos 6 y 9.

La esfera, en cambio, forma con el suelo el ángulo complementario, 90° menos la latitud: unos 50° en Madrid. Ese es el ángulo que tendrán los dos triángulos que la sostienen.

Si el gnomon no queda paralelo al eje de la Tierra, la sombra ya no avanza a ritmo constante sobre la esfera y las líneas de 15° dejan de coincidir con las horas. A mediodía el error es nulo, pero crece a medida que te alejas de él, tanto hacia la mañana como hacia la tarde. Por eso el paso 9 pide medir con el transportador el ángulo entre la brocheta y la base, y corregirlo si hace falta.

## Una esfera con dos caras

La plantilla es un disco de 16 cm con 24 radios, uno por hora, separados 15°. Hay que numerarlos en las dos caras, porque el Sol ilumina una u otra según la época del año. En la cara superior, las horas avanzan en el sentido de las agujas del reloj, con las 12 junto a la marca N; en la inferior, que leerás desde abajo, van en sentido contrario.

:::callout{type="seguridad" title="Seguridad"}
- No mires nunca al Sol directamente, ni con gafas de sol ni a través de una lente: basta un instante para dañar la retina.
- El cúter lo maneja un adulto, siempre sobre la regla metálica y con el corte hacia fuera, lejos de los dedos.
- Protege la punta de la brocheta con una bola de plastilina.
:::

Trabajaréis por parejas: mientras uno sujeta las piezas, el otro mide y marca. Leed antes todo el procedimiento, repartid las tareas y tened a mano la hoja de datos de la página siguiente para comprobar las lecturas. Dedicad la primera sesión al montaje y la segunda, a las lecturas.

:::callout{type="datos" placement="top" title="Hoja de datos · Madrid, 40,4° N · 3,7° O"}
:::columns{count=2}
Cuándo marca las 12 tu reloj de sol en Madrid y qué sombra da entonces un palo de un metro.

Para otra localidad, suma 4 minutos por cada grado de longitud al oeste de Madrid; réstalos al este.
:::

:::space

::resource{id="mediodia"}
:::

:::callout{type="pasos" title="Procedimiento"}
1. Pega la plantilla sobre el cartón pluma y déjala secar cinco minutos.
2. Un adulto corta el disco de 16 cm con el cúter, en varias pasadas.
3. Pincha el centro con el compás y agranda el agujero con la brocheta.
4. Comprueba con el transportador que las líneas horarias están separadas 15° y repasa con rotulador las que van de las 6 de la mañana a las 6 de la tarde.
5. Numera las horas en las dos caras, como explica el apartado 4.2.
6. Recorta los dos soportes: triángulos rectángulos con 12 cm de base y, entre la base y la hipotenusa, 90° menos tu latitud (50° en Madrid).
7. Pasa la brocheta por el centro del disco, bien perpendicular a él, hasta que asomen 10 cm arriba y 4 cm abajo.
8. Pega los triángulos de pie sobre la base, a 12 cm uno de otro, de modo que la hipotenusa suba hacia el sur.
9. Apoya el disco en las hipotenusas, pégalo y comprueba que la brocheta forma con la base un ángulo igual a tu latitud.
10. Lleva el reloj a un sitio al que le dé el sol todo el día y nivela la base con el móvil.
11. Gira la base hasta que el extremo alto de la brocheta apunte al norte, con la brújula lejos de objetos de hierro.
12. A una hora en punto, lee la hora en el centro de la sombra y anota también la que marca el móvil.
13. Repite la lectura cada hora, al menos tres veces, y anota las horas en el apartado 4.5.
14. Calcula la diferencia media y compárala con la hoja de datos: si se aleja más de 15 minutos, revisa la orientación.
:::

## Cómo se lee

Lee la hora en el centro de la sombra y no en uno de sus bordes, porque la brocheta tiene grosor. Si la sombra cae entre dos líneas, calcula los minutos a ojo: cada línea es una hora, y cada cuarto de la separación entre dos líneas, 15 minutos. Con práctica apreciarás cinco minutos, casi dos milímetros en el borde del disco. Mira la esfera de frente, sin ladear la cabeza, y siempre desde el mismo lado. Y no esperes que coincida con el móvil: tu reloj marca la hora solar del lugar, y el móvil, la oficial; el apartado 4.4 explica de dónde sale la diferencia.

La sombra cae sobre la cara de la esfera que mira al Sol: la superior entre los equinoccios de marzo y de septiembre, y la inferior el resto del año.

De perfil se ve por qué (:ref{id="reloj"}): en verano, el Sol del mediodía está más alto que la esfera; en invierno, más bajo. Cerca de los equinoccios pasa rozando su plano, y durante unos días la sombra se ve tan borrosa que cuesta leerla, aunque el reloj esté bien montado.

## Hora solar y hora oficial

Tu reloj de sol marca la **hora solar**: las 12 en punto cuando el Sol cruza el meridiano del lugar y alcanza su mayor altura del día. El móvil da la **hora oficial**, la misma en toda la España peninsular. Entre las dos se acumulan tres diferencias.

La primera es el huso horario. La hora oficial española es la de Europa central, calculada para el meridiano 15° E, pero Madrid está a 3,7° al oeste de Greenwich: el Sol cruza su meridiano unos 75 minutos más tarde de lo que supone el reloj. Es una herencia de 1940, cuando España adelantó una hora sus relojes, que desde 1901 seguían la hora de Greenwich, para igualarlos con los de Europa central.

La segunda es el horario de verano, que añade otra hora entre el último domingo de marzo y el último de octubre.

La tercera es la ecuación del tiempo. Como la órbita de la Tierra es una elipse y su eje está inclinado, el mediodía solar se adelanta o se retrasa a lo largo del año, hasta unos 16 minutos en noviembre y 14 en febrero. Con las tres correcciones, en Madrid el Sol pasa por el meridiano entre las 12:58 y las 14:21, según la época del año.

**Un ejemplo.** El 15 de mayo miras el móvil a las 13:00. Según la hoja de datos, ese día el Sol cruza el meridiano de Madrid a las 14:11, así que aún faltan 71 minutos para el mediodía solar: la sombra de tu reloj debería marcar las 10:49. Si marca las 11:05, tu reloj adelanta 16 minutos y conviene revisar su orientación, como indica el paso 14 del procedimiento.

## Tus lecturas

Anota las lecturas de los pasos 12 y 13 en la :ref{id="lecturas" style="full" case="lower"} y compara cada diferencia con la que predice la hoja de datos para ese mes. Si tu reloj falla incluso a mediodía, revisa la orientación; si acierta a mediodía pero falla por la mañana y por la tarde, revisa la inclinación.

## Tu reloj mide la longitud

Los navegantes calculaban su longitud comparando el mediodía solar con la hora de un meridiano de referencia. Tú puedes hacer lo mismo. En el ejemplo, el Sol cruzó el meridiano de Madrid a las 14:11 de verano, es decir, a las 12:11 de Greenwich. Ese día la ecuación del tiempo adelanta el Sol unos 4 minutos, así que el mediodía medio habría llegado hacia las 12:15. Esos 15 minutos de retraso respecto a Greenwich, a 4 minutos por grado, dan unos 3,7° de longitud oeste: la de Madrid. Con tus lecturas no necesitas la ecuación del tiempo: anota la hora oficial a la que tu reloj marca las 12 y compárala con la que da la hoja de datos para ese mes. Cada 4 minutos de retraso te sitúan un grado al oeste de Madrid, y cada 4 de adelanto, un grado al este.

:::callout{type="resumen" title="Resumen"}
:::columns{count=3 breaks="2,3"}
- La Tierra gira 15° cada hora: por eso las líneas horarias de la esfera se separan 15°.
- El gnomon apunta al polo norte celeste, inclinado sobre el suelo tanto como la latitud del lugar.
- Huso horario, horario de verano y ecuación del tiempo apartan la hora solar de la oficial.
:::
:::

Antes de la próxima sesión, haced la autoevaluación de la práctica en el aula virtual y traed vuestras lecturas: con ellas calcularemos la longitud de nuestra localidad.

:::callout{type="autoevaluacion" title="Autoevaluación · 10 min"}
:::

:::paragraphs{style="colofon"}
Compuesto en Host Grotesk y Commit Mono (SIL Open Font License) · Texto y dibujos originales, CC BY 4.0 · Datos solares calculados para 2026.
:::
`; // content.<lang>.md, inlined by the Cookbook

// Madrid's solar noon on the 15th of each month of 2026 (NOAA's approximations; CET, and
// CEST from 29 March to 25 October), the Sun's height then and a 1 m stick's shadow.
const MONTHS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const NOON = ['13:23', '13:29', '13:24', '14:15', '14:11', '14:15', '14:21', '14:20', '14:10',
  '14:00', '13:00', '13:10'];
const HEIGHT = [28, 37, 47, 59, 68, 73, 71, 64, 53, 41, 31, 26]; // degrees
const SHADOW = ['1,86', '1,34', '0,93', '0,60', '0,40', '0,31', '0,34', '0,49', '0,76', '1,14',
  '1,65', '2,02']; // metres
const cell = (content, extra) => ({ content, align: 'center', ...extra });
const row = (label, values) => [cell(label, { align: 'left' }), ...values.map((v) => cell(v))];

// Each drawing's viewBox (in mm for the band's chart), rasterised at PX pixels a unit.
const ART = { sombras: [ART_W, BAND], reloj: [156, 36], aviso: [24, 24], compas: [24, 24],
  mano: [28, 24] };
const PX = 10;
const svgResource = (id, extra) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0,
  updatedAt: 0, svg: { fileId: `${id}.svg`, width: ART[id][0] * PX, height: ART[id][1] * PX },
  ...extra });
const resources = [
  // Uncited, so never placed: the opener and the box styles use them by id.
  svgResource('sombras'), svgResource('aviso'), svgResource('compas'), svgResource('mano'),
  svgResource('reloj', { placement: { position: 'top', span: 'page' },
    caption: 'El reloj visto desde el este. A mediodía, el Sol ilumina la cara superior en verano '
      + '(izquierda) y la inferior en invierno.',
    altText: 'Perfil del reloj ecuatorial con los rayos del Sol de verano y de invierno' }),
  { id: 'mediodia', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'here' },
    caption: 'Mediodía solar en Madrid, día 15 de cada mes de 2026. De abril a octubre rige el '
      + 'horario de verano.',
    table: { model: { headerRowCount: 1, columnWidths: [2.9, ...MONTHS.map(() => 1)], rows: [
      [cell('', { isHeader: true }), ...MONTHS.map((m) => cell(m, { isHeader: true }))],
      row('Mediodía solar', NOON),
      row('Altura del Sol', HEIGHT.map((h) => `${h}°`)),
      row('Sombra de 1 m', SHADOW),
    ] } } },
  // The students' log: the worked example, then empty rows tall enough to write in.
  { id: 'lecturas', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'top' }, caption: 'Tus lecturas. La primera fila es la del ejemplo.',
    table: { styleId: 'registro', model: { headerRowCount: 1, columnWidths: [1, 1, 1], rows: [
      ['Hora oficial', 'Reloj de sol', 'Diferencia'].map((h) => cell(h, { isHeader: true })),
      ['13:00', '10:49', '2 h 11 min'].map((v) => cell(v)),
      ...Array.from({ length: 3 }, () => ['', '', ''].map((v) => cell(v))),
    ] } } },
];

// #region art: the band's shadow chart, the figure and three icons, in the palette (no words)
// An SVG drawn as an image cannot use the page's fonts (gotcha: svg-no-webfonts).
const n = (v) => +v.toFixed(2);
const svg = (id, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${ART[id][0] * PX}" `
  + `height="${ART[id][1] * PX}" viewBox="0 0 ${ART[id].join(' ')}">${body}</svg>`;
const path = (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 dot = (x, y, r, fill) => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${fill}"/>`;
const line = (pts) => `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join('L')}`;
const rad = (deg) => (deg * Math.PI) / 180;
const LAT = rad(40.4); // Madrid

// Where the tip of a vertical stick's shadow falls on flat ground (x east, y north, in stick
// heights), for the Sun at declination d and hour angle h; null when the Sun is too low.
function tip(d, h) {
  const up = Math.sin(LAT) * Math.sin(d) + Math.cos(LAT) * Math.cos(d) * Math.cos(h);
  if (up < Math.sin(rad(6))) return null;
  const north = Math.cos(LAT) * Math.sin(d) - Math.sin(LAT) * Math.cos(d) * Math.cos(h);
  return [(Math.cos(d) * Math.sin(h)) / up, -north / up];
}
function sombras() { // a stick's shadows from noon to 5 p.m. on flat ground, from above, north up
  const [g, x0, y0] = [36, 7, BAND - 11]; // the stick's height and its foot, mm
  const at = ([x, y]) => [x0 + x * g, y0 - y * g];
  const hours = [12, 13, 14, 15, 16, 17];
  let out = '';
  for (const hour of hours) { // hour lines: straight, from the summer to the winter solstice
    const pts = [-23.44, -11.5, 0, 11.5, 23.44].map((d) => tip(rad(d), rad(15 * (hour - 12))));
    out += path(line(pts.filter(Boolean).map(at)), palette.charcoal, 0.35);
  }
  for (const d of [-23.44, 0, 23.44]) { // date lines: the equinox is straight, the rest curve
    const pts = [];
    for (let m = 0; m <= 84; m += 2) { const p = tip(rad(d), rad(m)); if (p) pts.push(at(p)); }
    out += path(line(pts), palette.charcoal, d === 0 ? 0.5 : 0.35);
  }
  for (const hour of hours) { // the equinox shadows themselves, from the foot of the stick
    out += path(line([[x0, y0], at(tip(0, rad(15 * (hour - 12))))]), palette.charcoal, 1.1);
  }
  return svg('sombras', out + dot(x0, y0, 1.8, palette.charcoal));
}
function reloj() { // the dial in profile, seen from the east: south left, north right
  const panel = (ox, sunDeg, lit) => { // one noon: the Sun at sunDeg above the south horizon
    const [ground, u] = [33, 1.2]; // the ground line; drawing units per centimetre
    const foot = [ox + 42, ground]; // the triangle's north corner, at the hypotenuse's foot
    const up = [-Math.cos(rad(50)), -Math.sin(rad(50))]; // along the hypotenuse, 90° − 40°
    const along = (p, d, k) => [p[0] + d[0] * k, p[1] + d[1] * k];
    const hyp = 12 * u / Math.cos(rad(50)); // a 12 cm base: the hypotenuse's length
    const top = along(foot, up, hyp);
    const mid = along(foot, up, hyp / 2); // the dial's centre
    const g = [Math.cos(LAT), -Math.sin(LAT)]; // the gnomon, up to the north at the latitude
    const s = [-Math.cos(rad(sunDeg)), -Math.sin(rad(sunDeg))]; // towards the Sun
    const sun = along(mid, s, 19);
    const face = along([0, 0], [g[0], g[1]], lit === 'top' ? 0.9 : -0.9); // the lit side
    const board = `<rect x="${n(top[0] - 3 * u)}" y="${ground - 0.7}" `
      + `width="${n(foot[0] - top[0] + 6 * u)}" height="0.7" fill="${palette.charcoal}"/>`; // base
    let out = path(`M${ox} ${ground}H${ox + 74}`, palette.charcoal, 0.4) + board
      + shape(`${line([[foot[0], ground - 0.7], top, [top[0], ground - 0.7]])}Z`, palette.rule)
      + path(line([along(mid, g, -4 * u), along(mid, g, 10 * u)]), palette.charcoal, 0.9)
      + path(line([along(mid, up, -8 * u), along(mid, up, 8 * u)]), palette.charcoal, 1.6)
      + path(line([along(along(mid, up, -8 * u), face, 1), along(along(mid, up, 8 * u), face, 1)]),
        palette.sun, 0.9)
      + path(line([along(mid, g, 10.6 * u), along(mid, g, 17 * u)]), palette.charcoal, 0.35,
        ' stroke-dasharray="1 1.4"') // on to the Pole Star
      + star(...along(mid, g, 18.6 * u), 1.6);
    for (const k of [-1, 0, 1]) { // three rays, travelling from the Sun to the dial
      const start = along(along(sun, [s[1], -s[0]], k * 4), s, -3.6);
      out += path(line([start, along(start, s, -8)]), palette.sun, 0.8);
    }
    return out + dot(...sun, 2.6, palette.sun);
  };
  const star = (x, y, r) => shape(`M${n(x)} ${n(y - r)}L${n(x + r * 0.3)} ${n(y - r * 0.3)} `
    + `${n(x + r)} ${n(y)} ${n(x + r * 0.3)} ${n(y + r * 0.3)} ${n(x)} ${n(y + r)} `
    + `${n(x - r * 0.3)} ${n(y + r * 0.3)} ${n(x - r)} ${n(y)} ${n(x - r * 0.3)} `
    + `${n(y - r * 0.3)}Z`, palette.charcoal);
  return svg('reloj', panel(2, 73, 'top') + panel(82, 26, 'bottom'));
}
function aviso() { // a yellow warning triangle on the charcoal bar
  return svg('aviso', shape('M12 2.6 22.4 20.6H1.6Z', palette.sun, ' stroke-linejoin="round" '
    + `stroke="${palette.sun}" stroke-width="1.6"`)
    + shape('M10.9 8.4h2.2l-.4 6.6h-1.4Z', palette.charcoal)
    + dot(12, 17.4, 1.2, palette.charcoal));
}
function compas() { // the procedure's corner badge: a pair of compasses on a charcoal disc
  return svg('compas', dot(12, 12, 12, palette.charcoal) + dot(12, 6.2, 1.7, palette.light)
    + path('M12 7.4 7.6 18.6M12 7.4 16.4 18.6', palette.light, 1.5)
    + path('M9.2 14.6q2.8 1.5 5.6 0', palette.light, 1));
}
function mano() { // a hand that points right, at the badge
  return svg('mano', shape('M3 9.6h7.4l2.2-2.8a1.6 1.6 0 0 1 2.5 2l-.9 1.2H25a1.6 1.6 0 0 1 0 3.2'
    + 'H16.4v.2h1.2a1.5 1.5 0 0 1 0 3h-1.2a1.5 1.5 0 0 1 0 3h-1.4a1.4 1.4 0 0 1 0 2.8H9.6'
    + 'L3 21.2Z', palette.charcoal));
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses: layout measures with the browser's fonts (gotcha: fonts-first).
const FONTS = { 'Host Grotesk': ['400', '700', '800'], 'Commit Mono': ['400', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const drawings = { sombras, reloj, aviso, compas, mano };
await Promise.all([loadFonts(FONTS, markdown),
  ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]);
// Folio 41 is odd like page 1, a recto; the next # is Práctica 4, so the figure is 4.1.
const continuation = { pageNumbering: { startAt: 41 }, headings: { h1: 3 } };
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: 'Taller de ciencias · Práctica 4' });
// Layout warnings in the bar: a box that no cut could split overflows as calloutOverflow.
const warnings = (doc.warnings ?? []).map((w) => w.kind).join(', ') || 'none';
kitStatus(`${doc.pages.length} pages · layout warnings: ${warnings}`);

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

### Mantén entero el procedimiento

Sin ese ajuste, el procedimiento, que cabe en una columna, pasa entero a la cabeza de la columna derecha y deja 43 mm vacíos al pie de la izquierda.

```diff
-  box('pasos', { keepTogether: false, background: col('light'),
+  box('pasos', { background: col('light'),
```

### Haz flotar la hoja de datos al pie

Con `placement="bottom"` en `content.es.md`, la hoja de datos ocupa el pie de la página donde cae su valla, la 42. El procedimiento, desplazado, empieza en la cabeza de la columna derecha y se parte entre las dos páginas hasta la cabeza de la 43.

```diff
-:::callout{type="datos" placement="top" title="Hoja de datos · Madrid, 40,4° N · 3,7° O"}
+:::callout{type="datos" placement="bottom" title="Hoja de datos · Madrid, 40,4° N · 3,7° O"}
```

## Errores frecuentes

- **:::columns solo funciona dentro de un recuadro y no se parte.** :::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno.
- **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.
- **Un flotante superior puede bajar la última columna en una página de cierre.** En postext 1.4.1, cuando un capítulo o un reportaje termina en una página que se abre con un flotante superior a todo el ancho y sus líneas se reparten de forma desigual entre las columnas, el ajuste stretchAfterFloats añade una línea en blanco bajo el flotante en la columna más corta en vez de dejar que termine antes, y las dos columnas ya no empiezan en la misma línea. Pon headings.balancing.stretchAfterFloats a false o ajusta el texto a un número par de líneas.
- **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.
- **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.
- **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.
- **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.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **El 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.
- **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().
- **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.
- **Aviso de maquetación: El recuadro desborda su columna** (`calloutOverflow`). Una caja que ningún corte puede partir (una figura, una tabla o un grupo :::columns más alto que la columna, o un splitMinLines demasiado alto) se colocó desbordada; el motor lo anota en doc.warnings y el Sandbox lo muestra. Solución: Acorta la caja, deja que se parta con keepTogether: false, baja splitMinLines o dale otro span. ([Documentación](https://postext.dev/es/docs/configuration.md#el-contenedor-callout))

- Un recuadro más alto que una columna entera se parte diga lo que diga `keepTogether`. El ajuste solo decide en los recuadros que caben en una columna, como este procedimiento de 192 mm en una columna de 211.
- `splitMinLines` cuenta las líneas de todo el recuadro a cada lado del corte, no las del paso que corta, así que el corte aún puede dejar sola una línea de un paso. El texto anterior al procedimiento está ajustado para que el corte caiga entre los pasos 4 y 5; en la variante con la hoja de datos al pie, la última línea del paso 11 abre sola la página 43. Un cambio en el texto que precede a un recuadro partido puede mover el corte, así que vuelve a mirar dónde cae después de cada retoque.
- Un icono en línea ocupa una columna propia, y la continuación la devuelve. Si el corte cae entre pasos, el resto del recuadro se desplaza a la izquierda lo que miden el icono y su separación; si cae dentro de un paso, el resto del paso se recompone más ancho y puede repetir palabras. Un icono en la esquina, o una franja lateral sobre la que asentarlo, mantiene las dos partes a la misma medida.
- Un recuadro con `placement="top"` encabeza la página siguiente a aquella en la que cae su valla, así que la valla va en la página anterior a la que debe encabezar. Aquí está justo antes del procedimiento, en la 42.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: La tabla del mediodía solar: calculada para Madrid con las ecuaciones solares de la NOAA: NOAA Global Monitoring Laboratory ([fuente](https://gml.noaa.gov/grad/solcalc/solareqns.PDF)), dominio público
- Imágenes: El gráfico de sombras, el reloj de perfil y los iconos, dibujados en código con la paleta de la página: Ignacio Ferro, CC-BY-4.0
- Tipografías: Host Grotesk (OFL-1.1), Commit Mono (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 008 · Recuadros de libro de texto con código de colores](https://postext.dev/es/cookbook/textbook-box-family.md): 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. · Nivel 2 (Intermedio) · Libros de texto
- [N.º 027 · Portada de periódico](https://postext.dev/es/cookbook/newspaper-front-page.md): Portada y página 2 de un semanario local a dos columnas con filete, titulares a todo el ancho en recuadros y una franja de breves a cuatro columnas al pie. · Nivel 3 (Avanzado) · Periódicos y boletines
- [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
