# Programa de congreso con una parrilla de celdas combinadas

> Un programa de dos jornadas: mergeCells extiende la plenaria por las tres salas y los talleres por dos franjas, y cada ponencia lleva el color de su itinerario.

- Versión HTML: https://postext.dev/es/cookbook/conference-programme
- Receta N.º 053 · Tablas · Nivel 3 (Avanzado) · Salidas: Canvas
- Géneros: Hojas sueltas y efímeros
- Requiere postext ≥ 1.4.1 · probada con 1.4.1 el 2026-09-26
- Páginas: [1](https://postext.dev/cookbook/conference-programme/es/p01.webp?v=b86ce7cb), [2](https://postext.dev/cookbook/conference-programme/es/p02.webp?v=b86ce7cb), [3](https://postext.dev/cookbook/conference-programme/es/p03.webp?v=b86ce7cb), [4](https://postext.dev/cookbook/conference-programme/es/p04.webp?v=b86ce7cb), [5](https://postext.dev/cookbook/conference-programme/es/p05.webp?v=b86ce7cb), [6](https://postext.dev/cookbook/conference-programme/es/p06.webp?v=b86ce7cb)
- Última actualización: 2026-09-27
- Otros idiomas: [en](https://postext.dev/en/cookbook/conference-programme.md)

## Lo que vas a componer

El programa impreso, en catalán, de unas jornadas de tipografía inventadas que se celebran en Girona. La cubierta dibuja la ele geminada mayúscula, L·L, con tres módulos de color sobre negro. Cada jornada se abre con una banda negra de 76 mm y el día del mes en una cifra de 144 pt. La parrilla del jueves es una tabla. La conferencia inaugural ocupa una celda negra que abarca las tres salas, y cada taller, dos franjas horarias. Las partes del día encabezan sus filas a todo lo ancho, y cada ponencia es una pieza del color de su itinerario; las muestras de color del pie hacen de leyenda. La parrilla no cabe en una página, así que llena la 3 y termina en la 4, bajo su fila de cabecera repetida. La del viernes, más corta, cabe entera al pie de su página de apertura.

**Esta receta responde a:**

- ¿Cómo combino las celdas de una parrilla para que una plenaria ocupe todas las salas y un taller dos franjas?
- ¿Cómo parto una tabla larga entre páginas con la cabecera repetida y un aviso de «continúa»?
- ¿Cómo añado muestras de color como leyenda en el texto, los pies o las notas de tabla?
- ¿Cómo consigo las etiquetas «Figura» y «Tabla» en el idioma de mi documento?
- ¿Cómo hago una apertura de capítulo con una banda de color a sangre, un número de capítulo grande y el título?

## La respuesta corta

```js
// script.js, líneas 33–66
const ROOMS = ['Sala Gran', 'Sala de les Premses', 'Aula Taller'];
const TRACKS = { T: 'tipo', D: 'digital', E: 'edicio', '*': 'ink' }; // '*': a plenary
const at = (row, c) => ({ row, col: c });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const lines = (code, text) => text.split(' / ').map((line, i) => { // title, then speaker
  const run = i === 0 ? `**${line}**` : line;
  return code === '*' ? chip(run, 'blanc') : run; // paper-white type on the ink fill
}).join('\n'); // a line break in a cell starts a new paragraph
function schedule(text) { // '11.15 | T Title / Speaker | ^' · a bare line: a part of the day
  let m = { headerRowCount: 1, columnWidths: [12, 42, 42, 42], // weights of the 138 mm measure
    rows: [['Hora', ...ROOMS].map((room) => ({ content: room.toUpperCase(), isHeader: true }))] };
  for (const line of text.trim().split('\n')) {
    const [time, ...slots] = line.split(' | ');
    const r = m.rows.push(['', ...ROOMS].map(() => ({ content: '' }))) - 1; // four empty cells
    if (!slots.length) { // one cell across the table; a split keeps it with the rows it heads
      m = setCellContent(m, at(r, 0), chip(time.toUpperCase(), 'franja'));
      m = mergeCells(m, span(r, 0, r, 3));
      continue;
    }
    m = setCellContent(m, at(r, 0), chip(time, 'hora'));
    slots.forEach((slot, i) => {
      if (slot === '^') { // the session above runs on: one cell down both rows, centred in them
        m = mergeCells(m, span(r - 1, i + 1, r, i + 1));
        m = setAlignment(m, at(r - 1, i + 1), 'left', 'middle');
        return;
      }
      const [, code = '', body = slot] = /^([TDE*]) (.+)$/.exec(slot) ?? [];
      m = setCellContent(m, at(r, i + 1), lines(code, body));
      m = setCellBackground(m, at(r, i + 1), col(TRACKS[code] ?? 'pause'));
    });
    if (slots.length === 1) m = mergeCells(m, span(r, 1, r, 3)); // the same for all three rooms
  }
  return m; // mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby)
}
```

## Ingredientes

**Enseña**

- [Tablas a partir de datos](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Tablas como recursos con filas de cabecera, celdas combinadas, proporciones de columna, alineación por celda y listas dentro de las celdas; las tablas con barras no se interpretan.
- [Tablas que pasan de página](https://postext.dev/es/docs/configuration.md#tablas-más-altas-que-la-página): Las tablas largas se parten entre filas con la cabecera repetida, «(cont.)» en el pie y un aviso de «Continúa», nunca dentro de un rowspan.
- [Rellenos de celda](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita): Un fondo por celda enlazado a la paleta, para mapas de calor, matrices de compatibilidad y filas alternas hechas a mano.

**También usa**

- [Muestras de color](https://postext.dev/es/docs/document-format.md#formato-en-línea)
- [Chips en línea](https://postext.dev/es/docs/configuration.md#estilos-de-chip)
- [Colocación de figuras](https://postext.dev/es/docs/document-format.md#colocación)
- [Estilo de tablas](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)
- [Estilo de los pies](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso)
- [Aperturas diseñadas](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Banda de capítulo a todo el ancho](https://postext.dev/es/docs/configuration.md#span-y-diseño-avanzado)
- [Atributos de título](https://postext.dev/es/docs/document-format.md#atributos-de-encabezado)
- [Estilos de título](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Cubiertas, portadas y colofones](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Figura y Tabla en tu idioma](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Tipos de recurso propios](https://postext.dev/es/docs/configuration.md#tipos-de-recurso)
- [Estilos de párrafo](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Saltos de página y de columna](https://postext.dev/es/docs/document-format.md#pagebreak)
- [Cabeceras y folios](https://postext.dev/es/docs/configuration.md#encabezados-y-pies)
- [Paleta de color semántica](https://postext.dev/es/docs/configuration.md#paleta-de-colores)
- [Bibliografías y glosarios](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo)
- [Citas que colocan las figuras](https://postext.dev/es/docs/document-format.md#referencia-en-línea-la-forma-principal)
- [Cabeceras según el tipo de página](https://postext.dev/es/docs/configuration.md#elementos-de-texto)
- [Figuras y tablas como recursos](https://postext.dev/es/docs/document-format.md#recursos)
- [Cabeceras por sección](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)
- [Capítulos sin número](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado)

**La configuración de un vistazo**

- [`bodyText`](https://postext.dev/es/docs/configuration.md#texto-de-cuerpo), [`captionStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-pies-de-recurso), [`chipStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-chip), [`colorPalette`](https://postext.dev/es/docs/configuration.md#paleta-de-colores), [`footer`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`header`](https://postext.dev/es/docs/configuration.md#encabezados-y-pies), [`headingStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-encabezado), [`headings`](https://postext.dev/es/docs/configuration.md#encabezados), [`layout`](https://postext.dev/es/docs/configuration.md#disposición), [`page`](https://postext.dev/es/docs/configuration.md#página), [`paragraphStyles`](https://postext.dev/es/docs/configuration.md#estilos-de-párrafo), [`resourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`tableStyle`](https://postext.dev/es/docs/configuration.md#estilo-de-tablas)

**API**

- [`buildDocument`](https://postext.dev/es/docs/configuration.md#construir-un-documento), [`clearMeasurementCache`](https://postext.dev/es/docs/configuration.md#caché-de-medidas), [`defaultResourceTypes`](https://postext.dev/es/docs/configuration.md#tipos-de-recurso), [`mergeCells`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`registerResourceImage`](https://postext.dev/es/docs/architecture.md#superficie-de-api), [`renderPageToCanvas`](https://postext.dev/es/docs/configuration.md#renderizar-una-página-a-un-bitmap), [`setAlignment`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), [`setCellBackground`](https://postext.dev/es/docs/document-format.md#inserción-en-bloque-opcional-colocación-en-línea-explícita), `setCellContent`

**Tipografías**

- Schibsted Grotesk (OFL-1.1), Unbounded (OFL-1.1), Chivo Mono (OFL-1.1)

## Elaboración

### 1 · Convierte cada línea del programa en una fila

El código es [la respuesta corta](#la-respuesta-corta) de arriba. Cada jornada es texto plano en un archivo de contenido, con una línea por franja horaria: `|` separa las salas, una letra indica el itinerario, `*` marca una plenaria sobre relleno negro, `/` abre cada línea que sigue al título y `^` prolonga la sesión de la franja anterior. Todas las combinaciones salen de `mergeCells`. Una línea con una sola sesión, como la plenaria o una pausa, ocupa las tres columnas de sala. Un taller ocupa su celda y la de debajo, y una parte del día (MATÍ, TARDA), que se escribe como una línea sin `|`, las cuatro columnas. Las celdas cubiertas siguen en la cuadrícula, marcadas con `hiddenBy`, porque la columna de cada celda es su posición en la fila ([Construir modelos de tabla](/es/docs/configuration#construir-modelos-de-tabla)). `headerRowCount: 1` convierte los nombres de las salas en la fila de cabecera. Los `columnWidths` son pesos relativos, y 12 + 3 × 42 suman los 138 mm del ancho de caja, así que la columna de la hora mide 12 mm y cada sala 42. `setAlignment(…, 'left', 'middle')` centra cada taller en vertical en sus dos filas.

### 2 · Deja que el jueves continúe y el viernes quede entero

```js
// script.js, líneas 70–83
const CATALAN = { // 1.4.1 names types in English and Spanish only (gotcha: resource-types-locale)
  figure: { name: 'Figura', namePlural: 'Figures', shortLabel: 'fig.', captionPrefix: 'Figura' },
  table: { name: 'Taula', namePlural: 'Taules', shortLabel: 'taula', captionPrefix: 'Taula',
    captionStyle: { position: 'above' } },
};
const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type, ...CATALAN[type.id],
  numberingTemplate: '{n}', resetOn: 'never' })); // Taula 1, Taula 2: one count, not per day
const SPLIT = { overflow: 'split', // the default; the other values are 'clip' and 'hide'
  continuedSuffix: '(continuació)', // after the caption of every part but the first
  continuesMarker: 'Continua a la pàgina següent' }; // under every part but the last
// Thursday is cited on page 2 and placed at the top, so it opens page 3 (gotcha:
// top-float-next-page) and splits; Friday goes whole to the foot of the page that cites it.
const PLACE = { dijous: { position: 'top', span: 'page' },
  divendres: { position: 'bottom', span: 'page' } };
```

La parrilla del jueves se cita en la página 2 y va en la cabeza de una página. Un flotante nunca sube por encima de su referencia, así que la parrilla abre la página 3. No cabe en ella, y `overflow: 'split'` la corta entre dos filas, nunca dentro de una celda combinada ([Tablas más altas que la página](/es/docs/configuration#tablas-más-altas-que-la-página)). La página 3 acaba con la fila de las 17.30 y el aviso debajo. La 4 repite la fila de cabecera y el pie completo, con la leyenda de colores, seguido de `(continuació)`, y la nota solo aparece bajo esta última parte. La del viernes va al pie de una página y cabe bajo el texto que la cita en la página 5. Postext 1.4.1 solo trae en inglés y en español los nombres de los tipos de recurso y estos dos textos, así que las palabras catalanas, plurales incluidos, se escriben sobre `defaultResourceTypes('ca')` y en el estilo de tabla. `numberingTemplate: '{n}'` con `resetOn: 'never'` numera las dos parrillas en una sola serie: Taula 1 y Taula 2.

### 3 · Corta los rellenos en piezas

```js
// script.js, líneas 87–96
const tableStyle = { ...SPLIT, borderColor: col('paper'), borderWidth: pt(1.6), // grid rules
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(7.5), bodyFontSize: pt(8.2),
  cellPadding: mm(1.5) };
const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), ...extra }); // a chip that only changes the face, the size or the colour
const chipStyles = [bare('blanc', { color: col('paper') }), // the plenary's type, on ink
  bare('hora', { fontFamily: LABEL, fontSize: em(0.96), bold: true }),
  bare('franja', { fontFamily: LABEL, fontSize: em(0.92), bold: true, color: col('accent') }),
  bare('sala')]; // keeps each room of the plan's note on one line (gotcha: nbsp-breaks)
```

`setCellBackground`, en la respuesta corta, da a cada sesión el color de la paleta de su itinerario, y unos filetes de 1,6 pt del color del papel cortan los rellenos en piezas separadas por líneas blancas ([Estilo de tablas](/es/docs/configuration#estilo-de-tablas)). Una tabla tiene un solo color de texto para las celdas del cuerpo, así que el blanco de la plenaria es un chip cuyo estilo fija el color y quita el relleno, el borde y el margen lateral del chip. Las horas y las partes del día también son chips, en Chivo Mono ([Estilos de chip](/es/docs/configuration#estilos-de-chip)). Un chip nunca se parte entre dos líneas; el título de una plenaria y la línea de su ponente caben cada uno en una línea de la celda de tres salas. En la nota del plano, el chip `sala` deja el texto tal cual y mantiene en una línea el número y el nombre de cada sala, cosa que en 1.4.1 no consigue un espacio de no separación.

### 4 · Abre cada jornada con una banda

```js
// script.js, líneas 100–124
const BAND = 76, STRIP = 8, AIR = 7; // mm: band from the trim, its strip of modules, air below
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left',
  overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const caps = (size) => ({ fontWeight: 500, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) });
const NUMERAL = 144; // pt
// mm: Unbounded 800's 1 at 144 pt starts its ink 0.76 mm into its box, the date's first capital
// at 8 pt 0.1 to 0.2 mm into its own (P 0.19, S 0.09): 0.6 mm to the left lines up the two inks
const BEARING = 0.6;
const BASELINE = 0.8; // a design line's baseline sits 0.8 down its box (lineHeight 1)
const opener = { enabled: true, minHeight: mm(BAND - PAGE.top + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
    placement: pin('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'strip', resourceId: 'strip', placement: pin('page', 'top-left', 0,
    BAND - STRIP, { width: mm(PAGE.w), height: mm(STRIP) }) }, // on the band's foot
  text('date', '{attr.date}', LABEL, 8, 'edicio', pin('container', 'top-left', 0, 0), caps(8)),
  text('numeral', '{attr.day}', DISPLAY, NUMERAL, 'paper', pin('#date', 'below', -BEARING, 1),
    { fontWeight: 800, lineHeight: 1 }), // a multiple (gotcha: design-lineheight-multiple)
  text('title', '{titleText}', DISPLAY, 26, 'paper', // its baseline level with the numeral's
    pin('#numeral', 'right-of', 5, BASELINE * (NUMERAL - 26) * PT),
    { fontWeight: 700, lineHeight: 1 }),
] } };
```

El diseño del título de primer nivel dibuja la banda: una caja de 76 mm de alto desde el borde de sangre, con la franja de módulos a lo largo de su pie. La fecha sale del atributo `date` del título, y la cifra, de `day`. El trazo del 1 de Unbounded 800 a 144 pt empieza 0,76 mm dentro de su caja, y el de la primera mayúscula de la fecha, entre 0,1 y 0,2 mm dentro de la suya; por eso la cifra se desplaza 0,6 mm a la izquierda, y la fecha, la cifra y el texto empiezan su trazo a menos de 0,2 mm unos de otros. El nombre del día va a la derecha de la cifra, bajado `BASELINE` × (144 − 26) pt para que las dos líneas de base coincidan, porque en un texto de diseño con `lineHeight: 1` la línea de base queda a 0,8 de la altura de su caja. `minHeight` reserva la página hasta 7 mm por debajo de la banda, y la rejilla base redondea esa reserva hacia arriba hasta 14 líneas, así que la caja de la primera línea de texto empieza 10,2 mm bajo la banda. Con `AIR = 0` manda la caja de la propia cifra, que baja más allá del pie de la banda, y el texto empieza 5,6 mm bajo la banda.

### 5 · Sangra las notas de los ponentes

```js
// script.js, líneas 128–132
const paragraphStyles = [
  { id: 'bio', hangingIndent: mm(4) }, // the name at the column edge, the lines under it 4 mm in
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.4), lineHeight: pt(LEAD * 0.75),
    textAlign: 'left', color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
```

Cada ponente es un párrafo de un bloque `:::paragraphs{style="bio"}`. El nombre en negrita empieza en el borde de la columna y las líneas siguientes entran 4 mm, en sangría francesa, así que los nombres quedan alineados columna abajo, como en un índice ([Estilos de párrafo](/es/docs/configuration#estilos-de-párrafo)). El texto va en bandera, un ajuste en el que 1.4.1 no evita las últimas líneas de una sola palabra, así que cada nota se reescribió hasta que su última línea tuvo al menos dos.

## 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/conference-programme

### script.js

```js
// ═══ Postext Cookbook · Nº 053 · Conference programme with a merged schedule grid ═══════
// https://postext.dev/en/cookbook/conference-programme
// Code: MIT · Text: original, in Catalan (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Schibsted Grotesk, Unbounded, Chivo Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, mergeCells, setCellContent, setCellBackground, setAlignment,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1d1b24', paper: '#ffffff', // a violet near-black; type on the ink fills
  tipo: '#b09cf2', digital: '#4fc7bb', edicio: '#f4b43a', // the three tracks: fills and swatches
  pause: '#ecebf1', // registration, breaks and meals
  accent: '#6a3ed3', // kickers, caption labels, the parts of the day
  muted: '#67636f', // running heads and notes
};
// 1.4.1 paints design slots from the hex, not the id: col() writes both
// (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) // defaults' id
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Schibsted Grotesk', 'Unbounded', 'Chivo Mono'];
const PAGE = { w: 170, h: 240, top: 22, bottom: 20, inner: 17, outer: 15 }; // mm, mirrored
const LEAD = 13; // pt: the body's leading and baseline grid
const PT = 25.4 / 72; // mm in a point
const chip = (text, style) => `:chip[${text}]{style="${style}"}`;

// #region answer: a day's schedule as a table with merged cells, track fills and a head row
const ROOMS = ['Sala Gran', 'Sala de les Premses', 'Aula Taller'];
const TRACKS = { T: 'tipo', D: 'digital', E: 'edicio', '*': 'ink' }; // '*': a plenary
const at = (row, c) => ({ row, col: c });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const lines = (code, text) => text.split(' / ').map((line, i) => { // title, then speaker
  const run = i === 0 ? `**${line}**` : line;
  return code === '*' ? chip(run, 'blanc') : run; // paper-white type on the ink fill
}).join('\n'); // a line break in a cell starts a new paragraph
function schedule(text) { // '11.15 | T Title / Speaker | ^' · a bare line: a part of the day
  let m = { headerRowCount: 1, columnWidths: [12, 42, 42, 42], // weights of the 138 mm measure
    rows: [['Hora', ...ROOMS].map((room) => ({ content: room.toUpperCase(), isHeader: true }))] };
  for (const line of text.trim().split('\n')) {
    const [time, ...slots] = line.split(' | ');
    const r = m.rows.push(['', ...ROOMS].map(() => ({ content: '' }))) - 1; // four empty cells
    if (!slots.length) { // one cell across the table; a split keeps it with the rows it heads
      m = setCellContent(m, at(r, 0), chip(time.toUpperCase(), 'franja'));
      m = mergeCells(m, span(r, 0, r, 3));
      continue;
    }
    m = setCellContent(m, at(r, 0), chip(time, 'hora'));
    slots.forEach((slot, i) => {
      if (slot === '^') { // the session above runs on: one cell down both rows, centred in them
        m = mergeCells(m, span(r - 1, i + 1, r, i + 1));
        m = setAlignment(m, at(r - 1, i + 1), 'left', 'middle');
        return;
      }
      const [, code = '', body = slot] = /^([TDE*]) (.+)$/.exec(slot) ?? [];
      m = setCellContent(m, at(r, i + 1), lines(code, body));
      m = setCellBackground(m, at(r, i + 1), col(TRACKS[code] ?? 'pause'));
    });
    if (slots.length === 1) m = mergeCells(m, span(r, 1, r, 3)); // the same for all three rooms
  }
  return m; // mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby)
}
// #endregion

// #region split: where each day's grid lands and how it continues, labelled in Catalan
const CATALAN = { // 1.4.1 names types in English and Spanish only (gotcha: resource-types-locale)
  figure: { name: 'Figura', namePlural: 'Figures', shortLabel: 'fig.', captionPrefix: 'Figura' },
  table: { name: 'Taula', namePlural: 'Taules', shortLabel: 'taula', captionPrefix: 'Taula',
    captionStyle: { position: 'above' } },
};
const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type, ...CATALAN[type.id],
  numberingTemplate: '{n}', resetOn: 'never' })); // Taula 1, Taula 2: one count, not per day
const SPLIT = { overflow: 'split', // the default; the other values are 'clip' and 'hide'
  continuedSuffix: '(continuació)', // after the caption of every part but the first
  continuesMarker: 'Continua a la pàgina següent' }; // under every part but the last
// Thursday is cited on page 2 and placed at the top, so it opens page 3 (gotcha:
// top-float-next-page) and splits; Friday goes whole to the foot of the page that cites it.
const PLACE = { dijous: { position: 'top', span: 'page' },
  divendres: { position: 'bottom', span: 'page' } };
// #endregion

// #region tiles: paper-coloured rules cut the fills into tiles; chips set the times and heads
const tableStyle = { ...SPLIT, borderColor: col('paper'), borderWidth: pt(1.6), // grid rules
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: LABEL,
  headerFontSize: pt(7.5), bodyFontSize: pt(8.2),
  cellPadding: mm(1.5) };
const bare = (id, extra) => ({ id, backgroundEnabled: false, borderWidth: pt(0),
  paddingX: pt(0), ...extra }); // a chip that only changes the face, the size or the colour
const chipStyles = [bare('blanc', { color: col('paper') }), // the plenary's type, on ink
  bare('hora', { fontFamily: LABEL, fontSize: em(0.96), bold: true }),
  bare('franja', { fontFamily: LABEL, fontSize: em(0.92), bold: true, color: col('accent') }),
  bare('sala')]; // keeps each room of the plan's note on one line (gotcha: nbsp-breaks)
// #endregion

// #region opener: an ink band per day with the date, a 144 pt numeral and the day's name
const BAND = 76, STRIP = 8, AIR = 7; // mm: band from the trim, its strip of modules, air below
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left',
  overflow: 'wrap', ...extra }); // not '…' at the edge (gotcha: overflow-ellipsis-default)
const pin = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const caps = (size) => ({ fontWeight: 500, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) });
const NUMERAL = 144; // pt
// mm: Unbounded 800's 1 at 144 pt starts its ink 0.76 mm into its box, the date's first capital
// at 8 pt 0.1 to 0.2 mm into its own (P 0.19, S 0.09): 0.6 mm to the left lines up the two inks
const BEARING = 0.6;
const BASELINE = 0.8; // a design line's baseline sits 0.8 down its box (lineHeight 1)
const opener = { enabled: true, minHeight: mm(BAND - PAGE.top + AIR), slot: { elements: [
  { kind: 'box', id: 'band', style: { backgroundColor: col('ink') },
    placement: pin('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(BAND) }) },
  { kind: 'image', id: 'strip', resourceId: 'strip', placement: pin('page', 'top-left', 0,
    BAND - STRIP, { width: mm(PAGE.w), height: mm(STRIP) }) }, // on the band's foot
  text('date', '{attr.date}', LABEL, 8, 'edicio', pin('container', 'top-left', 0, 0), caps(8)),
  text('numeral', '{attr.day}', DISPLAY, NUMERAL, 'paper', pin('#date', 'below', -BEARING, 1),
    { fontWeight: 800, lineHeight: 1 }), // a multiple (gotcha: design-lineheight-multiple)
  text('title', '{titleText}', DISPLAY, 26, 'paper', // its baseline level with the numeral's
    pin('#numeral', 'right-of', 5, BASELINE * (NUMERAL - 26) * PT),
    { fontWeight: 700, lineHeight: 1 }),
] } };
// #endregion

// #region bios: a hanging indent for the speakers' notes
const paragraphStyles = [
  { id: 'bio', hangingIndent: mm(4) }, // the name at the column edge, the lines under it 4 mm in
  { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.4), lineHeight: pt(LEAD * 0.75),
    textAlign: 'left', color: col('muted'), firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

const head = (id, content, parity, x, extra) => text(id, content, LABEL, 7.5, 'muted',
  pin('page', x > 0 ? 'top-left' : 'top-right', x, 11), { ...caps(7.5), parity, pages: 'body',
    ...extra }); // body pages only: the cover and the openers have none
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', PAGE.outer, folio),
  head('verso-title', '{title}', 'even', PAGE.outer + 8),
  head('recto-title', '{chapterTitle} {attr.day} de novembre', 'odd', -(PAGE.outer + 8)),
  head('recto-folio', '{pageNumber}', 'odd', -PAGE.outer, folio)] };
const footer = { elements: [text('drop-folio', '{pageNumber}', LABEL, 7.5, 'ink',
  pin('page', 'bottom', 0, -11), { ...folio, pages: 'opener', align: 'center' })] };

// The cover is a heading style: an ink page with the L·L of modules and the title. The next
// heading breaks the page, so the cover needs no :::pagebreak after it.
const coverStyle = { id: 'coberta', numbered: false,
  header: { elements: [] }, footer: { elements: [] }, // no running heads on the cover
  advancedDesign: { enabled: true, minHeight: mm(PAGE.h - PAGE.top - PAGE.bottom), slot: {
    elements: [
      { kind: 'box', id: 'field', style: { backgroundColor: col('ink') },
        placement: pin('bleed', 'top-left', 0, 0, { width: 'fill', height: mm(PAGE.h) }) },
      { kind: 'image', id: 'modules', resourceId: 'modules',
        placement: pin('page', 'top-left', 0, 0, { width: mm(PAGE.w), height: mm(142) }) },
      text('kicker', '{attr.kicker}', LABEL, 8.5, 'edicio', pin('page', 'top-left', 17, 162),
        caps(8.5)),
      text('name', '{titleText}', DISPLAY, 31, 'paper', pin('#kicker', 'below', -0.8, 3,
        { width: mm(138) }), { fontWeight: 700, lineHeight: 1.08 }),
      text('dates', '{attr.dates}', DISPLAY, 17, 'tipo', pin('#name', 'below', 0, 6),
        { fontWeight: 700, lineHeight: 1 }),
      text('place', '{attr.place}', LABEL, 8.5, 'paper', pin('#dates', 'below', 0.8, 3),
        caps(8.5)),
    ] } } };

const config = () => ({ // a factory: configs are cached by identity
  // (gotcha: config-cache-identity)
  resourceTypes, colorPalette, tableStyle, chipStyles, paragraphStyles,
  header, footer, headingStyles: [coverStyle],
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, // a 1,004 px canvas per page
    margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // ragged, spaced
  headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: opener, marginBottom: pt(0) },
    { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(LEAD / 2) },
  ] },
  captionStyle: { fontFamily: LABEL, fontSize: pt(7.6), labelColor: col('accent'), gap: mm(2),
    note: { fontSize: pt(7.4), color: col('muted') } },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "7es Jornades de Tipografia i Edició Digital"
author: "Comitè organitzador de les Jornades"
---

# Jornades de Tipografia i Edició Digital {style="coberta" kicker="7es Jornades · Programa" dates="12–13.11.2026" place="Girona · La Impremta, Barri Vell"}

# Dijous {day="12" date="Primera jornada · 12 de novembre"}

La Impremta, l’antiga fàbrica de llibretes del Barri Vell, torna a acollir les Jornades. Hi ha ponències a la Sala de les Premses, on encara funciona la minerva de 1908, i a la Sala Gran, la nau on hi havia les guillotines. Els tallers ocupen l’Aula Taller, al primer pis. El plànol de l’última pàgina situa les sales i l’escala.

L’acreditació obre a les 9.00 al vestíbul. La tarja dona accés a totes les sessions; els tallers, de vint places cadascun, demanen inscripció al mateix taulell fins a mitja hora abans de començar. Les ponències de la Sala Gran es tradueixen a l’anglès: demaneu els auriculars al taulell.

La :ref{id="dijous" style="full" case="lower"} recull el programa del dia, franja a franja. Cada sessió porta el color del seu itinerari: :swatch{color="tipo"} Tipografia, :swatch{color="digital"} Digital o :swatch{color="edicio"} Edició. Els tallers de l’Aula Taller ocupen dues franges seguides. La conferència inaugural, en negre, no coincideix amb cap altra sessió.

## Qui parla dijous

:::paragraphs{style="bio"}
**Marta Casadevall** (Barcelona, 1971) dibuixa lletres des del 1998. Ha fet famílies de text per a tres diaris en català i, amb la Galligants, de 2021, va proposar un glif propi per a la ela geminada. Obre les Jornades.

**Pere Ametller** (Vic, 1965) és filòleg i estudia com han compost el punt volat les impremtes catalanes des de les Normes ortogràfiques de 1913. Prepara una història gràfica de l’ortografia.

**Laia Pujolàs** (Girona, 1983) dirigeix la correcció d’Edicions Llumeneres, on ha passat tot el procés d’edició a XML. Explicarà què ha guanyat la correcció d’estil i què hi ha perdut.

**Quim Serrallonga** (Olot, 1958) va treballar trenta anys de caixista i ara ensenya composició manual a La Impremta. Al seu taller cada participant compon i estampa una targeta amb tipus de plom.

**Irene Bosch** (València, 1989) programa interfícies per a una agència de notícies i fa servir fonts variables per ajustar l’amplada dels titulars a cada pantalla, del rellotge al televisor.

**Arnau Vidal Roca** (Manresa, 1977) publica llibres de butxaca amb tirades de tres-cents exemplars i impressió digital. Ensenyarà els comptes de deu anys d’editorial, títol per títol.

**Sílvia Ferrer** (Lleida, 1980) assessora editorials en accessibilitat. Des que la directiva europea s’aplica als llibres electrònics, el juny de 2025, revisa fitxers EPUB per a segells petits. Modera la taula rodona.

**Jordi Baulenas** (Terrassa, 1969) aplega mostraris de les foneries catalanes i estudia el Supertipo Veloz, els mòduls que Joan Trochut va publicar el 1942.

**Clara Tió** (Sabadell, 1986) fa fonts per encàrrec i ensenya a editors i maquetistes a treure profit de les taules OpenType: lligadures, versaletes, xifres elzevirianes i formes locals.

**Nil Garriga** (Reus, 1992) treballa en un motor de maquetació per a navegadors. Explicarà per què el guionet de final de ratlla no parteix igual les paraules en català, en castellà i en anglès.

**Ada Rigau** (Barcelona, 1990) dissenya lletres grotesques. Ha dibuixat els accents, la ce trencada i la ela geminada d’una família de set gruixos i en portarà els esbossos.

:::columnbreak

**Rosa Fontanet** (Girona, 1975) fotografia els rètols pintats de la ciutat des del 2004 i n’ha publicat dos llibres. Fa una ponència a la tarda i guia el passeig del vespre pel Barri Vell.

**Oriol Mas** (Tarragona, 1984) compon llibres amb codi: escriu el text en Markdown i descriu la pàgina en un fitxer de configuració. Ha compost així aquest programa.
:::

## Al vespre

El passeig tipogràfic surt a les 19.00 del vestíbul i dura una hora i mitja: rètols pintats, làpides i aparadors, amb parades a la Rambla i a la plaça de l’Oli. Es fa encara que plogui; porteu calçat pla, perquè el Barri Vell és ple d’escales.

El sopar, al pati, comença a les 21.00. Costa 28 euros, amb menú vegetarià per a qui el demani, i cal reservar-lo al taulell d’acreditació abans de les 14.00. Si plou, es fa a la Sala Gran.

# Divendres {day="13" date="Segona jornada · 13 de novembre"}

Divendres és mitja jornada: la cloenda acaba a les 14.00, a temps per als trens de la tarda. La :ref{id="divendres" style="full" case="lower"} fa servir els mateixos colors que la de dijous, i el taller de pinzell ocupa dues franges. L’acreditació obre a les 9.00; qui ja té la tarja pot anar directament a la Sala Gran (:ref{id="planol" style="full" case="lower"}).

## Qui parla divendres

:::paragraphs{style="bio"}
**Ingrid Aalto** (Hèlsinki, 1968) dissenya fonts per a les llengües del nord d’Europa i ha estudiat com llegim en pantalla les llengües amb molts diacrítics. Parla en anglès, amb traducció.

**Pau Estrany** (Palma, 1987) escriu programari de composició i explicarà com mesura el text un navegador, de la mètrica de la font fins a la línia.

**Montse Garcia** (Badalona, 1972) és bibliotecària i coordina el dipòsit de llibres digitals d’una xarxa de biblioteques públiques, que ja en conserva més de dotze mil.

**Joana Pla** (Figueres, 1981) és retolista. Al seu taller s’hi pinta amb pinzell pla sobre paper d’embalar: majúscules romanes primer, cursiva després.

**Toni Masó** (Alcoi, 1966) ha digitalitzat quatre tipus de plom d’una foneria valenciana a partir del mostrari de 1920. Portarà les proves de cada gruix, impreses en tres cossos.

**Berta Soms** (Mataró, 1990) prepara PDF etiquetats per a lectors de pantalla i ensenya a revisar-los.

**Jaume Rovira** (Girona, 1979) és editor i publica els preus de fabricació de cada llibre de la seva editorial.

**Núria Solà** (Vilafranca del Penedès, 1985) ajusta l’espaiat de fonts per a revistes. Parlarà de l’apòstrof: *l’*, *d’*, *s’hi*.
:::

## Com arribar

La Impremta és al Barri Vell, a un quart d’hora a peu de les estacions de tren i d’autobusos; des del pont de Pedra, seguiu els senyals de les Jornades. La :ref{id="planol" style="full" case="lower"} situa les sales. L’entrada és pel vestíbul, on hi ha l’acreditació i el guarda-roba; el pati fa de menjador a les pauses.

:::paragraphs{style="colophon"}
Programa compost amb Postext en Schibsted Grotesk, Unbounded i Chivo Mono (SIL OFL 1.1). Text i dibuixos: CC BY 4.0. Les Jornades, La Impremta i les persones d’aquest programa són inventades.
:::
`; // the programme's text, in Catalan
const dijous = String.raw`Matí
9.00 | Acreditació i cafè de benvinguda / Vestíbul
9.45 | * Una llengua amb punt volat / Marta Casadevall · conferència inaugural
10.45 | Pausa / Pati
11.15 | T El punt volat, de 1913 a Unicode / Pere Ametller | E Corregir dins d’un flux XML / Laia Pujolàs | T Taller: compondre a mà amb tipus de plom / Quim Serrallonga / 11.15–12.45 · 20 places
12.00 | D Fonts variables, del rellotge al televisor / Irene Bosch | E Tirades de tres-cents exemplars / Arnau Vidal Roca | ^
Migdia
12.45 | Dinar lliure als restaurants del Barri Vell
Tarda
14.30 | E EPUB accessible: què demana la llei / Sílvia Ferrer | T El Supertipo Veloz de Trochut / Jordi Baulenas | D Taller: OpenType per a editors / Clara Tió / 14.30–16.00 · 20 places
15.15 | D El guionet de final de ratlla / Nil Garriga | T Accents, ce trencada i ela geminada / Ada Rigau | ^
16.00 | Pausa / Pati
16.30 | E Taula rodona: editar en català / Sílvia Ferrer, amb Laia Pujolàs i Arnau Vidal Roca | T Rètols pintats de Girona / Rosa Fontanet | D Del Markdown a la pàgina / Oriol Mas
17.30 | E Presentació: la col·lecció Lletra Petita / Arnau Vidal Roca | D Demostració: eixos variables en directe / Irene Bosch | T Visita a la minerva de 1908 / Quim Serrallonga
Vespre
19.00 | T Passeig tipogràfic pel Barri Vell / Rosa Fontanet · sortida del vestíbul
21.00 | Sopar de les Jornades / Al pati · amb inscripció prèvia
`; // Thursday's sessions, one line per time slot
const divendres = String.raw`Matí
9.30 | * El català a la pantalla, vist de fora / Ingrid Aalto · en anglès, amb traducció
10.30 | D Com mesura el text un navegador / Pau Estrany | E El dipòsit dels llibres digitals / Montse Garcia | T Taller: majúscules amb pinzell pla / Joana Pla / 10.30–12.00 · 20 places
11.15 | T Del plom al fitxer variable / Toni Masó | D PDF per a lectors de pantalla / Berta Soms | ^
12.00 | Pausa / Pati
12.30 | E Què costa fer un llibre / Jaume Rovira | T L’apòstrof i l’espaiat / Núria Solà | T Taller obert de composició / Quim Serrallonga
13.30 | * Cloenda / Comitè organitzador
`; // Friday's

// #region art: the cover's L·L in modules, the band's strip and the venue's floor plan
const n = (v) => +v.toFixed(2);
function mulberry32(seed) { // a seeded PRNG: the same modules in every capture
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" `
  + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const P = palette;
// One module in an s × s cell at (x, y), turned by quarter turns about the cell's centre:
// 0 a quarter disc, 1 a half disc, 2 a half-width bar, 3 a disc, 4 a square.
function module(kind, turn, x, y, s, fill) {
  const c = `transform="rotate(${turn * 90} ${n(x + s / 2)} ${n(y + s / 2)})" fill="${fill}"`;
  if (kind === 0) return `<path d="M${n(x)} ${n(y)}h${s}A${s} ${s} 0 0 1 ${n(x)} ${n(y + s)}Z" `
    + `${c}/>`;
  if (kind === 1) return `<path d="M${n(x)} ${n(y + s)}A${s / 2} ${s / 2} 0 0 1 ${n(x + s)} `
    + `${n(y + s)}Z" ${c}/>`;
  if (kind === 2) return `<rect x="${n(x)}" y="${n(y)}" width="${n(s / 2)}" height="${s}" ${c}/>`;
  if (kind === 3) return `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s / 2)}" `
    + `${c}/>`;
  return `<rect x="${n(x)}" y="${n(y)}" width="${s}" height="${s}" ${c}/>`;
}
// The cover: L·L, the capital ela geminada, drawn with the modules on a 26 mm grid: each L is
// a stem five cells tall with a quarter-disc foot, the punt volat a disc at mid cap height.
function coverArt(w, h, s, x0, y0) {
  const ell = (c, id) => { const x = x0 + c * s; // one path: no seam between stem and foot
    return `<path d="M${x} ${y0}h${s}v${4 * s}a${s} ${s} 0 0 1 ${s} ${s}H${x}Z" `
      + `fill="${P[id]}"/>`; };
  const dot = `<circle cx="${x0 + 2.5 * s}" cy="${y0 + 2.5 * s}" r="${n(0.36 * s)}" `
    + `fill="${P.edicio}"/>`;
  return svg(w, h, ell(0, 'tipo') + dot + ell(3, 'digital'));
}
function strip(w, s) { // the band's foot: one row of modules in the three track colours
  const rand = mulberry32(12);
  let out = '';
  for (let x = 0; x < w; x += s) {
    out += module(Math.floor(rand() * 5), Math.floor(rand() * 4), x, 0, s,
      P[['tipo', 'digital', 'edicio'][Math.floor(rand() * 3)]]);
  }
  return svg(w, s, out);
}
// The floor plan: ink walls, a tinted courtyard and the rooms numbered in discs. The digits
// are strokes, because an SVG picture cannot use the page's web fonts (gotcha:
// svg-no-webfonts); the caption's note names the rooms.
const DIGITS = { 1: 'M1.8 2.6 3.6 1V9', 2: 'M1 3A2.5 2.5 0 1 1 5.2 4.8L1 9H5.4',
  3: 'M1.2 1H5L2.8 4.2A2.6 2.6 0 1 1 1 8.2', 4: 'M4.2 9V1L.8 6.6H5.6',
  5: 'M5.2 1H1.6L1.2 4.6A2.8 2.8 0 1 1 1.2 8.6',
  6: 'M4.6 1Q1 2.2.8 6.5A2.5 2.5 0 0 0 5.8 6.5 2.5 2.5 0 0 0 .8 6.5' };
const disc = (d, x, y) => `<circle cx="${x}" cy="${y}" r="3.4" fill="${P.ink}"/>`
  + `<path d="${DIGITS[d]}" transform="translate(${n(x - 1.05)} ${n(y - 1.65)}) scale(.33)" `
  + 'fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>';
const line = (d, w, color = P.ink) => `<path d="${d}" fill="none" stroke="${color}" `
  + `stroke-width="${w}" stroke-linecap="square"/>`;
function plan() { // ground floor, 138 × 66 mm: the street is at the foot, the courtyard east
  const treads = Array.from({ length: 6 }, (_, i) => `M${77 + i * 2.2} 41V55`).join('');
  return svg(138, 66, `<rect x="2" y="2" width="96" height="36" fill="${P.pause}"/>` // rooms 1–2
    + `<rect x="98" y="2" width="38" height="56" fill="${P.digital}" fill-opacity=".18"/>`
    + `<circle cx="126" cy="14" r="6" fill="${P.digital}"/>` // two trees in the courtyard
    + `<circle cx="108" cy="48" r="4.5" fill="${P.digital}"/>`
    + line(treads, 0.35, P.muted) // the stairs, and the lift beside them
    + line('M91 42h5v12h-5zM91 42l5 12M96 42l-5 12', 0.35, P.muted)
    + line('M44 58A6 6 0 0 1 50 52M56 58A6 6 0 0 0 50 52', 0.3, P.muted) // the street doors
    + line('M44 58H2V2H98V14M98 26V58H56', 1) // outer walls, with the doors left open
    + line('M52 2V38M2 38H34M44 38H60M70 38H98M26 38V43M26 53V58M74 38V43M74 53V58', 0.6)
    + line('M98 2H136V58H98', 0.6) // the courtyard
    + `<path d="M49.3 65.5V61.2H47L50 58.6l3 2.6h-2.3v4.3Z" fill="${P.accent}"/>` // way in
    + disc(1, 27, 20) + disc(2, 75, 20) + disc(3, 83.5, 48) + disc(4, 50, 47)
    + disc(5, 14, 48) + disc(6, 117, 30));
}
// #endregion

// #region resources: the two grids, keyed by swatches in their captions, and the plan
const KEY = ':swatch{color="tipo"} Tipografia · :swatch{color="digital"} Digital · '
  + ':swatch{color="edicio"} Edició';
const table = (id, caption, model, note) => ({ id, typeId: 'table', kind: 'table',
  caption: `${caption} ${KEY}`, note, table: { model }, placement: PLACE[id],
  createdAt: 0, updatedAt: 0 });
const picture = (id, markup, w, h, extra) => ({ id, typeId: 'figure', kind: 'svg', markup,
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0,
  ...extra });
const drawings = [ // the cover and the band draw the first two by id; the plan is cited
  picture('modules', coverArt(PAGE.w, 142, 26, 20, 12), PAGE.w, 142,
    { altText: 'L·L, la ela geminada majúscula, feta de mòduls violeta, turquesa i ambre' }),
  picture('strip', strip(PAGE.w, STRIP), PAGE.w, STRIP,
    { altText: 'Una filera de mòduls de lletra en els colors dels itineraris' }),
  // Cited on page 5, under the opener, so it heads page 6 (gotcha: top-float-next-page).
  picture('planol', plan(), 138, 66, { caption: 'La Impremta, planta baixa.',
    note: ['1 Sala Gran', '2 Sala de les Premses', '3 Aula Taller, al primer pis', '4 Vestíbul',
      '5 Guarda-roba', '6 Pati'].map((room) => chip(room, 'sala')).join(' · '),
    placement: { position: 'top', span: 'page' },
    altText: 'Planta de La Impremta amb les sales numerades de l’1 al 6' }),
];
const resources = [...drawings.map(({ markup, ...r }) => r),
  table('dijous', 'Dijous 12 de novembre.', schedule(dijous), 'Les ponències duren quaranta '
    + 'minuts, més cinc de preguntes; els tallers, noranta.'),
  table('divendres', 'Divendres 13 de novembre.', schedule(divendres)),
];
for (const { svg: { fileId }, markup } of drawings) await loadSvg(fileId, markup);
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first)
  'Schibsted Grotesk': ['400', '400i', '700'], // text, bios and cells
  Unbounded: ['600', '700', '800'], // sections; titles; the day's numeral
  'Chivo Mono': ['400', '400i', '500', '700'] }; // labels, times, captions and the colophon

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, dijous, divendres].join('\n');
await loadFonts(FONTS, allText);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: 'Jornades de Tipografia i Edició Digital · Programa' });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variantes

### Redondea las esquinas de la parrilla

Una tabla partida redondea las esquinas superiores de su primera parte, en la página 3, y las inferiores de la última, en la página 4, donde solo se ve la derecha porque la columna de la hora no tiene relleno.

```diff
-  cellPadding: mm(1.5) };
+  cellPadding: mm(1.5), borderRadius: mm(2.5) };
```

### Numera cada parrilla por jornada

Si se cuenta por títulos de primer nivel, como en los tipos predefinidos, salen la Taula 1.1 y la Taula 2.1, y el plano pasa a ser la Figura 2.1; el estilo de la cubierta no se numera, así que el jueves es el título 1.

```diff
-  numberingTemplate: '{n}', resetOn: 'never' })); // Taula 1, Taula 2: one count, not per day
+  numberingTemplate: '{h1}.{n}', resetOn: 'h1' })); // Taula 1.1, Taula 2.1: per day
```

## Errores frecuentes

- **Las celdas combinadas necesitan hiddenBy: usa mergeCells.** Las celdas se colocan según su posición en la fila, así que una celda combinada necesita celdas de relleno marcadas con hiddenBy donde se extiende; omitirlas, como en HTML, desplaza todas las columnas siguientes. Combina celdas con mergeCells.
- **Un flotante 'top' nunca cae en la página que lo cita.** Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita.
- **Una tabla 'here' nunca se parte.** Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea.
- **Traduce Figura y Tabla con defaultResourceTypes(locale).** El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes.
- **En el texto en bandera no se evitan las líneas cortas.** optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta.
- **Cualquier objeto headings desactiva el salto de página del H1.** Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración.
- **Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones.** postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer.
- **El lineHeight de un texto de diseño es un múltiplo, nunca una medida.** En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título.
- **El desbordamiento del texto de diseño es 'ellipsis-end' por defecto.** Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas.
- **El texto dentro de un SVG <img> no puede usar fuentes web.** Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie.
- **Un espacio de no separación sigue partiendo la línea.** En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase.
- **Una configuración se cachea por identidad: crea un objeto nuevo.** El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config().
- **Carga todas las fuentes antes de componer.** La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde.

- Las columnas de la página 4 se igualan a mano, con un `:::columnbreak` antes de la nota de Rosa Fontanet. Postext 1.4.1 no iguala la última página de un capítulo cuando la abre el resto de una tabla partida, así que la primera columna llega al pie y la segunda se queda corta. Mueve el salto cada vez que cambie el texto del jueves.

## Créditos

- Receta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texto: El texto catalán del programa, con las dos parrillas, escrito para esta receta, y los dibujos de la cubierta, la banda y el plano, generados en código: Ignacio Ferro, CC-BY-4.0
- Tipografías: Schibsted Grotesk (OFL-1.1), Unbounded (OFL-1.1), Chivo Mono (OFL-1.1)
- Código: MIT · Contenido de ejemplo: CC-BY-4.0

## Relacionadas

- [N.º 035 · Almanaque del huerto: calendario y gráfico apaisado](https://postext.dev/es/cookbook/garden-almanac.md): Un gráfico de siembras apaisado en dos páginas, un calendario calculado con fechas y una matriz de cultivos asociados, coloreados con la paleta. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
- [N.º 036 · Catálogo de venta por correo con imágenes en las celdas](https://postext.dev/es/cookbook/seed-catalogue.md): Un catálogo de semillas cuya lista de precios, una tabla leída de un TSV, lleva un sobre dibujado en la celda de cada variedad y se parte entre dos páginas. · Nivel 3 (Avanzado) · Catálogos
- [N.º 010 · Hoja técnica: tablas de datos con cabeceras combinadas](https://postext.dev/es/cookbook/technical-datasheet.md): Tablas pegadas como TSV, leídas con parseTSV y ajustadas con mergeCells, setAlignment y setCellBackground; un mapa de registros que se parte solo. · Nivel 3 (Avanzado) · Manuales, guías y obras de consulta
