# Requadres de llibre de text amb codi de colors

> Sis tipus de requadre en tres colors, que es distingeixen per una franja amb icona, un distintiu, una pestanya numerada, pictogrames o una marca lateral.

- Versió HTML: https://postext.dev/ca/cookbook/textbook-box-family
- Recepta Núm. 008 · Requadres i notes · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Llibres de text
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [27](https://postext.dev/cookbook/textbook-box-family/es/p01.webp?v=b1177702), [28](https://postext.dev/cookbook/textbook-box-family/es/p02.webp?v=b1177702), [29](https://postext.dev/cookbook/textbook-box-family/es/p03.webp?v=b1177702)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=textbook-box-family&lang=es (.postext: https://postext.dev/cookbook/textbook-box-family/es/textbook-box-family.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/textbook-box-family.md), [es](https://postext.dev/es/cookbook/textbook-box-family.md), [zh](https://postext.dev/zh/cookbook/textbook-box-family.md), [ar](https://postext.dev/ar/cookbook/textbook-box-family.md)

## En poques paraules

Un capítol d'un llibre de biologia amb sis tipus de requadres, com avisos o notes de laboratori. Cada tipus té el seu color i el seu símbol, perquè l'alumne els distingeixi d'un cop d'ull.

## Què compondràs

El capítol 2 de *Materia viva*, llibre de biologia de batxillerat en pàgina de 210 × 280 mm, s'obre sota una cèl·lula que surt per la cantonada superior de la pàgina, amb el número del capítol a dins. Segueixen sis tipus de requadre en tres colors, cadascun amb un element gràfic propi, perquè l'alumne distingeixi un objectiu d'un advertiment abans de llegir-los. En magenta van els objectius, sobre una franja amb una diana, i l'autoavaluació, amb una casella marcada fora del marc. En verd, el «Para recordar», al costat d'un filet fi rematat per una bombeta, i la pràctica, amb tres pictogrames de seguretat. L'advertiment és ambre i porta un distintiu d'alerta a la cantonada exterior. El RECUADRO 2.1 és lavanda, sota una pestanya negra i un matràs. Les negretes prenen el color del requadre, magenta al RECUADRO 2.1.

**Aquesta recepta respon a:**

- Com faig requadres d'objectius, consells i advertiments amb icona, franja, cantonades arrodonides o pestanya?
- Com afegeixo una pestanya numerada («REQUADRE 1-1»), una icona de cantonada o una icona al marge amb un filet?
- Com dono color als termes clau (en negreta o cursiva) en el text o dins dels requadres?

## La resposta curta

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

## Ingredients

**Ensenya**

- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís): Estils de requadre amb nom per a notes, consells i advertiments: fons, vora, radi, franja, títol i tipografia pròpia de text i llistes.
- [Pestanyes numerades als requadres](https://postext.dev/ca/docs/configuration.md#estils-davís): Una pestanya a la vora del requadre («REQUADRE 1-1») presa de l'atribut label de la tanca.
- [Icones i distintius de cantonada](https://postext.dev/ca/docs/configuration.md#estils-davís): Un glif o una imatge al costat del contingut d'un requadre, penjat de la seva cantonada superior o com a tira ampla de pictogrames.

**També fa servir**

- [Columna de marca al costat del requadre](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Requadres a tota l'amplada](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Requadres flotants](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Negreta, cursiva i els seus colors](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Imatges als dissenys de pàgina](https://postext.dev/ca/docs/configuration.md#elements-dimatge)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació)
- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- citations
- [Títols numerats](https://postext.dev/ca/docs/configuration.md#configuració-per-nivell)
- [Figures just aquí](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`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), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-ordenades), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`unorderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)

**API**

- [`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)

**Tipus de lletra**

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

## Elaboració

### 1 · Dona un color a cada tipus de requadre

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

Sis tipus de requadre es reparteixen tres tons: magenta per als objectius i l'autoavaluació, verd per al «Para recordar» i la pràctica, i ambre per a l'advertiment. El RECUADRO 2.1 té fons lavanda i fa servir el magenta en els termes clau. Cada to té un tint clar per als fons, i tots els paràmetres de color enllacen amb una entrada de la paleta, així que per canviar un to n'hi ha prou d'editar un valor. Amb un cos tan petit, el text necessita un contrast de 4,5:1. El magenta sobre el lavanda arriba a 4,9:1, però l'ambre sobre el fons de l'advertiment es queda en 2,2:1; per això l'advertiment reserva l'ambre per al marc i el distintiu, i compon el títol i els termes clau en `warnInk`, un ambre més fosc que arriba a 5,2:1.

### 2 · Comparteix tota la resta

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

`box()` aplega els paràmetres comuns a tots els requadres: els marges, el farciment, la lletra del títol i del cos, i l'espaiat de les llistes. El seu argument `hue` acoloreix el títol, les vinyetes i els números i les negretes. `body.boldColor` posa en magenta el **model de mosaic fluid** dels objectius i en ocre la **paret cel·lular** de l'advertiment; les negretes del text corrent continuen en tinta ([tipografia dins d'un requadre](/ca/docs/configuration#tipografia-dins-dun-requadre)). Si declares `calloutStyles`, substitueixes l'únic estil que porta el motor, un requadre gris anomenat `note`. Un requadre que no té `type` o el té mal escrit pren el primer estil, així que la llista comença pels objectius, que apareixen a tots els capítols.

### 3 · Franja, pestanya i distintiu

El codi és [la resposta curta](#la-resposta-curta) de més amunt. La franja dels objectius fa 7,5 mm i la diana 5 mm, així que la icona queda dins del color amb aire a tots dos costats. L'`offset` de la pestanya és igual a la seva alçada (`height`), de manera que la pestanya es recolza a la vora superior del requadre, sense tocar el títol. `'top-left'` és la cantonada exterior d'aquesta pàgina parella; el matràs va a continuació de la pestanya, i el filet recorre la vora superior des del matràs fins a la cantonada oposada. `position: 'corner'` penja el distintiu de l'advertiment amb la meitat de l'amplada fora de la vora, i `cornerSide: 'outer'` el porta al costat exterior, l'esquerre en aquesta pàgina parella. En aquest costat el motor sagna el títol perquè no topi amb el distintiu, tret que el farciment ja cobreixi mig distintiu més l'espai del títol; per això l'advertiment porta `BADGE / 2 + GAP` de farciment a tots dos costats, i el títol queda alineat amb el text. Només aquest requadre porta `borderRadius`. En un amb franja, la franja es pinta a part, com una barra rectangular, i les cantonades d'aquell costat continuarien rectes ([estils d'avís](/ca/docs/configuration#estils-davís)).

### 4 · Tres elements gràfics més

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

La franja del «Para recordar» fa 2,5 pt, enfront dels 6,4 mm del distintiu, de manera que es veu com un filet fi amb la bombeta damunt de l'extrem superior; un farciment esquerre de mig distintiu més 2 mm aparta el text de la bombeta. `icon.width` encaixa la imatge en una caixa ampla en lloc d'un quadrat, així que els tres pictogrames de seguretat, dibuixats com una sola imatge de 24,5 mm, formen la columna d'icones del requadre de seguretat, centrada respecte al text. La marca de l'autoavaluació va fora del marc, en aquest ordre: marca, filet, espai i caixa. La casella quadrada sobre un filet de 0,75 pt la distingeix del «Para recordar», que porta un distintiu rodó. La tanca hi afegeix `span="page" placement="bottom"` perquè l'autoavaluació floti al peu de la [pàgina 29](https://postext.dev/cookbook/textbook-box-family/es/p03.webp?v=b1177702).

### 5 · Les icones també són recursos

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

Totes les icones, el matràs de la pestanya i la cèl·lula de l'obertura són recursos SVG declarats al costat del contingut i registrats pel seu identificador de fitxer. Els estils els anomenen pel seu id de recurs i, com que el text no els cita, cap no es converteix en una figura numerada. Glifs com ✓ o ☞ serien més còmodes d'escriure, però no són als fitxers latin de Fontsource que carreguen les pàgines: el llenç els prendria d'una font del sistema i un PDF els perdria. Els dibuixos tampoc no porten text, perquè un SVG pintat com a imatge no pot fer servir les fonts de la pàgina; els colors s'anomenen als peus.

### 6 · Obre el capítol amb una cèl·lula

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

La cèl·lula és un element d'imatge ancorat a la cantonada superior dreta de la pàgina i desplaçat més enllà, de manera que en sobresurt sobretot el quart inferior esquerre, amb el número a dins. Tots dos caben en els 66 mm que `minHeight` reserva per a l'obertura. L'avanttítol i l'entradeta surten de la línia del títol, `# Células y \\ membranas {unit="La célula" lead="…"}`, i el `\\` parteix el títol només a l'obertura.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). 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/textbook-box-family

### script.js

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

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

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

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

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

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

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

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

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

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

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

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

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

## Una frontera con puertas

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

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

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

## El mosaico fluido

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

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

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

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

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

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

## Atravesar la membrana

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

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

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

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

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

## Cargas a granel

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

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

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

### Experimenta: ósmosis en una patata

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

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

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

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

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

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

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

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

// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ─────

// ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ─────────
function mm(value) { return { value, unit: 'mm' }; }
function pt(value) { return { value, unit: 'pt' }; }
function em(value) { return { value, unit: 'em' }; }
/** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */
function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; }
/** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */
function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; }

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

/** True when a loaded FontFace covers exactly this family, weight and style
 *  (document.fonts.check() is also true for families nobody declared). */
function hasFace(family, weight, style) {
  for (const face of document.fonts) {
    if (face.status !== 'loaded' || face.style !== style) continue;
    if (face.family.replace(/^["']|["']$/g, '') !== family) continue;
    const [low, high = low] = face.weight.split(' ').map(Number);
    if (weight >= low && weight <= high) return true;
  }
  return false;
}

/** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */
function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); }

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

// ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ───────
/** Shows the pages as facing spreads on a dark desk: the first page is a
 *  recto on its own, then verso | recto pairs, as in a bound book. Pages
 *  are painted when they scroll near the screen. */
function showPages(docs, { title, width = 460 } = {}) {
  const root = viewer(title);
  const pages = [docs].flat().flatMap((doc) =>
    doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index })));
  const spreads = [];
  let verso = null;
  for (const p of pages) {
    if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; }
    else { spreads.push([verso, p]); verso = null; }
  }
  if (verso) spreads.push([verso, null]);
  const density = Math.min(window.devicePixelRatio || 1, 2);
  showPages.painter?.disconnect();
  const painter = new IntersectionObserver((entries) => {
    for (const { isIntersecting, target } of entries) {
      if (!isIntersecting) continue;
      painter.unobserve(target);
      const { doc, page } = target.postext;
      renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width });
    }
  }, { rootMargin: '800px' });
  showPages.painter = painter;
  root.replaceChildren(...spreads.map((pair) => {
    const spread = document.createElement('div');
    spread.className = 'pt-spread';
    for (const p of pair) {
      const figure = document.createElement('figure');
      if (p) {
        const label = p.page.pageLabel || String(p.n + 1);
        const canvas = document.createElement('canvas');
        canvas.postext = p;
        canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`;
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', `Page ${label}`);
        const folio = document.createElement('figcaption');
        folio.textContent = label;
        figure.append(canvas, folio);
        painter.observe(canvas);
      } else figure.className = 'pt-blank';
      spread.append(figure);
    }
    return spread;
  }));
  kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`);
  document.documentElement.dataset.postext = 'ready';
  return pages.length;
}

/** The desk, the bar and the error reporting, created once. */
function viewer(title) {
  if (!document.getElementById('pt-kit')) {
    document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit">
      :root { color-scheme: dark; }
      body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; }
      #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center;
        gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px);
        border-bottom: 1px solid #23262d; }
      #pt-bar strong { color: #f4f1ea; font-weight: 600; }
      #pt-actions { display: flex; gap: 12px; margin-left: auto; }
      #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
      #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; }
      .pt-spread { display: flex; }
      .pt-spread figure { margin: 0; width: min(460px, 44vw); }
      .pt-spread canvas { display: block; width: 100%; background: #fff;
        box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); }
      .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif;
        letter-spacing: .18em; text-transform: uppercase; color: #6c7079; }
      .pt-blank { visibility: hidden; }
      @media (max-width: 760px) {
        .pt-spread { flex-direction: column; gap: 32px; }
        .pt-spread figure { width: min(460px, 92vw); }
        .pt-blank { display: none; }
      }
    </style>`);
    document.body.insertAdjacentHTML('afterbegin',
      '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>');
    document.getElementById('pt-title').textContent = document.title || 'Postext';
    addEventListener('error', (event) => kitFail(event.error ?? event.message));
    addEventListener('unhandledrejection', (event) => kitFail(event.reason));
  }
  if (title) document.getElementById('pt-title').textContent = title;
  return document.getElementById('pages')
    ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' }));
}

function kitStatus(text) {
  viewer();
  document.getElementById('pt-status').textContent = text;
}

function kitFail(error) {
  document.documentElement.dataset.postext = 'error';
  kitStatus(`Error: ${error?.message ?? error}`);
}

// ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ──────────
/** Registers a photo or PNG for the canvas and keeps its bytes for the PDF.
 *  fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */
async function loadImage(fileId, url) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  registerResourceImage(fileId, await createImageBitmap(new Blob([bytes])));
  (loadImage.bytes ??= new Map()).set(fileId, bytes);
}

/** Registers SVG markup (drawn in code, or fetched) as a vector image. */
async function loadSvg(fileId, svg) {
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** renderToPdf({ resourceBytes: imageBytes }) */
function imageBytes(fileId) { return loadImage.bytes?.get(fileId); }

/** renderToHtml({ resourceImageUrl: imageUrl }) */
function imageUrl(fileId) {
  const bytes = imageBytes(fileId);
  if (!bytes) return undefined;
  imageUrl.urls ??= new Map();
  if (!imageUrl.urls.has(fileId)) {
    const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg';
    imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type })));
  }
  return imageUrl.urls.get(fileId);
}

// ─── /Kit ───────────────────────────────────────────────────────────────────────
```

## Variants

### Estén un requadre sobre les dues columnes

`span="page"` a la tanca estén qualsevol requadre sobre les dues columnes; el RECUADRO 2.1, a més, hi niua un grup `:::columns` per posar la figura al costat del text.

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

### Penja el distintiu del costat del llom

`'inner'` porta el distintiu al costat del llom, l'esquerre a la pàgina senar i el dret a la parella; deixa lloc en aquell costat per a la meitat que sobresurt del requadre.

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

### Posa la pestanya a la cantonada dreta

`'top-right'`, el valor per defecte, reflecteix tota l'etiqueta: la pestanya passa a la cantonada dreta, el matràs a la seva esquerra i el filet arriba des de l'esquerra.

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

## Errors freqüents

- **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ó.
- **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.
- **Un espai de no separació continua partint la línia.** A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase.
- **Els fitxers latin de Fontsource només porten glifs de l'interval llatí.** El proveïdor del PDF incrusta els fitxers latin de Fontsource, que cobreixen el castellà i les llengües de l'Europa occidental però no →, ≈, ✓, ★, el grec ni les lletres de l'Europa central; aquests glifs falten al PDF. Mantén el text del PDF dins de l'interval latin.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **:::columns només funciona dins d'un requadre i no es parteix.** :::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un.
- **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à.
- **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.
- **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.
- **La pàgina 1 és senar: planifica amb números físics.** La pàgina 1 queda a la dreta i la 2 és la primera pàgina parella, així que planifica els plecs amb números de pàgina físics: una obertura en pàgina parella queda davant de la senar que la segueix.
- **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().
- **Avís de maquetació: Tipus de requadre desconegut** (`unknownCalloutType`). Un :::callout anomena un tipus que calloutStyles no defineix, de manera que pren sense avisar el primer estil. Solució: Defineix l'estil o corregeix l'atribut type. ([Documentació](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout))

- La pestanya només s'imprimeix si la tanca porta l'atribut `label="RECUADRO 2.1"` i l'estil té un objecte `label`. Un estil sense aquest objecte ignora l'atribut.
- Postext no numera els requadres. L'etiqueta és el text literal de la tanca, així que cada número, RECUADRO 2.1, RECUADRO 2.2, s'escriu a mà.
- Un distintiu de cantonada sobresurt la meitat de la seva amplada pel costat del requadre, cap al marge al costat exterior de la pàgina. A l'altra columna d'aquella mateixa pàgina sobresurt cap a l'espai entre columnes, així que deixa els requadres amb distintiu a la columna exterior o fes-los lloc.
- Un requadre només canvia el color de les vinyetes i els números si el seu `lists.color` és diferent d'`unorderedLists.color`. Si tots dos són el mateix magenta, una llista numerada dins del requadre pren `orderedLists.color`; per això aquest document posa les seves vinyetes en tinta.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Tipus de lletra: Noto Serif (OFL-1.1), Lexend (OFL-1.1), Barlow Semi Condensed (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 050 · Manual de producte amb avisos de seguretat](https://postext.dev/ca/cookbook/product-manual-warnings.md): Manual d'un bullidor en alemany: els requadres WARNUNG i VORSICHT porten el triangle en una franja del color d'avís, i els peus diuen Abbildung i Tabelle. · Nivell 2 (Intermedi) · Manuals, guies i obres de consulta
- [Núm. 021 · Requadres que es parteixen, floten i es fixen](https://postext.dev/ca/cookbook/boxes-split-float-pin.md): Un full de pràctiques en què el procediment es parteix entre dues columnes, el full de dades encapçala la pàgina següent i una insígnia va fixa al peu. · Nivell 3 (Avançat) · Quaderns i exercicis, Llibres de text
- [Núm. 022 · Fitxa amb caixes de resposta i banc de paraules](https://postext.dev/ca/cookbook/worksheet-answer-boxes.md): Una fitxa de ciències de quatre pàgines: caixes de resposta blanques en targetes verd clar, a 2 mm de cada pregunta, i xips per a bancs de paraules i buits. · Nivell 2 (Intermedi) · Quaderns i exercicis
