# CV d’une page avec colonne latérale

> CV A4 d’une page : la bande prune est un encadré dans une colonne gauche de flottants, prolongé dans les marges par l’en-tête ; dates alignées à droite.

- Version HTML: https://postext.dev/fr/cookbook/cv-with-sidebar
- Recette Nº 057 · Page et grille · Niveau 2 (Intermédiaire) · Sorties: Canvas
- Genres: Feuilles volantes et éphémères
- Nécessite postext ≥ 1.25.0 · testée avec 1.31.0 le 2026-10-13
- Pages: [1](https://postext.dev/cookbook/cv-with-sidebar/en/p01.webp?v=85dc1567)
- Ouvrir dans le Sandbox: https://postext.dev/fr/sandbox#recipe=cv-with-sidebar&lang=en (.postext: https://postext.dev/cookbook/cv-with-sidebar/en/cv-with-sidebar.postext)
- Dernière mise à jour: 2026-10-11
- Autres langues: [en](https://postext.dev/en/cookbook/cv-with-sidebar.md), [es](https://postext.dev/es/cookbook/cv-with-sidebar.md), [ca](https://postext.dev/ca/cookbook/cv-with-sidebar.md), [pt](https://postext.dev/pt/cookbook/cv-with-sidebar.md), [zh](https://postext.dev/zh/cookbook/cv-with-sidebar.md), [ja](https://postext.dev/ja/cookbook/cv-with-sidebar.md), [ar](https://postext.dev/ar/cookbook/cv-with-sidebar.md)

## En bref

Un CV d’une page pour une graphiste du livre, avec une bande colorée sur le côté gauche pour les coordonnées et les compétences. Les dates de chaque poste s’alignent à droite.

## Ce que vous allez composer

Le CV d’Irene Salcedo, une graphiste du livre fictive installée à Madrid, sur une feuille A4. Une bande prune de 66 mm de large descend le long du bord gauche, du haut de la feuille jusqu’en bas. En tête, une pile de livres reliés toile, dessinée en code, se tient au niveau de la ligne de base de son nom. Dessous viennent ses coordonnées, ses compétences et ses outils composés en pastilles arrondies, ses langues, sa formation et son enseignement, et ce qu’elle fait en dehors du travail. Son nom, en Hedvig Letters Serif de 46 pt, ouvre une colonne de texte de 118 mm avec un profil de cinq lignes, trois postes et la liste des livres qu’elle a mis en page. Chaque titre de poste place l’intitulé à gauche et les dates à droite sur la même ligne de base, avec l’employeur en italique dessous. Le premier titre de la colonne latérale partage sa ligne de base avec le métier inscrit sous le nom.

**Cette recette répond à:**

- Comment mettre en page un CV d’une page avec une colonne latérale colorée et les dates alignées à droite ?
- Comment ajouter un filigrane, une teinte de fond ou une image décorative sur chaque page ?
- Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?

## La réponse courte

```js
// script.js, lignes 47–67
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins
  sideColumnSide: 'left',
  sideColumnRole: 'floats', // no body text: only boxes fenced with span="side"
  gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm
};
// A side box stands where the text has reached at its fence, so the Markdown opens with it,
// before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence):
//   :::callout{type="sidebar" span="side"}
//   :::callout{type="section" title="Contact"} … :::     ← the sections nest inside it
//   …
//   :::space{lines=2.76}    ← last: runs the band to the column's foot; a fifth of a line
//   :::                       more and the whole box moves to page 2
//   # Irene Salcedo {role="Book designer and art director"}
const sidebar = { id: 'sidebar', background: col('band'),
  // The first title stands on the role's line. The text starts 10 mm from the trim, the
  // left margin plus 1 mm, and stops 10 mm short of the band's right edge.
  padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) },
  body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'),
    boldColor: col('paper'), paragraphSpacing: false } };
```

## Ingrédients

**Enseigne**

- [Colonne marginale pour les flottants](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page): Une page en colonne et demie dont la colonne latérale ne reçoit pas de texte courant, seulement les figures, tableaux et encadrés qu’on y place, comme dans la marge de nombreux manuels.
- [Colonne et demie](https://postext.dev/fr/docs/configuration-page-layout.md#types-de-mise-en-page): Une mise en page asymétrique : une colonne principale large et une colonne latérale étroite, d’un côté fixe ou sur le bord extérieur de chaque page.
- [Notes marginales](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré): Des encadrés composés dans la colonne latérale à la hauteur du paragraphe qu’ils glosent, dans une mise en page en colonne et demie avec un canal pour les flottants.

**Utilise aussi**

- [Encadrés imbriqués](https://postext.dev/fr/docs/configuration-styles.md#le-conteneur-callout)
- [Encadrés](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré)
- [Espace vertical explicite](https://postext.dev/fr/docs/document-format.md#space)
- [Pastilles dans le texte](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)
- [Texte, filets et boîtes dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)
- [Images dans les maquettes de page](https://postext.dev/fr/docs/configuration-page-layout.md#éléments-image)
- [Ancrer les éléments de maquette](https://postext.dev/fr/docs/configuration-page-layout.md#placement-des-éléments)
- [Niveaux de titre](https://postext.dev/fr/docs/configuration-text.md#réglages-par-niveau)
- [Attributs de titre](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)
- [Listes à puces et listes de contrôle](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)
- [Styles de paragraphe](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe)
- [Palette de couleurs sémantique](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs)
- [Pages sur un canvas](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)
- [Ouvertures composées](https://postext.dev/fr/docs/configuration-text.md#étendue-et-maquette-avancée)
- [Figures et tableaux comme ressources](https://postext.dev/fr/docs/document-format.md#ressources)

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

- [`bodyText`](https://postext.dev/fr/docs/configuration-text.md#texte-courant), [`calloutStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-dencadré), [`chipStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille), [`colorPalette`](https://postext.dev/fr/docs/configuration-fonts-colors-viewers.md#palette-de-couleurs), [`footer`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`header`](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page), [`headings`](https://postext.dev/fr/docs/configuration-text.md#titres), [`layout`](https://postext.dev/fr/docs/configuration-page-layout.md#mise-en-page), [`locale`](https://postext.dev/fr/docs/configuration-text.md#césure), [`page`](https://postext.dev/fr/docs/configuration-page-layout.md#page), [`paragraphStyles`](https://postext.dev/fr/docs/configuration-styles.md#styles-de-paragraphe), [`unorderedLists`](https://postext.dev/fr/docs/configuration-text.md#listes-à-puces)

**API**

- [`buildDocumentWithFonts`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#charger-les-polices-avant-la-mise-en-page), [`registerResourceImage`](https://postext.dev/fr/docs/architecture.md#surface-de-lapi), [`renderPageToCanvas`](https://postext.dev/fr/docs/configuration-programmatic-usage.md#rendre-une-page-en-bitmap)

**Caractères**

- Hedvig Letters Serif (OFL-1.1), Hanken Grotesk (OFL-1.1)

## Préparation

### 1 · Imbriquer les sections dans un encadré latéral

```js
// script.js, lignes 91–102
// The sections nest in one side box. As separate side boxes they would stand at least a line
// of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside
// its parent (gotcha: nested-callout-limits).
const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  marginTop: mm(10),
  titleStyle: { ...caps('glow'), gap: mm(2.4) },
  body: { ...sidebar.body, lineHeight: pt(lineHeight) } });
const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true,
  background: col('chip'), color: col('paper'), borderWidth: pt(0),
  borderRadius: em(1), // past half the chip's height: a pill
  paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }];
```

Dans la colonne, la bande est l’encadré de [la réponse courte](https://postext.dev/fr/cookbook/cv-with-sidebar.md#la-réponse-courte). Sa clôture vient avant le nom : `span="side"` le place donc en tête de la colonne réservée aux flottants. Les sections sont des encadrés imbriqués dedans, sans fond propre, si bien que le prune court sans interruption de « Contact » à « Outside work ». En encadrés latéraux séparés, elles seraient espacées de 4,7 à 9,1 mm, même avec `marginBottom` à 0. Les pastilles font environ 3,9 mm de haut. « Skills » et « Tools » utilisent leur propre style de section, `chips`, dont l’interlignage de 16 pt laisse 1,8 mm entre les rangées ; les 12,4 pt des autres sections laisseraient un demi-millimètre ([styles de pastille](https://postext.dev/fr/docs/configuration-styles.md#styles-de-pastille)).

### 2 · Peindre les marges autour de la colonne

```js
// script.js, lignes 71–87
// Header elements are painted after the text, over it, so these stay in the margins, where
// no text runs (gotcha: header-paints-over-text). They repeat on every page.
const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge
const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them
const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box
const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') },
  placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const header = { elements: [
  bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin
  bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom
  bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it
  // Painted last, over the box's top padding, where no text runs. The bottom of the stack
  // sits on the name's baseline.
  { kind: 'image', id: 'books', resourceId: 'books', placement: {
    ...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h),
    size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } },
] };
```

La colonne latérale se trouve à l’intérieur des marges de la page : l’encadré de la bande s’arrête donc à 9 mm du bord gauche et à 18 mm du haut. Trois boîtes d’en-tête remplissent la marge du haut au-dessus de la colonne, la marge de gauche du haut jusqu’en bas et la marge du bas sous la colonne. Les éléments d’en-tête sont peints après le texte et par-dessus : la recette les garde donc dans les marges, où ne court aucun texte. Ils se répètent aussi sur chaque page, et une deuxième page porterait également la bande ([en-têtes et pieds de page](https://postext.dev/fr/docs/configuration-page-layout.md#en-têtes-et-pieds-de-page)). Les boîtes du haut et de gauche chevauchent l’encadré de 0,5 mm, et la boîte du pied remonte de 1 mm au-dessus du pied de la colonne, sur la fin de l’encadré, si bien qu’aucun filet de papier ne paraît aux raccords. Le `:::space` qui ferme l’encadré le prolonge jusqu’à 278,0 mm, 0,8 mm au-dessus du pied de la colonne, avec 2,76 lignes en anglais et 1,76 en espagnol.

### 3 · Placer le nom en tête de la colonne de texte

```js
// script.js, lignes 106–118
const nameplate = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF,
    fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'),
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'),
    placement: at('#name', 'below', 0, NAME.gap) },
] } };
const nameLevel = { level: 1, // span stays 'column': the name heads the text column only
  // No break before the name: the sidebar box is already on the page, and a break would
  // move the name and the whole text column to page 2.
  breakBefore: { enabled: false },
  marginBottom: pt(LEAD), // one grid line of air under the role
  advancedDesign: nameplate };
```

Le H1 garde la valeur par défaut `span: 'column'`. Avec `'page'`, il devient une ouverture sur les deux colonnes ; placé après l’encadré de la bande, il passe en page 2, et la colonne latérale y commence sous le nom, à 45,9 mm du haut. `breakBefore` est désactivé parce que l’encadré est déjà sur la page : avec un saut, le nom et toute la colonne de texte passent en page 2. La `marginBottom` du niveau, d’une ligne de la grille, fait commencer le profil à 45,9 mm au lieu de 41,3 mm. La marge intérieure haute de l’encadré vaut `ROLE_Y`, la hauteur du nom plus l’espace dessous : le premier titre de la colonne latérale et le métier partagent ainsi une ligne de base, à 39,3 mm du bord supérieur.

### 4 · Mettre les dates sur la ligne de l’intitulé

```js
// script.js, lignes 130–149
const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading
const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS,
  fontSize: pt(JOB), lineHeight: LEAD / JOB, // the body's leading, as a multiple of the size
  align: 'left', ...look }); // without align, design text is centred in its box
const job = { enabled: true, slot: { elements: [
  jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap',
    placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates
  jobText('dates', '{attr.dates}', { color: col('muted'),
    placement: at('container', 'top-right') }), // the title's top and size, so its baseline
  jobText('org', '{attr.org}', { italic: true, color: col('muted'),
    placement: at('#title', 'below') }),
] } };
const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9),
  marginBottom: pt(0), advancedDesign: job };
// The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head
// starts the same 9 pt under a grid line after a section head as after a list. The 9 pt
// below it merge with a job head's 9 pt above. Under Selected books they push the list to
// the next grid line, as the default 7 pt would; with 0 it would start one line higher.
const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20),
  marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead };
```

Chaque poste est un H3 dont les attributs portent l’employeur et les dates : `### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"}` ([attributs des titres](https://postext.dev/fr/docs/document-format.md#attributs-de-titre)). L’intitulé et les dates sont ancrés en haut du même conteneur, chacun à un coin, avec le même corps et le même interlignage : ils tombent donc sur une seule ligne de base. L’intitulé est limité à 84 mm et peut passer à la ligne : un intitulé long se coupe sur une deuxième ligne avant d’atteindre les dates, et l’employeur descend sous lui. Avec l’espace au-dessus, chaque titre de section remplit exactement trois lignes de la grille, ce qui place chaque titre de poste 9 pt sous une ligne de la grille, qu’il suive un titre de section ou une liste.

## 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/cv-with-sidebar

### script.js

```js
// ═══ Postext Cookbook · Nº 057 · One-page CV with a sidebar ═══════════════════════
// https://postext.dev/en/cookbook/cv-with-sidebar
// Code: MIT · Text: original (CC BY 4.0) · Drawing: made in code (CC BY 4.0)
// Fonts: Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'cv-with-sidebar';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#231b22', // text: a plum-tinted near-black
  band: '#3a2235', // the sidebar
  accent: '#8f3b62', // on paper: the role and the section heads (7.1:1)
  glow: '#f1b98f', // on the band: the sidebar titles (8.3:1)
  chip: '#744d6c', // on the band: the skill chips
  rule: '#d9cdd5', // the hairlines after the section heads
  muted: '#6c5f69', // dates, employers, the colophon (6.0:1)
  paper: '#ffffff', // the page, and the text on the band (14.4:1)
};
// Each colour carries its hex and the palette entry it follows.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [SERIF, SANS] = ['Hedvig Letters Serif', 'Hanken Grotesk'];
const PT = 25.4 / 72; // mm in a point
const [TRIM_W, TRIM_H] = [210, 297]; // mm: A4, printed on one side, so nothing is mirrored
const [TOP, LEFT, RIGHT] = [18, 9, 16]; // mm
const LEAD = 13.2; // pt: the body leading
const BOTTOM = TRIM_H - TOP - 56 * LEAD * PT; // mm: the text block holds 56 lines
const CONTENT = TRIM_W - LEFT - RIGHT; // 185 mm between the margins
const [SIDE, GUTTER] = [57, 10]; // mm: the sidebar column, and the white after it
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const LABEL = 7.8; // pt: the role and the sidebar titles, tracked capitals
const caps = (colour) => ({ fontFamily: SANS, fontSize: pt(LABEL), fontWeight: 700,
  textTransform: 'uppercase', letterSpacing: pt(LABEL * 0.18), color: col(colour) });
const NAME = { size: 46, gap: 2.6 }; // pt and mm: the name, and the room under it
const ROLE_Y = NAME.size * PT + NAME.gap; // mm under the top margin: the role's line
// mm under the top margin: the name's baseline. Hedvig Letters Serif, set solid, puts it 0.795
// of the size down the line (measured on the page); another face needs its own ratio.
const BASELINE = 0.795 * NAME.size * PT;
const BOOKS = { w: 46, h: 18 }; // mm: the drawing at the head of the band

// #region answer: a float-only column on the left, filled by one box
const layout = {
  layoutType: 'oneAndHalf',
  sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins
  sideColumnSide: 'left',
  sideColumnRole: 'floats', // no body text: only boxes fenced with span="side"
  gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm
};
// A side box stands where the text has reached at its fence, so the Markdown opens with it,
// before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence):
//   :::callout{type="sidebar" span="side"}
//   :::callout{type="section" title="Contact"} … :::     ← the sections nest inside it
//   …
//   :::space{lines=2.76}    ← last: runs the band to the column's foot; a fifth of a line
//   :::                       more and the whole box moves to page 2
//   # Irene Salcedo {role="Book designer and art director"}
const sidebar = { id: 'sidebar', background: col('band'),
  // The first title stands on the role's line. The text starts 10 mm from the trim, the
  // left margin plus 1 mm, and stops 10 mm short of the band's right edge.
  padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) },
  body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'),
    boldColor: col('paper'), paragraphSpacing: false } };
// #endregion

// #region margins: header boxes paint the band into the margins around the column
// Header elements are painted after the text, over it, so these stay in the margins, where
// no text runs (gotcha: header-paints-over-text). They repeat on every page.
const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge
const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them
const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box
const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') },
  placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const header = { elements: [
  bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin
  bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom
  bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it
  // Painted last, over the box's top padding, where no text runs. The bottom of the stack
  // sits on the name's baseline.
  { kind: 'image', id: 'books', resourceId: 'books', placement: {
    ...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h),
    size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } },
] };
// #endregion

// #region sections: boxes nested in the band, one per section; chips for the skills
// The sections nest in one side box. As separate side boxes they would stand at least a line
// of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside
// its parent (gotcha: nested-callout-limits).
const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  marginTop: mm(10),
  titleStyle: { ...caps('glow'), gap: mm(2.4) },
  body: { ...sidebar.body, lineHeight: pt(lineHeight) } });
const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true,
  background: col('chip'), color: col('paper'), borderWidth: pt(0),
  borderRadius: em(1), // past half the chip's height: a pill
  paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }];
// #endregion

// #region name: the H1 sets the name and the role at the head of the text column
const nameplate = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF,
    fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'),
    placement: at('container', 'top-left') },
  { kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'),
    placement: at('#name', 'below', 0, NAME.gap) },
] } };
const nameLevel = { level: 1, // span stays 'column': the name heads the text column only
  // No break before the name: the sidebar box is already on the page, and a break would
  // move the name and the whole text column to page 2.
  breakBefore: { enabled: false },
  marginBottom: pt(LEAD), // one grid line of air under the role
  advancedDesign: nameplate };
// #endregion

const sectionHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF, fontSize: pt(14),
    lineHeight: 20 / 14, color: col('accent'), placement: at('container', 'top-left') },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
    // 3 mm after the title, level with the middle of its lower case, on to the column's edge
    placement: { ...at('#title', 'right-of', 3, 3.9), size: { width: 'fill' } } },
] } };

// #region jobs: each job heading sets the title left and the dates flush right
const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading
const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS,
  fontSize: pt(JOB), lineHeight: LEAD / JOB, // the body's leading, as a multiple of the size
  align: 'left', ...look }); // without align, design text is centred in its box
const job = { enabled: true, slot: { elements: [
  jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap',
    placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates
  jobText('dates', '{attr.dates}', { color: col('muted'),
    placement: at('container', 'top-right') }), // the title's top and size, so its baseline
  jobText('org', '{attr.org}', { italic: true, color: col('muted'),
    placement: at('#title', 'below') }),
] } };
const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9),
  marginBottom: pt(0), advancedDesign: job };
// The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head
// starts the same 9 pt under a grid line after a section head as after a list. The 9 pt
// below it merge with a job head's 9 pt above. Under Selected books they push the list to
// the next grid line, as the default 7 pt would; with 0 it would start one line higher.
const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20),
  marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead };
// #endregion

const config = () => ({
  colorPalette, layout, chipStyles, header, footer: { elements: [] },
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } },
  bodyText: { fontFamily: SANS, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'),
    italicColor: col('ink'), // the titles in Selected books
    referenceColor: col('ink'), // no :ref yet; one added later prints in ink, not default blue
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // Each level draws its own design. The headings still name the text face, so the config
  // asks for no face beyond FONTS: their default is Open Sans.
  headings: { fontFamily: SANS, levels: [nameLevel, sectionLevel, jobLevel] },
  unorderedLists: { bulletChar: '–', color: col('ink'), fontWeight: 400, itemSpacing: pt(0),
    marginTop: pt(0), marginBottom: pt(0) },
  paragraphStyles: [
    { id: 'lead', fontSize: pt(11), lineHeight: pt(16) },
    { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted'),
      marginTop: pt(LEAD) },
  ],
  calloutStyles: [sidebar, section('section', 12.4), section('chips', 16)],
});

// #region art: a stack of cloth-bound books, drawn in the palette
function books() { // BOOKS.w × BOOKS.h mm, in tenths of a millimetre; the bottom book first
  const lilac = mix(palette.band, palette.paper, 0.55);
  const stack = [ // [left edge, length, thickness, cloth, label]
    [0, 430, 40, palette.glow, palette.paper],
    [22, 380, 32, palette.paper, palette.accent],
    [6, 400, 36, palette.accent, palette.glow],
    [40, 330, 30, lilac, palette.paper],
    [18, 300, 34, mix(palette.glow, palette.paper, 0.45), palette.accent],
  ];
  let y = BOOKS.h * 10;
  let art = '';
  for (const [x, length, thick, cloth, label] of stack) {
    y -= thick;
    const [top, h] = [y + 3, thick - 3]; // a dark hairline above each book
    art += `<rect x="${x}" y="${top}" width="${length}" height="${h}" rx="3" fill="${cloth}"/>`
      + `<rect x="${x + 14}" y="${top}" width="5" height="${h}" fill="${label}"/>` // bands
      + `<rect x="${x + length - 19}" y="${top}" width="5" height="${h}" fill="${label}"/>`
      + `<rect x="${x + length * 0.34}" y="${top + h * 0.28}" width="${length * 0.32}" `
      + `height="${h * 0.44}" rx="2" fill="${label}"/>`; // the title label
  }
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BOOKS.w * 10} ${BOOKS.h * 10}" `
    + `width="${BOOKS.w * 10}" height="${BOOKS.h * 10}">${art}</svg>`;
}
function mix(a, b, k) { // a blend of two palette colours, k of the way from a to b
  const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
  const [p, q] = [rgb(a), rgb(b)];
  return `#${p.map((v, i) => Math.round(v + (q[i] - v) * k).toString(16).padStart(2, '0'))
    .join('')}`;
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Irene Salcedo · Curriculum vitae"
author: "Irene Salcedo"
---

:::callout{type="sidebar" span="side"}
:::callout{type="section" title="Contact"}
Madrid, Spain

irene@salcedo.example

salcedo.example/books
:::

:::callout{type="chips" title="Skills"}
:chip[Book typography]{style="skill"} :chip[Series design]{style="skill"} :chip[Covers]{style="skill"} :chip[Grids and styles]{style="skill"} :chip[Typesetting]{style="skill"} :chip[Prepress]{style="skill"} :chip[Colour proofing]{style="skill"} :chip[EPUB 3]{style="skill"} :chip[Accessible PDF]{style="skill"} :chip[Art direction]{style="skill"}
:::

:::callout{type="chips" title="Tools"}
:chip[InDesign]{style="skill"} :chip[Illustrator]{style="skill"} :chip[Photoshop]{style="skill"} :chip[Glyphs]{style="skill"} :chip[Python]{style="skill"}
:::

:::callout{type="section" title="Languages"}
**Spanish** · native

**Catalan** · C2

**English** · C1, working language

**Italian** · B2
:::

:::callout{type="section" title="Education"}
**MA in Book Design**

Escuela del Libro del Turia, Valencia, 2011–2012

:::space{lines=0.5}

**BA in Graphic Design**

Escuela de Diseño Almenara, Castellón, 2007–2011
:::

:::callout{type="section" title="Teaching"}
Guest tutor in book typography at the Escuela del Libro del Turia, spring terms 2022–2024
:::

:::callout{type="section" title="Outside work"}
Letterpress printing at a shared workshop in Lavapiés; walking the long-distance paths of the Sierra de Guadarrama.
:::

:::space{lines=2.76}
:::

# Irene Salcedo {role="Book designer and art director"}

:::paragraphs{style="lead"}
Book designer with fourteen years in trade and academic publishing, the last five as art director of a house with three imprints. I work on series and covers as closely as on the page, and write the specifications printers work from. I am looking for an art director’s post with a literary or illustrated list.
:::

## Experience

### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"}

- Set and maintain the house style of three imprints: 58 new titles in 2025, from pocket novels to illustrated non-fiction.
- Redesigned the pocket series on a 110 × 178 mm trim. Its grid holds 32 lines a page instead of 29, so the 2024 list needed about 9 per cent fewer pages than the 2023 list for the same number of titles.
- Lead two designers and a typesetter, and commission the illustrators and photographers for each season’s list.
- Moved print and ebook editions to one source file, so the ebook now ships the same week as the hardback.

### Senior book designer {org="Casa Albero, Barcelona" dates="2016 – 2021"}

- Designed interiors and covers for about 40 titles a year of literary fiction and essays, in Spanish and Catalan.
- Wrote the typesetting rules for the Catalan list: hyphenation, the l·l, quotation marks and dialogue dashes.
- Designed the twelve volumes of the collected poems of Elvira Montcada, with the notes set in the margin.

### Designer and typesetter {org="Imprenta Quirós, Valencia" dates="2012 – 2016"}

- Set critical editions with notes on up to three levels, and the bibliographies and indexes that go with them, for two university presses.
- Prepared files for offset printing: imposition, colour proofs and the paper and binding specifications.

## Selected books

- *Los años del salitre*, Marta Ibarra. Albardín, 2023. Cover, interior and a two-colour map section.
- *Cartas desde Menorca*, Miquel Truyols. Casa Albero, 2019. A bilingual edition on facing pages.
- *Obra reunida*, Elvira Montcada. Casa Albero, 2018–2020. Twelve volumes in a cloth slipcase.

:::paragraphs{style="colophon"}
Set in Hedvig Letters Serif and Hanken Grotesk. Text: CC BY 4.0.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses, loaded before the first build.
const FONTS = { 'Hedvig Letters Serif': ['400'], 'Hanken Grotesk': ['400', '400i', '700'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('books.svg', books());
const resources = [{ id: 'books', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'books.svg', width: BOOKS.w * 10, height: BOOKS.h * 10 },
  altText: t({ en: 'A stack of five cloth-bound books',
    es: 'Una pila de cinco libros encuadernados en tela' }) }];
const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'One-page CV with a sidebar',
  es: 'Currículum de una página con barra lateral' }) });

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Variantes

### Changer la couleur de la bande

Chaque couleur est écrite une seule fois, dans `palette`, et les boîtes de la bande, l’encadré, les pastilles, le métier, les titres et le dessin la reprennent de là. Ces trois lignes passent la bande, la couleur d’accent et les pastilles en bleu canard.

```diff
-  band: '#3a2235', // the sidebar
+  band: '#1d3b3a', // the sidebar
-  accent: '#8f3b62', // on paper: the role and the section heads (7.1:1)
+  accent: '#2b5f5b', // on paper: the role and the section heads (7.3:1)
-  chip: '#744d6c', // on the band: the skill chips
+  chip: '#456d69', // on the band: the skill chips
```

### Faire flotter des figures dans la même colonne

Le [manuel avec colonne marginale](https://postext.dev/fr/cookbook/textbook-margin-column.md) place figures, légendes latérales et gloses dans une colonne réservée aux flottants, sur le bord extérieur de chaque page.

## Erreurs fréquentes

- **Un encadré latéral commence au niveau du bloc qui suit sa clôture.** Dans postext 1.4.1, un encadré span: 'side' se place dans la colonne latérale à la hauteur atteinte par le texte à sa clôture, sur la ligne de grille suivante, et sous tout encadré déjà présent. Placez une glose juste avant le paragraphe qu’elle commente : placée après, elle commence en face du paragraphe suivant. Un encadré qui dépasserait le pied de la colonne remonte jusqu’à ce que son pied repose sur celui de la colonne, autant que le permet l’encadré au-dessus ; s’il ne tient toujours pas, il attend la colonne latérale de la page suivante.
- **Les encadrés latéraux empilés dans le canal gardent une ligne de papier entre eux.** Dans postext 1.4.1, un encadré span: 'side' laisse au moins une ligne de texte courant sous lui avant l’élément suivant du canal, arrondie à la grille de ligne de base, quoi que dise son marginBottom. Des encadrés qui partagent un fond ne se touchent donc jamais : empilés, ils se lisent comme des fiches séparées. Pour un panneau continu, créez un seul encadré latéral et imbriquez-y les sections.
- **:::space est supprimé en haut d’un encadré ou d’une colonne.** :::space est supprimé en haut d’une colonne, d’un groupe :::columns, d’un encadré sans titre qui a d’autres contenus et de la suite d’un encadré coupé. Depuis postext 1.5, un encadré fait seulement de :::space est aussi haut que l’espace le demande (une case de réponse, de la place pour écrire), et un espace juste sous le titre d’un encadré fait commencer le corps d’autant plus bas. Pour ouvrir un encadré sans titre par un espace, donnez-lui un titre ou une première ligne et ajoutez l’espace après.
- **Les éléments d’en-tête et de pied de page se peignent par-dessus le texte.** Les éléments d’en-tête et de pied de page sont peints par-dessus la page et la zone de texte ne leur fait pas de place. Gardez-les dans les marges, qui sont ce qui leur réserve de l’espace.
- **Un encadré imbriqué ignore span, placement et snapToGrid.** Un encadré imbriqué dans un autre ignore ses span, placement, snapToGrid et floatBarrier : il s’écoule toujours à l’intérieur de son parent, à la largeur intérieure du parent.

- Ajustez le `:::space` à la page. Dans l’édition anglaise, 2,76 lignes font finir l’encadré 0,8 mm au-dessus du pied de la colonne. À 2,96 lignes, il dépasse le pied de 0,1 mm et toute la colonne latérale passe en page 2 ; à 2,66, une bande de papier s’ouvre entre l’encadré et la boîte du pied.
- Une boîte dans la maquette du nom ne permet pas de peindre la bande. Le titre réserve la hauteur de la boîte : une boîte aussi haute que la bande lui fait réserver plus que les 260,8 mm de la colonne, et le texte passe en page 2. Avec `reserve: false`, la boîte ne réserve rien, mais une maquette placée dans la colonne est coupée au pied de la colonne, et la marge du bas resterait blanche.

## Crédits

- Recette: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Images: The stack of books at the head of the sidebar, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- Caractères: Hedvig Letters Serif (OFL-1.1), Hanken Grotesk (OFL-1.1)
- Code: MIT · Contenu d’exemple: CC-BY-4.0

## Recettes liées

- [Nº 001 · Manuel avec une colonne marginale](https://postext.dev/fr/cookbook/textbook-margin-column.md): Une page à colonne et demie dont la colonne extérieure ne prend que des flottants : figures span 'side' et gloses s’y empilent, captionSide y met les légendes. · Niveau 3 (Avancé) · Manuels scolaires
- [Nº 045 · Titres en marge, numéros suspendus et titres en ligne](https://postext.dev/fr/cookbook/side-heads-hanging-numbers.md): Un règlement de concours aux titres de section en colonne marginale, sur les lignes de base du texte, numéros pendus dans la gouttière, titres en ligne rouges. · Niveau 3 (Avancé) · Rapports
- [Nº 059 · Un poster scientifique sur une seule grande page](https://postext.dev/fr/cookbook/research-poster.md): Poster de congrès de 600 × 800 mm : bandeau de titre sur trois colonnes de panneaux en un groupe pleine largeur, carte thermique à pastilles, −4,2 °C en 150 pt. · Niveau 3 (Avancé) · Articles et publications universitaires, Feuilles volantes et éphémères
