# Programa de congrés amb una graella de cel·les combinades

> Un programa de dues jornades: mergeCells estén la plenària per les tres sales i els tallers per dues franges, i cada ponència porta el color del seu itinerari.

- Versió HTML: https://postext.dev/ca/cookbook/conference-programme
- Recepta Núm. 053 · Taules · Nivell 3 (Avançat) · Sortides: Canvas
- Gèneres: Fulls solts i efímers
- Requereix postext ≥ 1.4.1 · provada amb 1.4.1 el 2026-09-26
- Pàgines: [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)
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=conference-programme&lang=es (.postext: https://postext.dev/cookbook/conference-programme/es/conference-programme.postext)
- Última actualització: 2026-09-27
- Altres idiomes: [en](https://postext.dev/en/cookbook/conference-programme.md), [es](https://postext.dev/es/cookbook/conference-programme.md), [zh](https://postext.dev/zh/cookbook/conference-programme.md), [ar](https://postext.dev/ar/cookbook/conference-programme.md)

## En poques paraules

El programa imprès d'un congrés de tipografia de dos dies. El seu horari deixa que una xerrada ocupi totes les sales o dues franges horàries, amb cada tema del seu color.

## Què compondràs

El programa imprès, en català, d'unes jornades de tipografia inventades que se celebren a Girona. La coberta dibuixa la ela geminada majúscula, L·L, amb tres mòduls de color sobre negre. Cada jornada s'obre amb una banda negra de 76 mm i el dia del mes en una xifra de 144 pt. La graella del dijous és una taula. La conferència inaugural ocupa una cel·la negra que abasta les tres sales, i cada taller, dues franges horàries. Les parts del dia encapçalen les seves files a tota l'amplada, i cada ponència és una peça del color del seu itinerari; les mostres de color del peu fan de llegenda. La graella no cap en una pàgina, així que omple la 3 i acaba a la 4, sota la seva fila de capçalera repetida. La del divendres, més curta, cap sencera al peu de la seva pàgina d'obertura.

**Aquesta recepta respon a:**

- Com combino les cel·les d'una graella perquè una plenària ocupi totes les sales i un taller dues franges?
- Com parteixo una taula llarga entre pàgines amb la capçalera repetida i un avís de «continua»?
- Com afegeixo mostres de color com a llegenda en el text, els peus o les notes de taula?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
- Com faig una obertura de capítol amb una banda de color a sang, un número de capítol gran i el títol?

## La resposta curta

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

## Ingredients

**Ensenya**

- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita): Taules com a recursos amb files de capçalera, cel·les combinades, proporcions de columna, alineació per cel·la i llistes dins de les cel·les; les taules amb barres no s'interpreten.
- [Taules que passen de pàgina](https://postext.dev/ca/docs/configuration.md#taules-més-altes-que-la-pàgina): Les taules llargues es parteixen entre files amb la capçalera repetida, «(cont.)» al peu i un avís de «Continua», mai dins d'un rowspan.
- [Emplenaments de cel·la](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita): Un fons per cel·la enllaçat a la paleta, per a mapes de calor, matrius de compatibilitat i files alternes fetes a mà.

**També fa servir**

- [Mostres de color](https://postext.dev/ca/docs/document-format.md#format-en-línia)
- [Chips en línia](https://postext.dev/ca/docs/configuration.md#estils-de-xip)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació)
- [Estil de taules](https://postext.dev/ca/docs/configuration.md#estil-de-taules)
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Banda de capítol d'amplada completa](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Cobertes, portades i colofons](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Salts de pàgina i de columna](https://postext.dev/ca/docs/document-format.md#pagebreak)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Bibliografies i glossaris](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- citations
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos)
- [Capçaleres per secció](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`chipStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-xip), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules)

**API**

- [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`mergeCells`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`setAlignment`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`setCellBackground`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), `setCellContent`

**Tipus de lletra**

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

## Elaboració

### 1 · Converteix cada línia del programa en una fila

El codi és [la resposta curta](#la-resposta-curta) de més amunt. Cada jornada és text pla en un fitxer de contingut, amb una línia per franja horària: `|` separa les sales, una lletra indica l'itinerari, `*` marca una plenària sobre emplenament negre, `/` obre cada línia que segueix el títol i `^` allarga la sessió de la franja anterior. Totes les combinacions surten de `mergeCells`. Una línia amb una sola sessió, com la plenària o una pausa, ocupa les tres columnes de sala. Un taller ocupa la seva cel·la i la de sota, i una part del dia (MATÍ, TARDA), que s'escriu com una línia sense `|`, les quatre columnes. Les cel·les cobertes continuen a la quadrícula, marcades amb `hiddenBy`, perquè la columna de cada cel·la és la seva posició a la fila ([Construir models de taula](/ca/docs/configuration#construir-models-de-taula)). `headerRowCount: 1` converteix els noms de les sales en la fila de capçalera. Els `columnWidths` són pesos relatius, i 12 + 3 × 42 sumen els 138 mm de l'amplada de caixa, així que la columna de l'hora fa 12 mm i cada sala 42. `setAlignment(…, 'left', 'middle')` centra cada taller en vertical a les seves dues files.

### 2 · Deixa que el dijous continuï i el divendres quedi sencer

```js
// script.js, línies 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 graella del dijous es cita a la pàgina 2 i va a dalt d'una pàgina. Un flotant no puja mai per sobre de la seva referència, així que la graella obre la pàgina 3. No hi cap, i `overflow: 'split'` la talla entre dues files, mai dins d'una cel·la combinada ([Taules més altes que la pàgina](/ca/docs/configuration#taules-més-altes-que-la-pàgina)). La pàgina 3 acaba amb la fila de les 17.30 i l'avís a sota. La 4 repeteix la fila de capçalera i el peu complet, amb la llegenda de colors, seguit de `(continuació)`, i la nota només apareix sota aquesta última part. La del divendres va al peu d'una pàgina i cap sota el text que la cita a la pàgina 5. Postext 1.4.1 només inclou en anglès i en castellà els noms dels tipus de recurs i aquests dos textos, així que les paraules catalanes, plurals inclosos, s'escriuen sobre `defaultResourceTypes('ca')` i a l'estil de taula. `numberingTemplate: '{n}'` amb `resetOn: 'never'` numera les dues graelles en una sola sèrie: Taula 1 i Taula 2.

### 3 · Talla els emplenaments en peces

```js
// script.js, línies 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`, a la resposta curta, dona a cada sessió el color de la paleta del seu itinerari, i uns filets d'1,6 pt del color del paper tallen els emplenaments en peces separades per línies blanques ([Estil de taules](/ca/docs/configuration#estil-de-taules)). Una taula té un sol color de text per a les cel·les del cos, així que el blanc de la plenària és un xip l'estil del qual fixa el color i treu l'emplenament, la vora i el marge lateral del xip. Les hores i les parts del dia també són xips, en Chivo Mono ([Estils de xip](/ca/docs/configuration#estils-de-xip)). Un xip no es parteix mai entre dues línies; el títol d'una plenària i la línia del seu ponent caben cadascun en una línia de la cel·la de tres sales. A la nota del plànol, el xip `sala` deixa el text tal com és i manté en una línia el número i el nom de cada sala, cosa que a la 1.4.1 no aconsegueix un espai de no separació.

### 4 · Obre cada jornada amb una banda

```js
// script.js, línies 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 disseny del títol de primer nivell dibuixa la banda: una caixa de 76 mm d'alçada des de la vora de sang, amb la franja de mòduls al llarg del seu peu. La data surt de l'atribut `date` del títol, i la xifra, de `day`. El traç de l'1 d'Unbounded 800 a 144 pt comença 0,76 mm dins de la seva caixa, i el de la primera majúscula de la data, entre 0,1 i 0,2 mm dins de la seva; per això la xifra es desplaça 0,6 mm a l'esquerra, i la data, la xifra i el text comencen el seu traç a menys de 0,2 mm els uns dels altres. El nom del dia va a la dreta de la xifra, baixat `BASELINE` × (144 − 26) pt perquè les dues línies de base coincideixin, perquè en un text de disseny amb `lineHeight: 1` la línia de base queda a 0,8 de l'alçada de la seva caixa. `minHeight` reserva la pàgina fins a 7 mm per sota de la banda, i la retícula de base arrodoneix aquesta reserva cap amunt fins a 14 línies, així que la caixa de la primera línia de text comença 10,2 mm sota la banda. Amb `AIR = 0` mana la caixa de la mateixa xifra, que baixa més enllà del peu de la banda, i el text comença 5,6 mm sota la banda.

### 5 · Sagna les notes dels ponents

```js
// script.js, línies 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 ponent és un paràgraf d'un bloc `:::paragraphs{style="bio"}`. El nom en negreta comença a la vora de la columna i les línies següents entren 4 mm, en sagnat francès, així que els noms queden alineats columna avall, com en un índex ([Estils de paràgraf](/ca/docs/configuration#estils-de-paràgraf)). El text va en bandera, un ajust en què la 1.4.1 no evita les últimes línies d'una sola paraula, així que cada nota es va reescriure fins que la seva última línia en va tenir almenys dues.

## La recepta completa

Un sol fitxer, compost a partir de la carpeta de la recepta amb el text d'exemple i el kit comú del Receptari ja inclosos; construeix la seva pròpia pàgina. Per executar-lo, posa'l en un `<script type="module">` d'una pàgina buida o enganxa'l al tauler JS d'un pen nou de CodePen (com a mòdul). Importa postext des d'esm.sh, així que no cal instal·lar ni compilar res.

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

## Variants

### Arrodoneix les cantonades de la graella

Una taula partida arrodoneix les cantonades superiors de la seva primera part, a la pàgina 3, i les inferiors de l'última, a la pàgina 4, on només es veu la dreta perquè la columna de l'hora no té emplenament.

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

### Numera cada graella per jornada

Si es compta per títols de primer nivell, com als tipus predefinits, surten la Taula 1.1 i la Taula 2.1, i el plànol passa a ser la Figura 2.1; l'estil de la coberta no es numera, així que el dijous és el títol 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
```

## Errors freqüents

- **Les cel·les combinades necessiten hiddenBy: fes servir mergeCells.** Les cel·les es col·loquen segons la seva posició a la fila, així que una cel·la combinada necessita cel·les de farciment marcades amb hiddenBy on s'estén; ometre-les, com en HTML, desplaça totes les columnes següents. Combina cel·les amb mergeCells.
- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **Una taula 'here' no es parteix mai.** Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia.
- **Tradueix Figura i Taula amb defaultResourceTypes(locale).** El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes.
- **En el text en bandera no s'eviten les línies curtes.** optimalLineBreaking, avoidRunts, runtPenalty i runtMinCharacters actuen sobre l'algorisme de Knuth–Plass, que postext 1.4.1 només aplica al text justificat. Un paràgraf en bandera es talla línia a línia i pot acabar en una sola paraula curta, diguin el que diguin aquestes opcions. Revisa les últimes línies del text en bandera i reescriu el paràgraf que acabi en una línia curta.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Una paleta canviada no arriba als elements de disseny ni al color de les remissions.** postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre.
- **El lineHeight d'un text de disseny és un múltiple, mai una mesura.** En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol.
- **El desbordament del text de disseny és 'ellipsis-end' per defecte.** Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies.
- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Un espai de no separació continua partint la línia.** A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.

- Les columnes de la pàgina 4 s'igualen a mà, amb un `:::columnbreak` abans de la nota de Rosa Fontanet. Postext 1.4.1 no iguala l'última pàgina d'un capítol quan l'obre la resta d'una taula partida, així que la primera columna arriba al peu i la segona es queda curta. Mou el salt cada vegada que canviï el text del dijous.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: The programme’s Catalan text, both schedules included, written for this recipe, and the cover, band and floor-plan drawings, generated in code: Ignacio Ferro, CC-BY-4.0
- Tipus de lletra: Schibsted Grotesk (OFL-1.1), Unbounded (OFL-1.1), Chivo Mono (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 035 · Almanac de l'hort: calendari i gràfic apaïsat](https://postext.dev/ca/cookbook/garden-almanac.md): Un gràfic de sembres apaïsat en dues pàgines, un calendari calculat amb dates i una matriu de cultius associats, acolorits amb la paleta. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
- [Núm. 036 · Catàleg de venda per correu amb imatges a les cel·les](https://postext.dev/ca/cookbook/seed-catalogue.md): Un catàleg de llavors: la llista de preus, una taula llegida d'un TSV, porta un sobre pintat a la cel·la de cada varietat i es parteix entre dues pàgines. · Nivell 3 (Avançat) · Catàlegs
- [Núm. 010 · Full tècnic: taules de dades amb capçaleres combinades](https://postext.dev/ca/cookbook/technical-datasheet.md): Taules enganxades com a TSV, llegides amb parseTSV i ajustades amb mergeCells, setAlignment i setCellBackground; un mapa de registres que es parteix sol. · Nivell 3 (Avançat) · Manuals, guies i obres de consulta
