# Einseitiger Lebenslauf mit Seitenleiste

> Ein einseitiger Lebenslauf in A4, dessen pflaumenfarbene Seitenleiste ein Kasten in einer linken Spalte nur für Gleitobjekte ist, mit rechtsbündigen Daten.

- HTML-Fassung: https://postext.dev/de/cookbook/cv-with-sidebar
- Rezept Nº 057 · Seite und Raster · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Einzelblätter und Akzidenzen
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/cv-with-sidebar/en/p01.webp?v=85dc1567)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=cv-with-sidebar&lang=en (.postext: https://postext.dev/cookbook/cv-with-sidebar/en/cv-with-sidebar.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [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), [fr](https://postext.dev/fr/cookbook/cv-with-sidebar.md), [it](https://postext.dev/it/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)

## Kurz gesagt

Ein einseitiger Lebenslauf für eine Buchgestalterin, mit einem farbigen Streifen am linken Rand für Kontaktdaten und Fähigkeiten. Die Daten jeder Stelle stehen rechtsbündig.

## Was Sie setzen

Den Lebenslauf von Irene Salcedo, einer erfundenen Buchgestalterin in Madrid, auf einem A4-Blatt. Ein pflaumenfarbenes Band von 66 mm Breite läuft am linken Rand von der Oberkante des Blattes bis zu seinem Fuß. An seinem Kopf steht ein im Code gezeichneter Stapel leinengebundener Bücher auf der Höhe der Grundlinie ihres Namens. Darunter folgen ihre Kontaktdaten, ihre Fähigkeiten und Werkzeuge als Pillen, ihre Sprachen, Ausbildung und Lehrtätigkeit und was sie außerhalb der Arbeit tut. Ihr Name in der Hedvig Letters Serif zu 46 pt eröffnet eine Textspalte von 118 mm mit einem fünfzeiligen Profil, drei Stellen und einer Liste der Bücher, die sie gestaltet hat. Jeder Stellenkopf setzt den Titel links und die Daten rechtsbündig auf dieselbe Grundlinie, den Arbeitgeber kursiv darunter. Der erste Titel der Seitenleiste teilt sich eine Grundlinie mit der Funktion unter dem Namen.

**Dieses Rezept beantwortet:**

- Wie gestalte ich einen einseitigen Lebenslauf mit farbiger Seitenleiste und rechtsbündigen Daten?
- Wie füge ich auf jeder Seite ein Wasserzeichen, eine Hintergrundtönung oder ein Schmuckbild ein?
- Wie erstelle ich Inline-Chips: Tastaturtasten, Schlagwörter, Wortspeicher für Übungen?

## Die kurze Antwort

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

## Zutaten

**Zeigt**

- [Randspalte für Gleitobjekte](https://postext.dev/de/docs/configuration-page-layout.md#layout): Eine anderthalbspaltige Seite, deren Nebenspalte keinen Fließtext aufnimmt, nur die dort platzierten Abbildungen, Tabellen und Kästen, wie am Rand vieler Lehrbücher.
- [Anderthalb Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen): Ein asymmetrisches Layout: eine breite Hauptspalte und eine schmale Nebenspalte, auf einer festen Seite oder am Außenrand jeder Seite.
- [Marginalien](https://postext.dev/de/docs/configuration-styles.md#kastenstile): Kästen in der Nebenspalte auf Höhe des Absatzes, den sie kommentieren, in einem anderthalbspaltigen Layout mit Kanal für Gleitobjekte.

**Verwendet außerdem**

- [Verschachtelte Kästen](https://postext.dev/de/docs/configuration-styles.md#der-container-callout)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)
- [Inline-Chips](https://postext.dev/de/docs/configuration-styles.md#chip-stile)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Gestaltungselemente verankern](https://postext.dev/de/docs/configuration-page-layout.md#platzierung-von-elementen)
- [Überschriftenebenen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Aufzählungen und Checklisten](https://postext.dev/de/docs/configuration-text.md#aufzählungen)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`chipStyles`](https://postext.dev/de/docs/configuration-styles.md#chip-stile), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`unorderedLists`](https://postext.dev/de/docs/configuration-text.md#aufzählungen)

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`registerResourceImage`](https://postext.dev/de/docs/architecture.md#api-oberfläche), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**Schriften**

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

## Zubereitung

### 1 · Die Abschnitte in einem Seitenkasten verschachteln

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

Innerhalb der Spalte ist das Band der Kasten aus [der kurzen Antwort](https://postext.dev/de/cookbook/cv-with-sidebar.md#die-kurze-antwort). Sein Direktivenblock steht vor dem Namen, deshalb setzt `span="side"` ihn an den Kopf der Spalte nur für Gleitobjekte. Die Abschnitte sind darin verschachtelte Kästen ohne eigene Füllung, sodass die Pflaumenfarbe ohne Unterbrechung von „Contact“ bis „Outside work“ läuft. Als getrennte Seitenkästen stünden sie 4,7 bis 9,1 mm auseinander, selbst mit `marginBottom` auf 0. Die Pillen sind etwa 3,9 mm hoch. „Skills“ und „Tools“ verwenden ein eigenes Abschnittsformat, `chips`, dessen Zeilenabstand von 16 pt 1,8 mm zwischen den Reihen lässt; die 12,4 pt der anderen Abschnitte ließen einen halben Millimeter ([Chip-Formate](https://postext.dev/de/docs/configuration-styles.md#chip-stile)).

### 2 · Die Ränder um die Spalte bemalen

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

Die Seitenspalte liegt innerhalb der Seitenränder, deshalb endet der Kasten der Seitenleiste 9 mm vor der linken Kante und 18 mm vor der Oberkante. Drei Kästen in der Kopfzeile füllen den oberen Rand über der Spalte, den linken Rand von oben bis unten und den unteren Rand unter der Spalte. Elemente der Kopfzeile werden nach dem Text und über ihm gemalt, deshalb hält das Rezept sie in den Rändern, wo kein Text läuft. Sie wiederholen sich außerdem auf jeder Seite, und eine zweite Seite trüge das Band ebenfalls ([Kopf- und Fußzeilen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)). Der obere und der linke Kasten überlappen den Kasten der Seitenleiste um 0,5 mm, und der Fußkasten reicht 1 mm über den Fuß der Spalte hinauf, über das Ende des Seitenleistenkastens, sodass an den Stößen keine Haarlinie Papier durchscheint. Das `:::space`, das den Kasten der Seitenleiste schließt, führt ihn bis 278,0 mm hinunter, 0,8 mm über dem Fuß der Spalte, mit 2,76 Zeilen auf Englisch und 1,76 auf Spanisch.

### 3 · Den Namen an den Kopf der Textspalte setzen

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

Die H1 behält den Standardwert `span: 'column'`. Mit `'page'` wird sie zu einem Auftakt über beide Spalten; nach dem Kasten der Seitenleiste gesetzt, wandert sie auf Seite 2, und dort beginnt die Seitenspalte unter dem Namen, 45,9 mm tiefer. `breakBefore` ist aus, weil der Kasten der Seitenleiste schon auf der Seite steht: Mit einem Umbruch wandern der Name und die ganze Textspalte auf Seite 2. Das `marginBottom` der Ebene von einer Rasterzeile lässt das Profil bei 45,9 mm statt bei 41,3 mm beginnen. Der obere Innenabstand des Seitenleistenkastens ist `ROLE_Y`, die Höhe des Namens plus der Abstand darunter, sodass der erste Titel der Seitenleiste und die Funktion eine Grundlinie teilen, 39,3 mm unter der Oberkante.

### 4 · Die Daten auf die Zeile des Stellentitels setzen

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

Jede Stelle ist eine H3, deren Attribute Arbeitgeber und Daten tragen: `### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"}` ([Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)). Titel und Daten sind am Kopf desselben Containers verankert, je an einer Ecke, in gleicher Größe und mit gleichem Zeilenabstand, sodass sie auf einer Grundlinie stehen. Der Titel ist auf 84 mm begrenzt und bricht um: Ein langer Titel geht auf eine zweite Zeile über, bevor er die Daten erreicht, und der Arbeitgeber rückt darunter. Mit dem Abstand darüber füllt jede Abschnittsüberschrift genau drei Rasterzeilen, was jeden Stellenkopf 9 pt unter eine Rasterzeile setzt, ob er einer Abschnittsüberschrift oder einer Liste folgt.

## Das ganze Rezept

Eine Datei, aus dem Ordner des Rezepts zusammengesetzt, mit dem Beispieltext und dem gemeinsamen Baukasten der Rezepte bereits eingebettet; sie baut ihre eigene Seite auf. Zum Ausführen setzen Sie sie in ein `<script type="module">` auf einer leeren Seite oder fügen sie in das JS-Feld eines neuen CodePen-Pens ein (als Modul). Sie importiert postext von esm.sh, es gibt also nichts zu installieren oder zu bauen.

- Quellordner: 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 ───────────────────────────────────────────────────────────────────────
```

## Varianten

### Das Band umfärben

Jede Farbe ist einmal in `palette` notiert, und die Kästen des Bandes, der Kasten der Seitenleiste, die Chips, die Funktion, die Überschriften und die Zeichnung nehmen sie von dort. Diese drei Zeilen färben Band, Akzent und Chips petrol.

```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
```

### Abbildungen in dieselbe Spalte gleiten lassen

Das [Lehrbuch mit Randspalte](https://postext.dev/de/cookbook/textbook-margin-column.md) setzt Abbildungen, seitliche Bildunterschriften und Glossen in eine Spalte nur für Gleitobjekte am Außenrand jeder Seite.

## Häufige Fehler

- **Ein Seitenkasten beginnt auf Höhe des Blocks nach seiner Einfassung.** In postext 1.4.1 steht ein Kasten mit span: 'side' in der Seitenspalte auf der Höhe, die der Text an seiner Einfassung erreicht hat, auf der nächsten Rasterlinie und unter einem bereits vorhandenen Kasten. Setzen Sie eine Glosse direkt vor den Absatz, den sie erläutert: Steht sie danach, beginnt sie neben dem nächsten Absatz. Ein Kasten, der über den Fuß der Spalte hinauslaufen würde, rutscht nach oben, bis sein Fuß auf dem Fuß der Spalte sitzt, soweit der Kasten darüber es zulässt; passt er dann immer noch nicht, wartet er auf die Seitenspalte der nächsten Seite.
- **Gestapelte Seitenkästen halten eine Zeile Papier Abstand.** In postext 1.4.1 lässt ein Kasten mit span: 'side' vor dem nächsten Element im Kanal mindestens eine Textzeile frei, aufgerundet auf das Grundlinienraster, was auch immer sein marginBottom angibt. Kästen mit gemeinsamem Hintergrund berühren sich daher nie: Gestapelt wirken sie wie getrennte Karten. Für eine durchgehende Fläche fassen Sie einen einzigen Seitenkasten ein und verschachteln die Abschnitte darin.
- **:::space entfällt am Anfang eines Kastens oder einer Spalte.** :::space entfällt am Anfang einer Spalte, einer :::columns-Gruppe, eines Kastens ohne Titel mit weiterem Inhalt und der Fortsetzung eines geteilten Kastens. Seit postext 1.5 ist ein Kasten, der nur aus :::space besteht, so hoch, wie der Abstand verlangt (ein Antwortkasten, Platz zum Schreiben), und Abstand direkt unter dem Titel eines Kastens setzt den Inhalt um so viel tiefer. Um einen Kasten ohne Titel mit Abstand zu beginnen, geben Sie ihm einen Titel oder eine erste Zeile und fügen den Abstand danach ein.
- **Elemente von Kopf- und Fußzeile malen über den Text.** Elemente von Kopf- und Fußzeile werden über die Seite gemalt, und der Textbereich macht ihnen keinen Platz. Halten Sie sie innerhalb der Ränder, denn die Ränder reservieren ihren Raum.
- **Ein verschachtelter Kasten ignoriert span, placement und snapToGrid.** Ein in einen anderen verschachtelter Kasten ignoriert seine Angaben span, placement, snapToGrid und floatBarrier: er fließt immer innerhalb seines Elternkastens, in dessen Innenbreite.

- Passen Sie das `:::space` an die Seite an. In der englischen Ausgabe beenden 2,76 Zeilen den Kasten 0,8 mm über dem Fuß der Spalte. Bei 2,96 Zeilen läuft er 0,1 mm über den Fuß hinaus, und die ganze Seitenleiste wandert auf Seite 2; bei 2,66 öffnet sich ein Streifen Papier zwischen dem Kasten und dem Fußkasten.
- Ein Kasten in der Gestaltung des Namens taugt nicht, um das Band zu malen. Die Überschrift reserviert die Höhe des Kastens, ein Kasten so hoch wie das Band ließe sie also mehr als die 260,8 mm der Spalte reservieren, und der Text wanderte auf Seite 2. Mit `reserve: false` reserviert der Kasten nichts, aber eine Gestaltung in der Spalte wird am Fuß der Spalte abgeschnitten, und der untere Rand bliebe weiß.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: The stack of books at the head of the sidebar, drawn in code in the page's palette: Ignacio Ferro, CC-BY-4.0
- Schrift: Hedvig Letters Serif (OFL-1.1), Hanken Grotesk (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 001 · Lehrbuch mit Randspalte](https://postext.dev/de/cookbook/textbook-margin-column.md): Anderthalb Spalten, die äußere nur für Gleitobjekte: Abbildungen mit span 'side' und Erläuterungen stapeln sich dort, captionSide holt die Legenden hinein. · Stufe 3 (Fortgeschritten) · Lehrbücher
- [Nº 045 · Marginalüberschriften, hängende Nummern, eingebundene Titel](https://postext.dev/de/cookbook/side-heads-hanging-numbers.md): Eine Wettbewerbsauslobung mit Abschnittstiteln in einer Randspalte auf den Grundlinien des Textes, hängenden Unternummern und roten Anfangsbegriffen. · Stufe 3 (Fortgeschritten) · Berichte
- [Nº 059 · Ein Forschungsposter auf einer großen Seite](https://postext.dev/de/cookbook/research-poster.md): Konferenzposter von 600 × 800 mm: ein Titelband über drei Spalten mit Panels in seitenbreiter Gruppe, eine Heatmap mit Farbfeldern, −4,2 °C in 150 pt. · Stufe 3 (Fortgeschritten) · Fachartikel und Wissenschaft, Einzelblätter und Akzidenzen
