# Un informe d'assaig clínic amb diagrama CONSORT

> Un assaig real, abreujat: resum estructurat a tota l'amplada, diagrama CONSORT dibuixat en codi amb les seves xifres, taules alineades i cites APA.

- Versió HTML: https://postext.dev/ca/cookbook/clinical-trial-consort-flow
- Recepta Núm. 142 · Figures i imatges · Nivell 3 (Avançat) · Sortides: Canvas, PDF
- Gèneres: Articles i treballs acadèmics
- Requereix postext ≥ 1.18.0, postext-pdf ≥ 1.18.0 · provada amb 1.18.0, postext-pdf 1.18.0 el 2026-10-06
- Pàgines: [1](https://postext.dev/cookbook/clinical-trial-consort-flow/en/p01.webp?v=75cc1800), [3](https://postext.dev/cookbook/clinical-trial-consort-flow/en/p03.webp?v=75cc1800), [5](https://postext.dev/cookbook/clinical-trial-consort-flow/en/p05.webp?v=75cc1800), [6](https://postext.dev/cookbook/clinical-trial-consort-flow/en/p06.webp?v=75cc1800), [7](https://postext.dev/cookbook/clinical-trial-consort-flow/en/p07.webp?v=75cc1800)
- PDF: https://postext.dev/cookbook/clinical-trial-consort-flow/en/clinical-trial-consort-flow.pdf?v=75cc1800
- Obre al Sandbox: https://postext.dev/ca/sandbox#recipe=clinical-trial-consort-flow&lang=en (.postext: https://postext.dev/cookbook/clinical-trial-consort-flow/en/clinical-trial-consort-flow.postext)
- Última actualització: 2026-10-06
- Altres idiomes: [en](https://postext.dev/en/cookbook/clinical-trial-consort-flow.md), [es](https://postext.dev/es/cookbook/clinical-trial-consort-flow.md), [zh](https://postext.dev/zh/cookbook/clinical-trial-consort-flow.md), [ja](https://postext.dev/ja/cookbook/clinical-trial-consort-flow.md), [ar](https://postext.dev/ar/cookbook/clinical-trial-consort-flow.md)

## En poques paraules

Un estudi publicat sobre un xatbot que proposa exercicis de teràpia, abreujat i recompost. El diagrama de qui va entrar i sortir de l'assaig es dibuixa amb les seves xifres, les taules alineen els decimals i les referències segueixen APA.

## Què compondràs

Un assaig controlat aleatoritzat de salut mental digital, compost de nou a partir del seu text en accés obert: l'assaig de Woebot publicat a JMIR Mental Health el 2017, un agent conversacional que aplica teràpia cognitivoconductual, abreujat a vuit pàgines. L'estil de la casa és el d'un informe de recerca sobri en 210 × 280 mm, amb Lora per al text, Nunito Sans per a rètols i xifres i un sol color d'accent, l'índigo. Les pàgines porten el que demana un informe d'assaig: el resum estructurat a l'amplada de les dues columnes, paraules clau i línia de registre, el flux de participants CONSORT redibuixat en codi amb les xifres del mateix article, dues taules de resultats amb els decimals alineats, el conflicte d'interessos dels autors paraula per paraula i cites autor-data APA 7 generades des de BibTeX. El text es manté en anglès, com es va publicar.

**Aquesta recepta respon a:**

- Com dibuixo un diagrama de flux CONSORT i componc el resum estructurat d'un assaig?
- Com faig una taula amb files de capçalera, cel·les combinades, amplades de columna i alineació per cel·la?
- Com cito obres i componc la bibliografia en APA, IEEE o un altre estil de cita?
- Com torno a compondre amb Postext un article d'accés obert d'arXiv o PubMed Central, amb les seves cites, les seves figures i la seva línia de llicència?

## La resposta curta

```js
// script.js, línies 30–51
// The abstract is a box whose span is 'page': the columns close above it and open again
// under it. Inside, :::columns sets Background to Results in two columns; the keywords and
// the registration line run across the box under them.
const abstractBox = { id: 'abstract', span: 'page', background: col('tint'),
  padding: { top: mm(3.5), right: mm(4.5), bottom: mm(3), left: mm(4.5) },
  marginTop: pt(0), marginBottom: pt(LEAD), columnGap: mm(7),
  titleStyle: { fontFamily: SANS, fontSize: pt(8.5), fontWeight: 800, letterSpacing: pt(1.6),
    textTransform: 'uppercase', color: col('accent'), gap: mm(2) },
  body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0), paragraphSpacing: true, boldColor: col('accent'),
    boldFontWeight: 800, italicColor: col('ink') } };
// The CONSORT diagram is an SVG drawn in code from the trial's numbers (see #region art),
// declared as a resource and placed by its first :ref, at the head of the next page.
const consort = { id: 'fig-consort', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  placement: { position: 'top', span: 'page' },
  svg: { fileId: 'consort.svg', width: 1760, height: CONSORT_H * 10 },
  caption: 'Participant flow through the trial (CONSORT diagram).',
  note: 'Redrawn from Figure 1 of the original; the Analysis row follows the Statistical '
    + 'Analysis section. The text reports 58 participants with T2 data.',
  altText: 'Flow chart: 204 registrations, 115 confirmed, 45 excluded as bot-generated, 70 '
    + 'randomized; 34 to Woebot (3 lost, 31 with T2 data) and 36 to the information-only '
    + 'control (11 lost, 25 with T2 data); 34 and 36 analysed.' };
```

## Ingredients

**Ensenya**

- [Figures i taules com a recursos](https://postext.dev/ca/docs/document-format.md#recursos): Les imatges, els SVG i les taules es declaren una vegada com a recursos amb el seu peu i es registren per a cada sortida; les imatges de Markdown s'eliminen.
- [Requadres a tota l'amplada](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout): Un requadre que travessa totes les columnes a mitja pàgina; les columnes de sobre acaben a la mateixa alçada i continuen a sota.
- [Taules a partir de dades](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita): Taules com a recursos amb files de capçalera, cel·les combinades, proporcions de columna, alineació per cel·la i llistes dins de les cel·les; les taules amb barres no s'interpreten.

**També fa servir**

- [Cites que col·loquen les figures](https://postext.dev/ca/docs/document-format.md#referència-en-línia-la-forma-principal)
- [Col·locació de figures](https://postext.dev/ca/docs/document-format.md#collocació)
- [Estil dels peus](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Línies de font i crèdit](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs)
- [Columnes dins d'un requadre](https://postext.dev/ca/docs/document-format.md#columns)
- [Requadres niats](https://postext.dev/ca/docs/configuration.md#el-contenidor-callout)
- [Estil de taules](https://postext.dev/ca/docs/configuration.md#estil-de-taules)
- [Cites en un estil de cita](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia)
- [Bibliografia a partir de les referències](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia)
- [Estils de títol](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Obertures dissenyades](https://postext.dev/ca/docs/configuration.md#span-i-disseny-avançat)
- [Atributs de títol](https://postext.dev/ca/docs/document-format.md#atributs-dencapçalament)
- [Capçaleres i folis](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus)
- [Superíndexs i subíndexs](https://postext.dev/ca/docs/document-format.md#format-en-línia)
- [Estils de paràgraf](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf)
- [Paleta de color semàntica](https://postext.dev/ca/docs/configuration.md#paleta-de-colors)
- [Exportació a PDF](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)
- [Requadres](https://postext.dev/ca/docs/configuration.md#estils-davís)
- [Figura i Taula en la teva llengua](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Capçaleres segons el tipus de pàgina](https://postext.dev/ca/docs/configuration.md#elements-de-text)
- [Fonts incrustades al PDF](https://postext.dev/ca/docs/configuration.md#per-què-un-proveïdor-de-fonts)
- [Tipus de recurs propis](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs)
- [Capítols sense número](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament)
- [Espai vertical explícit](https://postext.dev/ca/docs/document-format.md#space)

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

- [`bodyText`](https://postext.dev/ca/docs/configuration.md#text-de-cos), [`calloutStyles`](https://postext.dev/ca/docs/configuration.md#estils-davís), [`captionStyle`](https://postext.dev/ca/docs/configuration.md#estil-dels-peus-de-recurs), [`citations`](https://postext.dev/ca/docs/configuration.md#cites), [`colorPalette`](https://postext.dev/ca/docs/configuration.md#paleta-de-colors), [`footer`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`header`](https://postext.dev/ca/docs/configuration.md#capçaleres-i-peus), [`headingStyles`](https://postext.dev/ca/docs/configuration.md#estils-dencapçalament), [`headings`](https://postext.dev/ca/docs/configuration.md#encapçalaments), [`layout`](https://postext.dev/ca/docs/configuration.md#disposició), [`locale`](https://postext.dev/ca/docs/configuration.md#partició-de-mots), [`page`](https://postext.dev/ca/docs/configuration.md#pàgina), [`paragraphStyles`](https://postext.dev/ca/docs/configuration.md#estils-de-paràgraf), [`resourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`tableStyle`](https://postext.dev/ca/docs/configuration.md#estil-de-taules), [`unorderedLists`](https://postext.dev/ca/docs/configuration.md#llistes-no-ordenades)

**API**

- [`LOCALES`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`STYLES`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`buildDocument`](https://postext.dev/ca/docs/configuration.md#construir-un-document), [`clearMeasurementCache`](https://postext.dev/ca/docs/configuration.md#memòria-cau-de-mesures), [`createCiteprocEngine`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`decompressWoff2`](https://postext.dev/ca/docs/configuration.md#proveïdor-de-fonts-al-navegador-fontsource--woff2), [`defaultResourceTypes`](https://postext.dev/ca/docs/configuration.md#tipus-de-recurs), [`mergeCells`](https://postext.dev/ca/docs/document-format.md#inserció-en-bloc-opcional-collocació-en-línia-explícita), [`registerCitationEngine`](https://postext.dev/ca/docs/document-format.md#citacions-i-bibliografia), [`registerResourceImage`](https://postext.dev/ca/docs/architecture.md#superfície-dapi), [`renderPageToCanvas`](https://postext.dev/ca/docs/configuration.md#renderitzar-una-pàgina-a-un-bitmap), [`renderToPdf`](https://postext.dev/ca/docs/configuration.md#generació-de-pdf)

**Tipus de lletra**

- Lora (OFL-1.1), Nunito Sans (OFL-1.1)

## Elaboració

### 1 · El requadre del resum i el diagrama com a recurs

El codi és [la resposta curta](#la-resposta-curta) de dalt. Un estil de requadre amb `span: 'page'` tanca les dues columnes per sobre i les torna a obrir per sota, així que el resum pot anar a tota l'amplada a qualsevol alçada de la pàgina. A dins, `:::columns{count=2}` reparteix de Background a Conclusions en dues columnes, i les paraules clau i la línia de registre segueixen a tota l'amplada del requadre. El diagrama CONSORT és un recurs SVG com qualsevol altre: el número, el peu i la nota de procedència surten del recurs, i el seu primer `:ref` el col·loca al capdamunt de la pàgina següent.

### 2 · La capçalera de l'article, des dels atributs del títol

```js
// script.js, línies 55–78
const text = (id, content, family, size, extra) => ({ kind: 'text', id, content, align: 'left',
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const caps = { fontFamily: SANS, fontWeight: 800, letterSpacing: pt(1.5),
  textTransform: 'uppercase' };
const titleBlock = { enabled: true, minHeight: mm(70), slot: { elements: [
  { kind: 'rule', id: 'bar', thickness: pt(2.5), color: col('accent'),
    placement: at('container', 'top-left', 0, 0, MEASURE) },
  text('kind', '{attr.kind}', SANS, 8, { ...caps, color: col('accent'),
    placement: at('#bar', 'below', 0, 3, MEASURE) }),
  text('title', '{titleText}', SERIF, 18.5, { fontWeight: 700, lineHeight: 1.17,
    placement: at('#kind', 'below', 0, 3.5, MEASURE - 8) }),
  text('authors', '{attr.authors}', SANS, 10, { fontWeight: 700, inlineMarks: true,
    placement: at('#title', 'below', 0, 4.5, MEASURE) }),
  text('affiliations', '{attr.affiliations}', SANS, 7.4, { inlineMarks: true, lineHeight: 1.4,
    color: col('muted'), placement: at('#authors', 'below', 0, 1.8, MEASURE) }),
  { kind: 'rule', id: 'hair', thickness: pt(0.5), color: col('rule'),
    placement: at('#affiliations', 'below', 0, 2.6, MEASURE) },
  text('history', '{attr.history}', SANS, 7.2, { color: col('muted'),
    placement: at('#hair', 'below', 0, 1.6, MEASURE) }),
  text('source', '{attr.source}', SANS, 7.2, { fontWeight: 700, color: col('accent'),
    placement: at('#history', 'below', 0, 0.8, MEASURE) }),
] } };
```

El títol és l'únic títol de nivell 1 del document, amb un estil que ocupa tota la pàgina i es dibuixa amb elements. La resta surt d'atributs del títol: el tipus d'article, els autors amb marques de filiació `^1^` que `inlineMarks` posa volades, les filiacions separades per `\n`, les dates de recepció i acceptació i la línia de procedència. Per al teu article canvies els atributs i conserves el disseny. `numbered: false` a l'estil deixa les figures com a Figura 1, 2, 3 en lloc d'1.1.

### 3 · El flux CONSORT, dibuixat amb les xifres

```js
// script.js, línies 514–644
const n2 = (v) => +v.toFixed(2);
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
// An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so each
// drawing carries its faces inline, as data URLs of the Fontsource files.
async function inlineFaces(family, weights) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  let css = '';
  for (const w of weights) {
    const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}`
      + '-normal.woff2';
    const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
    let bin = '';
    for (const b of bytes) bin += String.fromCharCode(b);
    css += `@font-face{font-family:F;font-weight:${w};src:url(data:font/woff2;base64,`
      + `${btoa(bin)}) format('woff2')}`;
  }
  return `<style>${css}text{font-family:F}</style>`;
}
const label = (x, y, s, size, weight, fill, anchor = 'middle') => `<text x="${n2(x)}" `
  + `y="${n2(y)}" font-size="${size}" font-weight="${weight}" fill="${fill}" `
  + `text-anchor="${anchor}">${esc(s)}</text>`;
// A box of centred lines: the first bold, an "n = …" line bold in the accent (white on a
// filled box), the rest regular. Every box is 64, 56 or 26 mm wide; lines 1.3 × the size.
function node(cx, y, w, lines, { size = 2.6, fill = palette.tint, stroke = palette.accent } = {}) {
  const lh = size * 1.3;
  const h = lines.length * lh + 2.2;
  const solid = fill === palette.accent;
  let svg = `<rect x="${n2(cx - w / 2)}" y="${n2(y)}" width="${w}" height="${n2(h)}" rx="1.2" `
    + `fill="${fill}" stroke="${stroke}" stroke-width="0.3"/>`;
  lines.forEach((s, i) => {
    const isN = /^n = /.test(s);
    svg += label(cx, y + 1.1 + lh * (i + 0.78), s, size, i === 0 || isN ? 800 : 400,
      solid ? palette.paper : isN ? palette.accent : palette.ink);
  });
  return { svg, h, top: y, bottom: y + h, mid: y + h / 2 };
}
// Arrows are a line plus a filled triangle path: no <marker> (gotcha: svg-no-marker-filters).
const line = (pts) => `<path d="M${pts.map(([x, y]) => `${n2(x)} ${n2(y)}`).join('L')}" `
  + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`;
function arrow(pts) {
  const [[x0, y0], [x1, y1]] = pts.slice(-2);
  const a = Math.atan2(y1 - y0, x1 - x0);
  const p = (d, s) => `${n2(x1 - Math.cos(a) * d + Math.sin(a) * s)} `
    + `${n2(y1 - Math.sin(a) * d - Math.cos(a) * s)}`;
  return line([...pts.slice(0, -1), [x1 - Math.cos(a) * 1.4, y1 - Math.sin(a) * 1.4]])
    + `<path d="M${n2(x1)} ${n2(y1)}L${p(1.8, 0.9)}L${p(1.8, -0.9)}Z" fill="${palette.ink}"/>`;
}
// The four CONSORT stages as pills at the left edge, each centred on a y.
const stage = (y, s) => `<rect x="0" y="${n2(y - 2.2)}" width="${n2(s.length * 1.9 + 5)}" `
  + `height="4.4" rx="2.2" fill="${palette.ink}"/>`
  + label(2.5, y + 0.85, s.toUpperCase(), 2.3, 800, palette.paper, 'start');
function consortSvg(face) { // 176 mm wide, the full measure; one unit is a millimetre
  const [W, L, R, MID] = [176, 59, 117, 88];
  const side = { size: 2.5, fill: palette.paper, stroke: palette.rule };
  const a = node(MID, 0.5, 64, ['Registrations received by email', 'n = 204']);
  const b = node(MID, a.bottom + 3, 64, ['Responded to the email confirmation', 'n = 115']);
  const x = node(150, b.bottom + 0.5, 52, ['Excluded as ineligible', 'n = 45',
    'deemed bot-generated'], side);
  const c = node(MID, x.bottom + 2, 64, ['Randomized by computer algorithm', 'n = 70'],
    { fill: palette.accent });
  const split = c.bottom + 4; // the line that divides the sample between the arms
  const parts = [a.svg, b.svg, x.svg, c.svg, stage(a.mid, 'Enrollment'),
    arrow([[MID, a.bottom], [MID, b.top]]), arrow([[MID, b.bottom], [MID, c.top]]),
    arrow([[MID, x.mid], [124, x.mid]]), stage(split, 'Allocation'),
    line([[MID, c.bottom], [MID, split]]), line([[L, split], [R, split]])];
  const arms = [[L, 13, 'Allocated to Woebot', 34, 'up to 20 sessions over 2 weeks', 3, 31],
    [R, 163, 'Allocated to information-only control', 36, 'NIMH ebook on depression', 11, 25]];
  for (const [cx, sx, title, n, what, gone, kept] of arms) {
    const box = node(cx, split + 3.5, 56, [title, `n = ${n}`, what]);
    const lost = node(sx, box.bottom + 4.5, 26, ['Lost to follow-up', `n = ${gone}`], side);
    const t2 = node(cx, lost.bottom + 2, 56, ['Provided data at T2', `n = ${kept}`]);
    const an = node(cx, t2.bottom + 5.5, 56, ['Analysed (intention to treat)', `n = ${n}`,
      'missing T2 data imputed'], { fill: palette.accent });
    parts.push(box.svg, lost.svg, t2.svg, an.svg, arrow([[cx, split], [cx, box.top]]),
      arrow([[cx, box.bottom], [cx, t2.top]]), arrow([[cx, lost.mid], [sx + (sx < MID ? 13
        : -13), lost.mid]]), arrow([[cx, t2.bottom], [cx, an.top]]));
    if (cx === R) parts.push(stage(lost.top - 3, 'Follow-up'), stage(t2.bottom + 2.75, 'Analysis'));
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1760" height="${CONSORT_H * 10}" `
    + `viewBox="0 0 ${W} ${CONSORT_H}">${face}${parts.join('')}</svg>`;
}
// Figures 3 and 4 of the original are thematic maps; here they are bars, one per subtheme.
const THEMES = [
  { id: 'fig-best', file: 'best.svg', themes: [['Process', 31, [['Checking in / accountability', 9],
    ['Empathy / personality', 7], ['Learning', 12, [['Emotions', 5], ['General insight', 5],
      ['Cognitions', 2]]], ['Conversation', 3]]], ['Content', 16, [['Videos', 7], ['Games', 3],
    ['Suggestions', 2], ['Weekly graphs', 1]]]],
  caption: 'Best features of the Woebot experience: themes of the answers to “What was the best '
    + 'thing about your experience using Woebot?”',
  note: 'Number of participants per theme. Redrawn as bars from the thematic map in Figure 3 of '
    + 'the original; numbers as printed there.',
  alt: 'Bars for Process (31): checking in 9, empathy 7, learning 12 (emotions 5, general '
    + 'insight 5, cognitions 2), conversation 3; Content (16): videos 7, games 3, suggestions 2, '
    + 'weekly graphs 1.' },
  { id: 'fig-worst', file: 'worst.svg', themes: [['Process violations', 15,
    [['Not being able to converse naturally', 10], ['Repetitive', 2], ['Miscellaneous', 3]]],
  ['Technical problems', 8, [['Glitches', 4], ['Looping', 4]]], ['Content', 8,
    [['Emoticons', 2], ['Interactions too short', 2], ['Videos too long', 2], ['Other', 2]]]],
  caption: 'Least favored experiences: themes of the answers to “What was the worst thing about '
    + 'your experience of using Woebot?”',
  note: 'Number of participants per theme. Redrawn as bars from the thematic map in Figure 4 of '
    + 'the original; numbers as printed there.',
  alt: 'Bars for Process violations (15): not conversing naturally 10, repetitive 2, '
    + 'miscellaneous 3; Technical problems (8): glitches 4, looping 4; Content (8): emoticons, '
    + 'short interactions, long videos and other, 2 each.' },
];
const rows = (themes) => themes.flatMap(([name, n, subs]) => [{ name, n, depth: 0 },
  ...subs.flatMap(([s, k, deeper = []]) => [{ name: s, n: k, depth: 1 },
    ...deeper.map(([d, m]) => ({ name: d, n: m, depth: 2 }))])]);
const ROW = 4.4;
function themeHeight(id) { return rows(THEMES.find((f) => f.id === id).themes).length * ROW + 2; }
function themeSvg({ id, themes }, face) { // 84.5 mm wide: one column
  const [W, X0, X1] = [84.5, 48, 79];
  const H = themeHeight(id);
  let svg = '';
  rows(themes).forEach(({ name, n, depth }, i) => {
    const y = 1 + i * ROW;
    if (!depth) {
      svg += `<path d="M0 ${n2(y + 0.2)}H${W}" stroke="${palette.rule}" stroke-width="0.25"/>`
        + label(0, y + 3.1, `${name} (${n})`, 2.75, 800, palette.ink, 'start');
      return;
    }
    const w = (n / 12) * (X1 - X0);
    svg += label(depth * 3 - 1, y + 3, name, 2.45, 400, palette.ink, 'start')
      + `<rect x="${X0}" y="${n2(y + 0.9)}" width="${n2(w)}" height="2.6" `
      + `fill="${depth === 1 ? palette.accent : palette.soft}"/>`
      + label(X0 + w + 1.2, y + 3, String(n), 2.55, 800, palette.ink, 'start');
  });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="845" height="${Math.round(H * 10)}" `
    + `viewBox="0 0 ${W} ${n2(H)}">${face}${svg}</svg>`;
}
```

Cada caixa és una crida a `node()` amb les seves línies; les fases són píndoles al marge esquerre i les fletxes són una línia i un triangle ple, perquè un `<marker>` acaba com a imatge al PDF. Les xifres són les de l'assaig: 204 inscripcions, 115 confirmacions, 45 altes generades per bots excloses, 34 i 36 assignats, 3 i 11 perduts, 31 i 25 amb dades a T2. Per al teu assaig canvies els textos i els recomptes; la composició segueix l'alçada de les caixes. El text d'un SVG dibuixat com a imatge no veu les fonts de la pàgina, així que el dibuix porta Nunito Sans incrustada com a URL de dades.

### 4 · Xifres alineades a les taules

```js
// script.js, línies 459–510
// Nunito Sans sets tabular figures, so numbers right-aligned in a column line up on the
// decimal point; the (SD) or (SE) sits in a column of its own, flush left beside it.
// aligns: one letter per column, l or r; header cells over the numbers are centred.
const cell = (content, align, head) => ({ content, ...(head && { isHeader: true }),
  align: head && align !== 'left' ? 'center' : align });
function grid(rows, widths, aligns, heads, spans) { // spans: [row, col, lastRow, lastCol]
  const a = [...aligns].map((k, c) => (c && k === 'r' ? 'right' : 'left'));
  let m = { headerRowCount: heads, columnWidths: widths,
    rows: rows.map((row, r) => widths.map((_, c) => cell(row[c] ?? '', a[c], r < heads))) };
  for (const [r0, c0, r1, c1] of spans) {
    m = mergeCells(m, { start: { row: r0, col: c0 }, end: { row: r1, col: c1 } });
  }
  return m; // mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby)
}
const pair = (s) => s.split(' '); // '13.25 (5.17)' → ['13.25', '(5.17)']
const BASE = [['Depression (PHQ-9)', '13.25 (5.17)', '14.30 (6.65)'],
  ['Anxiety (GAD-7)', '19.02 (4.27)', '18.05 (5.89)'],
  ['Positive affect', '26.19 (8.37)', '25.54 (9.58)'],
  ['Negative affect', '28.74 (8.92)', '24.87 (8.13)'], ['Age, mean (SD)', '21.83 (2.24)',
    '22.58 (2.38)'], ['**Gender, n (%)**'], ['Male', '4 (7)', '7 (21)'],
  ['Female', '20 (55)', '27 (79)'], ['**Ethnicity, n (%)**'], ['Latino/Hispanic', '2 (8)', '2 (6)'],
  ['Non-Latino/Hispanic', '22 (92)', '32 (94)'], ['Caucasian', '18 (75)', '28 (82)'],
  ['Non-Caucasian', '6 (25)', '6 (18)']];
const ITT = [['PHQ-9', '13.67 (.81)', '12.07-15.27', '11.14 (0.71)', '9.74-12.32', '6.03',
  '.017', '0.44'], ['GAD-7', '16.84 (.67)', '15.52-18.56', '17.35 (0.60)', '16.16-18.13', '0.38',
  '.581', '0.14'], ['PANAS positive affect', '26.02 (1.45)', '23.17-28.86', '26.88 (1.29)',
  '24.35-29.41', '0.17', '.707', '0.02'], ['PANAS negative affect', '27.53 (1.42)',
  '24.73-30.32', '25.98 (1.24)', '23.54-28.42', '0.91', '.912', '0.344']];
const table = (id, model, span, caption, note) => ({ id, typeId: 'table', kind: 'table',
  createdAt: 0, updatedAt: 0, placement: { position: 'top', span }, caption, note,
  table: { model } });
const baseline = grid([['', 'Information control', '', 'Woebot', ''],
  ['**Scale, mean (SD)**'], ...BASE.map(([k, a, b]) => (a ? [k, ...pair(a), ...pair(b)] : [k]))],
[26, 13, 10, 13, 10], 'lrlrl', 1, [[0, 1, 0, 2], [0, 3, 0, 4], [1, 0, 1, 4], [7, 0, 7, 4],
  [10, 0, 10, 4]]);
const itt = grid([['', 'Information-only control', '', '', 'Woebot', '', '', '*F*', '*P*',
  '*d*^c^'], ['', 'T2^a^', '', '95% CI^b^', 'T2^a^', '', '95% CI^b^', '', '', ''],
...ITT.map(([k, a, ca, b, cb, ...s]) => [k, ...pair(a), ca, ...pair(b), cb, ...s])],
[30, 10, 9, 18, 10, 9, 18, 8, 8, 8], 'lrlrrlrrrr', 2, [[0, 0, 1, 0], [0, 1, 0, 3],
  [0, 4, 0, 6], [1, 1, 1, 2], [1, 4, 1, 5], [0, 7, 1, 7], [0, 8, 1, 8], [0, 9, 1, 9]]);
const resources = () => [consort,
  table('tbl-baseline', baseline, 'column', 'Demographic and clinical variables of '
    + 'participants at baseline.', 'Participants with data at T2 (N=58), as printed in the '
    + 'original Table 1.'),
  table('tbl-itt', itt, 'page', 'Results of ITT analysis of entire sample on primary outcomes '
    + 'in the study at T2.', '^a^Baseline=pooled mean (standard error). ^b^95% confidence '
    + 'interval. ^c^Cohen *d* shown for between-subjects effects using means and standard '
    + 'errors at Time 2.'),
  ...THEMES.map(({ id, file, caption, note, alt }) => ({ id, typeId: 'figure', kind: 'svg',
    createdAt: 0, updatedAt: 0, placement: { position: 'auto' }, caption, note, altText: alt,
    svg: { fileId: file, width: 845, height: Math.round(themeHeight(id) * 10) } })),
];
```

Nunito Sans compon les seves xifres amb una sola amplada, de manera que una columna de mitjanes alineades a la dreta quadra a la coma decimal. Cada mitjana i la seva DE o EE van en dues columnes, la mitjana a la dreta i el parèntesi a l'esquerra, enganxat a ella, i `mergeCells` construeix les capçaleres de grup («Information-only control» sobre tres columnes) i les files que travessen la taula. La Taula 2 és un flotant a tota l'amplada; la Taula 1 es queda en una columna.

### 5 · Les paraules del bot en requadres niats

```js
// script.js, línies 82–95
const bubbleTitle = { fontFamily: SANS, fontSize: pt(6.6), fontWeight: 800, letterSpacing: pt(1),
  textTransform: 'uppercase', color: col('muted'), gap: mm(0.8) };
const calloutStyles = [
  abstractBox,
  { id: 'chat', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(0),
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0.5), left: mm(0) },
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
    titleStyle: { ...bubbleTitle, fontSize: pt(7.6), color: col('accent'), gap: mm(2) } },
  { id: 'bubble', background: col('tint'), borderRadius: mm(2.4), marginTop: mm(1.6),
    marginBottom: mm(0), padding: { top: mm(1.6), right: mm(3), bottom: mm(1.8), left: mm(3) },
    titleStyle: bubbleTitle,
    body: { fontFamily: SANS, fontSize: pt(8.8), lineHeight: pt(11.4), textAlign: 'left',
      firstLineIndent: pt(0), color: col('ink') } },
];
```

L'article cita cinc missatges que va enviar el bot. La recepta els reuneix en un requadre amb una franja índigo a dalt; cada missatge és un requadre niat amb les cantonades arrodonides i un rètol petit que anomena el tret que il·lustra. Només hi apareixen les cites de l'article, sense afegir-hi res.

## La recepta completa

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

- Carpeta de la recepta: https://github.com/drnachio/postext/tree/main/cookbook/clinical-trial-consort-flow

### script.js

```js
// ═══ Postext Cookbook · Nº 142 · A clinical trial report with a CONSORT diagram ══════
// https://postext.dev/en/cookbook/clinical-trial-consort-flow
// Code: MIT · Text: Fitzpatrick, Darcy & Vierhile 2017 (CC BY 4.0), abridged · Figures: code
// Fonts: Lora, Nunito Sans (SIL OFL 1.1) · Needs postext ≥ 1.18.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine,
  registerResourceImage, defaultResourceTypes, mergeCells,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'clinical-trial-consort-flow';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a violet-tinted ink and one indigo accent; the control arm in a pale tint
const palette = { ink: '#1f1d2b', accent: '#4a3f8c', soft: '#a39cd0', tint: '#efedf7',
  rule: '#c7c2de', muted: '#5b586b', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [SERIF, SANS] = ['Lora', 'Nunito Sans'];
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [210, 280, 22, 22, 18, 16, 7];
const MEASURE = TRIM_W - INNER - OUTER; // 176 mm
const LEAD = 13; // pt: 9.4 pt Lora in two columns of 84.5 mm
const CONSORT_H = 107; // mm: the height of the CONSORT drawing (its resource is × 10 px)

// #region answer: a structured abstract across both columns, and a figure drawn from numbers
// The abstract is a box whose span is 'page': the columns close above it and open again
// under it. Inside, :::columns sets Background to Results in two columns; the keywords and
// the registration line run across the box under them.
const abstractBox = { id: 'abstract', span: 'page', background: col('tint'),
  padding: { top: mm(3.5), right: mm(4.5), bottom: mm(3), left: mm(4.5) },
  marginTop: pt(0), marginBottom: pt(LEAD), columnGap: mm(7),
  titleStyle: { fontFamily: SANS, fontSize: pt(8.5), fontWeight: 800, letterSpacing: pt(1.6),
    textTransform: 'uppercase', color: col('accent'), gap: mm(2) },
  body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(11.4), textAlign: 'left',
    firstLineIndent: pt(0), paragraphSpacing: true, boldColor: col('accent'),
    boldFontWeight: 800, italicColor: col('ink') } };
// The CONSORT diagram is an SVG drawn in code from the trial's numbers (see #region art),
// declared as a resource and placed by its first :ref, at the head of the next page.
const consort = { id: 'fig-consort', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  placement: { position: 'top', span: 'page' },
  svg: { fileId: 'consort.svg', width: 1760, height: CONSORT_H * 10 },
  caption: 'Participant flow through the trial (CONSORT diagram).',
  note: 'Redrawn from Figure 1 of the original; the Analysis row follows the Statistical '
    + 'Analysis section. The text reports 58 participants with T2 data.',
  altText: 'Flow chart: 204 registrations, 115 confirmed, 45 excluded as bot-generated, 70 '
    + 'randomized; 34 to Woebot (3 lost, 31 with T2 data) and 36 to the information-only '
    + 'control (11 lost, 25 with T2 data); 34 and 36 analysed.' };
// #endregion

// #region title: the article head across the page: kind, title, authors, history, source
const text = (id, content, family, size, extra) => ({ kind: 'text', id, content, align: 'left',
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const caps = { fontFamily: SANS, fontWeight: 800, letterSpacing: pt(1.5),
  textTransform: 'uppercase' };
const titleBlock = { enabled: true, minHeight: mm(70), slot: { elements: [
  { kind: 'rule', id: 'bar', thickness: pt(2.5), color: col('accent'),
    placement: at('container', 'top-left', 0, 0, MEASURE) },
  text('kind', '{attr.kind}', SANS, 8, { ...caps, color: col('accent'),
    placement: at('#bar', 'below', 0, 3, MEASURE) }),
  text('title', '{titleText}', SERIF, 18.5, { fontWeight: 700, lineHeight: 1.17,
    placement: at('#kind', 'below', 0, 3.5, MEASURE - 8) }),
  text('authors', '{attr.authors}', SANS, 10, { fontWeight: 700, inlineMarks: true,
    placement: at('#title', 'below', 0, 4.5, MEASURE) }),
  text('affiliations', '{attr.affiliations}', SANS, 7.4, { inlineMarks: true, lineHeight: 1.4,
    color: col('muted'), placement: at('#authors', 'below', 0, 1.8, MEASURE) }),
  { kind: 'rule', id: 'hair', thickness: pt(0.5), color: col('rule'),
    placement: at('#affiliations', 'below', 0, 2.6, MEASURE) },
  text('history', '{attr.history}', SANS, 7.2, { color: col('muted'),
    placement: at('#hair', 'below', 0, 1.6, MEASURE) }),
  text('source', '{attr.source}', SANS, 7.2, { fontWeight: 700, color: col('accent'),
    placement: at('#history', 'below', 0, 0.8, MEASURE) }),
] } };
// #endregion

// #region boxes: the bot's quoted messages as chat bubbles inside a box with a top stripe
const bubbleTitle = { fontFamily: SANS, fontSize: pt(6.6), fontWeight: 800, letterSpacing: pt(1),
  textTransform: 'uppercase', color: col('muted'), gap: mm(0.8) };
const calloutStyles = [
  abstractBox,
  { id: 'chat', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(0),
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0.5), left: mm(0) },
    stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') },
    titleStyle: { ...bubbleTitle, fontSize: pt(7.6), color: col('accent'), gap: mm(2) } },
  { id: 'bubble', background: col('tint'), borderRadius: mm(2.4), marginTop: mm(1.6),
    marginBottom: mm(0), padding: { top: mm(1.6), right: mm(3), bottom: mm(1.8), left: mm(3) },
    titleStyle: bubbleTitle,
    body: { fontFamily: SANS, fontSize: pt(8.8), lineHeight: pt(11.4), textAlign: 'left',
      firstLineIndent: pt(0), color: col('ink') } },
];
// #endregion

const head = (id, content, parity, edge, x, extra) => text(id, content, SANS, 7.6, {
  parity, pages: 'body', fontWeight: 600, letterSpacing: pt(0.3), color: col('muted'),
  overflow: 'clip', placement: at('page', edge, x, 12.5, 110), ...extra });
const folio = { fontWeight: 800, color: col('accent') };
const right = { align: 'right' }; // anchored top-right, an element ends at its offset
const header = { elements: [
  head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('v-title', 'Fitzpatrick, Darcy & Vierhile · JMIR Ment Health 2017;4(2):e19', 'even',
    'top-left', OUTER + 8),
  head('r-title', 'Woebot for symptoms of depression and anxiety · a randomized trial', 'odd',
    'top-right', -OUTER - 8, right),
  head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, { ...folio, ...right }),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -OUTER,
  { ...folio, ...right, pages: 'opener', placement: at('page', 'bottom-right', -OUTER, -12, 10) }),
] };

registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const sans = (size, weight) => ({ fontFamily: SANS, fontSize: pt(size), fontWeight: weight });
const small = (id, size, lead, extra) => ({ id, fontFamily: SANS, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', firstLineIndent: pt(0), ...extra });
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'en-us',
  // "Table 1" over its table, "Figure 1" under its figure; one count, not 1.1 (title unnumbered)
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette, calloutStyles, header, footer,
  // Psychology reports cite in APA 7: (Kessler et al., 2007), Bickmore et al. (2005).
  citations: { style: 'apa', link: true,
    bibliography: { fontSize: em(0.86), lineHeight: pt(10.6), hangingIndent: mm(4),
      entrySpacing: pt(2.4), doi: 'link' } },
  headingStyles: [
    { id: 'article', numbered: false, span: 'page', advancedDesign: titleBlock },
    { id: 'back', ...sans(8, 800), letterSpacing: pt(1.3), textTransform: 'uppercase',
      color: col('accent'), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) },
  ],
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { fontFamily: SERIF, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: SANS, color: col('accent'), fontWeight: 800, levels: [
    { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // gotcha: headings-drop-h1-break
    { level: 2, ...sans(11.5, 800), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) },
    { level: 3, ...sans(9.4, 700), color: col('ink'), lineHeight: pt(LEAD),
      marginTop: pt(LEAD / 2), marginBottom: pt(0) },
    { level: 4, fontFamily: SERIF, fontSize: pt(9.4), fontWeight: 700, italic: true,
      color: col('ink'), lineHeight: pt(LEAD), marginTop: pt(LEAD / 2), marginBottom: pt(0) },
  ] },
  unorderedLists: { bulletChar: '•', color: col('accent'), marginTop: pt(0),
    marginBottom: pt(0) },
  paragraphStyles: [
    small('abbr', 8, 11, { spaceBetween: pt(0), boldColor: col('accent'), boldFontWeight: 800 }),
    small('colophon', 7, 9.6, { color: col('muted'), spaceBetween: pt(3) }),
  ],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('accent'), headerColor: col('paper'), headerBold: true,
    headerFontFamily: SANS, headerFontSize: pt(7.4), bodyFontFamily: SANS,
    bodyFontSize: pt(7.5), bodyColor: col('ink'), cellPadding: mm(0.45) },
  captionStyle: { fontFamily: SANS, fontSize: pt(7.8), color: col('ink'), labelBold: true,
    labelColor: col('accent'), gap: mm(2), note: { fontSize: pt(6.9), color: col('muted') } },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Delivering Cognitive Behavior Therapy to Young Adults With Symptoms of Depression and Anxiety Using a Fully Automated Conversational Agent (Woebot): A Randomized Controlled Trial"
author: "Kathleen Kara Fitzpatrick, Alison Darcy and Molly Vierhile"
---

# Delivering Cognitive Behavior Therapy to Young Adults With Symptoms of Depression and Anxiety Using a Fully Automated Conversational Agent (Woebot): A Randomized Controlled Trial {style="article" kind="Randomized controlled trial · Digital mental health" authors="Kathleen Kara Fitzpatrick, PhD^1^\*, Alison Darcy, PhD^2^\*, Molly Vierhile, BA^1^" affiliations="^1^ Stanford School of Medicine, Department of Psychiatry and Behavioral Sciences, Stanford, CA, United States\n^2^ Woebot Labs Inc., San Francisco, CA, United States\n\* Contributed equally. Corresponding author: Alison Darcy, PhD" history="Received 29 March 2017 · Revised 5 May 2017 · Accepted 22 May 2017 · Published 6 June 2017" source="Abridged from JMIR Mental Health 2017;4(2):e19, doi:10.2196/mental.7785 · © the authors, CC BY 4.0"}

:::callout{type="abstract" title="Abstract" span="page"}
:::columns{count=2}
**Background** Web-based cognitive-behavioral therapeutic (CBT) apps have demonstrated efficacy but are characterized by poor adherence. Conversational agents may offer a convenient, engaging way of getting support at any time.

**Objective** The objective of the study was to determine the feasibility, acceptability, and preliminary efficacy of a fully automated conversational agent to deliver a self-help program for college students who self-identify as having symptoms of anxiety and depression.

**Methods** In an unblinded trial, 70 individuals age 18-28 years were recruited online from a university community social media site and were randomized to receive either 2 weeks (up to 20 sessions) of self-help content derived from CBT principles in a conversational format with a text-based conversational agent (Woebot) (n=34) or were directed to the National Institute of Mental Health ebook, “Depression in College Students,” as an information-only control group (n=36). All participants completed Web-based versions of the 9-item Patient Health Questionnaire (PHQ-9), the 7-item Generalized Anxiety Disorder scale (GAD-7), and the Positive and Negative Affect Scale at baseline and 2-3 weeks later (T2).

**Results** Participants were on average 22.2 years old (SD 2.33), 67% female (47/70), mostly non-Hispanic (93%, 54/58), and Caucasian (79%, 46/58). Participants in the Woebot group engaged with the conversational agent an average of 12.14 (SD 2.23) times over the study period. No significant differences existed between the groups at baseline, and 83% (58/70) of participants provided data at T2 (17% attrition). Intent-to-treat univariate analysis of covariance revealed a significant group difference on depression such that those in the Woebot group significantly reduced their symptoms of depression over the study period as measured by the PHQ-9 (*F*=6.47; *P*=.01) while those in the information control group did not. In an analysis of completers, participants in both groups significantly reduced anxiety as measured by the GAD-7 (*F*~1,54~= 9.24; *P*=.004). Participants’ comments suggest that process factors were more influential on their acceptability of the program than content factors mirroring traditional therapy.

**Conclusions** Conversational agents appear to be a feasible, engaging, and effective way to deliver CBT.
:::

:::space{lines=1}

**Keywords** conversational agents; mobile mental health; mental health; chatbots; depression; anxiety; college students; digital health

**Trial registration** None. The trial involved a nonclinical population of college students and was considered exempt from registration in a public trials registry; it is reported with the CONSORT-EHEALTH checklist.
:::

## Introduction

Up to 74% of mental health diagnoses have their first onset before the age of 24 [@kessler2007]. Depression and anxiety symptoms are particularly common among college students, with more than half reporting symptoms of anxiety and depression in the previous year that were so severe they had difficulty functioning [@zivin2009]. In addition, epidemiological data suggest that mental health problems are both increasing in prevalence and severity [@hunt2010]. However, up to 75% of the college students that need them do not access clinical services [@hunt2010]. While the reasons for this are varied, the ubiquity of free or inexpensive mental health services on campuses suggests that service availability and cost are not primary barriers to care [@hunt2010]. Like non-college populations, stigma is considered the primary barrier to accessing psychological health services.

Overcoming problems of stigma has been traditionally considered a major benefit of Internet-delivered and more recently mobile mental health interventions. In recent years, there has been an explosion of interest and development of such services to either supplement existing mental health treatments or expand limited access to quality mental health services [@bakker2016]. This development is matched by great patient demand with about 70% showing interest in using mobile apps to self-monitor and self-manage their mental health [@torous2014]. Internet interventions for anxiety and depression have empirical support [@spek2007] with outcomes comparable to therapist-delivered cognitive behavioral therapy (CBT) [@barak2008; @andersson2009]. Yet, despite demonstrated efficacy, they are characterized by relatively poor adoption and adherence. One review found a median minimal completion rate of 56% [@donkin2013]. A hypothesized reason for this lack of adherence is the loss of the human interactional quality that in-person CBT retains.

With recent advancements in voice recognition, conversational interfaces (ie, those that use natural language as inputs and outputs) have begun to emerge. Conversational agents (such as Apple’s Siri or Amazon’s Alexa) may be a more natural medium through which individuals engage with technology. Humans respond and converse with nonhuman agents in ways that mirror emotional and social discourse dynamics when discussing behavioral health [@bickmore2005] and their capacity to act as first responders has already been evaluated [@miner2016]. Theoretically, conversational interfaces may be better positioned than visually oriented mobile apps to deliver structured, manualized therapies because in addition to delivering therapeutic content, they can mirror therapeutic process. Indeed, @bickmore2005 demonstrated that a carefully designed health-related conversational agent could establish a therapeutic relationship with adults attempting to increase exercise. The intervention was an embodied conversational agent, that is, it was designed with a graphical face to mirror human interactions that are typically face-to-face.

Thus, the objective of this study was to assess the feasibility of delivering CBT in a conversational interface via an automated bot in a way that facilitates engagement and reduction in symptoms. The current study compared outcomes from 2 weeks of a CBT-oriented conversational agent (Woebot), or an information control group (National Institute of Mental Health’s [NIMH] ebook) in a nonclinical college population. We hypothesized that conversation with a therapeutic process-oriented conversational agent would lead to greater improvement in symptoms relative to the information control group. We also hypothesized that receiving psychoeducational material in a conversational manner would be more acceptable to those who received it.

## Methods

### Recruitment and Procedure

Potential participants were recruited using a flyer posted on social media websites targeting a US university community for students who self-identified as experiencing symptoms of depression and anxiety. Inclusion criteria included age 18 and over (screened at the first level via checkbox confirmation) and able to read English (implied). To guard against compromise, for example from malicious bots, all potential participants were sent an email requesting that they respond denoting their confirmation. Confirmed participants were randomized via computer algorithm that automatically generated a number between 0 and 1. Participants with numbers <0.5 were allocated to receive a direct link to begin chatting with Woebot in an instant messenger app, and participants with numbers >0.5 were sent a link to NIMH’s ebook on depression among college students [@nimh2017], after completion of online baseline questionnaires. Because the randomization allocation occurred algorithmically, allocation concealment was in place. However, the condition to which each participant was allocated was not masked for the service providers (Woebot Labs). After approximately 2 weeks (T2), participants were contacted again to complete a second set of questionnaires online. Participants were offered a prorated incentive of US \$10 per completed assessment (US \$20 for completion of both assessments).

Since this trial involved a nonclinical population of college students, it was considered exempt from registration in a public trials registry. See Multimedia Appendix 1 for the study’s CONSORT-EHEALTH checklist [@eysenbach2011].

### Woebot

Woebot is an automated conversational agent designed to deliver CBT in the format of brief, daily conversations and mood tracking. Woebot is used within an instant messenger app that is platform agnostic and can be used either on a desktop or mobile device. Each interaction begins with a general inquiry about context (eg, “What’s going on in your world right now?”), and mood (eg, “How are you feeling?”) with responses provided as word or emoji images to represent affect in that moment. After gathering mood data, participants are presented with core concepts related to CBT by link to short video, or by way of short “word games” designed to facilitate teaching participants about cognitive distortions. The first day included an “onboarding” process that introduced the bot, adding that while the bot may seem like a person, it is closer to a “choose your own adventure self-help book” and therefore not fully capable of understanding what the needs of the user may be. The bot also briefly explained CBT and notified the user that while a psychologist was “keeping an eye on things” (ie, monitoring), this was not happening in real time and thus the service should not be used as a replacement for therapy. In addition, participants were encouraged to call 911 for emergencies.

The bot’s conversational style was modeled on human clinical decision making and the dynamics of social discourse. Psychoeducational content was adapted from self-help for CBT [@burns1980; @burns2006; @towery2016]. Aside from CBT content, the bot was created to include the following therapeutic process-oriented features:

- **Empathic responses:** The bot replied in an empathic way appropriate to the participants’ inputted mood.
- **Tailoring:** Specific content is sent to individuals depending on mood state. For example, a participant indicating that they feel anxious is offered in-vivo assistance with the anxious event.
- **Goal setting:** The conversational agent asked participants if they had a personal goal that they hoped to achieve over the 2-week period.
- **Accountability:** To facilitate a sense of accountability, the bot set expectations of regular check-ins and followed up on earlier activities, for example, on the status of the stated goal.
- **Motivation and engagement:** To engage the individual in daily monitoring, the bot sent one personalized message every day or every other day to initiate a conversation (ie, prompting).
- **Reflection:** The bot also provided weekly charts depicting each participant’s mood over time. Each graph was sent with a brief description of the data to facilitate reflection.

:::callout{type="chat" title="The bot’s messages quoted in the Methods"}
:::callout{type="bubble" title="Check-in · context"}
What’s going on in your world right now?
:::
:::callout{type="bubble" title="Check-in · mood"}
How are you feeling?
:::
:::callout{type="bubble" title="Empathic response · endorsed loneliness"}
I’m so sorry you’re feeling lonely. I guess we all feel a little lonely sometimes
:::
:::callout{type="bubble" title="Empathic response · excitement"}
Yay, always good to hear that!
:::
:::callout{type="bubble" title="Reflection · weekly mood chart"}
Overall, your mood has been fairly steady, though you tend to become tired after periods of anxiety. It looks like Tuesday was your best day.
:::
:::

### Information Control Condition

In the information control condition, participants were directed to the NIMH resources section and specifically, a free publication entitled “Depression in College Students” [@nimh2017].

### Measures

#### The Patient Health Questionnaire-9

The Patient Health Questionnaire (PHQ-9) [@kroenke2001] is a 9-item, self-report questionnaire that assesses the frequency and severity of depressive symptomatology within the previous 2 weeks. Each of the 9 items is based on the Diagnostic and Statistical Manual of Mental Disorders, 4th edition (DSM-IV) criteria for major depressive disorder and can be scored on a 0 (not at all) to 3 (nearly every day) scale.

#### Generalized Anxiety Disorder-7

The Generalized Anxiety Disorder 7-item scale (GAD-7) [@spitzer2006] is a valid, brief self-report tool to assess the frequency and severity of anxious thoughts and behaviors over the past 2 weeks. Based on the DSM-IV diagnostic criteria for GAD, the scores of all 7 items range from 0 (not at all) to 3 (nearly every day). Therefore, the total score ranges from 0-21.

#### Positive and Negative Affect Schedule

The Positive and Negative Affect Schedule (PANAS) [@watson1988] is a 20-item self-report measure of current positive and negative affect. Items are scored on a 1 (very slightly or not at all) to 5 (extremely) scale, with higher scores representing higher affect. Positive and negative affect are summed independent of each other with possible scores from 10-50.

### Statistical Analysis

Statistical power calculations using analysis of covariance (ANCOVA) revealed that a sample size of 70 would have sufficient (80%) power to detect a moderate-large effect size (Cohen *d*=0.4) for depression, reported by a meta-analysis of Internet-delivered treatments for adult depression and anxiety [@andersson2009], with alpha at 5%.

To determine whether any significant differences between groups existed at baseline, independent *t* tests were conducted on continuous baseline variables (eg, age, PHQ-9, GAD-7, and PANAS), and chi-square analyses were conducted on categorical or nominal variables (gender, race, ethnicity). Univariate effects of group membership on T2 outcomes were examined using between-subjects ANCOVA adjusting for baseline measures. Cohen *d* effect sizes were calculated to examine the magnitude of between-group differences. All subjects were included in intention-to-treat (ITT) analyses. Prior to conducting these analyses, the multiple imputation procedure in SPSS v. 23 was used to handle missing data assumed to be missing at random. As secondary subgroup analyses, we conducted completer analyses using 2x2 repeated measures analysis of variance (ANOVA) to explore main and interaction effects.

### Ethics and Informed Consent

The study was reviewed and approved by Stanford School of Medicine’s Institutional Review Board. Participants indicated their consent to the terms of the study via checkbox on an information sheet. As additional safety measures, participants in the Woebot group who denoted long-standing depression, suicidality, or self-harm were automatically provided with helpline numbers and a crisis text line number, and were encouraged to call 911 in emergencies.
`; // title, abstract, Introduction, Methods
const results = String.raw`## Results

:ref{id="fig-consort" style="full"} shows the participant flow throughout the study. A total of 204 registrations were received between January 31 and February 20, 2017, and all registrants were asked to confirm their interest by return email. A total of 115 responded to this email, though 45 of these were deemed bot-generated (eg, email addresses with unusual almost identical formats and identical responses) and were deemed ineligible. The resultant sample of N=70 were randomized via computer algorithm to receive either a direct link to begin chatting with Woebot (n=34) in an instant messenger app, or NIMH’s ebook on depression among college students [@nimh2017] (n=36), after completion of online questionnaires at baseline.

### Attrition

Of the randomized participants, 83% (58/70) went on to provide partial or complete data at T2 representing an overall attrition rate of 17%. Attrition was not equal between the arms and was greater among the information control group (31% vs 9%; chi-square~1~=5.16; *P*=.023). However, independent *t* tests and chi-square analyses failed to detect evidence of significant differences at baseline between those who dropped out of the study versus those who did not on age (*t*~68~=1.18; *P*=.24); GAD-7 (*t*~68~=1.28; *P*=.89); PHQ-9 (*t*~68~=.63; *P*=.59); PANAS positive (*t*~68~=.79; *P*=.43) and negative (*t*~68~=.02; *P*=.98) affect scores; or on gender (chi-square~1~=1.75; *P*=.18) or ethnicity (chi-square~1~=.066; *P*=.79).

### Participant Demographics

:ref{id="tbl-baseline" style="full"} shows the demographic information and baseline scores on clinical variables for those with data from the entire sample (N=58). Participants were an average of 22.2 years old (SD 2.33) and over two-thirds female. Participants were mostly non-Hispanic (93%, 54/58), 79% Caucasian (46/58), with 7% (4/58) Asian, 9% (5/58) more than one race, 2% (2/58) African American, and 2% (2/58) Native American/Alaskan Native.

In terms of baseline characteristics, nearly half (46%, 32/69) of the sample was in the moderately-severe or severe range of depression at baseline as measured by the PHQ-9, while three-quarters (74%, 52/70) were in the severe range for anxiety as measured by the GAD-7.

### Preliminary Efficacy

:ref{id="tbl-itt" style="full"} shows the results of the primary ITT analyses conducted on the entire sample. Univariate ANCOVA revealed a significant treatment effect on depression revealing that those in the Woebot group significantly reduced PHQ-9 score while those in the information control group did not (*F*~1,48~=6.03; *P*=.017). This represented a moderate between-groups effect size (*d*=0.44). This effect is robust after Bonferroni correction for multiple comparisons (*P*=.04). No other significant between-group differences were observed on anxiety or affect.

### Completer Analysis

As a secondary analysis, to explore whether any main effects existed, 2x2 repeated measures ANOVAs were conducted on the primary outcome variables (with the exception of PHQ-9) among completers only. A significant main effect was observed on GAD-7 (*F*~1,54~=9.24; *P*=.004) suggesting that completers experienced a significant reduction in symptoms of anxiety between baseline and T2, regardless of the group to which they were assigned with a within-subjects effect size of *d*=0.37. No main effects were observed for positive (*F*~1,50~=.001; *P*=.951; *d*=0.21) or negative affect (*F*~1,50~=.06; *P*=.80; *d*=0.003) as measured by the PANAS.

### Use and Acceptability

Participants in the Woebot condition checked in with the bot (defined as at least providing context and mood information) an average of 12.14 times (SD 2.23; median 12; range 8-18) over the 2-week period, with almost all check-ins occurring on unique days.

While ratings indicated that both conditions were acceptable (above 3/5), participants in the Woebot condition reported significantly higher levels of satisfaction both overall (4.3 versus 3.4; *t*~48~=3.99; *P*<.001) and with content (4.0 versus 3.4; *t*~48~=2.30; *P*=.02), and they reported a significantly greater amount of emotional awareness as a result of using the bot (3.3 versus 2.7; *t*~47.06~=2.38; *P*=.021) than the information control group.

### Qualitative Results

:ref{id="fig-best" style="full"} shows a thematic map of participants’ responses to the question “What was the best thing about your experience using Woebot?” Two major themes emerged in respect to this question: process and content. In the process theme, the subthemes that emerged were accountability from daily check-ins (noted by 9 participants); the empathy that the bot showed, or other factors relating to his “personality” (n=7); and the learning that the bot facilitated (n=12), which in turn was divided into further subthemes of emotional insight (n=5), general insight (n=5), and insights about cognitions (n=2).

:ref{id="fig-worst" style="full"} illustrates a thematic map of participants’ responses to the question: “What was the worst thing about your experience with Woebot?” Three themes emerged: process violations (n=15), technical problems (n=8), and problems with content (n=8). By far the most common subtheme to emerge among the process violations related to the limitations in natural conversation such as the bot not being able to understand some responses or getting confused when unexpected answers were provided by participants (n=10), and 2 individuals noted that the conversations could get repetitive.

## Discussion

### Principal Results

To our knowledge this is the first randomized trial of a nonembodied text-based conversational agent designed for therapeutic use. The objective of the study was to explore whether a fully automated conversational agent based on CBT principals could deliver a therapeutic experience to college students over a 2-week period.

The study confirmed that after 2 weeks, those in the Woebot group experienced a significant reduction in depression, thus our hypothesis was partially supported. Woebot was associated with a high level of engagement with most individuals using the bot nearly every day and was generally viewed more favorably than the information-only comparison.

### Limitations

There are several methodological weaknesses that limit the generalizability of the findings. As a feasibility study, we recruited a limited number of participants to receive a relatively short intervention, and no follow-up data were available to assess whether gains were sustained. The small number of participants meant that a formal mediator analysis was not possible, thus we cannot formally test a theorized relationship between engagement and outcome in this context of conversational agents. The study should be replicated with more participants, a longer dose, and a follow-up period to investigate if findings persist. Nonetheless, the relatively strong comparison group can be viewed as a strength of the study. Indeed, the relative strength of the control group was illustrated by the fact that individuals providing data in that group saw a similar reduction in anxiety as those who received Woebot, which supports the literature that suggests minimal passive psychoeducation alone can reduce symptoms of psychological distress [@donker2009]. Nonetheless, the choice of control group was somewhat limiting for two main reasons. First, it may have contributed to the high attrition rate since an ebook is not designed for multiple or recurring sessions. It also did not introduce any CBT-specific material, thus it was not possible to evaluate whether the conversational delivery mediated symptom reduction, rather than the CBT content that the bot delivered.

Finally, the study was conducted in a New York area university community population and since we did not formally assess digital divide factors such as socioeconomic status, findings may be limited in their generalizability.

### Conclusions

While results should be viewed with some caution and the findings need to be replicated, this study nonetheless demonstrates that a text-based conversational agent designed to mirror therapeutic process has the potential to offer an alternative and engaging method of delivering CBT for some 10 million college students in the United States who experience debilitating anxiety and depression.

## Conflicts of Interest {style="back"}

The second author (AMD) is the founder of a commercial entity Woebot Labs Inc. (formerly, the Life Ninja Project) that created the intervention (Woebot) that is the subject of this trial and therefore has financial interest in that company. Woebot Labs Inc. covered the cost of participant incentives, though Standford [sic] made the payments.

## Abbreviations {style="back"}

:::paragraphs{style="abbr"}
**ANCOVA** analysis of covariance

**ANOVA** analysis of variance

**DSM-IV** Diagnostic and Statistical Manual of Mental Disorders, 4th edition

**GAD-7** Generalized Anxiety Disorders scale

**ITT** intention to treat

**NIMH** National Institute of Mental Health

**PANAS** Positive and Negative Affect Scale

**PHQ-9** Patient Health Questionnaire scale

**T2** time 2
:::

## References {style="back"}

:::bibliography{title=""}

## About This Edition {style="back"}

:::paragraphs{style="colophon"}
Abridged from Fitzpatrick KK, Darcy A, Vierhile M. Delivering Cognitive Behavior Therapy to Young Adults With Symptoms of Depression and Anxiety Using a Fully Automated Conversational Agent (Woebot): A Randomized Controlled Trial. JMIR Mental Health 2017;4(2):e19. doi:10.2196/mental.7785. © Kathleen Kara Fitzpatrick, Alison Darcy, Molly Vierhile. Originally published in JMIR Mental Health (http://mental.jmir.org), 06.06.2017. This is an open-access article distributed under the terms of the Creative Commons Attribution License (https://creativecommons.org/licenses/by/4.0/), which permits unrestricted use, distribution, and reproduction in any medium, provided the original work, first published in JMIR Mental Health, is properly cited.

Changes: sections, paragraphs and sentences cut; the original Figure 2 and Multimedia Appendix 1 left out; citations converted from numbered to APA author–date; Figure 1 redrawn as a CONSORT diagram and Figures 3 and 4 redrawn as bar charts (Figures 2 and 3 here); the bot’s quoted messages gathered in a box; the chi-square symbol spelled out. Set in Lora and Nunito Sans (SIL OFL) for the Postext Cookbook.
:::
`; // Results, Discussion, back matter
const references = String.raw`:::references{format=bibtex}
@article{kessler2007,
  author = {Kessler, Ronald C. and Amminger, G. Paul and Aguilar-Gaxiola, Sergio and Alonso, Jordi and Lee, Sing and {\"U}st{\"u}n, T. Bedirhan},
  title = {Age of onset of mental disorders: a review of recent literature},
  journal = {Current Opinion in Psychiatry}, year = 2007, volume = 20, number = 4, pages = {359--364},
  doi = {10.1097/YCO.0b013e32816ebc8c}}
@article{zivin2009,
  author = {Zivin, Kara and Eisenberg, Daniel and Gollust, Sarah E. and Golberstein, Ezra},
  title = {Persistence of mental health problems and needs in a college student population},
  journal = {Journal of Affective Disorders}, year = 2009, volume = 117, number = 3, pages = {180--185},
  doi = {10.1016/j.jad.2009.01.001}}
@article{hunt2010,
  author = {Hunt, Justin and Eisenberg, Daniel},
  title = {Mental health problems and help-seeking behavior among college students},
  journal = {Journal of Adolescent Health}, year = 2010, volume = 46, number = 1, pages = {3--10},
  doi = {10.1016/j.jadohealth.2009.08.008}}
@article{bakker2016,
  author = {Bakker, David and Kazantzis, Nikolaos and Rickwood, Debra and Rickard, Nikki},
  title = {Mental Health Smartphone Apps: Review and Evidence-Based Recommendations for Future Developments},
  journal = {JMIR Mental Health}, year = 2016, volume = 3, number = 1, pages = {e7},
  doi = {10.2196/mental.4984}}
@article{torous2014,
  author = {Torous, John and Chan, Steven Richard and Yee-Marie Tan, Shih and Behrens, Jacob and Mathew, Ian and Conrad, Erich J. and Hinton, Ladson and Yellowlees, Peter and Keshavan, Matcheri},
  title = {Patient Smartphone Ownership and Interest in Mobile Apps to Monitor Symptoms of Mental Health Conditions: A Survey in Four Geographically Distinct Psychiatric Clinics},
  journal = {JMIR Mental Health}, year = 2014, volume = 1, number = 1, pages = {e5},
  doi = {10.2196/mental.4004}}
@article{spek2007,
  author = {Spek, Viola and Cuijpers, Pim and Nyklícek, Ivan and Riper, Heleen and Keyzer, Jules and Pop, Victor},
  title = {Internet-based cognitive behaviour therapy for symptoms of depression and anxiety: a meta-analysis},
  journal = {Psychological Medicine}, year = 2007, volume = 37, number = 3, pages = {319--328},
  doi = {10.1017/S0033291706008944}}
@article{barak2008,
  author = {Barak, Azy and Hen, Liat and Boniel-Nissim, Meyran and Shapira, Na'ama},
  title = {A Comprehensive Review and a Meta-Analysis of the Effectiveness of Internet-Based Psychotherapeutic Interventions},
  journal = {Journal of Technology in Human Services}, year = 2008, volume = 26, number = {2--4}, pages = {109--160},
  doi = {10.1080/15228830802094429}}
@article{andersson2009,
  author = {Andersson, Gerhard and Cuijpers, Pim},
  title = {Internet-based and other computerized psychological treatments for adult depression: a meta-analysis},
  journal = {Cognitive Behaviour Therapy}, year = 2009, volume = 38, number = 4, pages = {196--205},
  doi = {10.1080/16506070903318960}}
@article{donkin2013,
  author = {Donkin, Liesje and Hickie, Ian B. and Christensen, Helen and Naismith, Sharon L. and Neal, Bruce and Cockayne, Nicole L. and Glozier, Nick},
  title = {Rethinking the dose-response relationship between usage and outcome in an online intervention for depression: randomized controlled trial},
  journal = {Journal of Medical Internet Research}, year = 2013, volume = 15, number = 10, pages = {e231},
  doi = {10.2196/jmir.2771}}
@article{bickmore2005,
  author = {Bickmore, Timothy and Gruber, Amanda and Picard, Rosalind},
  title = {Establishing the computer-patient working alliance in automated health behavior change interventions},
  journal = {Patient Education and Counseling}, year = 2005, volume = 59, number = 1, pages = {21--30},
  doi = {10.1016/j.pec.2004.09.008}}
@article{miner2016,
  author = {Miner, Adam S. and Milstein, Arnold and Schueller, Stephen and Hegde, Roshini and Mangurian, Christina and Linos, Eleni},
  title = {Smartphone-Based Conversational Agents and Responses to Questions About Mental Health, Interpersonal Violence, and Physical Health},
  journal = {JAMA Internal Medicine}, year = 2016, volume = 176, number = 5, pages = {619--625},
  doi = {10.1001/jamainternmed.2016.0400}}
@online{nimh2017,
  author = {{National Institute of Mental Health}},
  title = {Depression in college students}, year = 2017, urldate = {2017-03-29},
  url = {https://www.nimh.nih.gov/health/publications/depression-and-college-students/index.shtml}}
@article{eysenbach2011,
  author = {Eysenbach, Gunther and {CONSORT-EHEALTH Group}},
  title = {CONSORT-EHEALTH: Improving and Standardizing Evaluation Reports of Web-based and Mobile Health Interventions},
  journal = {Journal of Medical Internet Research}, year = 2011, volume = 13, number = 4, pages = {e126},
  doi = {10.2196/jmir.1923}}
@book{burns1980,
  author = {Burns, David D.}, title = {Feeling good: The new mood therapy},
  publisher = {Harper Collins}, address = {New York}, year = 1980}
@book{burns2006,
  author = {Burns, David D.}, title = {When panic attacks},
  publisher = {Harmony}, address = {New York}, year = 2006}
@book{towery2016,
  author = {Towery, Jacob}, title = {The anti-depressant book: A practical guide for teens and young adults to overcome depression and stay healthy},
  publisher = {Jacob Towery}, address = {Palo Alto, CA}, year = 2016}
@article{kroenke2001,
  author = {Kroenke, Kurt and Spitzer, Robert L. and Williams, Janet B. W.},
  title = {The PHQ-9: validity of a brief depression severity measure},
  journal = {Journal of General Internal Medicine}, year = 2001, volume = 16, number = 9, pages = {606--613},
  doi = {10.1046/j.1525-1497.2001.016009606.x}}
@article{spitzer2006,
  author = {Spitzer, Robert L. and Kroenke, Kurt and Williams, Janet B. W. and Löwe, Bernd},
  title = {A brief measure for assessing generalized anxiety disorder: the GAD-7},
  journal = {Archives of Internal Medicine}, year = 2006, volume = 166, number = 10, pages = {1092--1097},
  doi = {10.1001/archinte.166.10.1092}}
@article{watson1988,
  author = {Watson, David and Clark, Lee Anna and Tellegen, Auke},
  title = {Development and validation of brief measures of positive and negative affect: the PANAS scales},
  journal = {Journal of Personality and Social Psychology}, year = 1988, volume = 54, number = 6, pages = {1063--1070},
  doi = {10.1037//0022-3514.54.6.1063}}
@article{donker2009,
  author = {Donker, Tara and Griffiths, Kathleen M. and Cuijpers, Pim and Christensen, Helen},
  title = {Psychoeducation for depression, anxiety and psychological distress: a meta-analysis},
  journal = {BMC Medicine}, year = 2009, volume = 7, pages = {79},
  doi = {10.1186/1741-7015-7-79}}
:::
`; // the BibTeX of the works the text cites

// #region tables: Tables 1 and 2, means and their spread in columns of their own
// Nunito Sans sets tabular figures, so numbers right-aligned in a column line up on the
// decimal point; the (SD) or (SE) sits in a column of its own, flush left beside it.
// aligns: one letter per column, l or r; header cells over the numbers are centred.
const cell = (content, align, head) => ({ content, ...(head && { isHeader: true }),
  align: head && align !== 'left' ? 'center' : align });
function grid(rows, widths, aligns, heads, spans) { // spans: [row, col, lastRow, lastCol]
  const a = [...aligns].map((k, c) => (c && k === 'r' ? 'right' : 'left'));
  let m = { headerRowCount: heads, columnWidths: widths,
    rows: rows.map((row, r) => widths.map((_, c) => cell(row[c] ?? '', a[c], r < heads))) };
  for (const [r0, c0, r1, c1] of spans) {
    m = mergeCells(m, { start: { row: r0, col: c0 }, end: { row: r1, col: c1 } });
  }
  return m; // mergeCells marks the covered cells hiddenBy (gotcha: merged-cells-hiddenby)
}
const pair = (s) => s.split(' '); // '13.25 (5.17)' → ['13.25', '(5.17)']
const BASE = [['Depression (PHQ-9)', '13.25 (5.17)', '14.30 (6.65)'],
  ['Anxiety (GAD-7)', '19.02 (4.27)', '18.05 (5.89)'],
  ['Positive affect', '26.19 (8.37)', '25.54 (9.58)'],
  ['Negative affect', '28.74 (8.92)', '24.87 (8.13)'], ['Age, mean (SD)', '21.83 (2.24)',
    '22.58 (2.38)'], ['**Gender, n (%)**'], ['Male', '4 (7)', '7 (21)'],
  ['Female', '20 (55)', '27 (79)'], ['**Ethnicity, n (%)**'], ['Latino/Hispanic', '2 (8)', '2 (6)'],
  ['Non-Latino/Hispanic', '22 (92)', '32 (94)'], ['Caucasian', '18 (75)', '28 (82)'],
  ['Non-Caucasian', '6 (25)', '6 (18)']];
const ITT = [['PHQ-9', '13.67 (.81)', '12.07-15.27', '11.14 (0.71)', '9.74-12.32', '6.03',
  '.017', '0.44'], ['GAD-7', '16.84 (.67)', '15.52-18.56', '17.35 (0.60)', '16.16-18.13', '0.38',
  '.581', '0.14'], ['PANAS positive affect', '26.02 (1.45)', '23.17-28.86', '26.88 (1.29)',
  '24.35-29.41', '0.17', '.707', '0.02'], ['PANAS negative affect', '27.53 (1.42)',
  '24.73-30.32', '25.98 (1.24)', '23.54-28.42', '0.91', '.912', '0.344']];
const table = (id, model, span, caption, note) => ({ id, typeId: 'table', kind: 'table',
  createdAt: 0, updatedAt: 0, placement: { position: 'top', span }, caption, note,
  table: { model } });
const baseline = grid([['', 'Information control', '', 'Woebot', ''],
  ['**Scale, mean (SD)**'], ...BASE.map(([k, a, b]) => (a ? [k, ...pair(a), ...pair(b)] : [k]))],
[26, 13, 10, 13, 10], 'lrlrl', 1, [[0, 1, 0, 2], [0, 3, 0, 4], [1, 0, 1, 4], [7, 0, 7, 4],
  [10, 0, 10, 4]]);
const itt = grid([['', 'Information-only control', '', '', 'Woebot', '', '', '*F*', '*P*',
  '*d*^c^'], ['', 'T2^a^', '', '95% CI^b^', 'T2^a^', '', '95% CI^b^', '', '', ''],
...ITT.map(([k, a, ca, b, cb, ...s]) => [k, ...pair(a), ca, ...pair(b), cb, ...s])],
[30, 10, 9, 18, 10, 9, 18, 8, 8, 8], 'lrlrrlrrrr', 2, [[0, 0, 1, 0], [0, 1, 0, 3],
  [0, 4, 0, 6], [1, 1, 1, 2], [1, 4, 1, 5], [0, 7, 1, 7], [0, 8, 1, 8], [0, 9, 1, 9]]);
const resources = () => [consort,
  table('tbl-baseline', baseline, 'column', 'Demographic and clinical variables of '
    + 'participants at baseline.', 'Participants with data at T2 (N=58), as printed in the '
    + 'original Table 1.'),
  table('tbl-itt', itt, 'page', 'Results of ITT analysis of entire sample on primary outcomes '
    + 'in the study at T2.', '^a^Baseline=pooled mean (standard error). ^b^95% confidence '
    + 'interval. ^c^Cohen *d* shown for between-subjects effects using means and standard '
    + 'errors at Time 2.'),
  ...THEMES.map(({ id, file, caption, note, alt }) => ({ id, typeId: 'figure', kind: 'svg',
    createdAt: 0, updatedAt: 0, placement: { position: 'auto' }, caption, note, altText: alt,
    svg: { fileId: file, width: 845, height: Math.round(themeHeight(id) * 10) } })),
];
// #endregion

// #region art: the CONSORT diagram and the theme charts, their labels in Nunito Sans inline
const n2 = (v) => +v.toFixed(2);
const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;');
// An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so each
// drawing carries its faces inline, as data URLs of the Fontsource files.
async function inlineFaces(family, weights) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  let css = '';
  for (const w of weights) {
    const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}`
      + '-normal.woff2';
    const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer());
    let bin = '';
    for (const b of bytes) bin += String.fromCharCode(b);
    css += `@font-face{font-family:F;font-weight:${w};src:url(data:font/woff2;base64,`
      + `${btoa(bin)}) format('woff2')}`;
  }
  return `<style>${css}text{font-family:F}</style>`;
}
const label = (x, y, s, size, weight, fill, anchor = 'middle') => `<text x="${n2(x)}" `
  + `y="${n2(y)}" font-size="${size}" font-weight="${weight}" fill="${fill}" `
  + `text-anchor="${anchor}">${esc(s)}</text>`;
// A box of centred lines: the first bold, an "n = …" line bold in the accent (white on a
// filled box), the rest regular. Every box is 64, 56 or 26 mm wide; lines 1.3 × the size.
function node(cx, y, w, lines, { size = 2.6, fill = palette.tint, stroke = palette.accent } = {}) {
  const lh = size * 1.3;
  const h = lines.length * lh + 2.2;
  const solid = fill === palette.accent;
  let svg = `<rect x="${n2(cx - w / 2)}" y="${n2(y)}" width="${w}" height="${n2(h)}" rx="1.2" `
    + `fill="${fill}" stroke="${stroke}" stroke-width="0.3"/>`;
  lines.forEach((s, i) => {
    const isN = /^n = /.test(s);
    svg += label(cx, y + 1.1 + lh * (i + 0.78), s, size, i === 0 || isN ? 800 : 400,
      solid ? palette.paper : isN ? palette.accent : palette.ink);
  });
  return { svg, h, top: y, bottom: y + h, mid: y + h / 2 };
}
// Arrows are a line plus a filled triangle path: no <marker> (gotcha: svg-no-marker-filters).
const line = (pts) => `<path d="M${pts.map(([x, y]) => `${n2(x)} ${n2(y)}`).join('L')}" `
  + `fill="none" stroke="${palette.ink}" stroke-width="0.3"/>`;
function arrow(pts) {
  const [[x0, y0], [x1, y1]] = pts.slice(-2);
  const a = Math.atan2(y1 - y0, x1 - x0);
  const p = (d, s) => `${n2(x1 - Math.cos(a) * d + Math.sin(a) * s)} `
    + `${n2(y1 - Math.sin(a) * d - Math.cos(a) * s)}`;
  return line([...pts.slice(0, -1), [x1 - Math.cos(a) * 1.4, y1 - Math.sin(a) * 1.4]])
    + `<path d="M${n2(x1)} ${n2(y1)}L${p(1.8, 0.9)}L${p(1.8, -0.9)}Z" fill="${palette.ink}"/>`;
}
// The four CONSORT stages as pills at the left edge, each centred on a y.
const stage = (y, s) => `<rect x="0" y="${n2(y - 2.2)}" width="${n2(s.length * 1.9 + 5)}" `
  + `height="4.4" rx="2.2" fill="${palette.ink}"/>`
  + label(2.5, y + 0.85, s.toUpperCase(), 2.3, 800, palette.paper, 'start');
function consortSvg(face) { // 176 mm wide, the full measure; one unit is a millimetre
  const [W, L, R, MID] = [176, 59, 117, 88];
  const side = { size: 2.5, fill: palette.paper, stroke: palette.rule };
  const a = node(MID, 0.5, 64, ['Registrations received by email', 'n = 204']);
  const b = node(MID, a.bottom + 3, 64, ['Responded to the email confirmation', 'n = 115']);
  const x = node(150, b.bottom + 0.5, 52, ['Excluded as ineligible', 'n = 45',
    'deemed bot-generated'], side);
  const c = node(MID, x.bottom + 2, 64, ['Randomized by computer algorithm', 'n = 70'],
    { fill: palette.accent });
  const split = c.bottom + 4; // the line that divides the sample between the arms
  const parts = [a.svg, b.svg, x.svg, c.svg, stage(a.mid, 'Enrollment'),
    arrow([[MID, a.bottom], [MID, b.top]]), arrow([[MID, b.bottom], [MID, c.top]]),
    arrow([[MID, x.mid], [124, x.mid]]), stage(split, 'Allocation'),
    line([[MID, c.bottom], [MID, split]]), line([[L, split], [R, split]])];
  const arms = [[L, 13, 'Allocated to Woebot', 34, 'up to 20 sessions over 2 weeks', 3, 31],
    [R, 163, 'Allocated to information-only control', 36, 'NIMH ebook on depression', 11, 25]];
  for (const [cx, sx, title, n, what, gone, kept] of arms) {
    const box = node(cx, split + 3.5, 56, [title, `n = ${n}`, what]);
    const lost = node(sx, box.bottom + 4.5, 26, ['Lost to follow-up', `n = ${gone}`], side);
    const t2 = node(cx, lost.bottom + 2, 56, ['Provided data at T2', `n = ${kept}`]);
    const an = node(cx, t2.bottom + 5.5, 56, ['Analysed (intention to treat)', `n = ${n}`,
      'missing T2 data imputed'], { fill: palette.accent });
    parts.push(box.svg, lost.svg, t2.svg, an.svg, arrow([[cx, split], [cx, box.top]]),
      arrow([[cx, box.bottom], [cx, t2.top]]), arrow([[cx, lost.mid], [sx + (sx < MID ? 13
        : -13), lost.mid]]), arrow([[cx, t2.bottom], [cx, an.top]]));
    if (cx === R) parts.push(stage(lost.top - 3, 'Follow-up'), stage(t2.bottom + 2.75, 'Analysis'));
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" width="1760" height="${CONSORT_H * 10}" `
    + `viewBox="0 0 ${W} ${CONSORT_H}">${face}${parts.join('')}</svg>`;
}
// Figures 3 and 4 of the original are thematic maps; here they are bars, one per subtheme.
const THEMES = [
  { id: 'fig-best', file: 'best.svg', themes: [['Process', 31, [['Checking in / accountability', 9],
    ['Empathy / personality', 7], ['Learning', 12, [['Emotions', 5], ['General insight', 5],
      ['Cognitions', 2]]], ['Conversation', 3]]], ['Content', 16, [['Videos', 7], ['Games', 3],
    ['Suggestions', 2], ['Weekly graphs', 1]]]],
  caption: 'Best features of the Woebot experience: themes of the answers to “What was the best '
    + 'thing about your experience using Woebot?”',
  note: 'Number of participants per theme. Redrawn as bars from the thematic map in Figure 3 of '
    + 'the original; numbers as printed there.',
  alt: 'Bars for Process (31): checking in 9, empathy 7, learning 12 (emotions 5, general '
    + 'insight 5, cognitions 2), conversation 3; Content (16): videos 7, games 3, suggestions 2, '
    + 'weekly graphs 1.' },
  { id: 'fig-worst', file: 'worst.svg', themes: [['Process violations', 15,
    [['Not being able to converse naturally', 10], ['Repetitive', 2], ['Miscellaneous', 3]]],
  ['Technical problems', 8, [['Glitches', 4], ['Looping', 4]]], ['Content', 8,
    [['Emoticons', 2], ['Interactions too short', 2], ['Videos too long', 2], ['Other', 2]]]],
  caption: 'Least favored experiences: themes of the answers to “What was the worst thing about '
    + 'your experience of using Woebot?”',
  note: 'Number of participants per theme. Redrawn as bars from the thematic map in Figure 4 of '
    + 'the original; numbers as printed there.',
  alt: 'Bars for Process violations (15): not conversing naturally 10, repetitive 2, '
    + 'miscellaneous 3; Technical problems (8): glitches 4, looping 4; Content (8): emoticons, '
    + 'short interactions, long videos and other, 2 each.' },
];
const rows = (themes) => themes.flatMap(([name, n, subs]) => [{ name, n, depth: 0 },
  ...subs.flatMap(([s, k, deeper = []]) => [{ name: s, n: k, depth: 1 },
    ...deeper.map(([d, m]) => ({ name: d, n: m, depth: 2 }))])]);
const ROW = 4.4;
function themeHeight(id) { return rows(THEMES.find((f) => f.id === id).themes).length * ROW + 2; }
function themeSvg({ id, themes }, face) { // 84.5 mm wide: one column
  const [W, X0, X1] = [84.5, 48, 79];
  const H = themeHeight(id);
  let svg = '';
  rows(themes).forEach(({ name, n, depth }, i) => {
    const y = 1 + i * ROW;
    if (!depth) {
      svg += `<path d="M0 ${n2(y + 0.2)}H${W}" stroke="${palette.rule}" stroke-width="0.25"/>`
        + label(0, y + 3.1, `${name} (${n})`, 2.75, 800, palette.ink, 'start');
      return;
    }
    const w = (n / 12) * (X1 - X0);
    svg += label(depth * 3 - 1, y + 3, name, 2.45, 400, palette.ink, 'start')
      + `<rect x="${X0}" y="${n2(y + 0.9)}" width="${n2(w)}" height="2.6" `
      + `fill="${depth === 1 ? palette.accent : palette.soft}"/>`
      + label(X0 + w + 1.2, y + 3, String(n), 2.55, 800, palette.ink, 'start');
  });
  return `<svg xmlns="http://www.w3.org/2000/svg" width="845" height="${Math.round(H * 10)}" `
    + `viewBox="0 0 ${W} ${n2(H)}">${face}${svg}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { Lora: ['400', '400i', '700', '700i'],
  'Nunito Sans': ['400', '400i', '600', '700', '800'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const paper = `${markdown}\n\n${results}\n\n${references}`;
await loadFonts(FONTS, paper);
const face = await inlineFaces(SANS, [400, 800]);
await loadSvg('consort.svg', consortSvg(face));
for (const fig of THEMES) await loadSvg(fig.file, themeSvg(fig, face));
const content = { markdown: paper, resources: resources() };
const doc = await buildWithFonts(() => buildDocument(content, config()), paper);
showPages(doc, { title: 'A clinical trial report with a CONSORT diagram' });
offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
  `${RECIPE}.pdf`);

// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ──────────────
/** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen
 *  used, snapping to a weight the family ships and falling back to upright
 *  when it has no italic: the PDF asks for every face a block could use. */
async function fontsourceProvider(family, weight, style) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  const weights = meta?.weights?.length ? meta.weights : [400, 700];
  const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a));
  const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style;
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`);
  if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}

/** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new
 *  tab, since CodePen's preview frame cannot show PDFs) and a download link. */
function offerPdf(makePdf, filename) {
  viewer();
  const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' });
  button.dataset.postextPdf = filename;
  button.addEventListener('click', async () => {
    button.disabled = true;
    button.textContent = 'Building the PDF…';
    try {
      const bytes = await makePdf();
      const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
      const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`;
      button.replaceWith(
        Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }),
        Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` }));
    } catch (error) {
      button.disabled = false;
      button.textContent = 'Build the PDF';
      kitFail(error);
    }
  });
  document.getElementById('pt-actions').append(button);
}

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

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

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

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

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

## Variants

### Cita amb números a la manera de Vancouver

Les revistes mèdiques solen numerar les cites; el BibTeX i el text no canvien.

```diff
-  citations: { style: 'apa', link: true,
+  citations: { style: 'elsevier-vancouver', marker: 'superscript', link: true,
```

### Afegeix un número de ClinicalTrials.gov

Aquest assaig es va considerar exempt de registre i ho diu. Un assaig registrat dona el seu registre i el seu número a la mateixa línia del requadre, per exemple `**Trial registration** ClinicalTrials.gov NCT01234567`, amb el número del teu propi registre.

### Un diagrama sense la fila d'anàlisi

Si l'informe no diu a qui es va analitzar, acaba cada branca a les caixes de seguiment: treu el node `an` i la seva fletxa, i abaixa `CONSORT_H`.

## Errors freqüents

- **El text dins d'un SVG <img> no pot fer servir fonts web.** Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu.
- **Sense <marker> ni filtres als SVG, o passen a mapa de bits.** Una figura SVG només continua sent vectorial al PDF sense <marker>, filtres ni màscares; si no, passa a mapa de bits, i els filtres molt imbricats la poden deixar en blanc a Chrome. Dibuixa les puntes de fletxa com a traçats.
- **Els fitxers latin de Fontsource només porten glifs de l'interval llatí.** El proveïdor del PDF incrusta els fitxers latin de Fontsource, que cobreixen el castellà i les llengües de l'Europa occidental però no →, ≈, ✓, ★, el grec ni les lletres de l'Europa central; aquests glifs falten al PDF. Mantén el text del PDF dins de l'interval latin.
- **Les cel·les combinades necessiten hiddenBy: fes servir mergeCells.** Les cel·les es col·loquen segons la seva posició a la fila, així que una cel·la combinada necessita cel·les de farciment marcades amb hiddenBy on s'estén; ometre-les, com en HTML, desplaça totes les columnes següents. Combina cel·les amb mergeCells.
- **Un $ sol obre matemàtiques: escriu \$.** El signe de dòlar obre matemàtiques en línia, de manera que un preu com $40 comença una fórmula. Escriu \$40.
- **Qualsevol objecte headings desactiva el salt de pàgina de l'H1.** Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració.
- **Un flotant 'top' no cau mai a la pàgina que el cita.** Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita.
- **Carrega totes les fonts abans de compondre.** La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard.
- **Una configuració es desa a la memòria cau per identitat: crea un objecte nou.** El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config().

- L'article no té número NCT: els autors escriuen que l'assaig va quedar exempt de registre. La línia de registre ho diu, amb el motiu que dona l'article, en lloc d'inventar un número.
- Una taula que comparteix columna amb una figura a tota l'amplada s'atura tres línies abans del peu i continua a la pàgina següent. La Taula 1 hi cap sencera perquè les seves files van a 7,5 pt amb 0,45 mm de farciment i el dibuix CONSORT fa 107 mm d'alt.
- L'article escriu χ² a les proves de khi quadrat. Aquí les fonts del PDF vénen dels fitxers latin de Fontsource, que no tenen grec, així que aquesta edició ho escriu amb lletres i ho indica al colofó.

## Crèdits

- Recepta: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Text and data: Fitzpatrick KK, Darcy A, Vierhile M. Delivering Cognitive Behavior Therapy to Young Adults With Symptoms of Depression and Anxiety Using a Fully Automated Conversational Agent (Woebot): A Randomized Controlled Trial. JMIR Mental Health 2017;4(2):e19, doi:10.2196/mental.7785. Originally published in JMIR Mental Health (http://mental.jmir.org), 06.06.2017. Abridged; figures redrawn in code from the article's numbers; citations converted to APA author–date: © Kathleen Kara Fitzpatrick, Alison Darcy, Molly Vierhile ([font](https://doi.org/10.2196/mental.7785)), CC-BY-4.0
- Tipus de lletra: Lora (OFL-1.1), Nunito Sans (OFL-1.1)
- Codi: MIT · Contingut d'exemple: CC-BY-4.0

## Relacionades

- [Núm. 095 · Un article mèdic en estil Vancouver](https://postext.dev/ca/cookbook/medical-article-vancouver.md): Un assaig clínic en A4 a dues columnes: números de cita volats, una bibliografia numerada a la manera de Vancouver i DOI que s'obren com a enllaços al PDF. · Nivell 2 (Intermedi) · Articles i treballs acadèmics
- [Núm. 087 · Un capítol de tesi citat en APA 7](https://postext.dev/ca/cookbook/apa-thesis-with-bibtex.md): Un capítol de tesi doctoral amb citacions [@clau, p. 33] que surten en APA 7 gràcies a citeproc-js, amb la llista de referències presa d'un bloc BibTeX. · Nivell 2 (Intermedi) · Articles i treballs acadèmics, Informes i memòries
- [Núm. 009 · Figures que suren fins on les cites](https://postext.dev/ca/cookbook/figures-float-where-cited.md): Capítol a dues columnes amb set figures numerades: sis suren del primer :ref al primer buit que admet la seva col·locació; una va on la posa ::resource. · Nivell 3 (Avançat) · Llibres de text
