Aller au contenu principal
Recette numéro 50

Recettes · Chapitre 6 · Encadrés et notes

Notice de produit avec consignes de sécurité

La notice allemande d’une bouilloire, dont les encadrés WARNUNG et VORSICHT portent le triangle sur un bandeau de couleur de signal, avec légendes allemandes.

p. 2–3 sur 7

  • Exemple en anglais : pas encore d’édition française
  • Format 148 × 210 mm
  • 1 colonne
  • Red Hat Text 9,3/12,8
  • Red Hat Display
  • Red Hat Mono
  • 7 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 101 ms
  • 180 lignes de code

En bref

Une notice d’utilisation de sept pages, en allemand, pour une bouilloire électrique. On y voit comment faire des encadrés de mise en garde avec un triangle de danger, en rouge ou en ambre selon le niveau de risque.

Ce que vous allez composer

Une notice d’utilisation de sept pages pour la Verra W1, une bouilloire électrique inventée, en allemand, au format A5. La page de sécurité compose ses deux niveaux de danger en encadrés : un bandeau dans la couleur du signal court le long du bord gauche et porte le triangle de danger, et un fin cadre de la même couleur ferme l’encadré, rouge pour WARNUNG et ambre pour VORSICHT. HINWEIS, le niveau réservé aux dommages à l’appareil, abandonne le bandeau et le triangle pour une teinte bleu canard aux coins arrondis. Le paragraphe au-dessus des encadrés compose les trois mots-signaux en pastilles pleines. La page 3 porte un dessin des pièces avec des lignes de renvoi numérotées au-dessus de sa légende, et en page 4 les noms des touches sont des pastilles détourées à côté de grands numéros d’étape. Le tableau de dépannage court sur les pages 6 et 7. Les légendes affichent Abbildung et Tabelle, et le tableau coupé porte la mention Fortsetzung sur les deux pages.

Cette recette répond à

  • Comment faire des encadrés de note, d’astuce ou d’avertissement avec une icône, une bande de couleur et des coins arrondis ?
  • Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
  • Comment personnaliser les listes : puces par niveau, numérotation (a)/(i), cases à cocher, espacement qui reste sur la grille ?
  • Comment ajouter une figure avec une légende numérotée et la citer dans le texte (« voir fig. 3.2 ») ?
  • Comment faire un tableau avec rangées d’en-tête, cellules fusionnées, largeurs de colonne et alignement par cellule ?
  • Comment couper un long tableau sur plusieurs pages avec un en-tête répété et une mention « suite » ?
  • Comment obtenir les étiquettes « Figure » et « Tableau » dans la langue de mon document ?

La réponse courte

script.js · lignes 29–47dans le code complet
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; a box title's line is 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
];

Ingrédients

Typographie
Red Hat Text, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Un bandeau qui porte le triangle

Le code est dans la réponse courte plus haut. stripe peint le bandeau le long du bord gauche. Quand un encadré a une bande latérale, l’icône se centre sur la bande au lieu de prendre sa propre colonne (styles d’encadré), et align: 'top' aligne le haut de l’icône sur celui du titre. La border de la même couleur referme le bandeau en cadre ; sans elle, le texte de l’encadré resterait sur la page nue à côté d’une barre de couleur. PAD_Y est calculé pour que le titre, son espacement et les marges intérieures haute et basse fassent ensemble deux lignes de la grille de 12,8 pt. Le corps garde l’interlignage de la grille, si bien que chaque encadré mesure un nombre entier de lignes (WARNUNG 10, VORSICHT 8), et marginTop: pt(LEAD) laisse exactement une ligne entre les deux encadrés de la page 2. VORSICHT garde l’ambre pour le bandeau et le cadre mais compose son titre et ses puces à l’encre : l’ambre sur blanc mesure 2,0:1, et le petit texte demande 4,5:1. HINWEIS part de la même base (...notice(…)) et désactive le bandeau et le cadre : son icône d’information prend alors sa propre colonne et borderRadius arrondit une simple teinte.

#2 · Des noms allemands pour les figures et les tableaux

script.js · lignes 51–59dans le code complet
const counted = { numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // 1, 2…
// The built-in types in German, Abbildung and Tabelle; a table is captioned over its head.
const resourceTypes = defaultResourceTypes('de').map((type) => ({ ...type, ...counted,
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
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),
  // The engine's own German strings are '(Forts.)' and 'Wird fortgesetzt': these spell them out.
  continuedSuffix: '(Fortsetzung)', continuesMarker: 'Fortsetzung auf der nächsten Seite' };

locale: 'de' donne à la notice la césure allemande et des noms allemands pour les types intégrés : sans rien d’autre, les légendes affichent Abbildung 2.1 et Tabelle 5.1, et la note sous la première partie du tableau coupé dit Wird fortgesetzt. Le style de tableau écrit en toutes lettres cette note et le suffixe de la légende, et defaultResourceTypes('de') est passé pour deux changements. counted est le premier : {n} avec resetOn: 'never' compte à travers toute la notice (Abbildung 1, Tabelle 1 à 3) au lieu de repartir à chaque section. Seul le type tableau fixe captionStyle.position: 'above' : les tableaux prennent leur légende au-dessus de l’en-tête et le dessin garde la sienne en dessous.

#3 · Touches, mots-signaux et numéros de pièce en pastilles

script.js · lignes 63–71dans le code complet
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 détoure un nom de touche dans le caractère à chasse fixe, sans fond, comme l’étiquette imprimée sur le bouton. Les pastilles des mots-signaux sont remplies des couleurs de leurs encadrés, et la pastille ambre compose son mot à l’encre, comme le titre de VORSICHT. nr donne aux numéros de pièce un rayon supérieur à la moitié de leur hauteur : ils sortent en disques, comme les cercles numérotés du dessin. La taille des pastilles se règle en em du texte qui les entoure : une touche fait 8,6 pt dans le texte courant et 7,5 pt dans une cellule de tableau (styles de pastille).

#4 · Des numéros d’étape sur la ligne de base

script.js · lignes 75–80dans le code complet
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 –

Les étapes sont une liste ordonnée dont les numéros sont en Red Hat Display 800 à 15 pt, en bleu canard, sans séparateur. Un numéro de liste est centré sur sa première ligne, il ne repose pas sur la ligne de base : un chiffre de 15 pt à côté d’un texte de 9,3 pt pend donc environ 1 pt sous la ligne. numberVerticalOffset: pt(-1), une valeur mesurée sur la capture, remonte son pied sur la ligne de base. itemSpacing espace les étapes de 5 pt pour faire place aux grands chiffres. Les puces imbriquées sous une étape commencent à 6,5 mm, au niveau du texte de l’étape ; ce sont des tirets bleu canard, et le troisième niveau en a des gris (listes ordonnées).

#5 · Des numéros de section dans un onglet en réserve

script.js · lignes 84–94dans le code complet
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) },
  ] } } };

Chaque section est un titre de premier niveau dans la colonne, dessiné à partir d’un emplacement de maquette. L’onglet imprime {number} (rempli par numberingTemplate: '{1}') en blanc sur un carré bleu canard, et le titre commence 3 mm à sa droite. Le numéro et le titre partagent la même taille et la même hauteur de ligne : la même marge intérieure haute les pose donc sur une seule ligne de base. L’onglet est un carré de 22 pt, 3,6 pt de moins que deux lignes de la grille de 12,8 pt, si bien que le titre tient dans deux lignes de grille. La couverture est aussi un titre de premier niveau, composé dans un style de titre non numéroté : Sicherheitshinweise est donc la section 1.

#6 · Un tableau de dépannage à cellules fusionnées qui se coupe

script.js · lignes 293–311dans le code complet
function faultTable(tsv) { // the first line is the head row; the widths are weights
  let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [30, 34, 36] };
  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: it heads the page after its citation

Chaque tableau est un TSV dans son propre fichier de contenu. Dans les données de dépannage, une ligne dont la première cellule est vide appartient à la panne du dessus, et mergeCells réunit ces premières cellules en une seule cellule haute et marque comme masquées les cellules qu’elle recouvre. parseTSV ne définit aucune ligne d’en-tête : headerRowCount: 1 la déclare, et c’est cette ligne qui se répète en page 7. Le tableau est plus haut qu’une page : il est donc coupé et continue au verso. position: 'top' en fait un flottant en tête de la page 6 ; réglé sur 'here', il serait coupé de la même façon depuis postext 1.25, mais le texte qui le suit commencerait plus bas en page 7 et repousserait les dernières lignes sur une huitième page. La coupe tombe entre deux lignes et jamais à travers une cellule fusionnée, si bien que chaque panne reste sur la même page que ses causes (tableaux plus hauts que la page). Il est cité en page 5 et ouvre la page 6, la première page après sa citation qui puisse accueillir un flottant en tête. fold place côte à côte les deux moitiés d’une liste, pour la légende et pour les caractéristiques techniques, et la légende centre ses pastilles de numéro avec align: 'center'.

La recette complète

Sandbox
// ═══ 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.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, parseTSV,
  mergeCells, defaultResourceTypes, inlineSvgFonts } 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
// 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.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; a box title's line is 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…
// The built-in types in German, Abbildung and Tabelle; a table is captioned over its head.
const resourceTypes = defaultResourceTypes('de').map((type) => ({ ...type, ...counted,
  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
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),
  // The engine's own German strings are '(Forts.)' and 'Wird fortgesetzt': these spell them out.
  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', // an 'opener' page: the running head and the folio keep to 'body' pages
  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 = () => ({
  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
  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`---
Exemple Markdown · 102 lignes · content.en.mdtitle: "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
Exemple Markdown · 7 lignes · content.teile.en.md: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
Exemple Markdown · 15 lignes · content.stoerungen.en.md**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
Exemple Markdown · 7 lignes · content.daten.en.md**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) { // the first line is the head row; the widths are weights let m = { ...parseTSV(tsv, { headerRows: 1 }), columnWidths: [30, 34, 36] }; 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: it heads the page after its citation // #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, face = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}"${face}>${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() { // the digits name their face on the root; loadSvg embeds it 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, ` font-family="${DISPLAY}" font-weight="700" font-size="3.3"`); } 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)); } const drawings = () => ({ 'kettle.svg': coverArt(), 'teile.svg': partsArt(), '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 // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, parts, faults, data].join('\n'); await Promise.all([prepareFonts(allText, config(), kitFonts(FONTS)), ...Object.entries(drawings()).map(([id, markup]) => loadSvg(id, markup))]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), { ...kitFonts(FONTS), text: allText }); showPages(doc, { title: 'Verra W1 · Bedienungsanleitung' }); // the sample is German in both
Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Numéroter figures et tableaux par section

Avec le numéro de section dans le modèle et une remise à zéro à chaque section, le dessin devient Abbildung 2.1 et le tableau de dépannage Tabelle 5.1.

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

Erreurs fréquentes

Erreur fréquente

Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite

Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite. Placement des figures →

Erreur fréquente

Les cellules fusionnées demandent des cellules hiddenBy : utilisez mergeCells

Les cellules sont placées selon leur position dans le tableau de la ligne : une cellule fusionnée demande donc des cellules de remplacement marquées hiddenBy là où elle s’étend ; les omettre, comme le fait le HTML, décale toutes les colonnes suivantes. Construisez les fusions avec mergeCells. Depuis postext 1.5, un tableau dont la grille est irrégulière est signalé dans doc.contentWarnings comme raggedTableGrid, et parseTSV(text, { headerRows }) marque les lignes d’en-tête. Tableaux à partir de données →

Erreur fréquente

Un numéro de liste est centré sur sa première ligne, pas posé sur la ligne de base

Le numéro d’une liste ordonnée est peint avec la ligne de base 'middle' du canvas, à 0,3 em de texte au-dessus de la première ligne de base de l’élément : un numéro dans un autre caractère ou avec un numberFontSize plus grand s’étend vers le haut et vers le bas à partir de là au lieu de reposer sur la ligne. Un caractère de titrage monte trop haut, et un grand numéro d’étape déborde sur la première ligne. Placez-le avec orderedLists.numberVerticalOffset et vérifiez le résultat en taille réelle. Les numéros de la table des matières reposent sur la ligne de base depuis postext 1.5 ; ceux des listes sont toujours centrés. Listes numérotées →

  • L’équilibrage des colonnes pousse un encadré qui termine une page jusqu’à la dernière ligne de grille de la page, si bien que toute place restante s’ouvre au-dessus de l’encadré (équilibrage des colonnes). Le texte de la page 2 a été ajusté pour remplir exactement sa colonne. Supprimez une puce de VORSICHT : l’encadré finit toujours en pied, mais l’écart entre les deux encadrés passe de 4,5 à 13,6 mm ; avec headings: { balancing: { enabled: false } }, l’encadré reste à une ligne sous WARNUNG et la page finit deux lignes trop tôt.

Crédits

Texte
Texte original, CC BY 4.0
Polices
Red Hat Text (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
Sandbox