# D'una cadena Markdown a una pàgina dissenyada

> Una cadena Markdown amb frontmatter es compon en pàgines de revista a dues columnes i es pinta en canvas, amb sis colors en lloc del blau per defecte.

- Versió HTML: https://postext.dev/ca/cookbook/first-page-from-markdown
- Recepta Núm. 012 · Sortida i integració · Nivell 2 (Intermedi) · Sortides: Canvas
- 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/first-page-from-markdown/es/p01.webp?v=8cf2c6ca), [2](https://postext.dev/cookbook/first-page-from-markdown/es/p02.webp?v=8cf2c6ca)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=first-page-from-markdown&lang=es (.postext: https://postext.dev/cookbook/first-page-from-markdown/es/first-page-from-markdown.postext)
- Última actualització: 2026-09-26
- Altres idiomes: [en](https://postext.dev/en/cookbook/first-page-from-markdown.md), [es](https://postext.dev/es/cookbook/first-page-from-markdown.md), [zh](https://postext.dev/zh/cookbook/first-page-from-markdown.md), [ar](https://postext.dev/ar/cookbook/first-page-from-markdown.md)

## En poques paraules

Les dues primeres pàgines d'un reportatge de viatges en una revista petita, amb una banda de color i un dibuix. Ensenya com un text senzill amb uns quants títols es converteix en una pàgina dissenyada amb els teus colors.

## Què compondràs

Les primeres pàgines d'un reportatge de viatges en una revista petita, *Quadern de camp*. La pàgina 1 és una obertura en pàgina senar: una fotografia de salines i una duna al vespre que ocupa el 58 % superior, el títol en Young Serif de 54 punts i, a sota, dues columnes justificades que arrenquen amb una frase en negreta. La pàgina 2, al revers, continua sota una capçalera en majúscules espaiades i acaba amb un signe de fi i un colofó. Tot el disseny cap en una funció de configuració i una paleta de sis colors. El titular surt del títol de primer nivell, i l'entradeta, l'autora i la data, del frontmatter del Markdown, de manera que el mateix pen compon qualsevol article el títol del qual càpiga en dues línies; només la fotografia és pròpia d'aquest.

**Aquesta recepta respon a:**

- Com passo una cadena Markdown a pàgines dissenyades, sense el blau per defecte en títols, negretes i pics?
- Com preparo una pàgina de llibre: format, marges interior i exterior simètrics, dues columnes i espai entre columnes?
- Com converteixo una cadena Markdown en pàgines compostes i en dibuixo una en un canvas?

## La resposta curta

```js
// script.js, línies 126–163
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. The restated break
      // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
```

## Ingredients

**Ensenya**

- [Metadades del document](https://postext.dev/ca/docs/document-format.md#frontmatter): Bloc frontmatter en YAML amb títol, subtítol, autor i data; l'imprimeixen els marcadors de les capçaleres i les cobertes, i dona el títol del PDF.
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors): Colors amb nom als quals cada ajust s'enllaça per id, de manera que tot el document canvia de to en modificar una mostra; els colors per defecte surten de main-color.
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus): Ranures de capçalera i peu amb marcadors ({pageNumber}, {chapterTitle}, {title}…) diferents en recto i verso, amb els títols llargs retallats o partits.

**També fa servir**

- [Pàgines en un canvas](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap)
- [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)
- [Ancoratge d'elements de disseny](https://postext.dev/ca/docs/configuration.md#posicionament-delements)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Fonts abans de compondre](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures)
- [Format de pàgina](https://postext.dev/ca/docs/configuration.md#mides-de-pàgina-predefinides)
- [Marges simètrics](https://postext.dev/ca/docs/configuration.md#marges-simètrics-mirall)
- [Una o dues columnes](https://postext.dev/ca/docs/configuration.md#tipus-de-disposició)
- [Tipografia del text](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Negreta, cursiva i els seus colors](https://postext.dev/ca/docs/configuration.md#text-de-cos)
- [Mostres de color](https://postext.dev/ca/docs/document-format.md#format-en-línia)
- [Partició de mots i llengua del document](https://postext.dev/ca/docs/justification.md#llengües-compatibles)
- [Llistes de pics i de comprovació](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Salts de línia als títols](https://postext.dev/ca/docs/document-format.md#salts-de-línia-als-títols)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)

**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), [`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), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`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), [`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**

- Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)

## Elaboració

### 1 · Totes les fonts, abans de la primera composició

```js
// script.js, línies 225–229
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
```

Postext mesura el text amb les fonts que el navegador ja ha carregat i desa les mesures a la memòria cau, de manera que una composició que s'avança a les fonts conserva els talls de línia equivocats. El kit carrega aquests fitxers de Fontsource, els mateixos que incrustaria un PDF. Després, `buildWithFonts` repassa les fonts que fan servir les pàgines. Si un bloc o un element de disseny està compost en una font que no s'ha carregat, avisa a la consola, la carrega i torna a compondre; si Fontsource no té aquesta font, el pen s'atura amb un error (consulta «Errors freqüents»). Les negretes i cursives d'una font de text es carreguen sense avís, i només si la família les inclou.

### 2 · Anomena els colors i apunta als noms

```js
// script.js, línies 23–43
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the folios and the drop folio too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
  // point it at the accent. Header, footer and opener defaults do not follow it, so they
  // are restated below.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
```

Cada color de la configuració és `col('id')`, un identificador de la paleta amb el seu valor hexadecimal al costat. `bodyText.boldColor`, `italicColor`, `referenceColor` i `unorderedLists.color` apunten a `ink`, i `headings.color`, a l'accent. Els valors per defecte del motor per a títols, negretes, cursives i pics estan enllaçats a `main-color`, i aquesta paleta dona a aquesta entrada el color de l'accent, de manera que qualsevol d'ells que no fixis surt anyil i no blau. A Postext 1.4.1, els elements de capçaleres, peus i obertures, i també `referenceColor`, es pinten amb el valor hexadecimal, no amb l'identificador; per això els objectes de disseny són funcions que `config()` crida a cada composició, i `col()` copia cada vegada el valor vigent de `palette`. El signe de fi és una mostra de color en línia que anomena `band`, així que també canvia amb la paleta. La capçalera per defecte no llegeix la paleta i es queda blava fins que la substitueixis (pas 5).

### 3 · Una fotografia retallada al seu marc

```js
// script.js, línies 47–53
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
```

Les salines, la duna i la fita són una fotografia generada amb models de difusió, presa al vespre perquè el títol clar es llegeixi sobre un cel fosc. El JPEG està retallat al marc de la banda, `PAGE.width` × `BAND` mil·límetres, i el recurs declara els seus píxels, 1440 × 1120, de manera que a tota l'amplada la foto omple exactament la banda. Si la banda creix, cal una foto retallada al nou marc. L'obertura la mostra amb un element d'imatge que anomena el recurs pel seu identificador.

### 4 · El frontmatter escriu l'obertura

```js
// script.js, línies 57–92
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
      // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        overflow: 'wrap', placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
```

El títol de primer nivell abasta les dues columnes, i la seva obertura és una ranura d'elements: la fotografia a sang i quatre textos encadenats l'un sota l'altre. Un títol més llarg empeny cap avall l'entradeta i la signatura en lloc de superposar-s'hi, però a partir de la tercera línia la signatura cau sobre les salines clares. `{titleText}` és el text del títol; `{subtitle}`, `{author}` i `{publishDate}` surten del frontmatter. La `\\` del títol talla la línia on la posis ([salts de línia en els títols](/ca/docs/document-format#salts-de-línia-als-títols)). `minHeight` es compta des del marge superior, així que l'alçada reservada acaba 5 mm per sota de la banda.

### 5 · Capçaleres en lloc del blau per defecte

```js
// script.js, línies 96–115
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
```

Si omets `header`, torna la capçalera per defecte del motor: Open Sans sobre un filet de banda a banda, a totes les pàgines. Aquí cada foli s'ancora a la pàgina física i es filtra per paritat, de manera que sempre queda a la vora exterior. El text que l'acompanya s'ancora al foli, a `GAP` mil·límetres, amb `right-of` o `left-of`, i un foli de tres o quatre xifres el desplaça en lloc de xocar-hi. `pages: 'body'` deixa els quatre elements fora de l'obertura. El peu de pàgina porta un únic foli, el de l'obertura, i com que `footer` està definit, les altres pàgines perden el foli centrat per defecte.

### 6 · Compon amb una configuració nova i pinta

```js
// script.js, línies 234–242
await loadFonts(FONTS, markdown);
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada' }),
});
```

`config()` és una funció perquè el motor desa a la memòria cau cada configuració resolta segons la identitat de l'objecte, de manera que un ajust editat només té efecte si cada composició rep un objecte nou. `showPages` col·loca les pàgines com anirien en un llibre enquadernat, amb la pàgina 1 sola a la dreta, i pinta cadascuna amb `renderPageToCanvas`.

## 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/first-page-from-markdown

### script.js

```js
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'first-page-from-markdown';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the folios and the drop folio too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
  // point it at the accent. Header, footer and opener defaults do not follow it, so they
  // are restated below.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
      // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        overflow: 'wrap', placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. The restated break
      // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook: every frontmatter value is quoted, since an ISO
// date or a number would print empty (gotcha: quote-frontmatter). The end mark is an inline
// swatch that names `band`, so it follows the palette too.
const markdown = String.raw`---
title: "La ruta de la sal"
subtitle: "Cuatro días a pie por la senda de los porteadores, de las salinas de Arvela al mercado de Castrel."
author: "Lena Varga"
publishDate: "mayo de 2026"
---

# La ruta \\ de la sal

**Los porteadores salían del dique de Arvela.** Con la marea baja, las marismas se extienden tres kilómetros al pie del dique, una llanura gris surcada de caños, y desde lo alto se ven las salinas antes que el mar: cuarenta balsas de salmuera dispuestas como un tablero de ajedrez, cada una un poco más blanca que la anterior.

Durante seis siglos, la sal de Arvela salió de la costa a cuestas de hombres y mujeres, que cargaban al amanecer treinta kilos por cesta y caminaban tierra adentro por una senda que nadie llegó a empedrar: cruzaban las marismas, las dunas de Sorra y el pinar, y subían hasta el mercado de Castrel, a cuatro días de marcha. El tren acabó con el oficio en 1911, pero los caminantes han mantenido abierta la senda.

La recorrí en abril con Tomás Reis, cuya abuela cargó sal de niña y que lleva veinte años señalando la ruta con mojones de piedras blancas: el invierno los derriba y él los vuelve a levantar cada mayo.

—Busca la *sal* en el suelo —me dijo la primera mañana—. Donde se derramó, no ha vuelto a crecer nada.

El segundo día, ya en las dunas, la senda desaparece durante horas, y de pronto asoma en la arena una veta pálida de unos centímetros de ancho, como trazada con tiza. Es sal caída de las cestas durante seis siglos, una costra que el viento descubre cada primavera. Tomás camina a su lado y nunca la pisa.

Esa noche dormimos en una hondonada entre las dunas que los porteadores llamaban la Cocina, porque allí no llega nunca el viento. Tomás no encendió fuego. Cenó pan y pescado seco y habló de su abuela, que distinguía la sal de Arvela de la de las salinas del sur con probar un solo grano y que, según él, jamás perdió la senda en la niebla.

## La carga de retorno

Las cestas volvían a la costa tan llenas como habían salido. Un libro de cuentas de 1887, que se conserva en el archivo parroquial de Sorra, abre con las cargas de regreso de una primavera:

- **Grano** de las granjas de la sierra, sobre todo centeno y cebada.
- **Resina y brea** de pino, para calafatear cada año las barcas de Arvela.
- **Lana y cueros** de la feria de mayo de Castrel.
- **Clavos y anzuelos** de la herrería de Orsa.

Hasta que llegó el tren, una cesta de sal se cambiaba por una cesta de grano. Un buen porteador hacía once viajes de ida y vuelta al año, ochenta y ocho días de camino.

En el libro hay tres letras. La primera, una caligrafía esmerada, anota cada carga al medio kilo; la segunda, desde 1893, no escribe más que cifras; la tercera, una letra infantil, aparece en los inviernos y escribe siempre «Castrell».

El libro se interrumpe en el otoño de 1910, a media página, con una carga de cebada que nunca se anotó como entregada. En Sorra nadie sabe por qué. Tomás cree que el porteador se marchó a tender la vía del tren, que pagaba en moneda.

## Hacia los pinos

El tercer día es el más largo. La senda entra en el pinar por Orsa y sube durante ocho horas entre troncos que huelen a resina, junto a las casas de posta en ruinas donde los porteadores dormían sobre paja y pagaban la cama con sal. Cada casa tiene un mojón junto a la puerta, y cada mojón, una piedra blanca que puso Tomás.

La mejor conservada está en los Nueve Pozos, a una hora por debajo del collado. El dintel de la puerta tiene hileras de muescas cortas, una por cada noche que durmió allí un porteador, y en algunos tramos están tan juntas que se han gastado hasta formar un solo surco. Tomás intentó contarlas un invierno y lo dejó al llegar a cuatro mil.

La cuarta tarde, la senda deja atrás los pinos y sube a una loma pelada, y abajo aparece Castrel: un pueblo gris en torno a una plaza que todavía se llama el Mercado de la Sal, aunque hace cien años que nadie vende sal allí. Como hacían los porteadores, Tomás saca un puñado de sal del bolsillo, lo deja en el borde de piedra de la fuente y bebe.

Luego da media vuelta y echa a andar de nuevo hacia el mar. Los porteadores nunca dormían en Castrel, dice, porque las posadas del pueblo cobraban en moneda. :swatch{color="band"}

:::callout{type="colophon"}
Obra de ficción: Arvela, Sorra, Orsa y Castrel son lugares imaginarios. Compuesto en Newsreader, Young Serif e Inter Tight (SIL Open Font License) · Texto: original, CC BY 4.0 · Foto: modelos de difusión.
:::
`;
// The opener's image element points at the photograph by id.
const resources = [landscape];

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// #region fonts: every face the design uses, loaded first (gotcha: fonts-first)
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};
// #endregion

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: fonts, the photograph, one buildDocument call with a fresh config, then paint
await loadFonts(FONTS, markdown);
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada' }),
});
// #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

### Mira què substitueix la configuració

Compon amb una configuració buida i el mateix Markdown surt en una pàgina de 17 × 24 cm, en EB Garamond de 8 punts, amb títols blaus en Open Sans i una capçalera blava sobre un filet; el kit carrega aquestes fonts per tu, amb un avís a la consola.

```diff
-  () => buildDocument({ markdown, resources }, config()), markdown);
+  () => buildDocument({ markdown, resources }, {}), markdown);
```

### Canvia el color de tota la pàgina

Canvia l'accent i la següent execució pinta amb el nou color els folis, els subtítols i el signe de fi, perquè `col()` copia el nou valor al costat de l'identificador cada vegada que es crida `config()`. La fotografia conserva els seus colors.

```diff
-  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+  band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark
```

### Pinta una pàgina en el teu propi canvas

`renderPageToCanvas` dona al canvas la mida de la pàgina en píxels a la resolució del document (300 ppp per defecte) multiplicada per `scale`, així que `scale: 0.5` pinta a uns 150 ppp, i el CSS decideix com de gran es veu a la pantalla.

```diff
-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });
```

## Errors freqüents

- **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.
- **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ó.
- **Posa entre cometes cada valor del frontmatter.** YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984".
- **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.
- **fontFamily és un nom de família, mai una pila CSS.** Una pila com 'Lora, serif' es llegeix com una única família que no existeix, així que el text es mesura sense avís amb una font de reserva i el canvas, l'HTML i el PDF no coincideixen. Escriu una sola família.
- **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().
- **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.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.

- Young Serif només té un pes. Els títols van en negreta per defecte, així que aquí `headings.fontWeight: 400` és obligatori: sense això, el kit cerca una Young Serif negreta que Fontsource no té i el pen s'atura amb un error.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Imatges: The salt pans and the dune at dusk on the opener, a photograph: Generated With Diffusion Models, original
- Tipus de lletra: Newsreader (OFL-1.1), Young Serif (OFL-1.1), Inter Tight (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [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
- [Núm. 005 · Capçaleres segons la paritat en un llibre d'assajos](https://postext.dev/ca/cookbook/running-heads-by-parity.md): Capçaleres per paritat i tipus de pàgina: el títol del llibre a les parelles, el de l'assaig tallat a les senars i només un foli al peu a les obertures. · Nivell 2 (Intermedi) · Narrativa, teatre i prosa literària
- [Núm. 003 · Obertura de capítol sobre banda a sang](https://postext.dev/ca/cookbook/chapter-opener-bleed-band.md): Obertura advancedDesign del títol de nivell 1: banda a sang amb el número de capítol sobre el filet, i avanttítol i entradeta presos dels seus atributs. · Nivell 3 (Avançat) · Llibres de text
