# Corales numerados en un método de armonía a dos columnas

> Corales a cuatro voces escritos en Humdrum **kern pasan a recursos de partitura de tipo ejemplo: numerados, citados con :ref y flotantes como figuras.

- Versión HTML: https://postext.dev/es/cookbook/conservatory-method-chorales
- Receta N.º 166 · Figuras e imágenes · Nivel 2 (Intermedio) · Salidas: Canvas
- Géneros: Libros de texto
- Requiere postext ≥ 1.54.0 · probada con 1.54.0 el 2026-10-22
- Páginas: [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)
- Abrir en el Sandbox: https://postext.dev/es/sandbox#recipe=conservatory-method-chorales&lang=es (.postext: https://postext.dev/cookbook/conservatory-method-chorales/es/conservatory-method-chorales.postext)
- Última actualización: 2026-10-22
- Otros idiomas: [en](https://postext.dev/en/cookbook/conservatory-method-chorales.md), [ca](https://postext.dev/ca/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 pocas palabras

Un capítulo sobre las cadencias de un libro de armonía de conservatorio, a dos columnas. Sus cinco ejemplos musicales se escriben como texto, se graban como partituras, se numeran como figuras y van junto al párrafo que los cita.

## Lo que vas a componer

El capítulo 4 de *Armonía a cuatro voces*, un método para los primeros cursos del conservatorio, en una página de 210 × 280 mm a dos columnas de Crimson Pro. La lección trata de las cadencias, y sus cinco ejemplos son corales a cuatro voces en dos pentagramas, con los grados en cifras romanas bajo el bajo: las cinco cadencias de sol mayor, tres versos de *Nun danket alle Gott* y el Salmo 134 de Ginebra completo. Cada uno lleva su número, de Ejemplo 4.1 a 4.5 (Example en la edición inglesa), un pie en la letra de rótulos con la etiqueta en granate, y se coloca en la cabeza o el pie de la columna que sigue al párrafo que lo cita; el salmo entero ocupa las dos columnas en la cabeza de la página 63. Las armonizaciones se escribieron para este método y se revisaron en busca de quintas y octavas paralelas, distancias entre voces y resolución de la sensible.

**Esta receta responde a:**

- ¿Cómo numero ejemplos musicales a cuatro voces y los hago flotar como figuras a dos columnas?
- ¿Cómo añado una figura con pie numerado y la cito en el texto («véase la fig. 3.2»)?
- ¿Cómo decido dónde va una figura: en la cabeza de la página, a lo ancho de las dos columnas, justo aquí o al margen?

## La respuesta corta

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

## Ingredientes

**Enseña**

- [Ejemplos musicales numerados](https://postext.dev/es/docs/music.md#ejemplos-musicales-numerados): Recursos de tipo score con el tipo example: numerados en la lengua del documento (Ejemplo 3.2), con pie, citados con :ref y colocados o flotantes como las figuras; cada uno se mantiene entero.
- [Colocación de figuras](https://postext.dev/es/docs/document-format-resources.md#colocación): Arriba, abajo, automática o aquí, en una columna o a todo el ancho, a una fracción del ancho, por recurso o por tipo; los flotantes de una misma serie nunca se adelantan entre sí.
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format-resources.md#referencia-en-línea-la-forma-principal): Un :ref cita un recurso («véase la fig. 3.2») y su primera cita lo coloca: la figura flota hasta el primer hueco libre después de ella.

**También usa**

- [Notación musical](https://postext.dev/es/docs/music.md#una-partitura-en-el-texto)
- [Estilo de partitura](https://postext.dev/es/docs/configuration-resources.md#estilo-de-partitura)
- [Pies numerados](https://postext.dev/es/docs/document-format-resources.md#numeración-por-primera-referencia)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso)
- [Estilo de los pies](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso)
- [Líneas de fuente y crédito](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso)
- [Una o dos columnas](https://postext.dev/es/docs/configuration-page-layout.md#tipos-de-disposición)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies)
- [Márgenes simétricos](https://postext.dev/es/docs/configuration-page-layout.md#márgenes-simétricos-espejo)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration-headers-footers.md#elementos-de-texto)
- [Recuadros](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores)
- [Figuras justo aquí](https://postext.dev/es/docs/document-format-resources.md#inserción-en-bloque-opcional-colocación-en-línea-explícita)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration-headings.md#apertura-a-toda-página-y-diseño-avanzado)
- [Superíndices y subíndices](https://postext.dev/es/docs/document-format-inline.md#formato-en-línea)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration-text.md#texto-base), [`calloutStyles`](https://postext.dev/es/docs/configuration-callouts.md#estilos-de-recuadro), [`captionStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-pies-de-recurso), [`colorPalette`](https://postext.dev/es/docs/configuration-fonts-colors-viewers.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`header`](https://postext.dev/es/docs/configuration-headers-footers.md#cabeceras-y-pies), [`headings`](https://postext.dev/es/docs/configuration-headings.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration-page-layout.md#disposición), [`locale`](https://postext.dev/es/docs/configuration-language.md#separación-silábica), [`orderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-ordenadas), [`page`](https://postext.dev/es/docs/configuration-page-layout.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration-styles.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso), [`scoreStyle`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-partitura), [`scoreStyles`](https://postext.dev/es/docs/configuration-resources.md#estilo-de-partitura), [`unorderedLists`](https://postext.dev/es/docs/configuration-lists-math.md#listas-no-ordenadas)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/es/docs/configuration-programmatic-usage.md#cargar-las-fuentes-antes-de-componer), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration-resources.md#tipos-de-recurso), [`initMusicEngine`](https://postext.dev/es/docs/music.md#una-partitura-en-el-texto), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration-programmatic-usage.md#renderizar-una-página-como-mapa-de-bits)

**Tipografías**

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

## Elaboración

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

```js
// script.js, líneas 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 escribe las voces en columnas separadas por tabuladores, de la más grave a la más aguda. Cada pentagrama es una columna (*spine*) dividida en dos con `*^`: tenor y bajo en el pentagrama de fa, soprano y contralto en el de sol, de modo que el grabador pone dos voces por pentagrama con las plicas separadas (la voz de la izquierda hacia arriba, la de la derecha hacia abajo). Una columna `**harm` tras el pentagrama del bajo imprime los grados bajo él; `Ib` es la primera inversión y `Ic`, la cuarta y sexta. El guion escribe las columnas con espacios y las convierte en tabuladores, así ningún editor puede estropear el archivo, y la edición española recibe los grados en mayúsculas (VI, IIb), como los escriben los libros de armonía en español.

### 2 · Haz de cada ejemplo un recurso de tipo ejemplo

La [respuesta corta](https://postext.dev/es/cookbook/conservatory-method-chorales.md#la-respuesta-corta) construye los cinco recursos. `typeId: 'example'` toma el tipo Ejemplo integrado, que el motor añade a los tipos de recurso del libro cuando un recurso lo usa, con el nombre en el idioma del documento (`locale: 'es'` da Ejemplo) y numerado por capítulo, de modo que el primer ejemplo del capítulo 4 es el 4.1. Un recurso de partitura se mantiene entero y flota como una figura: `position: 'top'` o `'bottom'` toma la cabeza o el pie de la siguiente columna con sitio tras la cita, y `span: 'page'` extiende el Salmo 134 por las dos columnas. Los pies, los créditos y los textos alternativos salen de un segundo archivo Markdown por edición.

### 3 · Da a los ejemplos de columna un pentagrama menor

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

Los dos corales a lo ancho de la página usan el `scoreStyle` del documento, con pentagramas de 5,4 mm y números de compás al principio de cada sistema. Los ejemplos de columna eligen el estilo con nombre `column`: pentagramas de 4,6 mm, sin números de compás y espaciado 0,8, que mantiene los cinco compases del ejemplo 4.2 en un solo sistema en lugar de dejar el último solo en un segundo.

### 4 · Arranca el grabador antes de componer

```js
// script.js, líneas 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()` carga Verovio, el grabador, desde el paquete; hasta que arranca, cada partitura se compone como una caja gris. La `continuation` hace de este el capítulo 4 de un libro más largo, así que los ejemplos se numeran del 4.1 al 4.5 y los folios empiezan en el 61, en página impar.

### 5 · Abre el capítulo sobre las dos columnas

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

La apertura ocupa las dos columnas (`span: 'page'` en el título de nivel uno) y toma el antetítulo y la entradilla de los atributos del título. `minHeight` hunde el texto 46 mm, de modo que las dos columnas empiezan en la misma línea bajo el filete.

## La receta completa

Un solo archivo, compuesto a partir de la carpeta de la receta con el texto de ejemplo y el kit común del Recetario ya incluidos; construye su propia página. Para ejecutarlo, ponlo en un `<script type="module">` de una página vacía o pégalo en el panel JS de un pen nuevo de CodePen (como módulo). Importa postext desde esm.sh, así que no hay nada que instalar ni compilar.

- Carpeta de la receta: https://github.com/drnachio/postext/tree/main/cookbook/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 ───────────────────────────────────────────────────────────────────────
```

## Variantes

### Pon un ejemplo sin número en el texto

Un bloque de código con la partitura la deja en el texto sin que el texto la cite, como una fórmula destacada: sin número ni pie.

```diff
- ::resource{id="ex-cadences"}
+ ```humdrum {style="column" alt="Las cinco cadencias en sol mayor"}
+ **kern	**harm	**kern
+ …
+ ```
```

### Llámalos ejemplos musicales

Define un tipo propio para otro nombre y apunta `typeId` a él.

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

## Errores frecuentes

- **::resource{id="…"} solo admite comillas dobles.** Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible.

- Un recurso de partitura nunca se parte entre columnas ni entre páginas. Limita los ejemplos flotantes a unos pocos compases y pon las piezas más largas en un bloque de código, que se parte entre sistemas.
- El grabador llena cada sistema hasta el ancho de su hueco, y el último sistema conserva su ancho natural. Si un ejemplo deja un compás solo en un segundo sistema, baja el `spacing` o el tamaño de pentagrama de su estilo.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: El texto del capítulo y las armonizaciones a cuatro voces de todos los ejemplos: Ignacio Ferro, CC-BY-4.0
- Texto: Melodía de Nun danket alle Gott (Praxis pietatis melica, 1647), tomada del soprano de la armonización de Bach BWV 386: Johann Crüger ([fuente](https://github.com/craigsapp/bach-370-chorales/blob/master/kern/chor032.krn)), dominio público
- Texto: Melodía del Old Hundredth (Salmo 134 del Salterio de Ginebra, 1551): Louis Bourgeois (attributed) ([fuente](https://en.wikipedia.org/wiki/Old_100th)), dominio público
- Tipografías: Crimson Pro (OFL-1.1), Playfair Display (OFL-1.1), Fira Sans Condensed (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 009 · Figuras que flotan hasta donde las citas](https://postext.dev/es/cookbook/figures-float-where-cited.md): Capítulo a dos columnas con siete figuras numeradas: seis flotan de su primer :ref al primer hueco que admite su colocación; una va donde la pone ::resource. · Nivel 3 (Avanzado) · Libros de texto
- [N.º 163 · Un manual de armonía con ejemplos musicales en ABC](https://postext.dev/es/cookbook/harmony-textbook-abc-examples.md): Un capítulo sobre las cadencias cuyos ejemplos son bloques ABC en el Markdown, grabados al ancho de la columna; uno de ellos sigue en la página siguiente. · Nivel 2 (Intermedio) · Libros de texto
- [N.º 001 · Libro de texto con columna al margen](https://postext.dev/es/cookbook/textbook-margin-column.md): Columna y media con la columna exterior solo para flotantes: en ella se apilan figuras y glosas con span 'side', y captionSide lleva allí los demás pies. · Nivel 3 (Avanzado) · Libros de texto
