# Un texte dans quatre styles de citation

> Un court essai avec six citations, composé en APA 7, Chicago auteur-date, IEEE et ISO 690 : seul le réglage du style change.

- Version HTML: https://postext.dev/fr/cookbook/one-text-four-citation-styles
- Recette Nº 093 · Structure du livre · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Articles et publications universitaires, Rapports
- Nécessite postext ≥ 1.28.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/one-text-four-citation-styles/en/p01.webp?v=03594731), [1](https://postext.dev/cookbook/one-text-four-citation-styles/en/p02.webp?v=03594731), [1](https://postext.dev/cookbook/one-text-four-citation-styles/en/p03.webp?v=03594731), [1](https://postext.dev/cookbook/one-text-four-citation-styles/en/p04.webp?v=03594731)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=one-text-four-citation-styles&lang=en (.postext: https://postext.dev/cookbook/one-text-four-citation-styles/en/one-text-four-citation-styles.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/one-text-four-citation-styles.md), [es](https://postext.dev/es/cookbook/one-text-four-citation-styles.md), [ca](https://postext.dev/ca/cookbook/one-text-four-citation-styles.md), [pt](https://postext.dev/pt/cookbook/one-text-four-citation-styles.md), [zh](https://postext.dev/zh/cookbook/one-text-four-citation-styles.md), [ja](https://postext.dev/ja/cookbook/one-text-four-citation-styles.md), [ar](https://postext.dev/ar/cookbook/one-text-four-citation-styles.md)

## En bref

Les deux mêmes paragraphes sur la typographie, avec les mêmes sources, imprimés quatre fois. Chaque page cite les sources à sa façon, et les citations comme la liste finale changent avec elle, alors que le texte n’a été écrit qu’une fois.

## Ce que vous allez composer

Un essai de deux paragraphes sur la raison pour laquelle la bonne typographie passe inaperçue, avec six sources : une citation narrative, une avec une page, une qui omet l’auteur parce que la phrase le nomme, et deux ouvrages cités ensemble. Le texte est composé quatre fois sur une page de 155 × 235 mm, et la seule différence entre les quatre compositions est `citations.style` : APA 7, Chicago auteur-date, IEEE et ISO 690. Chaque page porte le nom du style dans un bandeau de sa propre couleur ; posées côte à côte, elles se lisent comme un tableau comparatif. Les citations changent de forme, d’ordre et de ponctuation, la liste change de tri et de forme des noms, et l’essai lui-même reste le même fichier.

**Cette recette répond à:**

- Comment faire passer un texte d’un style de citation à un autre sans y toucher ?
- Comment citer des ouvrages et construire la bibliographie en APA, en IEEE ou dans un autre style de citation ?

## La réponse courte

```js
// script.js, lignes 28–47
// Citations are written once, Pandoc's way: [@key], [@a; @b], [-@key] without the author,
// @key [p. 17] inside the sentence. citeproc-js writes them, and the list, in the style named.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const SHOWN = [ // the same text, built once per entry
  { style: 'apa', band: 'apa', name: 'APA 7', system: t({ en: 'author-date', es: 'autor-año' }) },
  { style: 'chicago-author-date', band: 'chicago', name: 'Chicago',
    system: t({ en: 'author-date', es: 'autor-año' }) },
  { style: 'ieee', band: 'ieee', name: 'IEEE', system: t({ en: 'numbered', es: 'numérico' }) },
  { style: t({ en: 'iso690-author-date-en', es: 'iso690-author-date-es' }), band: 'iso',
    name: 'ISO 690', system: t({ en: 'author-date', es: 'autor-año' }) },
];
const citations = (style) => ({
  style, // 'apa' → '(Warde, 1955)'; 'ieee' → '[1]'; the list is sorted and set to match
  link: true,
  // The authors' names stay whole in the citations, the text and the list: the APA build
  // set '(Lup-' | 'ton, 2010)'.
  hyphenateNames: false,
  bibliography: { fontSize: em(0.85), lineHeight: pt(11.2), hangingIndent: mm(6),
    entrySpacing: pt(2), labelWidth: mm(6), doi: 'hide' },
});
```

## Ingrédients

**Enseigne**

- [Citations dans un style bibliographique](https://postext.dev/fr/docs/document-format.md#citations-et-bibliographie): Des ouvrages cités sous la forme [@key, p. 33] et mis en forme dans un style CSL (APA, Chicago, MLA, IEEE, Vancouver, ISO 690, GB/T 7714…) choisi dans les réglages, liés à leurs entrées.

**Utilise aussi**

- [Bibliographie tirée des références](https://postext.dev/fr/docs/document-format.md#citations-et-bibliographie)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`citations`](https://postext.dev/fr/docs/configuration-notes-references.md#citations), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page)

**API**

- [`LOCALES`](https://postext.dev/fr/docs/document-format.md#citations-et-bibliographie), [`STYLES`](https://postext.dev/fr/docs/document-format.md#citations-et-bibliographie), [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`createCiteprocEngine`](https://postext.dev/fr/docs/document-format.md#citations-et-bibliographie), [`registerCitationEngine`](https://postext.dev/fr/docs/document-format.md#citations-et-bibliographie), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

- Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)

## Préparation

### 1 · Un seul réglage

Le code est [la réponse courte](https://postext.dev/fr/cookbook/one-text-four-citation-styles.md#la-réponse-courte) ci-dessus. Les citations sont écrites une fois dans le contenu, dans la syntaxe de Pandoc, et les références se trouvent dans un bloc BibTeX à la fin. `citations.style` désigne un style CSL, et citeproc-js en tire chaque citation et chaque entrée : APA met une virgule avant l’année et trie deux ouvrages par auteur, Chicago garde l’ordre dans lequel ils ont été écrits, IEEE les transforme en numéros dans l’ordre de première citation, ISO 690 met les noms de famille de la liste en capitales. Pour un livre en cours, c’est tout le changement : une valeur dans la configuration, et le texte n’est pas touché.

### 2 · Quatre compositions, deux doubles pages

```js
// script.js, lignes 149–156
// The style is set for the whole document, so each style is a build of its own.
const docs = [];
for (at = 0; at < SHOWN.length; at++) {
  // pageIndexOffset makes each page the next one of a book, so the desk pairs them:
  // APA beside Chicago, IEEE beside ISO 690.
  const content = { markdown, continuation: { pageIndexOffset: at + 1 } };
  docs.push(await buildDocumentWithFonts(content, config(), kitFonts(FONTS)));
}
```

Le style vaut pour tout le document : les quatre versions sont donc quatre compositions du même Markdown. Chacune reçoit un `pageIndexOffset` supérieur d’une unité au précédent, ce qui fait de sa page la page suivante d’un livre imaginaire ; la table de travail les réunit ensuite en deux doubles pages, en plaçant côte à côte les deux styles auteur-date qui diffèrent le moins.

### 3 · Le nom du style dans un bandeau

```js
// script.js, lignes 51–72
const opener = (s, i) => ({
  enabled: true,
  minHeight: mm(41), // from the top margin to under the title, 63 mm from the trim
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col(s.band) },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(44) } } },
    { kind: 'text', id: 'kicker', content: `${t({ en: 'Citation style', es: 'Estilo de cita' })}`
      + ` · ${i + 1} / ${SHOWN.length} · ${s.system}`, fontFamily: SANS, fontSize: pt(8),
    fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('paper'),
    align: 'left',
    placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(18), y: mm(12) } } },
    { kind: 'text', id: 'name', content: s.name, fontFamily: SERIF, fontSize: pt(54),
      fontWeight: 600, lineHeight: 1, color: col('paper'), align: 'left', overflow: 'clip',
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(5) },
        size: { width: mm(119), height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF, fontSize: pt(20),
      fontWeight: 600, lineHeight: 1.15, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(18), y: mm(52) },
        size: { width: mm(119), height: 'auto' } } },
  ] },
});
```

La maquette du titre dessine le bandeau depuis le bord du format, le surtitre avec le numéro et le système du style, et le nom en 54 pt. Le nom et le système viennent du script, pas du Markdown, puisque le texte est le même dans les quatre documents.

```js
// script.js, lignes 15–22
const palette = {
  ink: '#1d1b19', muted: '#66605a', rule: '#cfc8bf', paper: '#ffffff',
  apa: '#2f5d8a', chicago: '#8c2f2b', ieee: '#24476b', iso: '#3c6b4f', // the four bands
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Each document points main-color at its own band, so any default left follows it.
const colorPalette = (band) => Object.entries({ ...palette, 'main-color': palette[band] })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
```

Chaque document fait pointer `main-color` vers son propre bandeau : les quatre couleurs sont des entrées de palette liées, et toute valeur par défaut que la configuration ne précise pas suit le bandeau de sa page.

## La recette complète

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

- Dossier de la recette: https://github.com/drnachio/postext/tree/main/cookbook/one-text-four-citation-styles

### script.js

```js
// ═══ Postext Cookbook · Nº 093 · One text in four citation styles ════════════════
// https://postext.dev/en/cookbook/one-text-four-citation-styles
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Source Serif 4, Source Sans 3 (SIL OFL 1.1) · Needs postext ≥ 1.28.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerCitationEngine,
} from 'https://esm.sh/postext';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'one-text-four-citation-styles';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink, and a colour for each style's band
const palette = {
  ink: '#1d1b19', muted: '#66605a', rule: '#cfc8bf', paper: '#ffffff',
  apa: '#2f5d8a', chicago: '#8c2f2b', ieee: '#24476b', iso: '#3c6b4f', // the four bands
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Each document points main-color at its own band, so any default left follows it.
const colorPalette = (band) => Object.entries({ ...palette, 'main-color': palette[band] })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [SERIF, SANS] = ['Source Serif 4', 'Source Sans 3'];
const LEAD = 14; // pt

// #region answer: the style is one setting; everything else stays the same
// Citations are written once, Pandoc's way: [@key], [@a; @b], [-@key] without the author,
// @key [p. 17] inside the sentence. citeproc-js writes them, and the list, in the style named.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const SHOWN = [ // the same text, built once per entry
  { style: 'apa', band: 'apa', name: 'APA 7', system: t({ en: 'author-date', es: 'autor-año' }) },
  { style: 'chicago-author-date', band: 'chicago', name: 'Chicago',
    system: t({ en: 'author-date', es: 'autor-año' }) },
  { style: 'ieee', band: 'ieee', name: 'IEEE', system: t({ en: 'numbered', es: 'numérico' }) },
  { style: t({ en: 'iso690-author-date-en', es: 'iso690-author-date-es' }), band: 'iso',
    name: 'ISO 690', system: t({ en: 'author-date', es: 'autor-año' }) },
];
const citations = (style) => ({
  style, // 'apa' → '(Warde, 1955)'; 'ieee' → '[1]'; the list is sorted and set to match
  link: true,
  // The authors' names stay whole in the citations, the text and the list: the APA build
  // set '(Lup-' | 'ton, 2010)'.
  hyphenateNames: false,
  bibliography: { fontSize: em(0.85), lineHeight: pt(11.2), hangingIndent: mm(6),
    entrySpacing: pt(2), labelWidth: mm(6), doi: 'hide' },
});
// #endregion

// #region band: the style's name, large, in a band of its colour
const opener = (s, i) => ({
  enabled: true,
  minHeight: mm(41), // from the top margin to under the title, 63 mm from the trim
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col(s.band) },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(44) } } },
    { kind: 'text', id: 'kicker', content: `${t({ en: 'Citation style', es: 'Estilo de cita' })}`
      + ` · ${i + 1} / ${SHOWN.length} · ${s.system}`, fontFamily: SANS, fontSize: pt(8),
    fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('paper'),
    align: 'left',
    placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(18), y: mm(12) } } },
    { kind: 'text', id: 'name', content: s.name, fontFamily: SERIF, fontSize: pt(54),
      fontWeight: 600, lineHeight: 1, color: col('paper'), align: 'left', overflow: 'clip',
      placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { x: mm(0), y: mm(5) },
        size: { width: mm(119), height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF, fontSize: pt(20),
      fontWeight: 600, lineHeight: 1.15, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(18), y: mm(52) },
        size: { width: mm(119), height: 'auto' } } },
  ] },
});
// #endregion

let at = 0; // the entry of SHOWN being built
const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette: colorPalette(SHOWN[at].band),
  citations: citations(SHOWN[at].style),
  page: { sizePreset: 'custom', width: mm(155), height: mm(235), dpi: 150,
    margins: { top: mm(22), bottom: mm(20), left: mm(18), right: mm(18) } },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: SERIF, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: SERIF, color: col('ink'), levels: [
    { level: 1, breakBefore: { enabled: true, parity: 'any' }, // the essay opens a page
      advancedDesign: opener(SHOWN[at], at) },
  ] },
  header: { elements: [] },
  footer: { elements: [
    { kind: 'text', id: 'colophon', content: t({
      en: 'Set in Source Serif 4 and Source Sans 3 (SIL OFL) · Text: original, CC BY 4.0',
      es: 'Compuesto en Source Serif 4 y Source Sans 3 (SIL OFL) · Texto: original, CC BY 4.0' }),
    fontFamily: SANS, fontSize: pt(7), color: col('muted'),
    placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(18), y: mm(-11) } } },
  ] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The invisible page"
author: "Postext Cookbook"
---

# The page that is not seen

Good typography is meant to go unnoticed. @warde1955 put the idea in an image that printers still quote: a page should be a crystal goblet, a clear glass that shows the wine, rather than a cup of gold that hides it. Tschichold argued much the same about the book, which in his essays serves its reader before it serves its designer [-@tschichold1991]. The argument is moral as much as practical, and @bringhurst2004 [p. 17] makes it the first principle of his manual: typography exists to honour the content.

The practical half has evidence behind it. Tests of legibility measured how the size of the type, the length of the line and the leading change the speed of reading, and the study of eye movements later explained why an even texture matters: the eye takes in a few letters at each fixation and plans its next jump from the shape of the line ahead [@tinker1963; @rayner1998]. A page that draws attention to itself costs the reader something, however little. None of this makes design a matter of taste alone. A reader who never notices a well-made page notices a badly made one at once, and the grammar of a good one can be taught [@lupton2010].

:::references{format=bibtex}
@book{warde1955,
  author = {Warde, Beatrice}, title = {The Crystal Goblet: Sixteen Essays on Typography},
  editor = {Jacob, Henry}, publisher = {Sylvan Press}, address = {London}, year = 1955}
@book{tschichold1991,
  author = {Tschichold, Jan}, title = {The Form of the Book: Essays on the Morality of Good Design},
  translator = {Hadeler, Hajo}, publisher = {Hartley \& Marks}, address = {Point Roberts, WA},
  year = 1991}
@book{bringhurst2004,
  author = {Bringhurst, Robert}, title = {The Elements of Typographic Style}, edition = {3},
  publisher = {Hartley \& Marks}, address = {Point Roberts, WA}, year = 2004}
@book{tinker1963,
  author = {Tinker, Miles A.}, title = {Legibility of Print},
  publisher = {Iowa State University Press}, address = {Ames, IA}, year = 1963}
@article{rayner1998,
  author = {Rayner, Keith},
  title = {Eye movements in reading and information processing: 20 years of research},
  journal = {Psychological Bulletin}, volume = 124, number = 3, pages = {372--422}, year = 1998,
  doi = {10.1037/0033-2909.124.3.372}}
@book{lupton2010,
  author = {Lupton, Ellen}, title = {Thinking with Type}, edition = {2},
  publisher = {Princeton Architectural Press}, address = {New York}, year = 2010}
:::
`;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = {
  'Source Serif 4': ['400', '400i', '600', '600i', '700', '700i'], // 700: the list's title
  'Source Sans 3': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
// #region build: one text, four documents, shown as two spreads
// The style is set for the whole document, so each style is a build of its own.
const docs = [];
for (at = 0; at < SHOWN.length; at++) {
  // pageIndexOffset makes each page the next one of a book, so the desk pairs them:
  // APA beside Chicago, IEEE beside ISO 690.
  const content = { markdown, continuation: { pageIndexOffset: at + 1 } };
  docs.push(await buildDocumentWithFonts(content, config(), kitFonts(FONTS)));
}
// #endregion
showPages(docs, { title: t({ en: 'One text, four styles', es: 'Un texto, cuatro estilos' }) });

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

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

// ─── Kit · fonts v3 ── the same in every recipe · postext.dev/cookbook
// Postext loads the faces a config asks for before layout (prepareFonts,
// buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the
// files the PDF embeds too.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variantes

### Essayer un style à notes

Avec Chicago notes et bibliographie, chaque citation devient une note de bas de page et la liste une bibliographie ; le texte ne change toujours pas.

```diff
-  { style: 'apa', band: 'apa', name: 'APA 7', system: t({ en: 'author-date', es: 'autor-año' }) },
+  { style: 'chicago-notes-bibliography', band: 'apa', name: 'Chicago', system: 'notes' },
```

### Écrire les numéros en exposant

Un style numérique peut garder sa liste et imprimer les citations en chiffres en exposant, comme Nature et Vancouver.

```diff
-  link: true,
+  link: true, marker: 'superscript',
```

## Erreurs fréquentes

- Un localisateur s’écrit une fois, et chaque style imprime son propre mot pour le désigner : « p. 17 » dans les quatre ici. Le style ISO 690 espagnol fourni imprime « p. » pour tout type de localisateur, section ou chapitre compris : ce texte cite donc une page.
- Les numéros d’édition s’écrivent en nombres dans BibTeX (`edition = {3}`) : chaque style écrit alors « 3rd ed. », « 3.ª ed. » ou « 3. » à sa manière. Écrit « 3rd », le mot s’imprime tel quel et la forme propre au style est perdue.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Caractères: Source Serif 4 (OFL-1.1), Source Sans 3 (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 097 · Une liste de lectures tirée d’un export BibTeX de Zotero](https://postext.dev/fr/cookbook/zotero-bibtex-nocite.md): Une liste de lectures de séminaire : un export BibTeX de Zotero collé tel quel, cité en Chicago auteur-date, avec nocite pour les ouvrages jamais cités. · Niveau 2 (Intermédiaire) · Manuels scolaires, Rapports
- [Nº 087 · Un chapitre de thèse cité en APA 7](https://postext.dev/fr/cookbook/apa-thesis-with-bibtex.md): Un chapitre de thèse dont les citations [@key, p. 33] passent en APA 7 grâce à citeproc-js, avec une liste de références construite à partir d’un bloc BibTeX. · Niveau 2 (Intermédiaire) · Articles et publications universitaires, Rapports
- [Nº 096 · Un essai de lettres avec ses œuvres citées MLA](https://postext.dev/fr/cookbook/mla-humanities-essay.md): Un essai littéraire dont les citations [@key, 48] deviennent des références auteur-page MLA 9, avec une citation en bloc et des Works Cited tirés de BibTeX. · Niveau 2 (Intermédiaire) · Articles et publications universitaires
