# Un sol original, edicions impresa i de pantalla

> Les dues edicions surten d'una configuració: htmlViewer.overrides desa el disseny fosc i applyHtmlViewerOverrides el fusiona abans de compondre l'HTML.

- Versió HTML: https://postext.dev/ca/cookbook/print-and-screen-editions
- Recepta Núm. 011 · Sortida i integració · Nivell 3 (Avançat) · Sortides: Canvas, HTML, Controls en viu
- Gèneres: Revistes i fanzins
- Requereix postext ≥ 1.4.1 · provada amb 1.9.2 el 2026-10-01
- Pàgines: [1](https://postext.dev/cookbook/print-and-screen-editions/es/p01.webp?v=1d757813), [2](https://postext.dev/cookbook/print-and-screen-editions/es/p02.webp?v=1d757813), [3](https://postext.dev/cookbook/print-and-screen-editions/es/p03.webp?v=1d757813), [4](https://postext.dev/cookbook/print-and-screen-editions/es/p04.webp?v=1d757813)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=print-and-screen-editions&lang=es (.postext: https://postext.dev/cookbook/print-and-screen-editions/es/print-and-screen-editions.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/print-and-screen-editions.md), [es](https://postext.dev/es/cookbook/print-and-screen-editions.md), [zh](https://postext.dev/zh/cookbook/print-and-screen-editions.md), [ar](https://postext.dev/ar/cookbook/print-and-screen-editions.md)

## En poques paraules

La secció del temps d'una revista, feta dues vegades amb el mateix text. Mostra com una mateixa configuració dona una versió impresa a columnes i una altra de fosca, d'una sola columna, per llegir en pantalla.

## Què compondràs

*Apuntes del tiempo*, la secció de tardor d'una revista petita, composta dues vegades amb el mateix Markdown. En paper és un número de 225 × 297 mm: una avantportada blau pluja, tres apunts que s'obren sota una banda gris boira, dues columnes justificades, dibuixos d'un pluviòmetre i d'una rosa dels vents, i una fotografia d'una vall amb boira. En pantalla és una sola columna en bandera, en Newsreader gris clar sobre fons gairebé negre, amb text HTML que es pot seleccionar i copiar, sense avantportada, bandes ni folis. El tauler s'obre pel pluviòmetre, reduït a l'alçada del tauler i dibuixat en colors de nit, al costat de la seva pàgina impresa. Més amunt hi ha l'obertura de Lluvia; si arrossegues la cantonada del tauler, el text es recompon a la mida nova. El disseny de pantalla ocupa nou claus de `htmlViewer.overrides`, a la mateixa configuració que l'imprès.

**Aquesta recepta respon a:**

- Com mostro el mateix document com a vista de lectura HTML adaptable, amb un disseny de pantalla més senzill?
- Com dono a l'edició de pantalla un disseny més senzill que a la impresa, amb una sola configuració?

## La resposta curta

```js
// script.js, línies 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 } } });
}
```

## Ingredients

**Ensenya**

- [Un disseny més senzill per a pantalla](https://postext.dev/ca/docs/configuration.md#visor-html): htmlViewer.overrides canvia la configuració d'impremta per a l'edició de pantalla i s'aplica amb applyHtmlViewerOverrides.
- [Edició de lectura en HTML](https://postext.dev/ca/docs/configuration.md#integrar-el-visor-html): renderToHtml escriu la mateixa composició com a HTML posicionat: text seleccionable, enllaços a les cites, una pàgina o diverses, una al costat de l'altra.

**També fa servir**

- [Figures ajustades a la pàgina](https://postext.dev/ca/docs/configuration.md#disposició)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Portadelles de part](https://postext.dev/ca/docs/configuration.md#el-contenidor-part)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Una o dues columnes](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició)
- [Color del paper](https://postext.dev/ca/docs/configuration.md#pàgina)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- citations
- [Equilibri de columnes](https://postext.dev/ca/docs/configuration.md#equilibratge-de-columnes)

**La configuració d'un cop d'ull**

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`htmlViewer`](https://postext.dev/ca/docs/configuration.md#visor-html), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`parts`](https://postext.dev/ca/docs/configuration.md#parts), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)

**API**

- [`applyHtmlViewerOverrides`](https://postext.dev/ca/docs/configuration.md#visor-html), [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToHtml`](https://postext.dev/ca/docs/configuration.md#integrar-el-visor-html)

**Tipus de lletra**

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

## Elaboració

### 1 · Un joc de noms de color, dos jocs de valors

```js
// script.js, línies 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ó remet a un identificador de la paleta; l'hex que porta al costat és només el valor de dia, de reserva. Per això a l'edició de pantalla li n'hi ha prou de canviar un array per passar als colors de nit. La [configuració de pantalla](/ca/docs/configuration#visor-html) de [la resposta curta](#la-resposta-curta) es fusiona clau a clau, i els nivells de títol per `level`; qualsevol altre array, `colorPalette` inclòs, se substitueix sencer. `main-color` forma part de la paleta, de manera que també canvien els valors per defecte del motor que en depenen. A postext 1.4.1 la paleta nova no arriba als elements de disseny ni al color de les remissions, i per això `relink()` reescriu cada color enllaçat amb el valor que té a la paleta de la configuració fusionada.

### 2 · Obertures: banda en paper, només paraules en pantalla

```js
// script.js, línies 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') },
];
```

Els elements d'avanttítol, títol i entradeta es defineixen una vegada i es copien als dos dissenys, així que les dues edicions mostren els mateixos atributs del títol: `{attr.kicker}`, `{attr.lead}` i el `{partTitle}` de la part. En paper, un títol de primer nivell obre pàgina i abasta les dues columnes, i el seu disseny penja una banda gris boira des de la sang. `minHeight` reserva l'alçada de la banda per sota del marge superior, més `AIR`, els 9 mm que separen la banda de la primera línia. Els títols impresos van a 54 pt perquè cadascun és una sola paraula curta i l'únic text gran de la seva pàgina. En pantalla, el mateix nivell passa a ser un títol en columna, sense banda ni aire. La configuració es fusiona per `level`, així que tot el que no repeteix queda com a l'edició impresa.

### 3 · Una avantportada que només existeix en paper

```js
// script.js, línies 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)) }),
  ] },
};
```

A l'edició impresa, `:::part` obre [l'avantportada blau pluja](https://postext.dev/cookbook/print-and-screen-editions/es/p01.webp?v=1d757813): una caixa a sang pels quatre costats, un element d'imatge amb traços de pluja que deixen lliure el text, el número i el títol que dona la tanca, i la llista d'apunts en blanc. La configuració de pantalla posa `parts.page: false`, així que no s'obre cap pàgina ni es compon la llista. La [part](/ca/docs/configuration#parts) continua aplicant el seu número i el seu títol als apunts que vénen al darrere, i l'avanttítol de pantalla diu «Otoño · Apunte 1», igual que l'imprès.

### 4 · Dibuixa cada figura dues vegades

```js
// script.js, línies 215–248
// The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels.
const photo = { kind: 'bitmap', svg: undefined,
  bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } };
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, [1610, 690], { 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 wooded hills lies under layers of fog at sunrise, with a church '
      + 'tower, trees and a river showing through it, seen from a fenced bank.',
    es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de '
      + 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo },
  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
];
```

Canviar la paleta no canvia els colors d'un SVG, així que cada dibuix es fa una vegada per paleta. La vall és una fotografia, un sol JPEG per a les dues edicions. `figures(edition)` crea els mateixos recursos per a les dues edicions, i cadascun apunta al fitxer de la seva edició: `gauge-day.svg` en paper i `gauge-night.svg` en pantalla. Identificadors, peus, textos alternatius i col·locació no canvien, de manera que cada `:ref` del Markdown porta a la mateixa figura, amb el mateix número, a les dues edicions. A la de pantalla, el text alternatiu va a l'`alt` de cada `<img>`, on el troba un lector de pantalla.

### 5 · Allotja l'edició de pantalla

```js
// script.js, línies 367–397
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 és tan alta com el tauler, així que `fitFiguresToPage` redueix el pluviòmetre fins que hi càpiga en una pàgina amb el seu peu ([disposició](/ca/docs/configuration#disposició)). A 96 ppp, la resolució de referència de CSS, els mil·límetres i punts heretats de l'edició impresa es veuen a la seva mida nominal ([integrar el visor HTML](/ca/docs/configuration#integrar-el-visor-html)). La recepta escriu les línies amb posició absoluta de `renderToHtml` dins d'un Shadow DOM, on no arriben els selectors de la pàgina. L'`all: initial` de l'embolcall talla a més les propietats heretades, com ara `letter-spacing`: el motor va mesurar les línies sense aquestes propietats, i amb un valor heretat el text se solaparia. Les pàgines HTML són transparents si res no les pinta, així que `page.backgroundColor` pren de la paleta el paper nocturn. El ResizeObserver espera 150 ms i només recompon, amb una `config()` nova, quan canvia l'amplada o l'alçada del tauler; després torna a desplaçar el tauler a la mateixa proporció de la seva alçada, perquè el lector continuï per on anava.

## La recepta completa

Els fitxers d'aquí sota es componen a partir de la carpeta de la recepta, amb el text d'exemple i el kit comú del Receptari ja inclosos. Per executar-los com una sola pàgina, posa l'HTML a `<body>`, el CSS en un element `<style>` i l'script en un `<script type="module">`; o enganxa cadascun al tauler corresponent d'un pen nou de CodePen (el JS com a mòdul). L'script importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

- Carpeta de la recepta: 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: code (CC BY 4.0) · Photo: diffusion models
// 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. La fotografía es generada.
:::
`; // content.<lang>.md, inlined by the Cookbook

// #region figures: the same resources for both editions, each pointing at its edition's drawing
// The valley is a photograph, one JPEG in assets/ that both editions share, at its pixels.
const photo = { kind: 'bitmap', svg: undefined,
  bitmap: { fileId: 'valley-1610.jpg', format: 'jpeg', width: 1610, height: 690 } };
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, [1610, 690], { 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 wooded hills lies under layers of fog at sunrise, with a church '
      + 'tower, trees and a river showing through it, seen from a fenced bank.',
    es: 'Un valle entre lomas boscosas yace bajo la niebla al amanecer; asoman la torre de '
      + 'una iglesia, árboles y un río, vistos desde un ribazo con una cerca.' }), ...photo },
  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 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 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() { // the drawings in both palettes, the part page's rain, the photo
  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] of [['day', day], ['night', night]]) {
    await loadSvg(`gauge-${edition}.svg`, gauge(p));
    await loadSvg(`rose-${edition}.svg`, rose(p));
  }
  await loadImage('valley-1610.jpg', asset('valley-1610.jpg'));
}
// #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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Llegeix de dia

Treu la paleta nocturna i fes servir els valors de dia per al tauler i les figures; l'edició de pantalla tindrà llavors pàgines blanques, text en tinta fosca i els dibuixos de dia.

```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') },
```

### Dona a l'obertura impresa un número de capítol sobre una banda de color

Per a una obertura impresa més rotunda, amb una banda saturada i un número de capítol gran, consulta [Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md); la configuració de pantalla no canvia.

## Errors freqüents

- **La sortida HTML no té filets, retícula ni marques, i és transparent.** renderToHtml no dibuixa filets de columna, retícula de base ni marques de tall, i les seves pàgines són transparents si no li passes un fons, cosa que en una pàgina fosca deixa text fosc sobre fosc.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **Alguns paràmetres d'htmlViewer només existeixen al Sandbox.** htmlViewer.maxCharsPerLine, columnGap i optimalLineBreaking els llegeix el visor del Sandbox, no renderToHtml. A la teva pròpia pàgina, dimensiona tu mateix la pàgina i passa mode i columnGap a renderToHtml; les correccions s'apliquen amb applyHtmlViewerOverrides.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una obertura reserva alçada fins al seu element ancorat més baix.** Una obertura de disseny avançat reserva l'alçada del seu element més baix, i també hi compten els ancorats a la pàgina o a la sang que queden per sota del títol, així que un ornament al peu de la pàgina empeny el text a la següent. Deixa aquests ornaments per sobre del títol, passa'ls a una ranura de capçalera o de peu, o fixa la reserva amb minHeight.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **Només 8 llengües tenen partició de mots, amb el codi exacte.** La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **Un flotant superior pot fer baixar l'última columna en una pàgina de tancament.** A postext 1.4.1, quan un capítol o un reportatge acaba en una pàgina que s'obre amb un flotant superior a tota l'amplada i les seves línies es reparteixen de manera desigual entre les columnes, l'ajust stretchAfterFloats afegeix una línia en blanc sota el flotant a la columna més curta en lloc de deixar que acabi abans, i les dues columnes ja no comencen a la mateixa línia. Posa headings.balancing.stretchAfterFloats a false o ajusta el text a un nombre parell de línies.
- **El text en bandera pot deixar sola la puntuació al costat d'una negreta o un :ref.** A postext 1.4.1, el text que no va justificat (cossos de requadre, paràgrafs en bandera) pot partir la línia entre una negreta, una cursiva o un :ref i el signe de puntuació que hi va enganxat: un punt pot obrir la línia següent i el «(» d'una remissió pot tancar l'anterior. El text justificat no es parteix mai en aquest punt. Revisa els requadres de cada edició i reescriu la frase afectada perquè aquest tram quedi a mitja línia.

- Revisa el final de cada apunt després d'editar. Si l'últim paràgraf d'un apunt deixa en una pàgina nova una cua de dues línies (el control de vídues no les separa), postext 1.4.1 dona a la columna d'aquella pàgina l'alçada d'una sola línia i el canvas retalla la segona. Els dos textos de mostra estan ajustats perquè cap apunt no acabi així.
- En text en bandera, la 1.4.1 pot tancar una línia amb el «(» que precedeix una remissió, i en un tauler que el lector pot redimensionar, alguna amplada acabarà donant aquest tall. Aquests apunts citen les figures dins de la frase («la fig. 2 muestra»), mai entre parèntesis.
- Deixa que un paràgraf de pantalla comenci a l'última línia d'una pàgina (`avoidWidows: false`). Amb aquesta regla activa, la 1.4.1 pot ficar sencer un paràgraf més alt que la pàgina al buit que queda sota una figura, i el paràgraf surt pel peu de la pàgina; passa, per exemple, amb taulers de 240 × 420 o 280 × 450 px.
- Mantén el tauler a 400 px d'alçada com a mínim (el `min-height` de la recepta). En un tauler estret de menys d'uns 350 px d'alçada, l'obertura de pantalla és més alta que la caixa de text de la pàgina; postext 1.4.1 li treu llavors tota la reserva i compon el primer paràgraf damunt del títol.
- Dona mida al tauler abans de compondre. Un tauler sense amplada, ocult o aixafat en una fila flex donaria una pàgina d'amplada zero; per això `showScreen()` descarta un tauler més estret o més baix que `FOLDED`, 200 px.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: Radiation fog in a valley at dawn (Figure 2): Generated With Diffusion Models, original
- Imatges: The rain gauge, the wind rose and the part page's rain, drawn in code in both palettes: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: Newsreader (OFL-1.1), Gloock (OFL-1.1), Reddit Sans (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 054 · Canviar els colors d'un document amb una sola paleta](https://postext.dev/ca/cookbook/live-palette-retint.md): Cada color de la configuració porta un id de paleta, i una sola funció compon el programa d'un festival en vermell, verd blavós, violeta o sorra. · Nivell 2 (Intermedi) · Fulls solts i efímers
- [Núm. 056 · Editor en directe amb la composició en un Web Worker](https://postext.dev/ca/cookbook/web-worker-live-editor.md): Markdown al costat d'una pàgina de butxaca: un worker creat des d'un blob compon amb fonts pròpies i cada pulsació cancel·la la composició en curs. · Nivell 3 (Avançat) · Narrativa, teatre i prosa literària
- [Núm. 004 · Reportatge de revista: de la foto d'obertura al signe final](https://postext.dev/ca/cookbook/magazine-feature-opener.md): Una obertura advancedDesign posa una foto a sang i treu del títol avantítol, titular, entradeta i signatura; segueixen requadres flotants i un xip de tancament. · Nivell 3 (Avançat) · Revistes i fanzins
