# セルを結合した時間割の学会プログラム

> 2日間のプログラムを時間割の表に組みます。mergeCellsで全体講演を3つの会場に、ワークショップを2コマにまたがせ、各発表にトラックの色を付けます。

- HTML版: https://postext.dev/ja/cookbook/conference-programme
- レシピ No. 053 · 表 · 難易度 3 (上級) · 出力: Canvas
- ジャンル: 一枚物とエフェメラ
- 必要なもの postext ≥ 1.4.1 · テスト環境 1.4.1 ／テスト日 2026-09-26
- ページ: [1](https://postext.dev/cookbook/conference-programme/en/p01.webp?v=b86ce7cb), [2](https://postext.dev/cookbook/conference-programme/en/p02.webp?v=b86ce7cb), [3](https://postext.dev/cookbook/conference-programme/en/p03.webp?v=b86ce7cb), [4](https://postext.dev/cookbook/conference-programme/en/p04.webp?v=b86ce7cb), [5](https://postext.dev/cookbook/conference-programme/en/p05.webp?v=b86ce7cb), [6](https://postext.dev/cookbook/conference-programme/en/p06.webp?v=b86ce7cb)
- Sandboxで開く: https://postext.dev/ja/sandbox#recipe=conference-programme&lang=en (.postext: https://postext.dev/cookbook/conference-programme/en/conference-programme.postext)
- 最終更新: 2026-09-27
- 他の言語: [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), [zh](https://postext.dev/zh/cookbook/conference-programme.md), [ar](https://postext.dev/ar/cookbook/conference-programme.md)

## かんたんな説明

印刷と文字をテーマにした2日間の学会の、印刷されたプログラムです。時間割では1つの講演がすべての会場や2つの時間帯にまたがり、テーマごとに色が分かれています。

## できあがり

ジローナで開かれる架空のタイポグラフィ学会の印刷プログラムで、カタルーニャ語で書かれています。表紙は墨色の地に、カタルーニャ語の大文字エラ・ジェミナーダ（L·L）を3つの色のモジュールで描きます。各日は76mmの墨色の帯で始まり、日付を144ptの数字で入れます。木曜日の時間割は表です。開会講演は3つの会場にまたがる墨色のセル1つを占め、ワークショップはそれぞれ2コマを取ります。一日の区切りは時間割の全幅にわたって行の頭に立ち、各発表はトラックの色のタイルに入って、キャプションの色見本が凡例になります。時間割は1ページより高いので、3ページを埋め、繰り返される見出し行の下で4ページで終わります。金曜日の短い時間割は、その日の最初のページの下部に分割されずに収まります。

**このレシピが答える問い:**

- 時間割のセルを結合し、全体講演を全会場に、ワークショップを2コマにまたがせるには？
- 長い表を、見出し行を繰り返し「続く」の表示を付けて、ページをまたいで分割するには？
- 本文、キャプション、表の注に色見本（凡例の色）を加えるには？
- 「図」や「表」のラベルを文書の言語で表示するには？
- 裁ち落としまで届く色帯、大きな章番号、タイトルを備えた章扉を作るには？

## 手短な答え

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

## 材料

**学べること**

- [データから作る表](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置): ヘッダー行、結合セル、列幅の比率、セルごとの行そろえ、セル内のリストを持つ表のリソースです。パイプ記法の表は解析されません。
- [ページをまたぐ表](https://postext.dev/ja/docs/configuration.md#ページより高い表): 長い表は行と行のあいだで分割され、ヘッダーが繰り返されます。キャプションには「（続き）」が付き、「続く」の表示が入ります。rowspanの途中では分割しません。
- [セルの塗り](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置): パレットにひも付けたセルごとの背景色です。ヒートマップ、互換性の表、手作業で付けるゼブラ縞などに使います。

**ほかに使うもの**

- [色見本](https://postext.dev/ja/docs/document-format.md#インライン書式)
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル)
- [図の配置](https://postext.dev/ja/docs/document-format.md#配置)
- [表スタイル](https://postext.dev/ja/docs/configuration.md#表スタイル)
- [キャプションのスタイル](https://postext.dev/ja/docs/configuration.md#キャプションスタイル)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [段抜きの章見出し帯](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [見出しスタイル](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [表紙、扉、奥付](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [「図」と「表」を文書の言語で](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [独自のリソースの種類](https://postext.dev/ja/docs/configuration.md#リソースの種類)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [改ページと改段](https://postext.dev/ja/docs/document-format.md#pagebreak)
- [柱とノンブル](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [参考文献と用語集](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- citations
- [ページの役割ごとの柱](https://postext.dev/ja/docs/configuration.md#テキスト要素)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)
- [節ごとの柱](https://postext.dev/ja/docs/configuration.md#見出しスタイル)
- [番号のない章](https://postext.dev/ja/docs/configuration.md#見出しスタイル)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`captionStyle`](https://postext.dev/ja/docs/configuration.md#キャプションスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headingStyles`](https://postext.dev/ja/docs/configuration.md#見出しスタイル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`resourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`tableStyle`](https://postext.dev/ja/docs/configuration.md#表スタイル)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`defaultResourceTypes`](https://postext.dev/ja/docs/configuration.md#リソースの種類), [`mergeCells`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する), [`setAlignment`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), [`setCellBackground`](https://postext.dev/ja/docs/document-format.md#ブロック埋め込み任意本文中への明示的な配置), `setCellContent`

**書体**

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

## 作り方

### 1 · プログラムの各行を表の行にする

コードは上の[手短な答え](#手短な答え)にあります。各日はコンテンツファイルのプレーンテキストで、1つの時間帯を1行に書きます。`|`が会場を区切り、文字がトラックを示し、`*`は墨色の塗りの全体講演を、`/`は題のあとの各行の始まりを示し、`^`は上の時間帯のセッションが続くことを示します。結合はすべて`mergeCells`が行います。全体講演や休憩のようにセッションが1つだけの行は、3つの会場の列を占めます。ワークショップは自分のセルとその下のセルを占め、`|`のない行として書く一日の区切り（MATÍ、TARDA）は4列すべてを占めます。結合で覆われるセルは`hiddenBy`の印を付けて表に残します。セルの列は行の中のインデックスで決まるからです（[表モデルの構築](/ja/docs/configuration#表モデルの構築)）。`headerRowCount: 1`で会場名が見出し行になります。`columnWidths`は相対的な重みで、12 + 3 × 42が138mmの行長に一致するので、時刻の列は幅12mm、各会場は42mmです。`setAlignment(…, 'left', 'middle')`は、各ワークショップを2行の中で上下中央に置きます。

### 2 · 木曜日は次ページへ続け、金曜日は分割しない

```js
// script.js, 行 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' } };
```

木曜日の時間割は2ページで参照され、ページの頭に置かれます。フロートは参照より上には置かれないので、時間割は3ページの頭から始まります。ページより高いため、`overflow: 'split'`が行と行のあいだで切ります。結合したセルの内部で切ることはありません（[ページより高い表](/ja/docs/configuration#ページより高い表)）。3ページは17.30の行とその下の案内で終わります。4ページでは見出し行と、色見本の凡例を含むキャプション全体を繰り返し、そのあとに`(continuació)`が続きます。注記はこの最後の部分の下にだけ印刷されます。金曜日の時間割はページの下部に置かれ、5ページでそれを参照する本文の下に収まります。Postext 1.4.1のリソースタイプ名とこの2つの文字列は英語とスペイン語しかないので、カタルーニャ語の語は複数形も含めて`defaultResourceTypes('ca')`の上から書き、表スタイルにも書き込みます。`numberingTemplate: '{n}'`と`resetOn: 'never'`で、2つの時間割を1つの通し番号でTaula 1、Taula 2と数えます。

### 3 · 塗りをタイルに切り分ける

```js
// script.js, 行 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`は各セッションにトラックのパレットの色を与え、紙の色で幅1.6ptにした罫が塗りをタイルに切り分けて、白い溝を作ります（[表スタイル](/ja/docs/configuration#表スタイル)）。表の本文セルの文字色は1つだけなので、全体講演の白い文字はチップで組みます。そのスタイルが色を設定し、チップの塗り、枠線、左右のパディングを切ります。時刻と一日の区切りもChivo Monoのチップです（[チップスタイル](/ja/docs/configuration#チップスタイル)）。チップは行をまたいで分割されません。全体講演の題と講演者の行は、3会場分のセルの中でそれぞれ1行に収まります。平面図の注記では、`sala`チップが文字をそのままにして各室の番号と名前を1行に保ちます。1.4.1では、ノーブレークスペースではこれができません。

### 4 · 各日を帯で始める

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

第1レベルの見出しのデザインが帯を描きます。裁ち落としの端から76mmの深さの箱で、その下端にモジュールが帯状に並びます。日付は見出しの`date`属性から、数字は`day`から取ります。144ptのUnbounded 800の1はインクが箱の0.76mm内側から始まり、日付の最初の大文字は自分の箱の0.1〜0.2mm内側から始まります。そこで数字を0.6mm左へ動かし、日付、数字、本文のインクの始まりを互いに0.2mm以内にそろえます。曜日名は数字の右に置き、`BASELINE` × (144 − 26)pt下げて2つのベースラインを合わせます。`lineHeight: 1`のデザインのテキストは、ベースラインが箱の上から0.8の位置にあるからです。`minHeight`は帯の下7mmまでのページを確保し、ベースライングリッドがその確保を14行に切り上げるので、本文の最初の行の箱は帯の10.2mm下から始まります。`AIR = 0`にすると、帯の下端より下に出る数字自体の箱が高さを決め、本文は帯の5.6mm下から始まります。

### 5 · 講演者の紹介の折り返しを字下げする

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

講演者はそれぞれ`:::paragraphs{style="bio"}`ブロックの1段落です。太字の名前は段の端から始まり、その下の行は4mm下げるので、索引のように名前が段の下へ並びます（[段落スタイル](/ja/docs/configuration#段落スタイル)）。本文は左そろえ（行末不ぞろい）で、1.4.1には行末不ぞろいの本文に対するラントの制御がないため、各紹介文は最終行に2語以上が残るまで書き直しました。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: 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 = '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
};
// 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 ───────────────────────────────────────────────────────────────────────
```

## アレンジ

### 時間割の角を丸める

分割された表は、最初の部分（3ページ）の上の角と、最後の部分（4ページ）の下の角を丸めます。4ページでは時刻の列に塗りがないので、見えるのは右の角だけです。

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

### 日ごとに時間割に番号を振る

組み込みのタイプと同じくレベル1の見出しごとに数えると、Taula 1.1とTaula 2.1になり、平面図はFigura 2.1になります。表紙のスタイルは番号を振らないので、木曜日が見出し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
```

## よくあるつまずき

- **結合セルにはhiddenByのプレースホルダーが要る。mergeCellsを使う.** セルは行の配列内の位置で配置されるため、結合セルが広がる位置にはhiddenByを付けたプレースホルダーのセルが必要です。HTMLのように省くと、それ以降の列がすべてずれます。結合はmergeCellsで作ってください。
- **'top'のフロートは参照したページには入らない.** フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。
- **'here'の表は分割されない.** 段やページをまたいで分割されるのはフロートの表だけで、'here'に置いた表はまとめて移動します。長い表はフロートにするか、インラインの表を短くしてください。
- **Figure/TableはdefaultResourceTypes(locale)でローカライズする.** 設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。
- **行末不ぞろいのテキストはラントを検査されない.** optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **デザインのテキストのoverflowの既定値は'ellipsis-end'.** 幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。
- **SVGの<img>内のテキストはWebフォントを使えない.** SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。
- **ノーブレークスペースでも改行される.** postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める（0.08%）か、文を書き換えてください。
- **設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る.** エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。

- 4ページの段は、Rosa Fontanetの紹介の前に置いた`:::columnbreak`で、手作業で高さをそろえています。分割された表の残りが章の最終ページの頭に来ると、Postext 1.4.1はそのページの段末をそろえないので、第1段が末尾まで流れ、第2段は短いままになります。木曜日の本文を変えたら、そのたびに区切りの位置を動かしてください。

## クレジット

- レシピ: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- テキスト: 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
- 書体: Schibsted Grotesk (OFL-1.1), Unbounded (OFL-1.1), Chivo Mono (OFL-1.1)
- コード: MIT · サンプルの内容: CC-BY-4.0

## 関連レシピ

- [No. 035 · 園芸暦：カレンダーの表と横向きの作付表](https://postext.dev/ja/cookbook/garden-almanac.md): 38作物の種まき表を専用の2ページに横向きで組み、日付から計算したカレンダーとコンパニオンプランツの表を添え、セルを色見本で塗ります。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
- [No. 036 · セルに絵を入れた通信販売カタログ](https://postext.dev/ja/cookbook/seed-catalogue.md): 種苗カタログの価格表をTSVから読んだ表で組み、各品種のセルに描いた種袋の絵を置き、表を見開きにまたがって分割します。 · 難易度 3 (上級) · カタログ
- [No. 010 · データシート：データから作る表とセル結合](https://postext.dev/ja/cookbook/technical-datasheet.md): TSVで貼り付けた表をparseTSVで読み、mergeCells、setAlignment、setCellBackgroundで整えます。レジスターマップは自動でページをまたいで分割されます。 · 難易度 3 (上級) · マニュアル・ガイド・リファレンス
