# Programme de colloque avec grille horaire fusionnée

> Programme de deux jours en grilles horaires : mergeCells étend une plénière sur trois salles et un atelier sur deux créneaux, aux couleurs de chaque thème.

- Version HTML: https://postext.dev/fr/cookbook/conference-programme
- Recette Nº 053 · Tableaux · Niveau 3 (Avancé) · Sorties: Canvas
- Genres: Feuilles volantes et éphémères
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/conference-programme/en/p01.webp?v=9b3f919d), [2](https://postext.dev/cookbook/conference-programme/en/p02.webp?v=9b3f919d), [3](https://postext.dev/cookbook/conference-programme/en/p03.webp?v=9b3f919d), [4](https://postext.dev/cookbook/conference-programme/en/p04.webp?v=9b3f919d), [5](https://postext.dev/cookbook/conference-programme/en/p05.webp?v=9b3f919d), [6](https://postext.dev/cookbook/conference-programme/en/p06.webp?v=9b3f919d)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=conference-programme&lang=en (.postext: https://postext.dev/cookbook/conference-programme/en/conference-programme.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/conference-programme.md), [es](https://postext.dev/es/cookbook/conference-programme.md), [ca](https://postext.dev/ca/cookbook/conference-programme.md), [pt](https://postext.dev/pt/cookbook/conference-programme.md), [zh](https://postext.dev/zh/cookbook/conference-programme.md), [ja](https://postext.dev/ja/cookbook/conference-programme.md), [ar](https://postext.dev/ar/cookbook/conference-programme.md)

## En bref

Le programme imprimé d’un colloque de deux jours sur l’imprimerie et le lettrage. Son tableau horaire permet à une intervention de s’étendre sur toutes les salles ou sur deux créneaux, chaque thème dans sa couleur.

## Ce que vous allez composer

Le programme imprimé d’un colloque de typographie imaginaire à Gérone, rédigé en catalan. La couverture dessine la capitale ela geminada, L·L, en trois modules colorés sur fond encre. Chaque journée s’ouvre sur un bandeau encre de 76 mm avec le jour du mois en chiffre de 144 pt. La grille du jeudi est un tableau. La conférence d’ouverture remplit une cellule encre sur les trois salles, et chaque atelier occupe deux créneaux. Les moments de la journée titrent leurs rangées sur toute la grille, et chaque intervention est une tuile à la couleur de son thème, expliquée par les pastilles de couleur de la légende. La grille est plus haute qu’une page : elle remplit la page 3 et se termine en page 4 sous sa rangée d’en-tête répétée. La grille du vendredi, plus courte, tient entière au pied de sa page d’ouverture.

**Cette recette répond à:**

- Comment fusionner des cellules pour qu’une plénière couvre toutes les salles et un atelier deux créneaux ?
- Comment couper un long tableau sur plusieurs pages avec un en-tête répété et une mention « suite » ?
- Comment ajouter des pastilles de couleur de légende au texte, aux légendes ou aux notes de tableau ?
- Comment obtenir les étiquettes « Figure » et « Tableau » dans la langue de mon document ?
- Comment faire une ouverture de chapitre avec une bande de couleur à fond perdu, un grand numéro de chapitre et le titre ?

## La réponse courte

```js
// script.js, lignes 32–65
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)
}
```

## Ingrédients

**Enseigne**

- [Tableaux à partir de données](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte): Des ressources tableau avec des lignes d’en-tête, des cellules fusionnées, des proportions de colonnes, un alignement par cellule et des listes dans les cellules ; les tableaux à barres verticales ne sont pas analysés.
- [Tableaux sur plusieurs pages](https://postext.dev/fr/docs/configuration-resources.md#tableaux-plus-hauts-que-la-page): Les tableaux longs se coupent entre les lignes avec l’en-tête répété, « (suite) » dans la légende et une mention « À suivre », jamais à l’intérieur d’un rowspan.
- [Fonds de cellule](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte): Un fond lié à la palette pour chaque cellule, pour les cartes de chaleur, les matrices de compatibilité et les bandes qui suivent les données ; les simples lignes alternées relèvent du style de tableau (bodyAlternateBackground).

**Utilise aussi**

- [Pastilles de couleur](https://postext.dev/fr/docs/document-format.md#mise-en-forme-en-ligne)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [Placement des figures](https://postext.dev/fr/docs/document-format.md#placement)
- [Style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)
- [Style des légendes](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Bandeau de chapitre sur toute la largeur](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Styles de titre](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Couvertures, pages de titre et colophons](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Figure et Tableau dans votre langue](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Types de ressource personnalisés](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Titres courants et folios](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Bibliographies et glossaires](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Des citations qui placent les figures](https://postext.dev/fr/docs/document-format.md#renvoi-dans-le-texte-la-forme-principale)
- [Titres courants selon le rôle de la page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-de-texte)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)
- [Titres courants par section](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)
- [Chapitres non numérotés](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre)

**La configuration en un coup d’œil**

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`captionStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-légende), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headingStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-titre), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`resourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`tableStyle`](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`defaultResourceTypes`](https://postext.dev/fr/docs/configuration-resources.md#types-de-ressources), [`mergeCells`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`prepareFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap), [`setAlignment`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), [`setCellBackground`](https://postext.dev/fr/docs/document-format.md#insertion-en-bloc-facultative-placement-explicite-dans-le-texte), `setCellContent`

**Caractères**

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

## Préparation

### 1 · Faire de chaque ligne du programme une rangée

Le code est dans [la réponse courte](https://postext.dev/fr/cookbook/conference-programme.md#la-réponse-courte) plus haut. Chaque journée est du texte brut dans un fichier de contenu, une ligne par créneau : `|` sépare les salles, une lettre nomme le thème, `*` signale une plénière sur fond encre, `/` commence chaque ligne après le titre, et `^` prolonge la session du créneau du dessus. `mergeCells` crée chaque fusion. Une ligne avec une seule session, comme la plénière ou une pause, prend les trois colonnes de salle. Un atelier prend sa cellule et celle du dessous, et un moment de la journée (MATÍ, TARDA), écrit comme une ligne sans `|`, prend les quatre colonnes. Les cellules couvertes par une fusion restent dans la grille, marquées `hiddenBy`, parce que la colonne d’une cellule est son indice dans la rangée ([construire des modèles de tableau](https://postext.dev/fr/docs/configuration-resources.md#construire-des-modèles-de-tableau)). `headerRowCount: 1` fait des noms de salle la rangée d’en-tête. Les `columnWidths` sont des poids relatifs, et 12 + 3 × 42 font les 138 mm de la justification : la colonne des heures fait 12 mm de large et chaque salle 42 mm. `setAlignment(…, 'left', 'middle')` centre verticalement chaque atelier dans ses deux rangées.

### 2 · Laisser déborder le jeudi et garder le vendredi entier

```js
// script.js, lignes 69–80
// The built-in types in Catalan, Figura and Taula; a grid's caption goes above it.
const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type,
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }),
  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'
  // The engine's own Catalan strings are '(cont.)' and 'Continua': these spell them out.
  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 grille du jeudi est appelée en page 2 et placée en tête de page. Un flottant ne remonte jamais au-dessus de son appel : la grille ouvre donc la page 3. Elle est plus haute que la page, et `overflow: 'split'` la coupe entre deux rangées, jamais à l’intérieur d’une cellule fusionnée ([tableaux plus hauts que la page](https://postext.dev/fr/docs/configuration-resources.md#tableaux-plus-hauts-que-la-page)). La page 3 se termine sur la rangée de 17.30 et la mention placée dessous. La page 4 répète la rangée d’en-tête et toute la légende, pastilles comprises, suivie de `(continuació)`, et la note ne s’imprime que sous cette dernière partie. La grille du vendredi va au pied d’une page et tient sous le texte qui l’appelle en page 5. `defaultResourceTypes('ca')` nomme les types en catalan, Figura et Taula. Les chaînes catalanes du moteur pour un tableau coupé sont `(cont.)` et `Continua` ; le style de tableau les écrit en entier. `numberingTemplate: '{n}'` avec `resetOn: 'never'` compte les deux grilles dans une seule série, Taula 1 et Taula 2.

### 3 · Découper les fonds en tuiles

```js
// script.js, lignes 84–93
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
```

`setCellBackground`, dans la réponse courte, donne à chaque session l’entrée de palette de son thème, et des filets de grille de 1,6 pt dans la couleur du papier découpent les fonds en tuiles séparées par des gouttières blanches ([style de tableau](https://postext.dev/fr/docs/configuration-resources.md#style-de-tableau)). Un tableau n’a qu’une couleur de texte pour ses cellules courantes : le texte blanc de la plénière est donc une pastille dont le style fixe la couleur et désactive le fond, la bordure et la marge latérale de la pastille. Les heures et les moments de la journée sont aussi des pastilles, en Chivo Mono ([styles de pastille](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)). Une pastille ne se coupe jamais d’une ligne à l’autre ; le titre d’une plénière et la ligne de son intervenant tiennent chacun sur une ligne de la cellule à trois salles. Dans la note du plan, la pastille `sala` laisse le texte tel quel et garde le numéro et le nom de chaque salle sur une ligne, comme le ferait une espace insécable entre ses mots.

### 4 · Ouvrir chaque journée sur un bandeau

```js
// script.js, lignes 97–121
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 }); // a long line wraps, in the running heads too
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 144 pt line box
  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 }),
] } };
```

La maquette du titre de niveau 1 dessine le bandeau : une boîte de 76 mm de haut depuis le bord du fond perdu, avec la bande de modules le long de son pied. La date vient de l’attribut `date` du titre, et le chiffre de `day`. Le 1 d’Unbounded 800 à 144 pt commence son encre à 0,76 mm dans sa boîte, et la première capitale de la date à 0,1 à 0,2 mm dans la sienne : le chiffre est donc décalé de 0,6 mm vers la gauche, et la date, le chiffre et le texte commencent leur encre à moins de 0,2 mm les uns des autres. Le nom du jour se place à droite du chiffre, abaissé de `BASELINE` × (144 − 26) pt pour que les deux lignes de base se rejoignent, parce qu’un texte de maquette avec `lineHeight: 1` a sa ligne de base aux 0,8 de sa boîte. `minHeight` réserve la page jusqu’à 7 mm sous le bandeau, et la grille de ligne de base arrondit la réserve à 14 lignes : la première boîte de ligne du texte commence 10,2 mm sous le bandeau. Avec `AIR = 0`, c’est la boîte du chiffre lui-même, qui dépasse le pied du bandeau, qui fixe la hauteur, et le texte commence 5,6 mm sous le bandeau.

### 5 · Mettre en retrait les lignes suivantes des notices

```js
// script.js, lignes 125–129
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) },
];
```

Chaque intervenant est un paragraphe d’un bloc `:::paragraphs{style="bio"}`. Le nom en gras commence au bord de la colonne et les lignes suivantes sont en retrait de 4 mm : les noms s’alignent le long de la colonne comme dans un index ([styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)).

## La recette complète

Un seul fichier, composé à partir du dossier de la recette avec le texte d’exemple et le kit commun des Recettes déjà inclus ; il construit sa propre page. Pour l’exécuter, placez-le dans un `<script type="module">` d’une page vide ou collez-le dans le panneau JS d’un nouveau pen CodePen (en tant que module). Il importe postext depuis esm.sh : il n’y a rien à installer ni à compiler.

- Dossier de la recette: 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.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
  defaultResourceTypes, mergeCells, setCellContent, setCellBackground, setAlignment,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @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
};
// Each colour carries its hex and the palette entry it follows.
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
// The built-in types in Catalan, Figura and Taula; a grid's caption goes above it.
const resourceTypes = defaultResourceTypes('ca').map((type) => ({ ...type,
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }),
  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'
  // The engine's own Catalan strings are '(cont.)' and 'Continua': these spell them out.
  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
// #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 }); // a long line wraps, in the running heads too
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 144 pt line box
  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 = () => ({
  locale: 'ca', // the programme is in Catalan in both editions: written out, never LANG
  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: [
    // A day opens a new page, on either side.
    { 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.

**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, so the plan carries no font; 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
  '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 prepareFonts(allText, config(), kitFonts(FONTS));
const doc = await buildDocumentWithFonts({ markdown, resources }, config(),
  { ...kitFonts(FONTS), text: 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 v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

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

/** The unicode-range of each Fontsource file the kit loads (a function: the
 *  kit is inlined after the pen's code, which runs before a const would be set). */
function kitRanges() {
  return {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
    greek: 'U+0370-03FF',
  };
}

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

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

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

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

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

/** The family's Fontsource metadata (weights, styles, subsets), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

// ─── Kit · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25).
 *  `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */
async function loadSvg(fileId, svg, options) {
  if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces);
  const img = new Image();
  img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
  await img.decode();
  registerResourceImage(fileId, img, options);
  (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg));
}

/** A face's Fontsource files: nearest weight; latin + what the SVG sets. */
async function svgFaces(family, weight, style, { codePoints = [] } = {}) {
  const [id, m] = [fontsourceId(family), await fontsourceMeta(family)];
  const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight;
  const s = m?.styles.includes(style) === false ? 'normal' : style;
  return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array(
    await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer())));
}

/** 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

### Arrondir les coins de la grille

Un tableau coupé arrondit les coins supérieurs de sa première partie, en page 3, et les coins inférieurs de sa dernière partie, en page 4, où seul celui de droite se voit, parce que la colonne des heures n’a pas de fond.

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

### Numéroter la grille de chaque journée séparément

Compter par titre de niveau 1, comme le font les types intégrés, donne Taula 1.1 et Taula 2.1 et fait du plan la Figura 2.1 ; le style de la couverture n’est pas numéroté : le jeudi est donc le titre 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
```

## Erreurs fréquentes

- **Les cellules fusionnées demandent des cellules hiddenBy : utilisez mergeCells.** Les cellules sont placées selon leur position dans le tableau de la ligne : une cellule fusionnée demande donc des cellules de remplacement marquées hiddenBy là où elle s’étend ; les omettre, comme le fait le HTML, décale toutes les colonnes suivantes. Construisez les fusions avec mergeCells. Depuis postext 1.5, un tableau dont la grille est irrégulière est signalé dans doc.contentWarnings comme raggedTableGrid, et parseTSV(text, { headerRows }) marque les lignes d’en-tête.
- **Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite.** Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Texte: 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
- Caractères: Schibsted Grotesk (OFL-1.1), Unbounded (OFL-1.1), Chivo Mono (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 035 · Almanach du jardinier : calendrier et tableau à l’italienne](https://postext.dev/fr/cookbook/garden-almanac.md): Semis de 38 cultures à l’italienne sur deux pages dédiées, calendrier calculé à partir de dates et matrice d’associations, aux cellules colorées par la palette. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
- [Nº 036 · Catalogue de vente par correspondance, images en cellules](https://postext.dev/fr/cookbook/seed-catalogue.md): Un catalogue de graines dont le tarif est un tableau lu depuis un TSV, un sachet peint dans la cellule de chaque variété, la liste coupée sur une double page. · Niveau 3 (Avancé) · Catalogues
- [Nº 010 · Fiche technique : tableaux de données, en-têtes fusionnés](https://postext.dev/fr/cookbook/technical-datasheet.md): Des tableaux collés en TSV, lus par parseTSV, façonnés par mergeCells, setAlignment et setCellBackground ; une carte de registres qui se coupe seule. · Niveau 3 (Avancé) · Manuels, guides et ouvrages de référence
