Vai al contenuto principale
Ricetta numero 50

Ricettario · Capitolo 6 · Riquadri e note

Manuale di prodotto con avvertenze di sicurezza

Il manuale tedesco di un bollitore i cui riquadri WARNUNG e VORSICHT portano il triangolo di pericolo su una fascia color segnale, con etichette tedesche.

pp. 2–3 di 7

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 148 × 210 mm
  • 1 colonna
  • Red Hat Text 9,3/12,8
  • Red Hat Display
  • Red Hat Mono
  • 7 pagine
  • Livello
  • Postext 1.31.0
  • Impaginato in 101 ms
  • 180 righe di codice

In breve

Un libretto di istruzioni di sette pagine in tedesco per un bollitore elettrico. Mostra come fare riquadri di avvertenza con il triangolo di pericolo, rossi o ambra secondo il livello di rischio.

Che cosa comporrai

Un manuale di istruzioni di sette pagine per il Verra W1, un bollitore elettrico immaginario, in tedesco su A5. La pagina della sicurezza compone i suoi due livelli di pericolo come riquadri: una fascia nel colore di segnalazione scende lungo il lato sinistro e porta il triangolo di pericolo, e una sottile cornice dello stesso colore chiude il riquadro, rossa per WARNUNG e ambra per VORSICHT. HINWEIS, il livello per i danni all’apparecchio, rinuncia alla fascia e al triangolo per una tinta verde acqua arrotondata. Il paragrafo sopra i riquadri compone tutte e tre le parole di segnalazione come chip pieni. La pagina 3 ha un esploso del prodotto con linee di richiamo numerate sopra la sua legenda, e a pagina 4 i nomi dei tasti sono chip bordati accanto a grandi numeri di passo. La tabella di risoluzione dei problemi occupa le pagine 6 e 7. Le didascalie dicono Abbildung e Tabelle, e la tabella divisa è marcata Fortsetzung su entrambe le pagine.

Questa ricetta risponde a

  • Come creo riquadri di nota, suggerimento o avvertenza con un’icona, una striscia colorata e angoli arrotondati?
  • Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
  • Come personalizzo gli elenchi: punti elenco per livello, numerazione (a)/(i), caselle di controllo, spaziature che restano sulla griglia?
  • Come aggiungo una figura con didascalia numerata e la cito nel testo («vedi Fig. 3.2»)?
  • Come creo una tabella con righe di intestazione, celle unite, larghezze delle colonne e allineamento per cella?
  • Come divido una tabella lunga su più pagine con l’intestazione ripetuta e un’indicazione «continua»?
  • Come ottengo le etichette «Figura» e «Tabella» nella lingua del mio documento?

La risposta breve

script.js · righe 29–47nel codice completo
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
];

Ingredienti

Tipo
Red Hat Text, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
Risorse
Nessuna: ogni immagine è disegnata nel codice

Preparazione

#1 · Una fascia che porta il triangolo

Il codice è la risposta breve qui sopra. stripe dipinge la fascia lungo il bordo sinistro. Quando un riquadro ha una fascia laterale, l’icona si centra sulla fascia invece di prendere una colonna propria (callout styles), e align: 'top' allinea il bordo superiore dell’icona con quello del titolo. Il border dello stesso colore chiude la fascia in una cornice; senza, il testo del riquadro starebbe sulla pagina nuda accanto a una barra colorata. PAD_Y è calcolato in modo che titolo, spazio sotto il titolo e spaziatura interna superiore e inferiore sommino due righe della griglia da 12,8 pt. Il corpo mantiene l’interlinea della griglia, quindi ogni riquadro è alto un numero intero di righe (WARNUNG 10, VORSICHT 8), e marginTop: pt(LEAD) lascia esattamente una riga tra i due riquadri di pagina 2. VORSICHT mantiene l’ambra per la fascia e la cornice e compone titolo e punti elenco in inchiostro: l’ambra sul bianco misura 2,0:1, e il testo piccolo richiede 4,5:1. HINWEIS parte dalla stessa base (...notice(…)) e disattiva fascia e cornice, così la sua icona informativa prende una colonna propria e borderRadius arrotonda una semplice tinta.

#2 · Nomi tedeschi per figure e tabelle

script.js · righe 51–59nel codice completo
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' dà al manuale la sillabazione tedesca e i nomi tedeschi per i tipi incorporati: senza altro, le didascalie dicono Abbildung 2.1 e Tabelle 5.1, e la nota sotto la prima parte della tabella divisa dice Wird fortgesetzt. Lo stile della tabella scrive per esteso quella nota e il suffisso della didascalia, e si passa defaultResourceTypes('de') per due modifiche. La prima è counted: {n} con resetOn: 'never' conta lungo tutto il manuale (Abbildung 1, Tabelle da 1 a 3) invece di ricominciare in ogni sezione. Solo il tipo tabella imposta captionStyle.position: 'above', così le tabelle hanno la didascalia sopra l’intestazione e il disegno la mantiene sotto.

#3 · Tasti, parole di segnalazione e numeri dei componenti come chip

script.js · righe 63–71nel codice completo
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 borda il nome di un tasto nel carattere monospaziato senza riempimento, come l’etichetta stampata sul pulsante. I chip delle parole di segnalazione sono riempiti con i colori dei loro riquadri, e il chip ambra compone la sua parola in inchiostro, come fa il titolo di VORSICHT. nr dà ai numeri dei componenti un raggio maggiore di metà della loro altezza, così escono come dischi, come i cerchi numerati del disegno. Le dimensioni dei chip si danno in em del testo attorno: un tasto misura 8,6 pt nel testo corrente e 7,5 pt in una cella di tabella (chip styles).

#4 · Numeri dei passi sulla linea di base

script.js · righe 75–80nel codice completo
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 –

I passi sono un elenco numerato con i numeri in Red Hat Display 800 a 15 pt, verde acqua, senza separatore. Il numero di un elenco si centra sulla sua prima riga, non poggia sulla linea di base, quindi una cifra da 15 pt accanto a un testo da 9,3 pt scende circa 1 pt sotto la riga. numberVerticalOffset: pt(-1), un valore misurato sulla cattura, ne solleva il piede sulla linea di base. itemSpacing separa i passi di 5 pt per fare posto alle cifre grandi. I punti elenco annidati sotto un passo cominciano a 6,5 mm, allineati al testo del passo; sono trattini verde acqua, e il terzo livello li ha grigi (ordered lists).

#5 · Numeri di sezione in una linguetta in negativo

script.js · righe 84–94nel codice completo
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) },
  ] } } };

Ogni sezione è un titolo di primo livello nella colonna, disegnato da uno spazio di design. La linguetta stampa {number} (riempito da numberingTemplate: '{1}') in bianco su un quadrato verde acqua, e il titolo comincia 3 mm alla sua destra. Numero e titolo condividono corpo e interlinea, quindi la stessa spaziatura superiore li mette su una sola linea di base. La linguetta è un quadrato di 22 pt, 3,6 pt meno di due righe della griglia da 12,8 pt, così il titolo resta entro due righe della griglia. Anche la copertina è un titolo di primo livello, composto in uno stile di titolo non numerato, quindi Sicherheitshinweise è la sezione 1.

#6 · Una tabella di risoluzione dei problemi con celle unite che si divide

script.js · righe 293–311nel codice completo
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

Ogni tabella è un TSV in un file di contenuto proprio. Nei dati della risoluzione dei problemi, una riga la cui prima cella è vuota appartiene al guasto sopra di essa, e mergeCells unisce quelle prime celle in una sola cella alta e marca come nascoste le celle che copre. parseTSV non imposta alcuna riga d’intestazione, quindi headerRowCount: 1 la dichiara, ed è quella la riga ripetuta a pagina 7. La tabella è più alta di una pagina, quindi viene tagliata e prosegue al verso. position: 'top' la rende un elemento flottante in testa a pagina 6; impostata su 'here' sarebbe tagliata allo stesso modo da postext 1.25, ma il testo che segue comincerebbe più in basso a pagina 7 e spingerebbe le ultime righe su un’ottava pagina. Il taglio cade tra le righe e mai attraverso una cella unita, così ogni guasto resta sulla stessa pagina delle sue cause (tables taller than the page). È citata a pagina 5 e apre la pagina 6, la prima pagina dopo la citazione che un elemento flottante in testa può occupare. fold affianca due metà di un elenco, per la legenda e per i dati tecnici, e la legenda centra i suoi chip numerati con align: 'center'.

La ricetta completa

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`---
Esempio Markdown · 102 righe · 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
Esempio Markdown · 7 righe · 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
Esempio Markdown · 15 righe · 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
Esempio Markdown · 7 righe · 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: uguale in ogni ricetta · 270 righe// ─── 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 ───────────────────────────────────────────────────────────────────────

Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)

Varianti

#Numera figure e tabelle per sezione

Con il numero di sezione nel modello e un azzeramento a ogni sezione, il disegno diventa Abbildung 2.1 e la tabella di risoluzione dei problemi Tabelle 5.1.

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

Errori frequenti

Errore frequente

Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita

Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita. Collocazione delle figure →

Errore frequente

Le celle unite richiedono segnaposto hiddenBy: usa mergeCells

Le celle si dispongono secondo la loro posizione nell’array della riga, quindi una cella unita richiede celle segnaposto marcate hiddenBy dove si estende; ometterle, come fa l’HTML, sposta tutte le colonne successive. Costruisci le unioni con mergeCells. Da postext 1.5 una tabella con una griglia irregolare viene segnalata in doc.contentWarnings come raggedTableGrid, e parseTSV(text, { headerRows }) marca le righe di testa. Tabelle dai dati →

Errore frequente

Il numero di un elenco è centrato sulla prima riga, non poggiato sulla linea di base

Il numero di un elenco numerato viene disegnato con la linea di base 'middle' del canvas, 0,3 em del testo sopra la prima linea di base della voce, quindi un numero in un altro carattere o con un numberFontSize maggiore cresce da lì verso l’alto e verso il basso invece di poggiare sulla riga: un carattere da titolazione sale troppo, e un grande numero di passaggio scende attraverso la prima riga. Posizionalo con orderedLists.numberVerticalOffset e controlla il risultato a grandezza reale. I numeri dell’indice poggiano sulla linea di base da postext 1.5; quelli degli elenchi sono ancora centrati. Elenchi numerati →

  • Il bilanciamento delle colonne spinge un riquadro che chiude una pagina fino all’ultima riga della griglia di quella pagina, quindi lo spazio che resta sulla pagina si apre sopra il riquadro (column balancing). Il testo di pagina 2 è stato adattato per riempire esattamente la sua colonna. Elimina un punto elenco di VORSICHT e il riquadro finisce ancora al piede, mentre lo spazio tra i due riquadri passa da 4,5 a 13,6 mm; con headings: { balancing: { enabled: false } } il riquadro resta una riga sotto WARNUNG e la pagina finisce due righe prima.

Crediti

Testo
Testo originale, CC BY 4.0
Font
Red Hat Text (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
Sandbox