# Un solo original, ediciones impresa y de pantalla

> Las dos ediciones salen de una configuración: htmlViewer.overrides guarda el diseño oscuro y applyHtmlViewerOverrides lo fusiona antes de componer el HTML.

- Versión HTML: https://postext.dev/es/cookbook/print-and-screen-editions
- Receta N.º 011 · Salida e integración · Nivel 3 (Avanzado) · Salidas: Canvas, HTML, Controles en vivo
- Géneros: Revistas y fanzines
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/print-and-screen-editions/es/p01.webp?v=3cd82cd0), [2](https://postext.dev/cookbook/print-and-screen-editions/es/p02.webp?v=3cd82cd0), [3](https://postext.dev/cookbook/print-and-screen-editions/es/p03.webp?v=3cd82cd0), [4](https://postext.dev/cookbook/print-and-screen-editions/es/p04.webp?v=3cd82cd0)
- Última actualización: 2026-09-26
- Otros idiomas: [en](https://postext.dev/en/cookbook/print-and-screen-editions.md)

## Lo que vas a componer

*Apuntes del tiempo*, la sección de otoño de una pequeña revista, compuesta dos veces con el mismo Markdown. En papel es un número de 225 × 297 mm: una portadilla azul lluvia, tres apuntes que se abren bajo una banda gris niebla, dos columnas justificadas y dibujos de un pluviómetro, un valle con niebla y una rosa de los vientos. En pantalla es una sola columna en bandera, en Newsreader gris claro sobre fondo casi negro, con texto HTML que se puede seleccionar y copiar, sin portadilla, bandas ni folios. El panel se abre por el pluviómetro, reducido a la altura del panel y dibujado en colores de noche, junto a su página impresa. Más arriba está la apertura de Lluvia; si arrastras la esquina del panel, el texto se recompone al nuevo tamaño. El diseño de pantalla ocupa nueve claves de `htmlViewer.overrides`, en la misma configuración que el impreso.

**Esta receta responde a:**

- ¿Cómo muestro el mismo documento como vista de lectura HTML adaptable, con un diseño de pantalla más sencillo?
- ¿Cómo doy a la edición de pantalla un diseño más sencillo que a la impresa, con una sola configuración?

## La respuesta corta

```js
// script.js, líneas 68–97
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}
```

## Ingredientes

**Enseña**

- [Un diseño más sencillo para pantalla](https://postext.dev/es/docs/configuration.md#visor-html): htmlViewer.overrides cambia la configuración de imprenta para la edición de pantalla y se aplica con applyHtmlViewerOverrides.
- [Edición de lectura en HTML](https://postext.dev/es/docs/configuration.md#integrar-el-visor-html): renderToHtml escribe la misma composición como HTML posicionado: texto seleccionable, enlaces en las citas, una página o varias, una junto a otra.

**También usa**

- [Figuras ajustadas a la página](https://postext.dev/es/docs/configuration.md#disposición)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Portadillas de parte](https://postext.dev/es/docs/configuration.md#el-contenedor-part)
- [Banda de capítulo a todo el ancho](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)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Una o dos columnas](https://postext.dev/es/docs/configuration.md#tipos-de-disposición)
- [Color del papel](https://postext.dev/es/docs/configuration.md#página)
- [Páginas en un canvas](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Equilibrado de columnas](https://postext.dev/es/docs/configuration.md#equilibrado-de-columnas)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`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), [`htmlViewer`](https://postext.dev/es/docs/configuration.md#visor-html), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`locale`](https://postext.dev/es/docs/configuration.md#separación-silábica), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`parts`](https://postext.dev/es/docs/configuration.md#partes), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)

**API**

- [`applyHtmlViewerOverrides`](https://postext.dev/es/docs/configuration.md#visor-html), [`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), [`renderToHtml`](https://postext.dev/es/docs/configuration.md#integrar-el-visor-html)

**Tipografías**

- Newsreader (OFL-1.1), Gloock (OFL-1.1), Reddit Sans (OFL-1.1)

## Elaboración

### 1 · Un juego de nombres de color, dos juegos de valores

```js
// script.js, líneas 14–31
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}
```

Cada color de la configuración remite a un identificador de la paleta; el hex que lleva al lado es solo el valor de día, de reserva. Por eso a la edición de pantalla le basta cambiar un array para pasar a los colores de noche. Los [ajustes de pantalla](/es/docs/configuration#visor-html) de [la respuesta corta](#la-respuesta-corta) se fusionan clave a clave, y los niveles de título por `level`; cualquier otro array, `colorPalette` incluido, se sustituye entero. `main-color` forma parte de la paleta, de modo que también cambian los valores por defecto del motor que dependen de él. En postext 1.4.1 la paleta nueva no llega a los elementos de diseño ni al color de las remisiones, y por eso `relink()` reescribe cada color enlazado con el valor que tiene en la paleta de la configuración fusionada.

### 2 · Aperturas: banda en papel, solo palabras en pantalla

```js
// script.js, líneas 37–64
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];
```

Los elementos de antetítulo, título y entradilla se definen una vez y se copian en los dos diseños, así que las dos ediciones muestran los mismos atributos del título: `{attr.kicker}`, `{attr.lead}` y el `{partTitle}` de la parte. En papel, un título de primer nivel abre página y abarca las dos columnas, y su diseño cuelga una banda gris niebla desde el sangrado. `minHeight` reserva la altura de la banda por debajo del margen superior, más `AIR`, los 9 mm que separan la banda de la primera línea. Los títulos impresos van a 54 pt porque cada uno es una sola palabra corta y el único texto grande de su página. En pantalla, el mismo nivel pasa a ser un título en columna, sin banda ni aire. El ajuste se fusiona por `level`, así que todo lo que no repite se queda como en la edición impresa.

### 3 · Una portadilla que solo existe en papel

```js
// script.js, líneas 101–117
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};
```

En la edición impresa, `:::part` abre [la portadilla azul lluvia](https://postext.dev/cookbook/print-and-screen-editions/es/p01.webp?v=3cd82cd0): una caja a sangre por los cuatro lados, un elemento de imagen con trazos de lluvia que dejan libre el texto, el número y el título que da la valla, y la lista de apuntes en blanco. Los ajustes de pantalla ponen `parts.page: false`, así que no se abre ninguna página ni se compone la lista. La [parte](/es/docs/configuration#partes) sigue aplicando su número y su título a los apuntes que vienen detrás, y el antetítulo de pantalla dice «Otoño · Apunte 1», igual que el impreso.

### 4 · Dibuja cada figura dos veces

```js
// script.js, líneas 215–245
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
  typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
  svg: { fileId: `${id}-${edition}.svg`, width, height },
  caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
  figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
    en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
    es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
  }, {
    en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
      + 'tube, half full, beside a graduated measuring stick.',
    es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
      + 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
  figure('valley', edition, [1400, 600], { position: 'bottom', span: 'page' }, {
    en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
    es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
  }, {
    en: 'A valley between two hills lies under layers of fog, with a church spire and a few '
      + 'trees showing above it, seen from a fenced bank under a pale sun.',
    es: 'Un valle entre dos lomas yace bajo capas de niebla; asoman la aguja de una iglesia '
      + 'y unos árboles, vistos desde un ribazo con una cerca bajo un sol pálido.' }),
  figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
    en: 'Where a year of morning winds came from at the garden station: west and south-west.',
    es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
  }, {
    en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
      + 'south-west, the shortest east.',
    es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
      + 'apuntan al oeste y al suroeste, los más cortos al este.' }),
  figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];
```

Cambiar la paleta no recolorea un SVG, así que cada dibujo se hace una vez por paleta. `figures(edition)` crea los mismos recursos para las dos ediciones, y cada uno apunta al archivo de su edición: `gauge-day.svg` en papel y `gauge-night.svg` en pantalla. Identificadores, pies, textos alternativos y colocación no cambian, de modo que cada `:ref` del Markdown lleva a la misma figura, con el mismo número, en las dos ediciones. En la de pantalla, el texto alternativo va en el `alt` de cada `<img>`, donde lo encuentra un lector de pantalla.

### 5 · Aloja la edición de pantalla

```js
// script.js, líneas 392–422
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
  // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
  pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
  + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
  const [width, height] = [pane.clientWidth, pane.clientHeight];
  if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
  const first = !size; // the first build opens on Figure 1's page, like the print proof
  size = `${width}×${height}`;
  const screenDoc = buildDocument({ markdown, resources: figures('night') },
    screenConfig({ width, height }));
  const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
  shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
    { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
  const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
  pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
  screenLabel.textContent =
    `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
  .observe(pane);
```

Cada página de pantalla es tan alta como el panel, así que `fitFiguresToPage` reduce el pluviómetro hasta que quepa en una página con su pie ([disposición](/es/docs/configuration#disposición)). A 96 ppp, la resolución de referencia de CSS, los milímetros y puntos heredados de la edición impresa se ven a su tamaño nominal ([integrar el visor HTML](/es/docs/configuration#integrar-el-visor-html)). La receta escribe las líneas con posición absoluta de `renderToHtml` dentro de un Shadow DOM, adonde no llegan los selectores de la página. El `all: initial` del envoltorio corta además las propiedades heredadas, como `letter-spacing`: el motor midió las líneas sin ellas, y con un valor heredado el texto se solaparía. Las páginas HTML son transparentes si nada las pinta, así que `page.backgroundColor` toma de la paleta el papel nocturno. El ResizeObserver espera 150 ms y solo recompone, con una `config()` nueva, cuando cambia el ancho o el alto del panel; después vuelve a desplazar el panel a la misma proporción de su altura, para que el lector siga por donde iba.

## La receta completa

Los archivos de abajo se componen a partir de la carpeta de la receta, con el texto de ejemplo y el kit común del Recetario ya incluidos. Para ejecutarlos como una sola página, pon el HTML en `<body>`, el CSS en un elemento `<style>` y el script en un `<script type="module">`; o pega cada uno en el panel correspondiente de un pen nuevo de CodePen (el JS como módulo). El script 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/print-and-screen-editions

### index.html

```html
<section id="editions">
  <figure class="edition"><canvas id="proof" role="img"></canvas><figcaption></figcaption></figure>
  <figure class="edition screen"><div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure>
</section>
<main id="pages"></main>
```

### style.css

```css
/* The reading room: the printed page and the screen edition side by side, on a fog-grey desk. */
#editions {
  display: flex; justify-content: center; align-items: center; gap: 32px;
  box-sizing: border-box; min-height: min(75vw, 100vh); padding: 28px;
  background: #dde3e9;
}
.edition { margin: 0; display: flex; flex-direction: column; gap: 14px; }
.edition figcaption {
  font: 600 11px/1 system-ui, sans-serif; letter-spacing: .16em; text-transform: uppercase;
  color: #4f5a66;
}
#proof {
  aspect-ratio: 225 / 297;
  display: block; height: min(580px, calc(100vh - 188px)); background: #fff;
  box-shadow: 0 1px 2px rgb(20 30 40 / .25), 0 20px 40px -18px rgb(20 30 40 / .5);
}
.screen { flex: 0 1 580px; min-width: 0; }
/* The pane the edition fills: its size is the page size, so resizing it re-lays the text.
   Drag its corner to try. Its background is the night paper, set by the script. At least
   400 px tall: in a shorter pane postext 1.4.1 sets the text over the opener
   (gotcha: opener-taller-than-column). */
#screen {
  height: min(580px, calc(100vh - 188px)); overflow-y: auto; scrollbar-gutter: stable;
  resize: both; min-width: 240px; min-height: 400px; max-width: 100%;
  border-radius: 10px; box-shadow: 0 0 0 7px #1c2129, 0 26px 48px -20px rgb(10 14 20 / .7);
  scrollbar-width: thin; scrollbar-color: #2e3742 transparent;
}
@media (max-width: 760px) {
  #editions { flex-direction: column; min-height: 0; padding: 24px 16px; }
  #proof { height: auto; width: min(420px, 100%); }
  .screen { flex: none; width: 100%; }
  #screen { height: 72vh; }
}
```

### script.js

```js
// ═══ Postext Cookbook · Nº 011 · One source, print and screen editions ═══════════
// https://postext.dev/en/cookbook/print-and-screen-editions
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Newsreader, Gloock, Reddit Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides,
  clearMeasurementCache, registerResourceImage, defaultResourceTypes,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'print-and-screen-editions';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one set of colour ids, two sets of values: day for paper, night for screens
const day = { ink: '#1b222b', rain: '#3d6f9e', slate: '#2d3a4a', fog: '#e9edf1',
  rule: '#c8d0d8', muted: '#5f6a76', paper: '#ffffff' };
const night = { ink: '#e6e8eb', rain: '#9cc3e6', slate: '#56657a', fog: '#1b2129',
  rule: '#2e3742', muted: '#98a2ae', paper: '#111418' };
const paletteOf = (values) => // main-color: the engine's defaults follow the rain blue
  Object.entries({ ...values, 'main-color': values.rain })
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const col = (id) => ({ hex: day[id], model: 'hex', paletteId: id }); // the hex: a day fallback
// Workaround (gotcha: palette-skips-designs): 1.4.1 re-reads the palette into the text,
// table and box styles and the page background, but not into design-slot elements or
// bodyText.referenceColor, so rewrite every linked colour from the palette the config carries.
function relink(config) {
  const hex = Object.fromEntries(config.colorPalette.map(({ id, value }) => [id, value.hex]));
  const walk = (v) => (Array.isArray(v) ? v.map(walk) : !v || typeof v !== 'object' ? v
    : Object.hasOwn(hex, v.paletteId) ? { ...v, hex: hex[v.paletteId] }
      : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])));
  return walk(config);
}
// #endregion
const px = (value) => ({ value, unit: 'px' }); // screen sizes, written as CSS pixels
const [TOP, BAND, AIR] = [22, 86, 9]; // mm: top margin, the fog band's depth, air under it

// #region opener: print opens each note under a fog band; the screen keeps the words only
const text = (id, content, fontFamily, style) => ({ kind: 'text', id, content, fontFamily,
  color: col('ink'), align: 'left', ...style,
  overflow: 'wrap' }); // titles break onto more lines (gotcha: overflow-ellipsis-default)
const kicker = text('kicker', '{partTitle} · {attr.kicker}', 'Reddit Sans',
  { fontWeight: 600, textTransform: 'uppercase', color: col('rain') });
const title = text('title', '{titleText}', 'Gloock');
const lead = text('lead', '{attr.lead}', 'Newsreader', { italic: true, hyphenate: true });
const at = (to, edge, y, x = px(0)) => ({ anchor: { to, edge }, offset: { x, y } });
const under = (id, y, width) => ({ ...at(`#${id}`, 'below', y), size: { width } });
const band = (y, height) => ({ ...at('bleed', 'top-left', y), size: { width: 'fill', height } });
const fromTop = (y, x) => at('container', 'top-left', y, x);
// minHeight sets the reservation: the band's foot below the top margin, plus AIR. The band hangs
// from the bleed and counts too, but ends higher (gotcha: opener-reserves-anchored).
const printOpener = { enabled: true, minHeight: mm(BAND - TOP + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('fog') },
    placement: band(mm(0), mm(BAND)) },
  { kind: 'rule', id: 'horizon', direction: 'horizontal', thickness: pt(2), color: col('rain'),
    placement: band(mm(BAND - 0.7), pt(2)) }, // 2 pt is 0.7 mm: the rule ends at the band's foot
  { ...kicker, fontSize: pt(8), letterSpacing: pt(1.8), placement: fromTop(mm(12)) },
  { ...title, fontSize: pt(54), lineHeight: 1.04, placement: under('kicker', mm(2.5), mm(150)) },
  { ...lead, fontSize: pt(11.5), lineHeight: 1.36, placement: under('title', mm(4), mm(118)) },
] } };
const screenOpener = [
  { ...kicker, fontSize: px(12), letterSpacing: px(2.6), placement: fromTop(px(4)) },
  { ...title, fontSize: px(48), lineHeight: 1.05, placement: under('kicker', px(6), 'fill') },
  { ...lead, fontSize: px(18), lineHeight: 1.45, color: col('muted'),
    placement: under('title', px(10), 'fill') },
];
// #endregion

// #region answer: the screen edition lives in the same config, as overrides of the print one
// config() stores it as htmlViewer: { overrides: screenOverrides() }; canvas and PDF ignore it.
const screenOverrides = () => ({
  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
  parts: { page: false }, // no divider page; the part still names the notes after it
  // 96 dpi: the mm and pt inherited from print render at their CSS size.
  page: { dpi: 96, margins: { top: px(40), bottom: px(40), mirror: false } },
  layout: { layoutType: 'single', // one column
    fitFiguresToPage: true }, // tall figures shrink to the pane; off by default, as in print
  bodyText: { fontSize: px(17), lineHeight: px(27), textAlign: 'left', // ragged for reading
    // A paragraph may start on the last line of a screen page. With the rule on, 1.4.1 can force
    // a paragraph taller than the pane whole into a one-line gap under a figure, and off the page.
    avoidWidows: false },
  // Heading levels merge on `level`: the print level keeps everything not restated here.
  headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
    marginTop: px(40), marginBottom: px(26),
    advancedDesign: { minHeight: px(0), slot: { elements: screenOpener } } }] }, // no band air
  footer: { elements: [] }, // a scrolling page runs no folios (print's header is empty already)
  captionStyle: { fontSize: px(13), gap: px(10) },
  paragraphStyles: [{ ...colophon, fontSize: px(13), lineHeight: px(20) }], // restated whole
});
// The host owns the page size: the pane's, in CSS pixels (gotcha: viewer-settings-sandbox-only).
// Wide panes get wider margins, so the measure stops at MEASURE.
const MEASURE = 470; // px: about 65 characters of Newsreader at 17 px
const MIN_SIDE = 34; // px: the side margins of a narrow pane
function screenConfig({ width, height }) {
  const merged = applyHtmlViewerOverrides(config()); // print + overrides, a fresh object
  const side = px(Math.max(MIN_SIDE, (width - MEASURE) / 2));
  return relink({ ...merged, page: { ...merged.page, width: px(width), height: px(height),
    margins: { ...merged.page.margins, left: side, right: side } } });
}
// #endregion

// #region part: the divider page, a rain field bled off every edge under the part's numeral
const onField = { color: col('paper'), lineHeight: 1 };
const parts = {
  margins: { top: mm(212), left: mm(24), right: mm(40) }, // the fence's list sits low
  bodyStyle: { fontSize: pt(13), lineHeight: pt(19), color: col('paper'),
    numberColor: col('paper') },
  design: { elements: [
    { kind: 'box', id: 'field', style: { backgroundColor: col('rain') },
      placement: band(mm(0), 'fill') },
    { kind: 'image', id: 'strokes', resourceId: 'rain', placement: band(mm(0), 'fill') },
    text('series', '{title}', 'Reddit Sans', { ...onField, fontWeight: 600, fontSize: pt(9),
      letterSpacing: pt(2.4), textTransform: 'uppercase', placement: fromTop(mm(30), mm(24)) }),
    text('numeral', '{number}', 'Gloock', { ...onField, fontSize: pt(190),
      placement: under('series', mm(10), mm(150)) }),
    text('name', '{titleText}', 'Gloock', { ...onField, fontSize: pt(60),
      placement: under('numeral', mm(-6), mm(150)) }),
  ] },
};
// #endregion

const foot = text('foot', '{pageNumber}   {title} · {partTitle}', 'Reddit Sans', {
  fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase',
  color: col('muted'), align: 'center', placement: { ...at('container', 'top', mm(9)),
    size: { width: 'fill' } },
  pages: 'opener' }); // the notes, not the part page: each fits its opening page (a note that ran
// on would need a copy of this element with pages: 'body')
const colophon = { id: 'colophon', fontFamily: 'Reddit Sans', fontSize: pt(7.5),
  lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
  marginTop: pt(14) };

// A factory: the engine caches resolved configs per object (gotcha: config-cache-identity).
const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  // The locale does not name the figures (gotcha: resource-types-locale): "Figura" in Spanish,
  // one count for the whole issue, and a lower-case "fig." in Spanish running text.
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}',
    resetOn: 'never', ...(type.id === 'figure' && { shortLabel: t({ en: 'Fig.', es: 'fig.' }) }),
  })),
  colorPalette: paletteOf(day),
  page: { width: mm(225), height: mm(297), dpi: 150, backgroundColor: col('paper'), // dark at night
    margins: { top: mm(TOP), bottom: mm(24), left: mm(20), right: mm(16), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: { fontFamily: 'Newsreader', fontSize: pt(10), lineHeight: pt(14), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rain'),
    textAlign: 'justify', // the default, stated for contrast with the screen's ragged 'left'
    firstLineIndent: mm(4.5), indentAfterHeading: false }, // hyphenation, widows: on by default
  headings: { fontFamily: 'Gloock', fontWeight: 400, color: col('ink'),
    // Off: 1.4.1 drops the column under a closing page's column float a line (here English Rain
    // under the gauge, Spanish Wind under the rose; 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: 'any' },
      marginTop: pt(0), marginBottom: pt(0), advancedDesign: printOpener },
  ] },
  captionStyle: { fontFamily: 'Reddit Sans', fontSize: pt(8), color: col('muted'),
    labelColor: col('rain'), gap: mm(2.4) },
  parts, paragraphStyles: [colophon], header: { elements: [] }, footer: { elements: [foot] },
  htmlViewer: { overrides: screenOverrides() }, // canvas and PDF ignore it; an HTML host applies it
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Apuntes del tiempo"
subtitle: "Otoño"
author: "Recetario de Postext"
---

:::part{number="I" title="Otoño"}
1. Lluvia, y treinta y un años de pluviómetro
2. Niebla, de dónde sale y cuándo se levanta
3. Viento, de la veleta a la escala de Beaufort
:::

# Lluvia {kicker="Apunte 1" lead="Un vaso de cobre al fondo del jardín recoge la lluvia de esta casa desde hace treinta y un años, y su cuaderno guarda ya más de once mil lecturas."}

La lluvia se mide como una altura: la que alcanzaría el agua si nada se escurriera ni se filtrara ni se evaporara. Un milímetro de lluvia es un litro en cada metro cuadrado de suelo, y así el hortelano y el hidrólogo pueden usar la misma cifra. El instrumento que la cuenta apenas ha cambiado en siglo y medio; la :ref{id="gauge"} lo muestra en sección. Un embudo de anchura conocida recoge la lluvia y la lleva a un tubo interior estrecho, con la décima parte de su área, así que cada milímetro caído sube diez dentro, lo bastante para leerlo hasta la décima con una regla. Lo que rebosa del tubo en un aguacero espera en el vaso exterior y se mide después.

La lectura se hace a la misma hora cada mañana, haga el tiempo que haga, y se apunta antes del desayuno. Es un trabajo aburrido. Los climatólogos describen un lugar por sus medias de treinta años, y nuestro cuaderno ya abarca treinta y uno. En ese tiempo, el pluviómetro ha registrado una media de 612 milímetros al año, desde 402 el año más seco hasta 871 el más lluvioso.

Ningún pluviómetro recoge toda la lluvia. El viento arrastra las gotas más allá de la boca del embudo, así que uno puesto en un poste expuesto recoge menos de lo que cae, y uno junto a una tapia o un árbol, menos todavía. La norma pide separarlo de cualquier obstáculo al menos el doble de la altura del obstáculo, con la boca a unos treinta centímetros del césped. Por eso el nuestro está solo, al fondo del jardín.

Ninguna gota cae con la forma de lágrima de los mapas del tiempo; la que tiene depende de su tamaño. Las más pequeñas son esferas perfectas, redondeadas por su propia tensión superficial. Por encima de unos dos milímetros, el aire que empuja desde abajo las aplasta hasta dejarlas más parecidas a un panecillo, y pasados los cinco o seis milímetros se rompen en gotas menores mientras caen. Una gota grande llega al suelo a unos nueve metros por segundo, con fuerza bastante para tamborilear en hojas y tejados; por eso un chaparrón de verano se oye antes de sentirse.

La lluvia más intensa sale de las nubes más altas, y rara vez dura. La lluvia larga del otoño viene de amplias capas de nubes a lo largo de un frente cálido, de las que se cierran poco a poco durante la mañana: primero una bruma delante del sol, luego una tapa gris, luego las primeras gotas en el cristal. Al anochecer puede haber diez o quince milímetros en el vaso.

# Niebla {kicker="Apunte 2" lead="La niebla es una nube que toca el suelo. En otoño llena el valle tras casi todas las noches despejadas, y a las once suele haberse ido."}

Los meteorólogos hablan de niebla cuando la visibilidad baja de un kilómetro; por encima, es neblina. En los dos casos está hecha de lo mismo que una nube: gotitas de agua de una centésima de milímetro, tan pequeñas que flotan en vez de caer. Un metro cúbico de niebla espesa contiene menos de medio gramo de agua, la décima parte de una cucharadita, y una capa de unas decenas de metros basta para tapar un pueblo hasta la aguja de la iglesia; la :ref{id="valley"} muestra una de esas mañanas.

La niebla de las mañanas de otoño suele ser de irradiación. En una noche clara y en calma, el suelo cede al cielo abierto el calor del día y enfría el aire que descansa sobre él. Cuando ese aire llega a su punto de rocío, su vapor se condensa en gotitas. El aire frío pesa, así que escurre ladera abajo y se remansa en hondonadas y vegas, y al amanecer las alturas quedan al sol sobre un mar blanco y llano. Un viento de más de unos pocos metros por segundo mezclaría la capa fría con el aire templado de encima, y una noche nublada no dejaría enfriarse al suelo.

Cuando el sol sube, calienta el suelo, el suelo calienta el aire y la niebla se adelgaza desde abajo, y a menudo se levanta en un techo gris antes de romperse. La niebla marina es otra cosa. Se forma cuando aire templado y húmedo pasa sobre agua fría, y como el viento que la trajo la sigue alimentando, puede quedarse días sobre una costa.

La niebla es blanca por la misma razón que una nube: sus gotitas dispersan por igual todos los colores de la luz. De noche devuelve al conductor la luz de sus faros.

Quien vive en un valle con niebla aprende sus costumbres. En el nuestro, la primera niebla del año llega tras la primera noche larga y despejada de septiembre. Se posa antes junto al río y tarda más en irse de detrás del molino. Si la torre de la iglesia asoma antes de las nueve, la tarde será buena.

# Viento {kicker="Apunte 3" lead="El viento se lee en lo que mueve: el humo, las hojas, el mar, la ropa tendida. En 1805, un oficial de marina le puso números."}

El viento es aire que va de las altas presiones a las bajas. Nunca toma el camino recto: el giro de la Tierra lo desvía, de modo que en el hemisferio norte rodea una borrasca en sentido contrario a las agujas del reloj, y cerca del suelo el rozamiento lo frena y lo inclina hacia dentro. Un viento se nombra por el lugar de donde viene, no por aquel adonde va, así que el viento del oeste sopla desde el oeste, desde el océano, y a menudo trae la lluvia. En nuestro jardín es el más frecuente de todos, y la rosa de la :ref{id="rose"} muestra por cuánto.

La rosa es un año de mañanas leídas en la veleta del tejado del cobertizo. Una veleta pone su cola ancha a favor del viento, así que la flecha apunta siempre contra él, hacia el lugar de donde llega el aire. Cada mañana, a las ocho, anotamos el más cercano de sus dieciséis rumbos, y cada pétalo de la rosa es tan largo como la parte de las mañanas en que el viento sopló de ese rumbo. Las mañanas de calma, más o menos una de cada ocho, no apuntan a ninguna parte y quedan fuera.

Francis Beaufort, oficial de la Marina británica, escribió su escala para los cuadernos de bitácora y describió cada fuerza por lo que hacía con las velas de un navío de guerra. Más tarde se reescribió para tierra firme, y esa versión sigue sirviendo a quien no tiene anemómetro. Con fuerza 2 se nota el viento en la cara y susurran las hojas. Con fuerza 4 levanta polvo y papeles y mueve las ramas pequeñas. Con fuerza 6 se agitan las ramas grandes y cuesta sujetar el paraguas. Con fuerza 8 se quiebran las ramitas de los árboles y cuesta avanzar contra él. La escala termina en la fuerza 12, huracán, un viento medio de 118 kilómetros por hora o más.

Cada valle tiene sus vientos, y sus nombres para ellos. Marineros y labradores los bautizaron mucho antes de que nadie los midiera, y los nombres aún dicen de dónde viene cada uno y qué trae: el cierzo, que baja frío y seco por el valle del Ebro; el terral, que en verano sopla de tierra adentro y abrasa la costa de Málaga; la brisa del mar, que entra por la tarde y muere al anochecer.

En las estaciones meteorológicas, el viento se mide a diez metros sobre terreno despejado y se promedia durante diez minutos, porque el viento nunca es constante. Llega en rachas y calmas, se arremolina junto a edificios y setos, y sopla más fuerte sobre el mar que sobre una ciudad; una racha puede ser un cincuenta por ciento más intensa que la media que la rodea. Por eso el pronóstico da dos cifras, la velocidad media y la de las rachas, y en el monte hay que contar con la segunda.

:::paragraphs{style="colophon"}
*Apuntes del tiempo*, parte I. Compuesto en Newsreader, Gloock y Reddit Sans (SIL Open Font License). Texto y dibujos: originales, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region figures: the same resources for both editions, each pointing at its edition's drawing
const figure = (id, edition, [width, height], placement, caption, alt) => ({ id,
  typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement,
  svg: { fileId: `${id}-${edition}.svg`, width, height },
  caption: caption && t(caption), altText: alt && t(alt) }); // the HTML edition's <img alt>
const figures = (edition) => [
  figure('gauge', edition, [900, 1260], { position: 'auto', span: 'column' }, {
    en: 'The rain gauge in section: the funnel feeds a tube with a tenth of its area.',
    es: 'El pluviómetro en sección: el embudo vierte en un tubo con la décima parte de su área.',
  }, {
    en: 'Rain falls on a funnel set in a can sunk in the lawn; the funnel drains into a narrow '
      + 'tube, half full, beside a graduated measuring stick.',
    es: 'La lluvia cae en un embudo sobre un vaso hundido en el césped; el embudo vierte en un '
      + 'tubo estrecho, medio lleno, junto a una regla graduada.' }),
  figure('valley', edition, [1400, 600], { position: 'bottom', span: 'page' }, {
    en: 'Radiation fog at dawn: cold air drains off the hills overnight and fills the valley.',
    es: 'Niebla de irradiación al amanecer: el aire frío baja de las lomas y llena el valle.',
  }, {
    en: 'A valley between two hills lies under layers of fog, with a church spire and a few '
      + 'trees showing above it, seen from a fenced bank under a pale sun.',
    es: 'Un valle entre dos lomas yace bajo capas de niebla; asoman la aguja de una iglesia '
      + 'y unos árboles, vistos desde un ribazo con una cerca bajo un sol pálido.' }),
  figure('rose', edition, [900, 900], { position: 'top', span: 'column' }, {
    en: 'Where a year of morning winds came from at the garden station: west and south-west.',
    es: 'De dónde vino el viento de un año de mañanas en el jardín: del oeste y del suroeste.',
  }, {
    en: 'A wind rose of sixteen petals on three rings; the longest petals point west and '
      + 'south-west, the shortest east.',
    es: 'Una rosa de los vientos de dieciséis pétalos sobre tres anillos; los más largos '
      + 'apuntan al oeste y al suroeste, los más cortos al este.' }),
  figure('rain', 'field', [2250, 2970]), // drawn by the part design, never cited: unnumbered
];
// #endregion

// #region art: a rain gauge in section, a fogged valley, a wind rose, the part page's rain
function mulberry(seed) { // a seeded PRNG: 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 svg = (w, h, body) =>
  `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const f1 = (n) => n.toFixed(1);
const between = (random, [a, b]) => a + random() * (b - a);
// Slanted rain strokes; `clear` lists boxes [x0, y0, x1, y1] the strokes stay out of.
const streaks = (random, n, { x, y, len, width, color, alpha, clear = [] }) =>
  Array.from({ length: n }, () => {
    const [x0, y0, l] = [between(random, x), between(random, y), between(random, len)];
    const [o, w] = [between(random, alpha).toFixed(2), between(random, width).toFixed(2)];
    const hits = clear.some(([a, b, c, d]) => x0 > a && x0 - 0.28 * l < c && y0 + l > b && y0 < d);
    return hits ? '' : `<path d="M${f1(x0)} ${f1(y0)}l${f1(-0.28 * l)} ${f1(l)}" stroke="${color}" `
      + `stroke-opacity="${o}" stroke-width="${w}" stroke-linecap="round"/>`;
  }).join('');
const shape = (tag, attrs) => `<${tag} ${Object.entries(attrs)
  .map(([k, v]) => `${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}="${v}"`).join(' ')}/>`;

function gauge(p) { // 300 × 420: rain, the funnel, the can sunk in the lawn, the tube, a stick
  const random = mulberry(7);
  const line = { stroke: p.ink, strokeWidth: 2 };
  const ticks = Array.from({ length: 23 }, (_, i) =>
    `M246 ${386 - i * 9}h${i % 5 === 0 ? 14 : 7}`).join('');
  const grass = Array.from({ length: 42 }, (_, i) => `M${f1(3 + i * 7.2 + random() * 4)} 356`
    + `l${f1((random() - 0.5) * 7)} -${f1(5 + random() * 9)}`).join('');
  return svg(300, 420, streaks(random, 44, { x: [40, 292], y: [0, 72], len: [12, 24],
    width: [1.6, 2.6], color: p.rain, alpha: [0.35, 0.95] })
    + shape('path', { d: 'M70 104H230', stroke: p.muted, strokeWidth: 1.4 })
    + shape('path', { d: 'M70 104l7 -4v8zM230 104l-7 -4v8z', fill: p.muted }) // arrowheads
    + shape('rect', { x: 0, y: 355, width: 300, height: 65, fill: p.rule, fillOpacity: 0.55 })
    + shape('path', { d: grass, stroke: p.slate, strokeWidth: 1.6, strokeLinecap: 'round' })
    + shape('rect', { x: 76, y: 128, width: 148, height: 268, rx: 5, fill: p.fog, ...line,
      strokeWidth: 2.5 })
    + shape('rect', { x: 124, y: 196, width: 52, height: 192, fill: p.paper, ...line })
    + shape('rect', { x: 126, y: 290, width: 48, height: 96, fill: p.rain, fillOpacity: 0.85 })
    + shape('path', { d: 'M70 118h160v10H70z', fill: p.slate })
    + shape('path', { d: 'M72 128h156l-72 60h-12z', fill: p.rule, ...line })
    + shape('rect', { x: 144, y: 184, width: 12, height: 16, fill: p.rule, ...line })
    + shape('rect', { x: 240, y: 176, width: 26, height: 214, fill: p.paper, ...line,
      strokeWidth: 1.6 })
    + shape('rect', { x: 241, y: 290, width: 24, height: 99, fill: p.rain, fillOpacity: 0.3 })
    + shape('path', { d: ticks, stroke: p.ink, strokeWidth: 1.2 }));
}

function valley(p, mist) { // 700 × 300: dawn over a valley full of fog, seen from a bank
  const random = mulberry(3);
  const skyline = (y0, amp, step) => { // a gentle ridge (or fog top) from edge to edge
    const y = () => f1(y0 + (random() - 0.5) * amp);
    let d = `M0 300V${y0}`;
    for (let x = step; x <= 700; x += step) d += `Q${x - step / 2} ${y()} ${x} ${y()}`;
    return `${d}V300Z`;
  };
  const slopes = 'M0 300V118C80 114 160 150 240 208C280 236 300 262 318 300Z'
    + 'M700 300V126C630 122 550 158 480 210C446 236 424 262 408 300Z';
  const crowns = [[292, 176, 11], [311, 172, 9], [326, 178, 8], [424, 174, 10], [441, 178, 8]]
    .map(([cx, cy, r]) => shape('circle', { cx, cy, r, fill: p.slate }));
  const fog = [[168, 0.3], [182, 0.38], [198, 0.45], [214, 0.5]].map(([y, a]) => // stacked
    shape('path', { d: skyline(y, 7, 70), fill: mist, fillOpacity: a }));
  const posts = [96, 150, 204, 258, 312].map((x, i) => `M${x} ${254 - i * 1.5}v-22`).join('');
  return svg(700, 300, shape('rect', { width: 700, height: 300, fill: p.fog }) // the sky
    + shape('circle', { cx: 566, cy: 66, r: 24, fill: p.rule })
    + shape('path', { d: skyline(128, 26, 100), fill: p.rule })
    + shape('path', { d: skyline(156, 22, 70), fill: p.muted, fillOpacity: 0.55 })
    + shape('rect', { y: 214, width: 700, height: 86, fill: p.muted }) // the fogged valley floor
    + shape('path', { d: slopes, fill: p.slate }) + crowns.join('')
    + shape('path', { d: 'M358 252V162h14V252zM358 162l7 -24l7 24z', fill: p.slate }) // tower
    + fog.join('')
    + shape('path', { d: 'M0 300V250C130 238 250 242 380 258C480 270 590 262 700 246V300Z',
      fill: p.slate }) // the bank we watch from, which gives the drawing its foot
    + shape('path', { d: `${posts}M96 239L312 233`, stroke: p.slate, strokeWidth: 2.4 }));
}

function rose(p) { // 300 × 300: where a year of morning winds came from, in sixteen petals
  const share = [6, 4, 3, 2, 2, 3, 4, 6, 9, 12, 19, 22, 24, 14, 9, 7]; // N first, clockwise
  const petal = (s, i) => {
    const [a, r] = [((i * 22.5 - 90) * Math.PI) / 180, s * 5.4]; // length ∝ share
    const end = (d) => `${f1(150 + r * Math.cos(a + d))} ${f1(150 + r * Math.sin(a + d))}`;
    return shape('path', { d: `M150 150L${end(-0.17)}A${f1(r)} ${f1(r)} 0 0 1 ${end(0.17)}Z`,
      fill: s > 12 ? p.rain : p.slate, fillOpacity: s > 12 ? 0.95 : 0.55 });
  };
  const rings = [40, 80, 120].map((r) =>
    shape('circle', { cx: 150, cy: 150, r, fill: 'none', stroke: p.rule, strokeWidth: 1.2 }));
  return svg(300, 300, rings.join('') + share.map(petal).join('')
    + shape('path', { d: 'M150 18V282M18 150H282', stroke: p.rule, strokeWidth: 1 })
    + shape('path', { d: 'M144 14V2L156 14V2', fill: 'none', stroke: p.ink, strokeWidth: 2 }) // N
    + shape('circle', { cx: 150, cy: 150, r: 5, fill: p.ink }));
}

async function drawFigures() { // every figure in both palettes, and the part page's rain
  const words = [[20, 26, 80, 36], [20, 100, 112, 128], [20, 206, 140, 238]]; // mm: the type
  await loadSvg('rain-field.svg', svg(225, 297, streaks(mulberry(19), 320, { x: [-10, 245],
    y: [-14, 292], len: [6, 18], width: [0.3, 0.8], color: day.paper, alpha: [0.12, 0.45],
    clear: words })));
  for (const [edition, p, mist] of [['day', day, day.paper], ['night', night, night.muted]]) {
    await loadSvg(`gauge-${edition}.svg`, gauge(p));
    await loadSvg(`valley-${edition}.svg`, valley(p, mist)); // night fog: pale, not black
    await loadSvg(`rose-${edition}.svg`, rose(p));
  }
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first)
  Newsreader: ['400', '400i', '700'], Gloock: ['400'],
  'Reddit Sans': ['400', '400i', '600', '700'] }; // 400: captions and the colophon

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadFonts(FONTS, markdown);
await drawFigures();
const doc = await buildWithFonts( // print: every page on the kit's desk, and one beside the screen
  () => buildDocument({ markdown, resources: figures('day') }, config()), markdown);
showPages(doc, { title: t({ en: 'One source, print and screen editions',
  es: 'Un solo original, ediciones impresa y de pantalla' }) });

// The two editions side by side, above the desk. index.html and style.css lay them out; pasted
// on its own, the script writes the same markup, and the screen region gives the pane a height.
if (!document.getElementById('editions')) {
  document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
  <figure class="edition"><canvas id="proof" role="img" style="width:300px"></canvas>
  <figcaption></figcaption></figure><figure class="edition screen">
  <div id="screen" role="region" tabindex="0"></div><figcaption></figcaption></figure></section>`);
}
const [proof, pane] = [document.getElementById('proof'), document.getElementById('screen')];
const [proofLabel, screenLabel] = document.querySelectorAll('#editions figcaption');
const holds = (page, id) => [...(page.floats ?? []), ...page.columns.flatMap((c) => c.blocks)]
  .some((block) => block.resourceBlock?.resource.id === id);
const note = doc.pages.find((page) => holds(page, 'gauge')) ?? doc.pages[0]; // Figure 1's page
const density = Math.min(window.devicePixelRatio || 1, 2);
renderPageToCanvas(note, doc, proof, { scale: (density * proof.clientWidth) / note.width });
const { width: trimW, height: trimH } = config().page;
proofLabel.textContent = `${t({ en: 'Print', es: 'Impresa' })} · canvas · `
  + `${trimW.value} × ${trimH.value} mm`;
proof.setAttribute('aria-label',
  `${t({ en: 'Print edition, page', es: 'Edición impresa, página' })} ${note.index + 1}`);
pane.setAttribute('aria-label', t({ en: 'Screen edition', es: 'Edición de pantalla' }));

// #region screen: the HTML edition in a Shadow DOM, laid out again when its pane resizes
const FOLDED = 200; // px: a pane narrower or shorter than this is hidden or squeezed; skip it
if (!pane.clientHeight) { // no style.css: a height, and a corner to drag the pane smaller, but
  // not under 400 px, where 1.4.1 sets text over the opener (gotcha: opener-taller-than-column)
  pane.style.cssText = 'height:580px;min-height:400px;min-width:240px;overflow:auto;resize:both';
}
pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
const shadow = pane.attachShadow({ mode: 'open' }); // the page's selectors cannot reach in, but
// inherited text properties (letter-spacing, text-transform…) can, and the lines were measured
// without them: `all: initial` on the wrapper stops them at the edition's edge.
const inShadow = `<style>:host>div{all:initial;display:block}`
  + `::selection{background:${night.rain}55}</style>`; // selected text takes the night blue
let size = '';
function showScreen() {
  const [width, height] = [pane.clientWidth, pane.clientHeight];
  if (`${width}×${height}` === size || width < FOLDED || height < FOLDED) return; // same, or folded
  const first = !size; // the first build opens on Figure 1's page, like the print proof
  size = `${width}×${height}`;
  const screenDoc = buildDocument({ markdown, resources: figures('night') },
    screenConfig({ width, height }));
  const place = pane.scrollTop / pane.scrollHeight; // the reader's place, kept across rebuilds
  shadow.innerHTML = `${inShadow}<div>${renderToHtml(screenDoc,
    { mode: 'single', padding: 0, resourceImageUrl: imageUrl })}</div>`;
  const fig = first && screenDoc.pages.find((page) => holds(page, 'gauge')); // 'single' mode
  pane.scrollTop = fig ? fig.index * height : place * pane.scrollHeight; // stacks pane-tall pages
  screenLabel.textContent =
    `${t({ en: 'Screen', es: 'Pantalla' })} · HTML · ${width} × ${height} px`;
}
showScreen();
let timer = 0; // debounced; its first call, on observe(), finds the size unchanged
new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(showScreen, 150); })
  .observe(pane);
// #endregion

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

### Lee de día

Quita la paleta nocturna y usa los valores de día para el panel y las figuras; la edición de pantalla tendrá entonces páginas blancas, texto en tinta oscura y los dibujos de día.

```diff
-  colorPalette: paletteOf(night), // arrays are replaced whole: the night values
   parts: { page: false }, // no divider page; the part still names the notes after it
@@
-pane.style.background = night.paper; // the pane's own ground, beside the pages and the scrollbar
+pane.style.background = day.paper; // the pane's own ground, beside the pages and the scrollbar
@@
-  const screenDoc = buildDocument({ markdown, resources: figures('night') },
+  const screenDoc = buildDocument({ markdown, resources: figures('day') },
```

### Dale a la apertura impresa un número de capítulo sobre una banda de color

Para una apertura impresa más rotunda, con una banda saturada y un número de capítulo grande, consulta [Apertura de capítulo sobre banda a sangre](https://postext.dev/es/cookbook/chapter-opener-bleed-band.md); los ajustes de pantalla no cambian.

## Errores frecuentes

- **La salida HTML no tiene filetes, retícula ni marcas, y es transparente.** renderToHtml no dibuja filetes de columna, rejilla base ni marcas de corte, y sus páginas son transparentes si no le pasas un fondo, lo que en una página oscura deja texto oscuro sobre oscuro.
- **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.
- **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().
- **Algunos ajustes de htmlViewer solo existen en el Sandbox.** htmlViewer.maxCharsPerLine, columnGap y optimalLineBreaking los lee el visor del Sandbox, no renderToHtml. En tu propia página, dimensiona tú la página y pasa mode y columnGap a renderToHtml; las correcciones se aplican con applyHtmlViewerOverrides.
- **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.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Una apertura reserva altura hasta su elemento anclado más bajo.** Una apertura de diseño avanzado reserva la altura de su elemento más bajo, y cuentan también los anclados a la página o a la sangre que quedan por debajo del título, así que un adorno al pie de la página empuja el texto a la siguiente. Deja esos adornos por encima del título, pásalos a una ranura de cabecera o de pie, o fija la reserva con minHeight.
- **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.
- **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.
- **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.
- **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.

- Revisa el final de cada apunte después de editar. Si el último párrafo de un apunte deja en una página nueva una cola de dos líneas (el control de viudas no las separa), postext 1.4.1 da a la columna de esa página la altura de una sola línea y el canvas recorta la segunda. Los dos textos de muestra están ajustados para que ningún apunte acabe así.
- En texto en bandera, la 1.4.1 puede cerrar una línea con el «(» que precede a una remisión, y en un panel que el lector puede redimensionar, alguna anchura acabará dando ese corte. Estos apuntes citan las figuras dentro de la frase («la fig. 2 muestra»), nunca entre paréntesis.
- Deja que un párrafo de pantalla empiece en la última línea de una página (`avoidWidows: false`). Con esa regla activa, la 1.4.1 puede meter entero un párrafo más alto que la página en el hueco que queda bajo una figura, y el párrafo se sale por el pie de la página; pasa, por ejemplo, con paneles de 240 × 420 o 280 × 450 px.
- Mantén el panel en 400 px de alto como mínimo (el `min-height` de la receta). En un panel estrecho de menos de unos 350 px de alto, la apertura de pantalla es más alta que la caja de texto de la página; postext 1.4.1 le quita entonces toda la reserva y compone el primer párrafo encima del título.
- Da tamaño al panel antes de componer. Un panel sin ancho, oculto o aplastado en una fila flex, daría una página de ancho cero; por eso `showScreen()` descarta un panel más estrecho o más bajo que `FOLDED`, 200 px.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipografías: Newsreader (OFL-1.1), Gloock (OFL-1.1), Reddit Sans (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 054 · Cambiar los colores de un documento con una sola paleta](https://postext.dev/es/cookbook/live-palette-retint.md): Cada color de la configuración lleva un id de paleta, y una sola función compone el programa de un festival en rojo, verde azulado, violeta o arena. · Nivel 2 (Intermedio) · Hojas sueltas y efímeros
- [N.º 056 · Editor en vivo con la composición en un Web Worker](https://postext.dev/es/cookbook/web-worker-live-editor.md): Markdown junto a una página de bolsillo: un worker creado desde un blob compone con fuentes propias y cada pulsación cancela la composición en curso. · Nivel 3 (Avanzado) · Narrativa, teatro y prosa literaria
- [N.º 004 · Reportaje de revista: de la foto de apertura al signo final](https://postext.dev/es/cookbook/magazine-feature-opener.md): Una apertura advancedDesign pone una foto a sangre y saca del título antetítulo, titular, entradilla y firma; siguen recuadros flotantes y un chip de cierre. · Nivel 3 (Avanzado) · Revistas y fanzines
