# Product manual with safety notices

> A German kettle manual whose WARNUNG and VORSICHT boxes carry the warning triangle on a signal-coloured band, with German figure and table labels.

- HTML version: https://postext.dev/en/cookbook/product-manual-warnings
- Recipe Nº 050 · Boxes & notes · Level 2 (Intermediate) · Outputs: Canvas
- Genres: Manuals, guides & reference
- Requires postext ≥ 1.4.1 · tested with 1.4.1 on 2026-09-26
- Pages: [1](https://postext.dev/cookbook/product-manual-warnings/en/p01.webp?v=f8dcac8e), [2](https://postext.dev/cookbook/product-manual-warnings/en/p02.webp?v=f8dcac8e), [3](https://postext.dev/cookbook/product-manual-warnings/en/p03.webp?v=f8dcac8e), [4](https://postext.dev/cookbook/product-manual-warnings/en/p04.webp?v=f8dcac8e), [5](https://postext.dev/cookbook/product-manual-warnings/en/p05.webp?v=f8dcac8e), [6](https://postext.dev/cookbook/product-manual-warnings/en/p06.webp?v=f8dcac8e), [7](https://postext.dev/cookbook/product-manual-warnings/en/p07.webp?v=f8dcac8e)
- Last updated: 2026-09-26
- Other languages: [es](https://postext.dev/es/cookbook/product-manual-warnings.md)

## What you'll build

A seven-page instruction manual for the Verra W1, an invented electric kettle, in German on A5. The safety page sets its two hazard levels as boxes: a band in the signal colour runs down the left side and carries the warning triangle, and a thin frame of the same colour closes the box, red for WARNUNG and amber for VORSICHT. HINWEIS, the level for damage to the appliance, drops the band and the triangle for a rounded teal tint. The paragraph above the boxes sets all three signal words as filled chips. Page 3 has a parts drawing with numbered leaders above its legend, and on page 4 the key names are outlined chips beside large step numbers. The troubleshooting table runs over pages 6 and 7. Captions read Abbildung and Tabelle, and the split table is marked Fortsetzung on both pages.

**This recipe answers:**

- How do I make warning boxes with an icon on a coloured stripe, in a language Postext does not label?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
- How do I customise lists: bullets per level, (a)/(i) numbering, task checkboxes, spacing that stays on the grid?
- How do I add a figure with a numbered caption and cite it in the text ("see Fig. 3.2")?
- How do I make a table with header rows, merged cells, column widths and per-cell alignment?
- How do I split a long table across pages with a repeated header and a "continued" marker?
- How do I get "Figure" and "Table" labels in my document's language?

## The short answer

signal-word boxes: a band in the hazard's colour carries its triangle.

```js
// script.js, lines 28–46
const BAND = 8.5; // mm: a stripe on the side is the icon's column; the icon is centred on it
const [TITLE, TITLE_GAP] = [8.6, 2.4]; // pt; 1.4.1 sets a box title 1.2 times its size
const PAD_Y = (2 * LEAD - 1.2 * TITLE - TITLE_GAP) / 2; // pt: title and padding fill two lines
const notice = (id, hue, ink, icon) => ({ id, backgroundEnabled: false,
  stripe: { enabled: true, side: 'left', width: mm(BAND), color: col(hue) },
  border: { enabled: true, color: col(hue), width: pt(0.75) }, // closes the band into a frame
  icon: { kind: 'resource', resourceId: icon, size: mm(5.6), align: 'top' },
  padding: { top: pt(PAD_Y), right: mm(3.2), bottom: pt(PAD_Y), left: mm(3.2) },
  titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(TITLE), color: col(ink),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: pt(TITLE_GAP) },
  body: { fontSize: pt(8.8), lineHeight: pt(LEAD) }, // on the grid: a box is whole lines tall
  lists: { color: col(ink), gap: mm(2) }, marginTop: pt(LEAD), marginBottom: pt(0) });
const calloutStyles = [
  notice('warnung', 'warning', 'warning', 'triangle-white'), // white triangle, red '!'
  notice('vorsicht', 'caution', 'ink', 'triangle-ink'), // amber type would fail contrast
  { ...notice('hinweis', 'brand', 'brand'), stripe: { enabled: false }, border: { enabled: false },
    backgroundEnabled: true, background: col('tint'), borderRadius: mm(2), // property damage:
    icon: { kind: 'resource', resourceId: 'info', size: mm(4.6) } }, // no band, an icon column
];
```

## Ingredients

**Teaches**

- [Box icons and corner badges](https://postext.dev/en/docs/configuration.md#callout-styles): A glyph or picture beside a box's content, hung on its top corner, or as a wide strip of pictograms.
- [Callout boxes](https://postext.dev/en/docs/configuration.md#callout-styles): Named box styles for notes, tips and warnings: background, border, radius, stripe, title and their own body and list typography.
- [Custom resource types](https://postext.dev/en/docs/configuration.md#resource-types): New numbered families (Map, Plate, Listing, Chart) with their own counters, labels and caption prefixes.

**Also uses**

- [Inline chips](https://postext.dev/en/docs/configuration.md#chip-styles)
- [Citations that place figures](https://postext.dev/en/docs/document-format.md#inline-reference-the-primary-form)
- [Figures exactly here](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Figure placement](https://postext.dev/en/docs/document-format.md#placement)
- [Tables from data](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement)
- [Table style](https://postext.dev/en/docs/configuration.md#table-style)
- [Tables across pages](https://postext.dev/en/docs/configuration.md#tables-taller-than-the-page)
- [Numbered headings](https://postext.dev/en/docs/configuration.md#per-level-overrides)
- [Numbered lists](https://postext.dev/en/docs/configuration.md#ordered-lists)
- [Bullet lists and checklists](https://postext.dev/en/docs/configuration.md#unordered-lists)
- [Hyphenation and document language](https://postext.dev/en/docs/justification.md#supported-locales)
- [Figure and Table in your language](https://postext.dev/en/docs/configuration.md#resource-types)
- [Designed openers](https://postext.dev/en/docs/configuration.md#span-and-advanced-design)
- [Heading styles](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Covers, title pages and colophons](https://postext.dev/en/docs/configuration.md#heading-styles)
- [Running heads and folios](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Text, rules and boxes in page designs](https://postext.dev/en/docs/configuration.md#headers--footers)
- [Heading attributes](https://postext.dev/en/docs/document-format.md#heading-attributes)
- [Page and column breaks](https://postext.dev/en/docs/document-format.md#pagebreak)
- [Heads by page role](https://postext.dev/en/docs/configuration.md#text-elements)
- [Paragraph styles](https://postext.dev/en/docs/configuration.md#paragraph-styles)
- [Figures and tables as resources](https://postext.dev/en/docs/document-format.md#resources)
- [Named table styles](https://postext.dev/en/docs/configuration.md#named-table-styles)
- [Unnumbered chapters](https://postext.dev/en/docs/configuration.md#heading-styles)

**Config at a glance**

- [`bodyText`](https://postext.dev/en/docs/configuration.md#body-text), [`calloutStyles`](https://postext.dev/en/docs/configuration.md#callout-styles), [`captionStyle`](https://postext.dev/en/docs/configuration.md#caption-style), [`chipStyles`](https://postext.dev/en/docs/configuration.md#chip-styles), [`colorPalette`](https://postext.dev/en/docs/configuration.md#color-palette), [`footer`](https://postext.dev/en/docs/configuration.md#headers--footers), [`header`](https://postext.dev/en/docs/configuration.md#headers--footers), [`headingStyles`](https://postext.dev/en/docs/configuration.md#heading-styles), [`headings`](https://postext.dev/en/docs/configuration.md#headings), [`layout`](https://postext.dev/en/docs/configuration.md#layout), [`locale`](https://postext.dev/en/docs/configuration.md#hyphenation), [`orderedLists`](https://postext.dev/en/docs/configuration.md#ordered-lists), [`page`](https://postext.dev/en/docs/configuration.md#page), [`paragraphStyles`](https://postext.dev/en/docs/configuration.md#paragraph-styles), [`resourceTypes`](https://postext.dev/en/docs/configuration.md#resource-types), [`tableStyle`](https://postext.dev/en/docs/configuration.md#table-style), [`tableStyles`](https://postext.dev/en/docs/configuration.md#named-table-styles), [`unorderedLists`](https://postext.dev/en/docs/configuration.md#unordered-lists)

**APIs**

- [`buildDocument`](https://postext.dev/en/docs/configuration.md#building-a-document), [`clearMeasurementCache`](https://postext.dev/en/docs/configuration.md#measurement-cache), [`mergeCells`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`parseTSV`](https://postext.dev/en/docs/document-format.md#block-embed-optional-explicit-inline-placement), [`registerResourceImage`](https://postext.dev/en/docs/architecture.md#api-surface), [`renderPageToCanvas`](https://postext.dev/en/docs/configuration.md#rendering-a-page-to-a-bitmap)

**Typefaces**

- Red Hat Text (OFL-1.1), Red Hat Display (OFL-1.1), Red Hat Mono (OFL-1.1)

## Method

### 1 · A band that carries the triangle

The code is [the short answer](#the-short-answer) above. `stripe` paints the band down the left edge. When a box has a side stripe, 1.4.1 centres the icon on the stripe instead of giving it a column of its own ([callout styles](/en/docs/configuration#callout-styles)), and `align: 'top'` puts the icon's top edge level with the title's. The `border` in the same colour closes the band into a frame; without it the text of the box would stand on the bare page beside a coloured bar. `PAD_Y` is worked out so that the title, its gap and the top and bottom padding add up to two lines of the 12.8 pt grid. The body keeps the grid's leading, so each box is a whole number of lines tall (WARNUNG 10, VORSICHT 8), and `marginTop: pt(LEAD)` leaves exactly one line between the two boxes on page 2. VORSICHT keeps amber for the band and the frame and sets its title and bullets in ink: amber on white measures 2.0:1, and small type needs 4.5:1. HINWEIS starts from the same base (`...notice(…)`) and turns the band and the frame off, so its info icon takes a column of its own and `borderRadius` rounds a plain tint.

### 2 · German names for figures and tables

```js
// script.js, lines 50–59
const counted = { numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // 1, 2…
const resourceTypes = [ // 1.4.1 names them in English or Spanish (gotcha: resource-types-locale)
  { id: 'figure', name: 'Abbildung', shortLabel: 'Abb.', captionPrefix: 'Abbildung', ...counted },
  { id: 'table', name: 'Tabelle', shortLabel: 'Tab.', captionPrefix: 'Tabelle', ...counted,
    captionStyle: { position: 'above' } }, // a table is captioned over its head
];
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: MONO,
  headerFontSize: pt(7.6), bodyFontSize: pt(8.2), cellPadding: mm(1.3),
  continuedSuffix: '(Fortsetzung)', continuesMarker: 'Fortsetzung auf der nächsten Seite' };
```

`locale: 'de'` gives the manual German hyphenation, but 1.4.1 has the names of resource types and the continuation notes of a split table in English and Spanish only. Without these lines the captions read Figure 2.1 and Table 5.1, and the note under the first part of the split table says Continued. A resource type written by hand has to give every field, the numbering included. `counted` supplies it: `{n}` with `resetOn: 'never'` counts through the whole manual (Abbildung 1, Tabelle 1 to 3) instead of starting again in every section. Only the table type sets `captionStyle.position: 'above'`, so tables take their caption over the head and the drawing keeps its caption underneath.

### 3 · Keys, signal words and part numbers as chips

```js
// script.js, lines 63–71
const filled = (id, fill, ink) => ({ id, fontFamily: DISPLAY, bold: true, fontSize: em(0.82),
  background: col(fill), color: col(ink), borderWidth: pt(0), paddingX: em(0.45) }); // no outline
const chipStyles = [
  { id: 'taste', fontFamily: MONO, bold: true, fontSize: em(0.92), backgroundEnabled: false,
    borderColor: col('ink'), borderWidth: pt(0.6), borderRadius: pt(2.2), paddingX: em(0.4) },
  filled('warnung', 'warning', 'paper'), filled('vorsicht', 'caution', 'ink'),
  filled('hinweis', 'brand', 'paper'), { ...filled('nr', 'brand', 'paper'), fontSize: em(0.95),
    borderRadius: em(1) }, // a part number, round like the drawing's
];
```

`taste` outlines a key name in the mono face with no fill, the way the label is printed on the button. The signal-word chips are filled with the colours of their boxes, and the amber chip sets its word in ink, as the VORSICHT title does. `nr` gives the part numbers a radius larger than half their height, so they come out as discs like the numbered circles on the drawing. Chip sizes are set in em of the text around them: a key is 8.6 pt in the running text and 7.5 pt in a table cell ([chip styles](/en/docs/configuration#chip-styles)).

### 4 · Step numbers on the baseline

```js
// script.js, lines 75–80
const orderedLists = { separator: '', fontFamily: DISPLAY, fontWeight: 800, color: col('brand'),
  numberFontSize: pt(15), gap: mm(3), itemSpacing: pt(5), marginTop: pt(LEAD / 2),
  marginBottom: pt(0), numberVerticalOffset: pt(-1) }; // gotcha: list-number-centred
const unorderedLists = { color: col('brand'), gap: mm(2), marginTop: pt(0), marginBottom: pt(0),
  levels: [{ level: 2, bulletChar: '–', indent: mm(6.5) }, // at a step's text: number + 3 mm gap
    { level: 3, bulletChar: '–', color: col('muted') }] }; // teal •, teal –, grey –
```

The steps are an ordered list with the numbers in Red Hat Display 800 at 15 pt, in teal, with no separator. 1.4.1 centres a list number on its first line instead of standing it on the baseline, so a 15 pt figure beside 9.3 pt text hangs about 1 pt below the line. `numberVerticalOffset: pt(-1)`, a value measured on the capture, lifts its foot onto the baseline. `itemSpacing` separates the steps by 5 pt to make room for the large figures. The bullets nested under a step start at 6.5 mm, level with the step's text; they are teal dashes, and the third level has grey ones ([ordered lists](/en/docs/configuration#ordered-lists)).

### 5 · Section numbers in a reversed tab

```js
// script.js, lines 84–94
const H1 = 14, TAB = 2 * LEAD - 3.6, PAD = (TAB - H1 * 1.2) / 2; // pt: a square 2 lines less 3.6
const face = { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(H1), lineHeight: 1.2 }; // both
const section = { level: 1, numberingTemplate: '{1}', // {number}: 1, 2, 3 …
  breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2), // run on
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'tab', content: '{number}', ...face, color: col('paper'), align: 'center',
      box: { backgroundColor: col('brand'), padding: pad(PAD) },
      placement: pin('container', 'top-left', 0, 0, { width: pt(TAB) }) },
    { kind: 'text', id: 'title', content: '{titleText}', ...face, color: col('ink'),
      overflow: 'wrap', box: { padding: { top: pt(PAD) } }, placement: pin('#tab', 'right-of', 3) },
  ] } } };
```

Each section is a first-level heading in the column, drawn from a design slot. The tab prints `{number}` (filled by `numberingTemplate: '{1}'`) in white on a teal square, and the title starts 3 mm to its right. Number and title share one size and line height, so the same top padding sets them on one baseline. The tab is a 22 pt square, 3.6 pt less than two lines of the 12.8 pt grid, so the heading stays within two grid lines. The cover is a first-level heading too, set in an unnumbered heading style, so Sicherheitshinweise is section 1.

### 6 · A troubleshooting table with merged cells that splits

```js
// script.js, lines 294–312
function faultTable(tsv) { // parseTSV leaves the head row to you: headerRowCount
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [30, 34, 36] }; // weights
  for (let r = 2, top = 1; r < m.rows.length; r++) { // a rowspan per fault: no cut runs through it
    if (m.rows[r][0].content) top = r; // mergeCells hides what it covers: merged-cells-hiddenby
    else m = mergeCells(m, { start: { row: top, col: 0 }, end: { row: r, col: 0 } });
  }
  return m;
}
const fold = (rows, columnWidths) => ({ columnWidths, rows: rows.slice(0, rows.length / 2) // 2 up
  .map((row, k) => [...row, ...rows[k + rows.length / 2]]) });
const legend = fold(parseTSV(parts).rows.map(([chip, name]) => [{ ...chip, align: 'center' },
  name]), [7, 43, 7, 43]); // each part's number chip centred in its narrow column
const HERE = { placement: { position: 'here' } }; // at the resource's ::resource line
const table = (id, caption, model, styleId = 'bare', where = HERE) => ({ id, typeId: 'table',
  kind: 'table', caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...where });
const tables = [table('legende', 'Teile des Verra W1', legend),
  table('daten', 'Kenndaten des VW-170', fold(parseTSV(data).rows, [20, 30, 20, 30])),
  table('stoerungen', 'Störungen und ihre Behebung', faultTable(faults), null, // the house style,
    { placement: { position: 'top' } })]; // a float, so it can split (gotcha: here-table-no-split)
```

Each table is TSV in a content file of its own. In the troubleshooting data, a line whose first cell is empty belongs to the fault above it, and `mergeCells` joins those first cells into one tall cell and marks the cells it covers as hidden. `parseTSV` sets no header row, so `headerRowCount: 1` declares it, and that row is the one repeated on page 7. The table is taller than a page, and in 1.4.1 only a floated table splits, so `position: 'top'` makes it a float. The cut falls between rows and never through a merged cell, so every fault stays on the same page as its causes ([tables taller than the page](/en/docs/configuration#tables-taller-than-the-page)). It is cited on page 5 and heads page 6, the first page after its citation that a top float can take. `fold` sets two halves of a list side by side, for the legend and for the technical data, and the legend centres its number chips with `align: 'center'`.

## The whole recipe

One file, composed from the recipe's folder with the sample text and the Cookbook's shared kit inlined; it builds its own page. To run it, put it in a `<script type="module">` on an empty page, or paste it into the JS panel of a new CodePen (as a module). It imports postext from esm.sh, so there is nothing to install or build.

- Source folder: https://github.com/drnachio/postext/tree/main/cookbook/product-manual-warnings

### script.js

```js
// ═══ Postext Cookbook · Nº 050 · Product manual with safety notices ════════════════
// https://postext.dev/en/cookbook/product-manual-warnings
// Code: MIT · Text: original, in German (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Red Hat Text, Red Hat Display, Red Hat Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'product-manual-warnings';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1a1f24', paper: '#ffffff', // a blue-black on white
  brand: '#10727b', tint: '#e4f0f1', // the house teal (5.7:1 on white) and its pale tint
  warning: '#d62e1f', caution: '#f2a900', // the signal colours of WARNUNG and VORSICHT
  rule: '#cfd5da', muted: '#5b6570' }; // hairlines; running heads and notes
// 1.4.1 design slots read the hex, not the id: col() writes both (gotcha: palette-skips-designs)
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.brand }) // the defaults
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, MONO] = ['Red Hat Text', 'Red Hat Display', 'Red Hat Mono'];
const PAGE = { w: 148, h: 210, top: 19, bottom: 19.4, inner: 17, outer: 13 }; // mm: A5, mirrored
const LEAD = 12.8; // pt: the body's leading and baseline grid, 38 lines to the page
const pin = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const pad = (y, x = 0) => ({ top: pt(y), bottom: pt(y), left: pt(x), right: pt(x) }); // pt

// #region answer: signal-word boxes: a band in the hazard's colour carries its triangle
const BAND = 8.5; // mm: a stripe on the side is the icon's column; the icon is centred on it
const [TITLE, TITLE_GAP] = [8.6, 2.4]; // pt; 1.4.1 sets a box title 1.2 times its size
const PAD_Y = (2 * LEAD - 1.2 * TITLE - TITLE_GAP) / 2; // pt: title and padding fill two lines
const notice = (id, hue, ink, icon) => ({ id, backgroundEnabled: false,
  stripe: { enabled: true, side: 'left', width: mm(BAND), color: col(hue) },
  border: { enabled: true, color: col(hue), width: pt(0.75) }, // closes the band into a frame
  icon: { kind: 'resource', resourceId: icon, size: mm(5.6), align: 'top' },
  padding: { top: pt(PAD_Y), right: mm(3.2), bottom: pt(PAD_Y), left: mm(3.2) },
  titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(TITLE), color: col(ink),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: pt(TITLE_GAP) },
  body: { fontSize: pt(8.8), lineHeight: pt(LEAD) }, // on the grid: a box is whole lines tall
  lists: { color: col(ink), gap: mm(2) }, marginTop: pt(LEAD), marginBottom: pt(0) });
const calloutStyles = [
  notice('warnung', 'warning', 'warning', 'triangle-white'), // white triangle, red '!'
  notice('vorsicht', 'caution', 'ink', 'triangle-ink'), // amber type would fail contrast
  { ...notice('hinweis', 'brand', 'brand'), stripe: { enabled: false }, border: { enabled: false },
    backgroundEnabled: true, background: col('tint'), borderRadius: mm(2), // property damage:
    icon: { kind: 'resource', resourceId: 'info', size: mm(4.6) } }, // no band, an icon column
];
// #endregion

// #region types: German names for figures and tables, and for a table's continuation
const counted = { numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // 1, 2…
const resourceTypes = [ // 1.4.1 names them in English or Spanish (gotcha: resource-types-locale)
  { id: 'figure', name: 'Abbildung', shortLabel: 'Abb.', captionPrefix: 'Abbildung', ...counted },
  { id: 'table', name: 'Tabelle', shortLabel: 'Tab.', captionPrefix: 'Tabelle', ...counted,
    captionStyle: { position: 'above' } }, // a table is captioned over its head
];
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: MONO,
  headerFontSize: pt(7.6), bodyFontSize: pt(8.2), cellPadding: mm(1.3),
  continuedSuffix: '(Fortsetzung)', continuesMarker: 'Fortsetzung auf der nächsten Seite' };
// #endregion

// #region chips: keys in the mono face, outlined; signal words and part numbers filled
const filled = (id, fill, ink) => ({ id, fontFamily: DISPLAY, bold: true, fontSize: em(0.82),
  background: col(fill), color: col(ink), borderWidth: pt(0), paddingX: em(0.45) }); // no outline
const chipStyles = [
  { id: 'taste', fontFamily: MONO, bold: true, fontSize: em(0.92), backgroundEnabled: false,
    borderColor: col('ink'), borderWidth: pt(0.6), borderRadius: pt(2.2), paddingX: em(0.4) },
  filled('warnung', 'warning', 'paper'), filled('vorsicht', 'caution', 'ink'),
  filled('hinweis', 'brand', 'paper'), { ...filled('nr', 'brand', 'paper'), fontSize: em(0.95),
    borderRadius: em(1) }, // a part number, round like the drawing's
];
// #endregion

// #region steps: big teal step numbers; teal dashes under them, grey at the third level
const orderedLists = { separator: '', fontFamily: DISPLAY, fontWeight: 800, color: col('brand'),
  numberFontSize: pt(15), gap: mm(3), itemSpacing: pt(5), marginTop: pt(LEAD / 2),
  marginBottom: pt(0), numberVerticalOffset: pt(-1) }; // gotcha: list-number-centred
const unorderedLists = { color: col('brand'), gap: mm(2), marginTop: pt(0), marginBottom: pt(0),
  levels: [{ level: 2, bulletChar: '–', indent: mm(6.5) }, // at a step's text: number + 3 mm gap
    { level: 3, bulletChar: '–', color: col('muted') }] }; // teal •, teal –, grey –
// #endregion

// #region section: the section number reversed out of a teal tab, the title beside it
const H1 = 14, TAB = 2 * LEAD - 3.6, PAD = (TAB - H1 * 1.2) / 2; // pt: a square 2 lines less 3.6
const face = { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(H1), lineHeight: 1.2 }; // both
const section = { level: 1, numberingTemplate: '{1}', // {number}: 1, 2, 3 …
  breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2), // run on
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'text', id: 'tab', content: '{number}', ...face, color: col('paper'), align: 'center',
      box: { backgroundColor: col('brand'), padding: pad(PAD) },
      placement: pin('container', 'top-left', 0, 0, { width: pt(TAB) }) },
    { kind: 'text', id: 'title', content: '{titleText}', ...face, color: col('ink'),
      overflow: 'wrap', box: { padding: { top: pt(PAD) } }, placement: pin('#tab', 'right-of', 3) },
  ] } } };
// #endregion

const words = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const ART = { x: 36, y: 52, w: 73 }; // mm: the cover's white kettle, on a teal wall and a worktop
const COUNTER = ART.y + (ART.w * 119.4) / 112; // mm: its base's foot (row 119.4 of 112 wide)
const cover = { id: 'cover', numbered: false, breakBefore: { enabled: false },
  span: 'page', // in the column, 1.4.1 clips the design at the column top, 19 mm down the page
  advancedDesign: { enabled: true, minHeight: mm(PAGE.h - PAGE.top - PAGE.bottom),
    slot: { elements: [
      { kind: 'box', id: 'wall', style: { backgroundColor: col('brand') },
        placement: pin('page', 'top-left', 0, 0, { width: 'fill', height: mm(COUNTER) }) },
      { kind: 'image', id: 'art', resourceId: 'kettle',
        placement: pin('page', 'top-left', ART.x, ART.y, { width: mm(ART.w) }) },
      words('title', '{titleText}', DISPLAY, 48, 800, 'paper',
        pin('page', 'top-left', PAGE.inner - 0.6, 24), { lineHeight: 1 }),
      words('product', '{attr.product}', DISPLAY, 17, 500, 'tint', pin('#title', 'below', 0.4, 1)),
      words('manual', 'Bedienungsanleitung', DISPLAY, 15, 800, 'ink',
        pin('page', 'top-left', PAGE.inner, COUNTER + 12)),
      words('keep', 'Vor dem ersten Gebrauch lesen und aufbewahren.', TEXT, 8.6, 400, 'ink',
        pin('#manual', 'below', 0, 1)),
      words('lang', 'DE', DISPLAY, 11, 800, 'paper', pin('page', 'top-right', -PAGE.outer,
        COUNTER + 12), { box: { backgroundColor: col('brand'), padding: pad(3, 5) } }),
      words('model', '{attr.model}', MONO, 7.5, 500, 'muted',
        pin('page', 'bottom-left', PAGE.inner, -PAGE.bottom)),
    ] } } };

const head = (id, content, parity, edge, x) => words(id, content, MONO, 7.5, 500, 'muted',
  pin('page', edge, x, 10.5), { parity, pages: 'body', letterSpacing: pt(1.1),
    textTransform: 'uppercase', align: x > 0 ? 'left' : 'right' });
const folio = (parity, edge) => words(`folio-${parity}`, '{pageNumber}', DISPLAY, 9, 800,
  'paper', pin('page', edge, 0, -9, { width: mm(11) }), { parity, pages: 'body', align: 'center',
    box: { backgroundColor: col('brand'), padding: pad(3.5) } });

const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity)
  locale: 'de', resourceTypes, colorPalette, calloutStyles, chipStyles, tableStyle, orderedLists,
  tableStyles: [{ id: 'bare', cellPadding: mm(1.1) }], // the legend and the data: no head row
  unorderedLists, headingStyles: [cover], layout: { layoutType: 'single' },
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top),
    bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'),
    boldFontWeight: 600, boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('ink'), referenceBold: false, firstLineIndent: pt(0),
    paragraphSpacing: true, minWordSpacing: 0.8, maxWordSpacing: 1.8, // from 0.6 and 2
    maxRuntTracking: 0 }, // gotcha: runt-tracking-unpainted
  headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), lineHeight: pt(LEAD),
    levels: [section, { level: 2, fontSize: pt(10.4), color: col('brand'), marginTop: pt(LEAD),
      marginBottom: pt(0), numberingTemplate: '{1}.{2}' }] }, // 3.1, 3.2 …
  captionStyle: { fontFamily: TEXT, fontSize: pt(8.2), labelColor: col('brand'), gap: mm(1.6) },
  paragraphStyles: [{ id: 'colophon', fontFamily: TEXT, fontSize: pt(7), lineHeight: pt(9.6),
    color: col('muted'), textAlign: 'left', marginTop: pt(LEAD) }],
  header: { elements: [head('verso', '{title}', 'even', 'top-left', PAGE.outer),
    head('recto', 'Wasserkocher VW-170', 'odd', 'top-right', -PAGE.outer)] },
  footer: { elements: [folio('odd', 'bottom-right'), folio('even', 'bottom-left')] }, // thumb
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Verra W1 · Bedienungsanleitung"
author: "Verra Haushaltsgeräte"
---

# Verra W1 {style="cover" product="Wasserkocher" model="Modell VW-170 · 1,7 l · 220–240 V · 2200 W"}

:::pagebreak

# Sicherheitshinweise

Lesen Sie diese Anleitung vor dem ersten Gebrauch ganz durch und bewahren Sie sie auf; wer das Gerät nach Ihnen benutzt, braucht sie auch. Der Verra W1 ist nur zum Erhitzen von Trinkwasser bestimmt. Milch, Instantgetränke und Suppen brennen am Heizboden an und schäumen über.

Kinder ab 8 Jahren und Personen mit eingeschränkten körperlichen, sensorischen oder geistigen Fähigkeiten dürfen das Gerät unter Aufsicht benutzen, oder wenn sie in seinen sicheren Gebrauch eingewiesen wurden und die Gefahren verstehen. Kinder dürfen nicht mit dem Gerät spielen; reinigen dürfen sie es erst ab 8 Jahren und unter Aufsicht.

Die Warnhinweise sind nach der Schwere der Gefahr gestuft: :chip[WARNUNG]{style="warnung"} warnt vor Lebensgefahr und schweren Verletzungen, :chip[VORSICHT]{style="vorsicht"} vor leichten Verletzungen und :chip[HINWEIS]{style="hinweis"} vor Sachschäden. Die Hinweise finden Sie bei den Arbeitsschritten, für die sie gelten.

:::callout{type="warnung" title="Warnung · Stromschlag"}
- Schließen Sie das Gerät nur an eine ordnungsgemäß installierte Schutzkontakt-Steckdose mit 220 bis 240 Volt an.
- Tauchen Sie Kanne, Sockel und Netzkabel nie in Wasser, und ziehen Sie vor dem Reinigen den Netzstecker.
- Stellen Sie den Sockel nicht neben die Spüle oder unter den Wasserhahn. Er muss trocken bleiben.
- Benutzen Sie das Gerät nicht, wenn Netzkabel, Stecker oder Kanne beschädigt sind. Ein beschädigtes Kabel ersetzt nur der Kundendienst.
:::

:::callout{type="vorsicht" title="Vorsicht · Verbrühungsgefahr"}
- Füllen Sie höchstens bis zur Marke MAX. Aus einer zu vollen Kanne spritzt kochendes Wasser.
- Öffnen Sie den Deckel nicht, solange das Wasser kocht, und fassen Sie die Kanne nur am Griff an. Die Wand aus Edelstahl wird beim Kochen heiß.
- Stellen Sie das Gerät auf eine feste, ebene Fläche, und lassen Sie das Netzkabel nicht über die Tischkante hängen.
:::

# Gerät im Überblick

:ref{id="teile" style="full"} zeigt den Verra W1 von der Seite, :ref{id="legende" style="full"} nennt seine Teile. Die Nummern gelten in der ganzen Anleitung.

::resource{id="teile"}

::resource{id="legende"}

Überschüssiges Netzkabel wickeln Sie unter dem Sockel auf und führen es durch eine der beiden Kerben nach außen, damit der Sockel eben steht.

Prüfen Sie nach dem Auspacken, ob alle Teile vorhanden und unbeschädigt sind:

- [ ] Kanne mit Deckel und Kalkfilter
- [ ] Sockel mit Netzkabel
- [ ] diese Bedienungsanleitung

# Bedienung

## Vor dem ersten Gebrauch

Entfernen Sie alle Aufkleber und Verpackungsreste. Kochen Sie zweimal eine volle Kanne Wasser auf und gießen Sie es jedes Mal weg; so spülen Sie Rückstände aus der Fertigung heraus.

## Wasser kochen

1. Heben Sie die Kanne ab und öffnen Sie den Deckel (1) mit der Taste (5).
2. Füllen Sie frisches, kaltes Leitungswasser ein, mindestens bis MIN (0,5 Liter) und höchstens bis MAX (1,7 Liter). Drücken Sie den Deckel zu, bis er einrastet.
3. Setzen Sie die Kanne auf den Sockel; sie passt in jeder Richtung.
4. Wählen Sie mit :chip[°C]{style="taste"} die Temperatur. Jeder Druck senkt sie um 10 Grad, eine Leuchte am Bedienfeld (4) zeigt die Wahl.
5. Drücken Sie :chip[EIN/AUS]{style="taste"}. Ist die Temperatur erreicht, ertönt ein Signal, und das Gerät schaltet sich ab.
   - Ein zweiter Druck auf :chip[EIN/AUS]{style="taste"} bricht vorher ab.
   - Mit :chip[WARM]{style="taste"} hält das Gerät die Temperatur danach 30 Minuten lang.
     - Ohne Kanne auf dem Sockel endet das Warmhalten nach 2 Minuten.

:::callout{type="hinweis" title="Hinweis"}
Schalten Sie das Gerät nie leer ein. Läuft es trocken, schaltet der Überhitzungsschutz ab, und die Leuchte blinkt rot. Lassen Sie es dann 10 Minuten abkühlen, bevor Sie Wasser einfüllen.
:::

## Die richtige Temperatur

- 100 °C für schwarzen Tee und Kräutertee
- 90 °C für Filterkaffee
- 80 °C für weißen Tee und Oolong
- 70 °C für grünen Tee

# Reinigung und Entkalken

Ziehen Sie vor dem Reinigen den Netzstecker und lassen Sie das Gerät abkühlen. Wischen Sie Kanne und Sockel außen mit einem feuchten Tuch ab. Den Kalkfilter im Ausgießer (2) ziehen Sie nach oben heraus und spülen ihn unter fließendem Wasser ab. Innen genügt es, die Kanne nach Gebrauch zu leeren und mit offenem Deckel trocknen zu lassen.

Wie oft Sie entkalken, hängt von der Härte Ihres Wassers ab: bei hartem Wasser über 14 °dH jeden Monat, bei weichem Wasser unter 8,4 °dH etwa alle drei Monate. Den Härtegrad nennt Ihnen Ihr Wasserversorger. Spätestens wenn der Heizboden eine weiße Schicht zeigt oder das Gerät beim Aufheizen lauter wird, ist es Zeit.

1. Füllen Sie 1 Liter kaltes Wasser ein und lösen Sie 2 Esslöffel Zitronensäure (etwa 30 g) darin auf.
2. Lassen Sie die Lösung eine Stunde einwirken, ohne das Gerät einzuschalten. Erhitzte Zitronensäure bildet mit dem Kalk schwer lösliches Calciumcitrat.
3. Gießen Sie die Lösung weg und spülen Sie die Kanne zweimal gründlich aus. Kochen Sie einmal frisches Wasser auf und gießen Sie es ebenfalls weg.

:::callout{type="hinweis" title="Hinweis"}
Scheuermittel und Stahlwolle zerkratzen den Edelstahl. Essigessenz greift die Dichtung des Deckels an und darf nicht in die Kanne. Kanne und Sockel gehören nicht in die Spülmaschine.
:::

# Störungen beheben

Viele Störungen können Sie selbst beheben. Suchen Sie in :ref{id="stoerungen" style="full"} die Beschreibung, die zu Ihrem Fall passt, und prüfen Sie die Ursachen der Reihe nach; die häufigste steht jeweils oben. Hilft keine der Lösungen, wenden Sie sich an den Kundendienst und öffnen Sie das Gerät nicht selbst. Halten Sie dafür die Modellbezeichnung VW-170 bereit; sie steht auf dem Typenschild unter dem Sockel. Die Anschrift des Kundendienstes finden Sie auf der beiliegenden Garantiekarte.

# Technische Daten

::resource{id="daten"}

# Entsorgung

Elektrogeräte gehören nicht in den Hausmüll. Geben Sie den ausgedienten Wasserkocher bei einer Sammelstelle für Elektroaltgeräte ab, etwa beim Wertstoffhof Ihrer Gemeinde. Auch Händler, die auf mindestens 400 m² Elektrogeräte verkaufen, nehmen Altgeräte kostenlos zurück. Die Verpackung besteht aus Pappe und gehört ins Altpapier.

:::paragraphs{style="colophon"}
Verra W1 · Bedienungsanleitung DE · Ausgabe 09/2026. Verra ist eine erfundene Marke; Gerät und Anleitung sind ein Beispiel aus dem Postext Cookbook. Gesetzt in Red Hat Text, Red Hat Display und Red Hat Mono (SIL OFL). Text und Zeichnungen: CC BY 4.0.
:::
`; // content.<lang>.md: the manual, in German
const parts = String.raw`:chip[1]{style="nr"}	Deckel
:chip[2]{style="nr"}	Ausgießer mit Kalkfilter
:chip[3]{style="nr"}	Sockel
:chip[4]{style="nr"}	Bedienfeld: :chip[°C]{style="taste"} :chip[EIN/AUS]{style="taste"} :chip[WARM]{style="taste"}
:chip[5]{style="nr"}	Deckeltaste
:chip[6]{style="nr"}	Griff
:chip[7]{style="nr"}	Wasserstandsanzeige
:chip[8]{style="nr"}	Netzkabel mit Stecker
`; // TSV: number chip, part; in the drawing's order
const faults = String.raw`Störung	Mögliche Ursache	Abhilfe
**Das Gerät lässt sich nicht einschalten.**	Der Stecker steckt nicht, oder die Steckdose führt keinen Strom.	Stecker einstecken; die Sicherung im Sicherungskasten prüfen.
	Die Kanne sitzt nicht richtig auf dem Sockel.	Kanne abheben und gerade wieder aufsetzen.
	Der Überhitzungsschutz hat ausgelöst.	Gerät 10 Minuten abkühlen lassen, dann Wasser einfüllen.
**Die Leuchte blinkt rot.**	Das Gerät wurde leer oder mit zu wenig Wasser eingeschaltet.	Abkühlen lassen und mindestens bis MIN füllen.
	Die Elektronik meldet einen Fehler.	Netzstecker für eine Minute ziehen. Blinkt die Leuchte weiter: Kundendienst.
**Das Gerät schaltet ab, bevor das Wasser kocht.**	Eine niedrigere Temperatur ist gewählt.	Mit :chip[°C]{style="taste"} 100 °C wählen.
	Kalk bedeckt den Heizboden.	Gerät entkalken, siehe Abschnitt 4.
**Weiße Flocken schwimmen im Wasser.**	Kalk aus hartem Wasser; er ist gesundheitlich unbedenklich.	Gerät entkalken und den Kalkfilter ausspülen.
**Beim Ausgießen läuft Wasser am Deckel vorbei.**	Die Kanne ist über MAX gefüllt.	Nur bis MAX füllen.
	Der Deckel ist nicht eingerastet.	Deckel zudrücken, bis er hörbar einrastet.
**Wasser steht auf dem Sockel.**	Die Kanne war beim Aufsetzen außen nass.	Netzstecker ziehen und den Sockel trocknen lassen.
	Die Kanne ist undicht.	Gerät nicht mehr benutzen und den Kundendienst anrufen.
**Das Wasser schmeckt nach Kunststoff.**	Das Gerät ist neu.	Zweimal Wasser aufkochen und weggießen.
	Das Wasser stand lange in der Kanne.	Kanne nach Gebrauch leeren und stets frisches Wasser einfüllen.
**Kein Signalton ertönt.**	Der Signalton ist ausgeschaltet.	:chip[WARM]{style="taste"} 3 Sekunden gedrückt halten, bis die Leuchte zweimal blinkt.
`; // TSV: fault, cause, remedy
const data = String.raw`**Modell**	VW-170
**Nennspannung**	220–240 V ~, 50/60 Hz
**Nennleistung**	2200 W
**Füllmenge**	0,5 bis 1,7 Liter
**Temperaturen**	70, 80, 90, 100 °C
**Schutzklasse**	I
**Netzkabel**	75 cm
**Gewicht**	1,2 kg mit Sockel
`; // TSV: technical data, two columns

// #region tables: a TSV per table; a blank first cell shares the fault above it
function faultTable(tsv) { // parseTSV leaves the head row to you: headerRowCount
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [30, 34, 36] }; // weights
  for (let r = 2, top = 1; r < m.rows.length; r++) { // a rowspan per fault: no cut runs through it
    if (m.rows[r][0].content) top = r; // mergeCells hides what it covers: merged-cells-hiddenby
    else m = mergeCells(m, { start: { row: top, col: 0 }, end: { row: r, col: 0 } });
  }
  return m;
}
const fold = (rows, columnWidths) => ({ columnWidths, rows: rows.slice(0, rows.length / 2) // 2 up
  .map((row, k) => [...row, ...rows[k + rows.length / 2]]) });
const legend = fold(parseTSV(parts).rows.map(([chip, name]) => [{ ...chip, align: 'center' },
  name]), [7, 43, 7, 43]); // each part's number chip centred in its narrow column
const HERE = { placement: { position: 'here' } }; // at the resource's ::resource line
const table = (id, caption, model, styleId = 'bare', where = HERE) => ({ id, typeId: 'table',
  kind: 'table', caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...where });
const tables = [table('legende', 'Teile des Verra W1', legend),
  table('daten', 'Kenndaten des VW-170', fold(parseTSV(data).rows, [20, 30, 20, 30])),
  table('stoerungen', 'Störungen und ihre Behebung', faultTable(faults), null, // the house style,
    { placement: { position: 'top' } })]; // a float, so it can split (gotcha: here-table-no-split)
// #endregion

const svgFile = (id, width = 240, height = 240) => ({ id, typeId: 'figure', kind: 'svg',
  svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0 });
const resources = [
  { ...svgFile('teile', 1180, 560), ...HERE, caption: 'Der Verra W1 von links, mit Kanne und '
    + 'Sockel', altText: 'Wasserkocher von der Seite; acht Linien zeigen auf seine Teile.' },
  ...tables, svgFile('kettle', 1120, 1300), svgFile('triangle-white'), svgFile('triangle-ink'),
  svgFile('info'), // never cited, so never placed: the cover and the boxes draw them by id
];

// #region art: the kettle, its parts diagram with embedded digits, and the three notice icons
const n = (v) => +v.toFixed(2);
const svg = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" `
  + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`;
const path = (d, fill, stroke = 'none', width = 0, extra = '') => `<path d="${d}" `
  + `fill="${fill}" stroke="${stroke}" stroke-width="${width}" stroke-linejoin="round" `
  + `stroke-linecap="round"${extra}/>`;
const pill = (x, y, w, h, fill, stroke, sw) => `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" `
  + `height="${n(h)}" rx="${n(h / 2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"/>`;
// The kettle in profile, spout left, handle right, on a 112 × 128 grid.
const K = {
  body: 'M23 106Q17.6 106 18 101L22.8 41H77.2L82 101Q82.4 106 77 106Z',
  collar: 'M22.4 34H77.6L77.2 41H22.8Z',
  lid: 'M24 34C31.5 25.8 68.5 25.8 76 34Z',
  spout: 'M22.4 35.2L8 29.4Q5.9 28.8 7 30.8Q14.6 42.4 21.9 47.8Z',
  handle: 'M77.6 35.2H93Q100.5 35.2 100.5 42.7V83.5Q100.5 91 93 91H81.7L81.1 83.6H89Q92.4 83.6'
    + ' 92.4 80.2V46.2Q92.4 42.8 89 42.8H77.3Z',
  release: 'M80.5 30.6H90.6Q92.6 30.6 92.6 32.6V35.2H78.6Z',
  base: 'M14 106.6H86Q90.6 106.6 90.6 111.2V113.8Q90.6 118.4 86 118.4H14Q9.4 118.4 9.4 113.8'
    + 'V111.2Q9.4 106.6 14 106.6Z',
};
function kettle(line, fill, water, sw) {
  let out = ['body', 'collar', 'lid', 'handle', 'release', 'spout']
    .map((k) => path(K[k], fill, line, sw)).join('');
  out += path('M63.7 66H68.2L69.8 98H65Z', water) // water in the window
    + path('M62.5 47H67.3L69.8 98H65Z', 'none', line, sw * 0.8);
  for (const [y, w] of [[51, 3.2], [61, 1.8], [71, 1.8], [81, 1.8], [91, 3.2]]) { // MAX … MIN
    out += path(`M${n(60.4 - w)} ${y}H60.4`, 'none', line, sw * 0.7);
  }
  out += path('M11.8 32.4L18.9 35.4M13.4 35.6L19.6 38.2', 'none', line, sw * 0.6) // the filter
    + path('M30 45V98', 'none', water, sw * 1.6) // light on the steel
    + path(K.base, fill, line, sw)
    + path('M90.6 114C97.6 114.4 100.6 118.6 100.6 122.4S105 127 111 127', 'none', line, sw);
  for (const x of [33, 45, 57]) out += pill(x, 110.3, 9, 4.4, water, line, sw * 0.6); // keys
  return out;
}
function coverArt() { // a white kettle: its teal outline does not show on the teal wall
  const steam = [0, 1, 2].map((k) => path(`M${3.5 + k * 4.6} 24q-2.8-4.6 0-9.2t0-9.2`, 'none',
    palette.paper, 1.2, ' stroke-opacity=".6"')).join('');
  return svg(112, 130, `<g transform="translate(0 1)">${kettle(palette.brand, palette.paper,
    palette.tint, 1.5)}${steam}</g>`);
}
// Parts diagram, 118 × 56 mm: the kettle at half size, numbered leaders in two columns.
const [S, X0, Y0] = [0.5, 30, -8.5];
const PARTS = [ // a point on the kettle grid, then the leader's corners in mm; the disk ends it
  [[42, 28.6], [[X0 + 21, 2.5], [8, 2.5]]], [[15, 34], [[8, 14]]], [[13, 112.5], [[8, 42]]],
  [[37.5, 112.5], [[8, 52]]], [[87, 30.6], [[X0 + 43.5, 2.5], [110, 2.5]]],
  [[100.5, 58], [[110, 22]]], [[68.2, 94], [[110, 38.5]]], [[104, 125], [[110, 52]]]];
function partsArt(face) { // the digits need a face embedded in the SVG (gotcha: svg-no-webfonts)
  let out = `<g transform="translate(${X0} ${Y0}) scale(${S})">`
    + kettle(palette.ink, palette.paper, palette.tint, 1.4) + '</g>';
  PARTS.forEach(([[x, y], corners], i) => {
    const pts = [[X0 + x * S, Y0 + y * S], ...corners];
    const [dx, dy] = pts.at(-1);
    out += path(`M${pts.map(([px, py]) => `${n(px)} ${n(py)}`).join('L')}`, 'none',
      palette.brand, 0.3) + `<circle cx="${n(pts[0][0])}" cy="${n(pts[0][1])}" r="0.65" `
      + `fill="${palette.brand}"/><circle cx="${dx}" cy="${dy}" r="2.5" fill="${palette.brand}"/>`
      + `<text x="${dx}" y="${n(dy + 1.2)}" text-anchor="middle" fill="${palette.paper}">`
      + `${i + 1}</text>`;
  });
  return svg(118, 56, out, `<style>${face}text{font-family:N;font-size:3.3px}</style>`);
}
function triangle(fg, mark) { // a rounded safety alert triangle, its '!' in the band's colour
  return svg(24, 24, path('M12 2.4L22.6 20.6H1.4Z', fg, fg, 2.2) + path('M12 8.6V14.4', 'none',
    mark, 2.4) + `<circle cx="12" cy="17.6" r="1.35" fill="${mark}"/>`);
}
function info() {
  return svg(24, 24, `<circle cx="12" cy="12" r="11" fill="${palette.brand}"/>`
    + `<circle cx="12" cy="7.2" r="1.6" fill="${palette.paper}"/>`
    + path('M12 11V17.6', 'none', palette.paper, 2.8));
}
async function embeddedFace(family, weight) { // a Fontsource file as a data URL
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-`
    + `${weight}-normal.woff2`);
  if (!res.ok) throw new Error(`Font not found (${res.status}): ${family} ${weight}`);
  const bytes = new Uint8Array(await res.arrayBuffer());
  let bin = '';
  for (let i = 0; i < bytes.length; i += 8192) {
    bin += String.fromCharCode(...bytes.subarray(i, i + 8192));
  }
  return `@font-face{font-family:N;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
}
const drawings = async () => ({ 'kettle.svg': coverArt(),
  'teile.svg': partsArt(await embeddedFace(DISPLAY, 700)),
  'triangle-white.svg': triangle(palette.paper, palette.warning),
  'triangle-ink.svg': triangle(palette.ink, palette.caution), 'info.svg': info() });
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { 'Red Hat Text': ['400', '400i', '600'], // text, continuation notes, bold runs
  'Red Hat Display': ['500', '600', '700', '800'], // heads, tabs and numbers; chips; the SVG
  'Red Hat Mono': ['500', '600'] }; // running heads, keys, table heads (gotcha: fonts-first)

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const allText = [markdown, parts, faults, data].join('\n');
await Promise.all([loadFonts(FONTS, allText),
  ...Object.entries(await drawings()).map(([id, markup]) => loadSvg(id, markup))]);
const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText);
showPages(doc, { title: 'Verra W1 · Bedienungsanleitung' }); // the sample is German in both

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

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

// ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ────────
// Postext measures text with the faces the browser has loaded, and caches the
// widths, so every face must be ready before the first build. Faces come from
// Fontsource: the same static files the PDF embeds, so screen and PDF agree.

/** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample:
 *  letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With
 *  `optional`, a face Fontsource does not ship is skipped instead of failing.
 *  Resolves to the number of faces added. */
async function loadFonts(faces, text = '', { optional = false } = {}) {
  kitStatus('Loading fonts…');
  const ranges = {
    latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,'
      + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
    'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,'
      + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF',
  };
  const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin'];
  const jobs = [];
  let added = 0;
  for (const [family, specs] of Object.entries(faces)) {
    const id = fontsourceId(family);
    const meta = optional ? await fontsourceMeta(family) : null;
    for (const spec of new Set(specs)) {
      const weight = parseInt(spec, 10);
      const style = spec.endsWith('i') ? 'italic' : 'normal';
      if (hasFace(family, weight, style)) continue;
      if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue;
      for (const subset of subsets) {
        const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`;
        const face = new FontFace(family, `url(${url}) format('woff2')`,
          { weight: String(weight), style, unicodeRange: ranges[subset] });
        jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => {
          if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
        }));
      }
    }
  }
  await Promise.all(jobs).catch((error) => { kitFail(error); throw error; });
  return added;
}

/** Runs `build` (a buildDocument or buildBundle call) and checks the faces
 *  the pages use. A regular face missing from FONTS is loaded with a warning;
 *  bold and italic variants are loaded when the family ships them. Then the
 *  measurement caches are cleared and the build runs again. */
async function buildWithFonts(build, text = '') {
  const tried = new Set();
  for (let round = 0; round < 3; round++) {
    kitStatus('Laying out…');
    await new Promise(requestAnimationFrame);          // let the status paint first
    const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; });
    const wanted = { base: {}, variants: {} };
    for (const { font, base } of [result].flat().flatMap(fontStringsOf)) {
      const { family, weight, style } = parseFont(font);
      const key = `${family}|${weight}|${style}`;
      if (tried.has(key) || hasFace(family, weight, style)) continue;
      tried.add(key);
      (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`);
    }
    if (Object.keys(wanted.base).length) {
      console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`);
    }
    const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true });
    if (added === 0) return result;
    clearMeasurementCache();
  }
  throw new Error('The fonts did not settle after three builds.');
}

/** Every font string of the layout. `base` marks a block's own face; its
 *  bold, italic and bold-italic variants are listed whether or not used. */
function fontStringsOf(doc) {
  const found = new Map();
  const walk = (node) => {
    if (!node || typeof node !== 'object') return;
    if (Array.isArray(node)) { node.forEach(walk); return; }
    for (const [key, value] of Object.entries(node)) {
      if (typeof value === 'string' && /fontString$/i.test(key)) {
        found.set(value, found.get(value) || key === 'fontString');
      } else if (value && typeof value === 'object') walk(value);
    }
  };
  walk(doc.pages);
  walk(doc.blocks);
  return [...found].map(([font, base]) => ({ font, base }));
}

/** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }.
 *  A string with no weight ('95.8px Young Serif', from a design text) is 400. */
function parseFont(font) {
  const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim());
  if (!m) throw new Error(`Unexpected font string: ${font}`);
  const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]);
  return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' };
}

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

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

/** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */
function fontsourceMeta(family) {
  fontsourceMeta.cache ??= new Map();
  const id = fontsourceId(family);
  if (!fontsourceMeta.cache.has(id)) {
    fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`)
      .then((res) => (res.ok ? res.json() : null), () => null));
  }
  return fontsourceMeta.cache.get(id);
}

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

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

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

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

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

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

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

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

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

## Variations

### Number figures and tables by section

With the section number in the template and a reset at every section, the drawing becomes Abbildung 2.1 and the troubleshooting table Tabelle 5.1.

```diff
-const counted = { numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // 1, 2…
+const counted = { numberingTemplate: '{h1}.{n}', resetOn: 'h1', counterFormat: 'decimal' };
```

## Pitfalls

- **Localise Figure/Table with defaultResourceTypes(locale).** The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes.
- **A 'here' table never splits.** Only floated tables split across columns and pages; a table placed 'here' moves whole. Let a long table float, or keep inline tables short.
- **A 'top' float never lands on its citing page.** A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page.
- **Merged cells need hiddenBy placeholders: use mergeCells.** Cells are laid out by their position in the row array, so a merged cell needs placeholder cells marked hiddenBy where it spreads; leaving them out, as HTML does, shifts every later column. Build merges with mergeCells.
- **A list number is centred on its first line, not set on the baseline.** In postext 1.4.1 an ordered list's number is painted with the canvas 'middle' baseline, 0.3 em of the text above the item's first baseline, so a number in another face or at a larger numberFontSize grows up and down from there instead of standing on the line: a display face rides high, and a big step number hangs across the first line. Place it with orderedLists.numberVerticalOffset, and check the result at full size.
- **Text inside an SVG <img> cannot use web fonts.** An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption.
- **A swapped palette misses design elements and the reference colour.** postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build.
- **A runt fix can tighten tracking that is never painted.** In postext 1.4.1, when a paragraph ends on a runt, the layout sets it one line shorter: first with tighter word spacing, then with up to maxRuntTracking thousandths of an em of negative tracking. The canvas and PDF renderers paint tracking only above zero, so a tracked paragraph prints untracked: its justified lines lose the difference from their word spaces and look crushed, and its last line can run past the measure and be clipped at the column edge. Set bodyText.maxRuntTracking: 0, which keeps the word-spacing fix, and reword any runt that comes back.

- In 1.4.1 the item after a nested list is spaced by the nested list's `itemSpacing`, not by its own list's. With 5 pt between steps and none between bullets, a step that followed a bullet list would sit 5 pt closer than the others, so the bullets here hang from the last step only.
- Column balancing pushes a box that closes a page down to the page's last grid line, so any room left on the page opens above the box ([column balancing](/en/docs/configuration#column-balancing)). The text of page 2 was fitted to fill its column exactly. Delete one VORSICHT bullet and the box still ends at the foot, while the gap between the two boxes grows from 4.5 to 13.6 mm; with `headings: { balancing: { enabled: false } }` the box stays one line under WARNUNG and the page ends two lines short.

## Credits

- Recipe: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Type: Red Hat Text (OFL-1.1), Red Hat Display (OFL-1.1), Red Hat Mono (OFL-1.1)
- Code: MIT · Sample content: CC-BY-4.0

## Related

- [Nº 008 · A colour-coded family of textbook boxes](https://postext.dev/en/cookbook/textbook-box-family.md): Six kinds of textbook box in three colours, told apart by a stripe with an icon, a badge, a numbered tab, a strip of pictograms or a marker outside the frame. · Level 2 (Intermediate) · Textbooks
- [Nº 048 · Code listings and keycaps without code blocks](https://postext.dev/en/cookbook/code-listings-and-keycaps.md): A shell guide whose fenced code becomes dark listing boxes before the build, with bold and italic runs as syntax colours and keys set as keycap chips. · Level 2 (Intermediate) · Manuals, guides & reference
- [Nº 010 · Datasheet: tables from data, merged headers](https://postext.dev/en/cookbook/technical-datasheet.md): Tables pasted as TSV, parsed with parseTSV and shaped with mergeCells, setAlignment and setCellBackground; a register map that splits across pages by itself. · Level 3 (Advanced) · Manuals, guides & reference
