# Corals numerats en un mètode d'harmonia a dues columnes

> Corals a quatre veus escrits en Humdrum **kern passen a recursos de partitura de tipus exemple: numerats, citats amb :ref i flotants com les figures.

- Versió HTML: https://postext.dev/ca/cookbook/conservatory-method-chorales
- Recepta Núm. 166 · Figures i imatges · Nivell 2 (Intermedi) · Sortides: Canvas
- Gèneres: Llibres de text
- Requereix postext ≥ 1.54.0 · provada amb 1.54.0 el 2026-10-22
- Pàgines: [61](https://postext.dev/cookbook/conservatory-method-chorales/es/p01.webp?v=f475c2ad), [62](https://postext.dev/cookbook/conservatory-method-chorales/es/p02.webp?v=f475c2ad), [63](https://postext.dev/cookbook/conservatory-method-chorales/es/p03.webp?v=f475c2ad)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=conservatory-method-chorales&lang=es (.postext: https://postext.dev/cookbook/conservatory-method-chorales/es/conservatory-method-chorales.postext)
- Última actualització: 2026-10-22
- Altres idiomes: [en](https://postext.dev/en/cookbook/conservatory-method-chorales.md), [es](https://postext.dev/es/cookbook/conservatory-method-chorales.md), [de](https://postext.dev/de/cookbook/conservatory-method-chorales.md), [fr](https://postext.dev/fr/cookbook/conservatory-method-chorales.md), [it](https://postext.dev/it/cookbook/conservatory-method-chorales.md), [pt](https://postext.dev/pt/cookbook/conservatory-method-chorales.md), [zh](https://postext.dev/zh/cookbook/conservatory-method-chorales.md), [ja](https://postext.dev/ja/cookbook/conservatory-method-chorales.md), [ar](https://postext.dev/ar/cookbook/conservatory-method-chorales.md)

## En poques paraules

Un capítol sobre les cadències d'un llibre d'harmonia de conservatori, a dues columnes. Els cinc exemples musicals s'escriuen com a text, es graven com a partitures, es numeren com les figures i van al costat del paràgraf que els cita.

## Què compondràs

El capítol 4 d'*Armonía a cuatro voces*, un mètode per als primers cursos del conservatori, en una pàgina de 210 × 280 mm a dues columnes de Crimson Pro. La lliçó tracta de les cadències, i els seus cinc exemples són corals a quatre veus en dos pentagrames, amb els graus en xifres romanes sota el baix: les cinc cadències de sol major, tres versos de *Nun danket alle Gott* i el Salm 134 de Ginebra sencer. Cadascun porta el seu número, d'Ejemplo 4.1 a 4.5 (Example a l'edició anglesa), un peu en la lletra de rètols amb l'etiqueta en granat, i es col·loca al cap o al peu de la columna que segueix el paràgraf que el cita; el salm sencer ocupa les dues columnes al cap de la pàgina 63. Les harmonitzacions es van escriure per a aquest mètode i es van revisar a la recerca de quintes i octaves paral·leles, distàncies entre veus i resolució de la sensible.

**Aquesta recepta respon a:**

- Com numero exemples musicals a quatre veus i els faig flotar com figures a dues columnes?
- Com afegeixo una figura amb peu numerat i la cito en el text («vegeu la fig. 3.2»)?
- Com decideixo on va una figura: al capdamunt de la pàgina, a l'amplada de les dues columnes, just aquí o al marge?

## La resposta curta

```js
// script.js, línies 26–41
// In the Markdown, :ref{id="ex-old-hundredth" style="full"} prints 'Example 4.4' ('Ejemplo
// 4.4' in Spanish) and ::resource{id="…"} anchors the float after the paragraph that cites it.
const example = (id, source, placement, styleId) => {
  const [caption, note, altText] = CAPTIONS[id];
  return { id, typeId: 'example', kind: 'score', caption, note, altText, placement,
    score: { format: 'humdrum', source, styleId }, createdAt: 0, updatedAt: 0 };
};
// The **kern sources are in #region art-scores; captions in content.captions.<lang>.md.
const resources = () => [
  example('ex-cadences', CADENCES, { position: 'bottom' }, 'column'),
  example('ex-nun-danket', NUN_DANKET, { position: 'top' }, 'column'),
  example('ex-nun-danket-3', NUN_DANKET_3, { position: 'auto' }, 'column'),
  // Across both columns in the document's own score style: the whole tune in two systems.
  example('ex-old-hundredth', OLD_HUNDREDTH, { position: 'top', span: 'page' }),
  example('ex-deceptive', DECEPTIVE, { position: 'auto', span: 'page' }),
];
```

## Ingredients

**Ensenya**

- [Exemples musicals numerats](https://postext.dev/ca/docs/music.md#exemples-musicals-numerats): Recursos de classe score amb el tipus example: numerats en la llengua del document (Exemple 3.2), amb peu, citats amb :ref i col·locats o flotants com les figures; cadascun es manté sencer.
- [Col·locació de figures](https://postext.dev/ca/docs/document-format-resources.md#collocació): A dalt, a baix, automàtica o aquí, en una columna o a tota l'amplada, a una fracció de l'amplada, per recurs o per tipus; els flotants d'una mateixa sèrie no s'avancen mai entre ells.
- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format-resources.md#referència-en-línia-la-forma-principal): Un :ref cita un recurs («vegeu la fig. 3.2») i la seva primera cita el col·loca: la figura flota fins al primer buit lliure després d'aquesta cita.

**També fa servir**

- [Notació musical](https://postext.dev/ca/docs/music.md#una-partitura-dins-del-text)
- [Estil de partitura](https://postext.dev/ca/docs/configuration-resources.md#estil-de-partitura)
- [Peus numerats](https://postext.dev/ca/docs/document-format-resources.md#numeració-per-primera-referència)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs)
- [Estil dels peus](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs)
- [Línies de font i crèdit](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs)
- [Una o dues columnes](https://postext.dev/ca/docs/configuration-page-layout.md#tipus-de-disposició)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus)
- [Marges simètrics](https://postext.dev/ca/docs/configuration-page-layout.md#marges-simètrics)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration-headers-footers.md#elements-de-text)
- [Requadres](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors)
- [Figures just aquí](https://postext.dev/ca/docs/document-format-resources.md#inserció-en-bloc-opcional-collocació-en-línia-explícita)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration-headings.md#extensió-i-disseny-avançat)
- [Superíndexs i subíndexs](https://postext.dev/ca/docs/document-format-inline.md#format-en-línia)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration-text.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration-callouts.md#estils-de-requadre), [`captionStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-dels-peus-de-recurs), [`colorPalette`](https://postext.dev/ca/docs/configuration-fonts-colors-viewers.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration-headers-footers.md#capçaleres-i-peus), [`headings`](https://postext.dev/ca/docs/configuration-headings.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration-page-layout.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration-language.md#partició-de-mots), [`orderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-numerades), [`page`](https://postext.dev/ca/docs/configuration-page-layout.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration-styles.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs), [`scoreStyle`](https://postext.dev/ca/docs/configuration-resources.md#estil-de-partitura), [`scoreStyles`](https://postext.dev/ca/docs/configuration-resources.md#estil-de-partitura), [`unorderedLists`](https://postext.dev/ca/docs/configuration-lists-math.md#llistes-amb-pics)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#carregar-les-fonts-abans-de-compondre), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration-resources.md#tipus-de-recurs), [`initMusicEngine`](https://postext.dev/ca/docs/music.md#una-partitura-dins-del-text), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration-programmatic-usage.md#renderitzar-una-pàgina-en-un-mapa-de-bits)

**Tipus de lletra**

- Crimson Pro (OFL-1.1), Playfair Display (OFL-1.1), Fira Sans Condensed (OFL-1.1)

## Elaboració

### 1 · Escriu cada coral en Humdrum **kern

```js
// script.js, línies 263–273
// Each example lists its spines as Humdrum wants them, lowest first: the bass staff split into
// tenor and bass, the **harm spine (printed under that staff), the treble staff split into
// soprano and alto. Writing them with spaces keeps the pen free of tab characters.
const kern = (meter, body) => [
  '**kern **harm **kern', '*clefF4 * *clefG2', '*k[f#] * *k[f#]', `*M${meter} * *M${meter}`,
  '*^ * *^', ...body.trim().split('\n'), '== == == == ==', '*- *- *- *- *-',
].map((row) => row.trim().split(/ +/)
  // Spanish harmony books write every degree in capitals: vi is VI, iib is IIb.
  .map((cell) => (LANG === 'es' && /^(?:i{1,3}|iv|vi{0,2})[bc7]?$/.test(cell)
    ? cell.replace(/^[iv]+/, (degree) => degree.toUpperCase()) : cell))
  .join('\t')).join('\n');
```

Humdrum escriu les veus en columnes separades per tabuladors, de la més greu a la més aguda. Cada pentagrama és una columna (*spine*) dividida en dues amb `*^`: tenor i baix al pentagrama de fa, soprano i contralt al de sol, de manera que el gravador posa dues veus per pentagrama amb les pliques separades (la veu de l'esquerra cap amunt, la de la dreta cap avall). Una columna `**harm` després del pentagrama del baix hi imprimeix els graus a sota; `Ib` és la primera inversió i `Ic`, la quarta i sexta. El guió escriu les columnes amb espais i les converteix en tabuladors, així cap editor no pot espatllar el fitxer, i l'edició castellana rep els graus en majúscules (VI, IIb), com els escriuen els llibres d'harmonia en castellà.

### 2 · Fes de cada exemple un recurs de tipus exemple

La [resposta curta](https://postext.dev/ca/cookbook/conservatory-method-chorales.md#la-resposta-curta) construeix els cinc recursos. `typeId: 'example'` pren el tipus Exemple integrat, que el motor afegeix als tipus de recurs del llibre quan un recurs l'usa, amb el nom en la llengua del document (`locale: 'es'` dona Ejemplo) i numerat per capítol, de manera que el primer exemple del capítol 4 és el 4.1. Un recurs de partitura es manté sencer i flota com una figura: `position: 'top'` o `'bottom'` pren el cap o el peu de la columna següent amb lloc després de la cita, i `span: 'page'` estén el Salm 134 per les dues columnes. Els peus, els crèdits i els textos alternatius surten d'un segon fitxer Markdown per edició.

### 3 · Dona als exemples de columna un pentagrama més petit

```js
// script.js, línies 45–53
const scoreStyle = {
  staffSize: mm(5.4), musicFont: 'Bravura', barNumbers: 'system', color: col('ink'),
  textFont: TEXT, // the Roman numerals under the bass, in the text face
  marginTop: pt(4), marginBottom: pt(2),
};
const scoreStyles = [ // rastral 4.6 mm: four staves of a chorale fit a column in two systems
  { id: 'column', name: t({ en: 'Column examples', es: 'Ejemplos de columna' }),
    staffSize: mm(4.6), barNumbers: 'none', spacing: 0.8 },
];
```

Els dos corals a l'amplada de la pàgina fan servir l'`scoreStyle` del document, amb pentagrames de 5,4 mm i números de compàs al principi de cada sistema. Els exemples de columna trien l'estil amb nom `column`: pentagrames de 4,6 mm, sense números de compàs i espaiat 0,8, que manté els cinc compassos de l'exemple 4.2 en un sol sistema en lloc de deixar l'últim sol en un segon.

### 4 · Engega el gravador abans de compondre

```js
// script.js, línies 398–404
await initMusicEngine(); // until it runs, every score is laid out as a grey box
const continuation = { pageNumbering: { startAt: 61 }, // odd: the opener is a recto
  headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4
const doc = await buildDocumentWithFonts({ markdown, resources: resources(), continuation },
  config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Numbered chorale examples in a harmony method',
  es: 'Corales numerados en un método de armonía' }) });
```

`initMusicEngine()` carrega Verovio, el gravador, des del paquet; fins que s'engega, cada partitura es compon com una capsa grisa. La `continuation` fa d'aquest el capítol 4 d'un llibre més llarg, així que els exemples es numeren del 4.1 al 4.5 i els folis comencen al 61, en pàgina senar.

### 5 · Obre el capítol sobre les dues columnes

```js
// script.js, línies 57–77
const label = { fontFamily: LABEL, fontWeight: 600, textTransform: 'uppercase' };
const opener = {
  enabled: true, minHeight: mm(46),
  slot: { elements: [
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(9),
        letterSpacing: pt(1.8), color: col('accent'), align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' },
          offset: { x: mm(0), y: mm(6) } } },
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(36),
        lineHeight: 1.05, fontWeight: 400, color: col('ink'), align: 'left',
        placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
          size: { width: mm(150), height: 'auto' } } },
      { kind: 'text', id: 'deck', content: '{attr.deck}', fontFamily: TEXT, fontSize: pt(13),
        lineHeight: 1.25, italic: true, color: col('muted'), align: 'left',
        placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
          size: { width: mm(120), height: 'auto' } } },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5), color: col('accent'),
        placement: { anchor: { to: '#deck', edge: 'below' }, offset: { x: mm(0), y: mm(5) },
          size: { width: mm(18) } } },
  ] },
};
```

L'obertura ocupa les dues columnes (`span: 'page'` al títol de nivell u) i pren l'avanttítol i l'entradeta dels atributs del títol. `minHeight` enfonsa el text 46 mm, de manera que les dues columnes comencen a la mateixa línia sota el filet.

## 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/conservatory-method-chorales

### script.js

```js
// ═══ Postext Cookbook · Nº 166 · Numbered chorale examples in a harmony method ══
// https://postext.dev/en/cookbook/conservatory-method-chorales
// Code: MIT · Text and four-part settings: original (CC BY 4.0) · Melodies: public domain
// Fonts: Crimson Pro, Playfair Display, Fira Sans Condensed (OFL 1.1) · Needs postext ≥ 1.54.0
import {
  buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes, initMusicEngine,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'conservatory-method-chorales';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A claret accent on warm paper tones, every colour linked by id.
const palette = { ink: '#1f1a17', accent: '#7a2531', tint: '#f5eee6', rule: '#cdbfb2',
  muted: '#6a5f57', paper: '#ffffff' };
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' } })),
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Crimson Pro', 'Playfair Display', 'Fira Sans Condensed'];
const OUTER = 16; // outer margin in mm: running heads and folios align to it
const LEAD = 14.2; // body leading in pt

// #region answer: score resources typed 'example', floated and numbered like figures
// In the Markdown, :ref{id="ex-old-hundredth" style="full"} prints 'Example 4.4' ('Ejemplo
// 4.4' in Spanish) and ::resource{id="…"} anchors the float after the paragraph that cites it.
const example = (id, source, placement, styleId) => {
  const [caption, note, altText] = CAPTIONS[id];
  return { id, typeId: 'example', kind: 'score', caption, note, altText, placement,
    score: { format: 'humdrum', source, styleId }, createdAt: 0, updatedAt: 0 };
};
// The **kern sources are in #region art-scores; captions in content.captions.<lang>.md.
const resources = () => [
  example('ex-cadences', CADENCES, { position: 'bottom' }, 'column'),
  example('ex-nun-danket', NUN_DANKET, { position: 'top' }, 'column'),
  example('ex-nun-danket-3', NUN_DANKET_3, { position: 'auto' }, 'column'),
  // Across both columns in the document's own score style: the whole tune in two systems.
  example('ex-old-hundredth', OLD_HUNDREDTH, { position: 'top', span: 'page' }),
  example('ex-deceptive', DECEPTIVE, { position: 'auto', span: 'page' }),
];
// #endregion

// #region scores: one score style for the page-wide chorale, a smaller one for the columns
const scoreStyle = {
  staffSize: mm(5.4), musicFont: 'Bravura', barNumbers: 'system', color: col('ink'),
  textFont: TEXT, // the Roman numerals under the bass, in the text face
  marginTop: pt(4), marginBottom: pt(2),
};
const scoreStyles = [ // rastral 4.6 mm: four staves of a chorale fit a column in two systems
  { id: 'column', name: t({ en: 'Column examples', es: 'Ejemplos de columna' }),
    staffSize: mm(4.6), barNumbers: 'none', spacing: 0.8 },
];
// #endregion

// #region opener: kicker, title, deck and a short rule across both columns, sunk 46 mm
const label = { fontFamily: LABEL, fontWeight: 600, textTransform: 'uppercase' };
const opener = {
  enabled: true, minHeight: mm(46),
  slot: { elements: [
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(9),
        letterSpacing: pt(1.8), color: col('accent'), align: 'left',
        placement: { anchor: { to: 'container', edge: 'top-left' },
          offset: { x: mm(0), y: mm(6) } } },
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(36),
        lineHeight: 1.05, fontWeight: 400, color: col('ink'), align: 'left',
        placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
          size: { width: mm(150), height: 'auto' } } },
      { kind: 'text', id: 'deck', content: '{attr.deck}', fontFamily: TEXT, fontSize: pt(13),
        lineHeight: 1.25, italic: true, color: col('muted'), align: 'left',
        placement: { anchor: { to: '#title', edge: 'below' }, offset: { x: mm(0), y: mm(3) },
          size: { width: mm(120), height: 'auto' } } },
      { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5), color: col('accent'),
        placement: { anchor: { to: '#deck', edge: 'below' }, offset: { x: mm(0), y: mm(5) },
          size: { width: mm(18) } } },
  ] },
};
// #endregion

// #region running-heads: book title on the verso, chapter on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, fontSize: pt(7.8),
  letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra,
});
const folio = { fontWeight: 700, color: col('accent') };
const header = {
  elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('verso-title', '{title}', 'even', 'top-left', OUTER + 9),
    head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 9)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ],
};
const footer = { // the opener's drop folio, centred under the text block
  elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, { ...folio, pages: 'opener',
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { x: mm(0), y: mm(8) } } })],
};
// #endregion

const config = {
  locale: t({ en: 'en-us', es: 'es' }), // hyphenation, and the name of the Example type
  resourceTypes: defaultResourceTypes(LANG), // the engine adds Example (Ejemplo) in locale
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150,
    margins: { top: mm(22), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true },
  },
  layout: { layoutType: 'double', gutterWidth: mm(7) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(10.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, color: col('ink'), fontWeight: 700,
    levels: [
      { level: 1, span: 'page', fontSize: pt(36), breakBefore: { parity: 'odd' },
        advancedDesign: opener, marginBottom: pt(0) },
      { level: 2, fontSize: pt(13.5), lineHeight: pt(LEAD), marginTop: pt(LEAD),
        marginBottom: pt(LEAD / 2) },
    ],
  },
  unorderedLists: { bulletChar: '–', color: col('accent'),
    marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { numberFormat: 'arabic', color: col('accent'), fontWeight: 700,
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  calloutStyles: [
    { id: 'rule', title: t({ en: 'Rule', es: 'Regla' }), background: col('tint'),
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
      padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) },
      titleStyle: { ...label, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1.2),
        color: col('accent') },
      body: { fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(13), color: col('ink'),
        firstLineIndent: pt(0), boldColor: col('ink'), italicColor: col('ink') } },
  ],
  captionStyle: { fontFamily: LABEL, fontSize: pt(8.4), color: col('ink'), gap: mm(1.5),
    labelBold: true, labelColor: col('accent'), note: { color: col('muted'), gap: mm(0.5) } },
  scoreStyle,
  scoreStyles,
  header,
  footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Armonía a cuatro voces"
---

# Las cadencias {kicker="Capítulo 4" deck="Dónde reposa una frase y cómo la llevan hasta allí las cuatro voces"}

Toda frase musical respira al final, y el acorde que la cierra le dice al oyente cuánto reposo ha alcanzado la música. Esa fórmula de cierre es la cadencia. El alumno que ya sabe enlazar dos acordes cualesquiera a cuatro voces suele tratar los dos últimos de la frase como a los demás, y sus ejercicios terminan sin convicción. Este capítulo se detiene en el final de la frase: nombra las cinco cadencias del modo mayor, explica cómo se dispone cada una y las sigue en dos melodías de coral que se cantan en todas las clases de armonía, *Nun danket alle Gott* y el Salmo 134 del Salterio de Ginebra, que los ingleses llaman *Old Hundredth*.

El :ref{id="ex-cadences" style="full" case="lower"} reúne las cinco en sol mayor, una por compás, con los grados en cifras romanas bajo el bajo. Tocadlas al piano antes de seguir leyendo y escuchad cuánto concluye cada una: el orden de los compases va de la más conclusiva a la más abierta.

::resource{id="ex-cadences"}

## La cadencia perfecta y la imperfecta

La cadencia auténtica es el enlace V–I, el cierre más firme de la tonalidad: el bajo desciende una quinta (o asciende una cuarta) de la dominante a la tónica, mientras la sensible, el séptimo grado, sube un semitono hasta la tónica en una de las voces superiores.

La llamamos **cadencia perfecta** cuando los dos acordes están en estado fundamental y el soprano termina en la tónica. Si falla alguna de las dos condiciones, la cadencia es **imperfecta**: el soprano acaba en la tercera o en la quinta del acorde de tónica, o uno de los dos acordes está invertido. Los dos primeros compases del :ref{id="ex-cadences" case="lower"} mantienen el mismo bajo y cambian la voz superior: fa sostenido que sube a sol en el primero, la que sube a si en el segundo.

La sensible pide cuidado. En el soprano debe subir a la tónica. En una voz interior puede bajar una tercera hasta la quinta del acorde de tónica, para que este quede completo, y Bach lo hace en muchos de sus corales; pero nunca la dejéis bajar en el bajo ni en el soprano. Tras una séptima de dominante, la séptima (do en sol mayor) resuelve bajando por grado conjunto. Si además la sensible sube, la tónica que sigue lleva la fundamental triplicada y no tiene quinta; si la sensible baja en una voz interior, el acorde queda completo. El último compás del :ref{id="ex-nun-danket" case="lower"} elige la segunda solución: el tenor mantiene el do del acorde anterior y baja a si, la contralto deja caer el fa sostenido a re y el soprano canta la–sol sobre el bajo re–sol.

::resource{id="ex-nun-danket"}

## La cadencia plagal

El enlace IV–I forma la cadencia plagal. No tiene sensible y su efecto es más suave; los himnarios la reservan para el *Amén* que sigue a una cadencia perfecta final. A cuatro voces, la tónica es la nota común de los dos acordes y se queda en la misma voz, mientras las otras dos voces superiores bajan por grado conjunto, el sexto grado al quinto y el cuarto al tercero, como en el cuarto compás del :ref{id="ex-cadences" case="lower"}.

Así termina en nuestra armonización el primer verso de *Nun danket alle Gott*. El soprano repite mi sobre do y baja después a re sobre sol (compases 1 y 2 del :ref{id="ex-nun-danket" case="lower"}). La cadencia cae en la parte fuerte y el calderón es breve, de modo que los cantores respiran y siguen: un reposo plagal en mitad de un himno se oye como una coma, no como un punto.

## La semicadencia

La frase que se detiene en la dominante termina en **semicadencia**. A ella puede llevar cualquier acorde, casi siempre el I, el IV o el II, y la dominante va siempre en estado fundamental. El oído percibe una pregunta que espera respuesta; por eso la semicadencia cierra la primera mitad de tantas melodías y casi nunca el último verso. En el tercer verso de *Nun danket alle Gott* (:ref{id="ex-nun-danket-3" case="lower"}) la tónica en primera inversión lleva al V bajo un soprano que desciende de si a la.

::resource{id="ex-nun-danket-3"}

Una forma frecuente de semicadencia llega a la dominante a través de la cuarta y sexta cadencial: el acorde de tónica con la quinta en el bajo, en parte fuerte, seguido de la dominante sobre la misma nota del bajo. La sexta y la cuarta sobre el bajo bajan por grado conjunto a la quinta y a la tercera. Tratad la cuarta y sexta como un adorno de la dominante, no como una tónica, y duplicad su bajo.

## Cuatro cadencias en una melodía

El Salmo 134, impreso por primera vez en el Salterio de Ginebra de 1551, cierra sus cuatro versos con tres clases de cadencia. El :ref{id="ex-old-hundredth" style="full" case="lower"} lo armoniza completo, en compás de 2/2 y con las notas largas y breves del ritmo ginebrino.

::resource{id="ex-old-hundredth"}

- **Verso 1**: acaba en si sobre sol, una cadencia imperfecta, porque el soprano se detiene en la tercera.
- **Verso 2**: termina con la cuarta y sexta cadencial y el V, una semicadencia con el retardo en el soprano.
- **Verso 3**: acaba en fa sostenido–sol sobre re–sol, una cadencia perfecta con la sensible resuelta en el soprano. Como llega antes del último verso, la contesta un cierre aún más firme.
- **Verso 4**: desciende si–la–sol sobre la cuarta y sexta cadencial, el V y el I, el cierre más completo de los cuatro.

Cantad el bajo mientras un compañero toca las voces superiores: la línea del bajo, por sí sola, anuncia qué cadencia viene.

## La cadencia rota

Cuando el V va al VI en lugar de al I, la cadencia es **rota**. La sensible sigue subiendo a la tónica, pero el bajo asciende un grado hasta el sexto, y la frase que parecía terminada tiene que continuar. Duplicad la tercera del VI (la tónica de la tonalidad) para que la sensible resuelva y las demás voces bajen sin octavas paralelas.

:::callout{type="rule" title="Antes de escribir la cadencia"}
- Decidid la cadencia a partir de la melodía: las dos últimas notas del soprano solo admiten algunas.
- Poned la dominante en estado fundamental en las cadencias perfecta, imperfecta, rota y en la semicadencia.
- Resolved la sensible hacia arriba en las voces extremas, y la séptima del V^7^ hacia abajo.
- Revisad las voces extremas: ni quintas ni octavas paralelas ni directas.
:::

Los compositores usan la cadencia rota para alargar una frase. El :ref{id="ex-deceptive" style="full" case="lower"} canta dos veces el último verso del salmo: la primera reposa en el VI, un acorde de mi menor bajo la nota tónica, y la segunda en la cadencia perfecta del :ref{id="ex-old-hundredth" case="lower"}.

::resource{id="ex-deceptive"}

## En modo menor

Todo lo dicho en este capítulo vale en menor, con una condición: la sensible no está en la armadura. En la menor el acorde de dominante es mi–sol sostenido–si, y el sostenido hay que escribirlo cada vez; sin él, el acorde es menor y la cadencia perfecta pierde su atracción. En la cadencia rota la sensible alterada sube a la tónica y el sexto grado, fa, queda en el bajo un semitono por encima de la dominante, así que la contralto o el tenor tienen que evitar la segunda aumentada sol sostenido–fa.

Dos cadencias son propias del modo menor. Un coral en menor termina a menudo en un acorde de tónica mayor, con la tercera elevada en el último momento: es la tercera de picardía, con la que Bach cierra la mayoría de sus corales en menor. Y una semicadencia puede llegar a la dominante desde la primera inversión del IV, con el bajo bajando un semitono del sexto grado al quinto: la semicadencia frigia, frecuente al final de un movimiento lento que precede a uno rápido.

## Ejercicios

1. Escribid las cinco cadencias del :ref{id="ex-cadences" case="lower"} en re mayor y en fa mayor, en posición cerrada y abierta.
2. Armonizad el primer verso del Salmo 134 con una cadencia perfecta en lugar de la imperfecta. ¿Qué nota de la melodía tiene que cambiar?
3. Buscad la cadencia del final de cada verso en tres corales a vuestra elección y ponedle nombre.
4. Reescribid los dos últimos compases del :ref{id="ex-old-hundredth" case="lower"} con un *Amén* plagal tras el acorde final.

:::paragraphs{style="colophon"}
*Armonía a cuatro voces*, método para los primeros cursos del conservatorio · Compuesto en Crimson Pro, Playfair Display y Fira Sans Condensed (SIL OFL) · Música grabada en Bravura · Melodías de dominio público; armonizaciones y texto originales, CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook
// Caption, credit note ('-' for none) and alt text of each example, one block per example.
const CAPTIONS = Object.fromEntries((String.raw`ex-cadences
Las cinco cadencias en sol mayor: perfecta, imperfecta, semicadencia, plagal y rota
-
Cinco cadencias de dos acordes en sol mayor a cuatro voces en dos pentagramas, una por compás, con los grados en cifras romanas bajo el bajo: V–I dos veces, I–V, IV–I y V–VI.

ex-nun-danket
*Nun danket alle Gott*, versos 1 y 2: un reposo plagal y una cadencia perfecta
Melodía: Johann Crüger, 1647, como en el BWV 386 de Bach, en sol · Armonización original
Los dos primeros versos del coral Nun danket alle Gott en sol mayor a cuatro voces; el primero termina IV–I y el segundo V7–I.

ex-nun-danket-3
*Nun danket alle Gott*, verso 3: una semicadencia
-
El tercer verso de Nun danket alle Gott en sol mayor a cuatro voces, que termina en el acorde de dominante.

ex-old-hundredth
El Salmo 134 de Ginebra: una cadencia imperfecta, una semicadencia y dos perfectas
Melodía: Salterio de Ginebra, 1551, atribuida a Louis Bourgeois · Armonización original
El Salmo 134 de Ginebra completo en sol mayor a cuatro voces en dos pentagramas: trece compases de 2/2 con un calderón al final de cada uno de sus cuatro versos.

ex-deceptive
El último verso del Salmo 134, que reposa primero en el VI grado y después en el I
-
El último verso del Salmo 134 cantado dos veces a cuatro voces: la primera, la dominante va a un acorde de mi menor; la segunda, a la tónica de sol mayor.
`).trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, note, alt]) => [id, [caption, note === '-' ? undefined : note, alt]]));

// #region humdrum: aligned columns become tab-separated **kern, the Spanish numerals uppercase
// Each example lists its spines as Humdrum wants them, lowest first: the bass staff split into
// tenor and bass, the **harm spine (printed under that staff), the treble staff split into
// soprano and alto. Writing them with spaces keeps the pen free of tab characters.
const kern = (meter, body) => [
  '**kern **harm **kern', '*clefF4 * *clefG2', '*k[f#] * *k[f#]', `*M${meter} * *M${meter}`,
  '*^ * *^', ...body.trim().split('\n'), '== == == == ==', '*- *- *- *- *-',
].map((row) => row.trim().split(/ +/)
  // Spanish harmony books write every degree in capitals: vi is VI, iib is IIb.
  .map((cell) => (LANG === 'es' && /^(?:i{1,3}|iv|vi{0,2})[bc7]?$/.test(cell)
    ? cell.replace(/^[iv]+/, (degree) => degree.toUpperCase()) : cell))
  .join('\t')).join('\n');
// #endregion

// #region art-scores: the five examples; spines: tenor bass | harm | soprano alto
const CADENCES = kern('2/2', `
  2A   2D    V    2f#  2d
  2B   2GG   I    2g   2d
  =||  =||   =||  =||  =||
  2d   2D    V    2a   2f#
  2d   2GG   I    2b   2g
  =||  =||   =||  =||  =||
  2d   2GG   I    2b   2g
  2d   2D    V    2a   2f#
  =||  =||   =||  =||  =||
  2c   2C    IV   2g   2e
  2B   2GG   I    2g   2d
  =||  =||   =||  =||  =||
  2A   2D    V    2f#  2d
  2G   2E    vi   2g   2B`);

const NUN_DANKET = kern('4/4', `
  4B   4GG   I    4dd  4g
  =    =     =    =    =
  4A   4D    V    4dd  4f#
  4G   4BB   Ib   4dd  4g
  4c   4AA   ii   4ee  4e
  4G   4C    IV   4ee  4e
  =    =     =    =    =
  2B   2GG;  I    2dd; 2g
  4r   4ry   .    4r   4ry
  4d   4GG   I    4b   4g
  =    =     =    =    =
  4e   4C    IV   4cc  4g
  4d   4GG   I    4b   4g
  4A   4FF#  Vb   4a   4d
  8G   8GG   I    8b   8d
  8G   8EE   IVb  8cc  8c
  =    =     =    =    =
  2c   4C    iib  2a   4e
  .    4D    V7   .    4f#
  4B   4GG;  I    4g;  4d`);

const NUN_DANKET_3 = kern('4/4', `
  4c   4AA   ii   4a   4e
  =    =     =    =    =
  4A   4C    iib  4a   4e
  4d   4D    V    4a   4f#
  4d   4GG   I    4b   4g
  4d   4BB   Ib   4b   4g
  =    =     =    =    =
  2d   2D;   V    2a;  2f#
  4r   4ry   .    4r   4ry`);

const OLD_HUNDREDTH = kern('2/2', `
  2B   2G    I    2g   2d
  4B   4E    vi   4g   4e
  4B   4BB   iii  4f#  4d
  =    =     =    =    =
  4G   4C    IV   4e   4c
  4G   4GG   I    4d   4B
  2G   2E    vi   2g   2B
  =    =     =    =    =
  2F#  2D    V    2a   2d
  2G   2GG;  I    2b;  2d
  =||  =||   =||  =||  =||
  2G   2GG   I    2b   2d
  4G   4E    vi   4b   4e
  4G   4D    Ic   4b   4g
  =    =     =    =    =
  4c   4C    iib  4a   4e
  4G   4BB   Ib   4g   4d
  2G   2E    IVb  2cc  2g
  =    =     =    =    =
  2d   2D    Ic   2b   2g
  2d   2D;   V    2a;  2f#
  =||  =||   =||  =||  =||
  2B   2GG   I    2g   2d
  4A   4FF#  Vb   4a   4d
  4G   4GG   I    4b   4d
  =    =     =    =    =
  4E   4C    iib  4a   4e
  4G   4BB   Ib   4g   4d
  2G   2E    IVb  2e   2c
  =    =     =    =    =
  2A   2D    V    2f#  2d
  2B   2GG;  I    2g;  2d
  =||  =||   =||  =||  =||
  2G   2BB   Ib   2dd  2d
  2G   2GG   I    2b   2d
  =    =     =    =    =
  2G   2C    IV   2g   2e
  4c   4C    iib  4a   4e
  4c   4AA   ii   4cc  4e
  =    =     =    =    =
  2G   2D    Ic   2b   2d
  2F#  2D    V    2a   2d
  =    =     =    =    =
  1G   1GG;  I    1g;  1B`);

// The last line twice: the first time V moves to vi (E minor under the tonic note).
const LAST_LINE = (bass, degree) => `
  2G   2BB   Ib   2dd  2d
  2G   2GG   I    2b   2d
  =    =     =    =    =
  2G   2C    IV   2g   2e
  4c   4C    iib  4a   4e
  4c   4AA   ii   4cc  4e
  =    =     =    =    =
  2G   2D    Ic   2b   2d
  2F#  2D    V    2a   2d
  =    =     =    =    =
  1G   ${bass}  ${degree}  1g;  1B`;
const DECEPTIVE = kern('2/2', `${LAST_LINE('1E;', 'vi')}
  =  =  =  =  =${LAST_LINE('1GG;', 'I')}`);
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Crimson Pro': ['400', '400i', '600', '700'], // text, and the numerals under the music
  'Playfair Display': ['400', '700'], // display: chapter title, section heads
  'Fira Sans Condensed': ['400', '600', '700'], // labels: kicker, heads, captions, the box
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: start the engraver, then set chapter 4 of the method from page 61
await initMusicEngine(); // until it runs, every score is laid out as a grey box
const continuation = { pageNumbering: { startAt: 61 }, // odd: the opener is a recto
  headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4
const doc = await buildDocumentWithFonts({ markdown, resources: resources(), continuation },
  config, kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Numbered chorale examples in a harmony method',
  es: 'Corales numerados en un método de armonía' }) });
// #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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

/** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts:
 *  the Fontsource resolver, and the faces of FONTS loaded with the ones the
 *  config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */
function kitFonts(FONTS = {}) {
  const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) =>
    ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' })));
  return { resolve: fontsourceResolver, faces };
}

/** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek
 *  when `text` needs them and the family ships them (kitSubsetsFor), each with
 *  its unicode-range. Null for a weight or slant Fontsource does not ship. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null;
  kitStatus('Loading fonts…');
  const at = await fontsourceAt(id);
  return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({
    source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`,
    unicodeRange: kitRanges()[subset],
  }));
}

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    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',
    greek: 'U+0370-03FF',
  };
}

/** For the cjk, arabic and comics blocks, which add their own files: the
 *  faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  The files are added once all have loaded, in the order asked (latin, then
 *  latin-ext, then greek), whatever order they arrive in: the last one added
 *  wins a character two files hold. Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = kitRanges();
  const jobs = [];
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'])
      .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await
    const at = fontsourceAt(id);
    const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null;
    const subsets = ['latin', ...kitSubsetsFor(text, meta)];
    for (const [weight, style] of todo) {
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `${await at}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().catch(() => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return ready.filter((face) => face && document.fonts.add(face)).length;
}

/** A loaded FontFace covers this family, weight and style (fonts.check() would
 *  also say yes 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;
}

/** The files beyond latin `text` needs that `meta`'s family ships. */
function kitSubsetsFor(text, meta) {
  return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']]
    .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x);
}

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

/** A package's folder at the one version @5 names now (#766). */
function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) {
  return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json())
    .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`);
}

/** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook
/** The pages as spreads on a dark desk, page 1 alone, then verso | recto,
 *  each painted when it scrolls near. Other options go to renderPageToCanvas. */
function showPages(docs, { title, width = 460, ...render } = {}) {
  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, { ...render, 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 ───────────────────────────────────────────────────────────────────────
```

## Variants

### Posa un exemple sense número al text

Un bloc de codi amb la partitura la deixa al text sense que el text la citi, com una fórmula destacada: sense número ni peu.

```diff
- ::resource{id="ex-cadences"}
+ ```humdrum {style="column" alt="Les cinc cadències en sol major"}
+ **kern	**harm	**kern
+ …
+ ```
```

### Anomena'ls exemples musicals

Defineix un tipus propi per a un altre nom i apunta-hi `typeId`.

```diff
- resourceTypes: defaultResourceTypes(LANG),
+ resourceTypes: [...defaultResourceTypes(LANG), { id: 'musex', name: 'Exemple musical',
+   shortLabel: 'Ex. mus.', captionPrefix: 'Exemple musical', numberingTemplate: '{h1}.{n}',
+   resetOn: 'h1', counterFormat: 'decimal' }],
```

## Errors freqüents

- **::resource{id="…"} només admet cometes dobles.** Una inserció de bloc només es reconeix com a ::resource{id="…"} amb cometes dobles; qualsevol altra forma es queda al text com una línia visible.

- Un recurs de partitura no es parteix mai entre columnes ni entre pàgines. Limita els exemples flotants a uns quants compassos i posa les peces més llargues en un bloc de codi, que es parteix entre sistemes.
- El gravador omple cada sistema fins a l'amplada del seu buit, i l'últim sistema conserva la seva amplada natural. Si un exemple deixa un compàs sol en un segon sistema, abaixa l'`spacing` o la mida de pentagrama del seu estil.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Chapter text and the four-part settings of every example: Ignacio Ferro, CC-BY-4.0
- Text: Melody of Nun danket alle Gott (Praxis pietatis melica, 1647), read from the soprano of Bach's setting BWV 386: Johann Crüger ([font](https://github.com/craigsapp/bach-370-chorales/blob/master/kern/chor032.krn)), domini públic
- Text: Melody of the Old Hundredth (Psalm 134 in the Genevan Psalter, 1551): Louis Bourgeois (attributed) ([font](https://en.wikipedia.org/wiki/Old_100th)), domini públic
- Tipus de lletra: Crimson Pro (OFL-1.1), Playfair Display (OFL-1.1), Fira Sans Condensed (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 009 · Figures que suren fins on les cites](https://postext.dev/ca/cookbook/figures-float-where-cited.md): Capítol a dues columnes amb set figures numerades: sis suren del primer :ref al primer buit que admet la seva col·locació; una va on la posa ::resource. · Nivell 3 (Avançat) · Llibres de text
- [Núm. 163 · Un manual d'harmonia amb exemples musicals en ABC](https://postext.dev/ca/cookbook/harmony-textbook-abc-examples.md): Un capítol sobre les cadències amb exemples en blocs ABC dins del Markdown, gravats a l'amplada de la columna; un d'ells continua a la pàgina següent. · Nivell 2 (Intermedi) · Llibres de text
- [Núm. 001 · Llibre de text amb columna al marge](https://postext.dev/ca/cookbook/textbook-margin-column.md): Columna i mitja amb la columna exterior només per a flotants: s'hi apilen figures i glosses amb span 'side', i captionSide hi porta la resta de peus. · Nivell 3 (Avançat) · Llibres de text
