Saltar al contenido principal
Receta número 50

Recetario · Capítulo 6 · Recuadros y notas

Manual de producto con avisos de seguridad

Manual de un hervidor en alemán: los recuadros WARNUNG y VORSICHT llevan el triángulo en una franja del color de aviso, y los pies dicen Abbildung y Tabelle.

pp. 2–3 de 7

  • Formato 148 × 210 mm
  • 1 columna
  • Red Hat Text 9,3/12,8
  • Red Hat Display
  • Red Hat Mono
  • 7 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 25 ms
  • 180 líneas de código

Lo que vas a componer

Un manual de siete páginas para el Verra W1, un hervidor inventado, en alemán y en A5. En la página de seguridad, los dos niveles de peligro van en recuadros: a la izquierda baja una franja del color de aviso con el triángulo de advertencia, y un marco fino del mismo color cierra el recuadro, rojo en WARNUNG y ámbar en VORSICHT. HINWEIS, que avisa de daños en el aparato, cambia franja y triángulo por un fondo verde azulado de esquinas redondeadas. Antes de los recuadros, un párrafo presenta las tres palabras de advertencia en chips rellenos. En la página 3, un dibujo con líneas de referencia numeradas va sobre su leyenda, y en la 4 las teclas son chips perfilados junto a números de paso grandes. La tabla de averías ocupa las páginas 6 y 7. Los pies dicen Abbildung y Tabelle, y la tabla partida, Fortsetzung.

Esta receta responde a

  • ¿Cómo hago recuadros de nota, consejo o advertencia con icono, franja de color y esquinas redondeadas?
  • ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
  • ¿Cómo personalizo las listas: viñetas por nivel, numeración (a)/(i), casillas de tareas y un espaciado que respete la rejilla?
  • ¿Cómo añado una figura con pie numerado y la cito en el texto («véase la fig. 3.2»)?
  • ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
  • ¿Cómo parto una tabla larga entre páginas con la cabecera repetida y un aviso de «continúa»?
  • ¿Cómo consigo las etiquetas «Figura» y «Tabla» en el idioma de mi documento?

La respuesta corta

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

Ingredientes

Tipografía
Red Hat Text, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Una franja que lleva el triángulo

El código es el de la respuesta corta, más arriba. stripe pinta la franja por el borde izquierdo. Cuando un recuadro lleva franja lateral, 1.4.1 centra el icono sobre ella en lugar de darle una columna propia (estilos de aviso), y align: 'top' alinea el borde superior del icono con el del título. El border del mismo color convierte la franja en el lado grueso de un marco; sin él, el texto del recuadro quedaría suelto en la página, al lado de una barra de color. PAD_Y está calculado para que el título, su separación y los rellenos superior e inferior sumen dos líneas de la rejilla de 12,8 pt. El cuerpo mantiene el interlineado de la rejilla, así que cada recuadro mide un número entero de líneas (10 el de WARNUNG, 8 el de VORSICHT), y marginTop: pt(LEAD) deja exactamente una línea entre los dos recuadros de la página 2. En VORSICHT, el ámbar se queda en la franja y el marco, y el título y las viñetas van en el color del texto: el ámbar sobre blanco da un contraste de 2,0:1, y la letra pequeña necesita 4,5:1. HINWEIS parte de la misma base (...notice(…)) y desactiva la franja y el marco, de modo que su icono de información ocupa una columna propia y borderRadius redondea un fondo liso.

#2 · Nombres alemanes para figuras y tablas

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

Con locale: 'de', la separación silábica es la alemana, pero 1.4.1 solo trae en inglés y en español los nombres de los tipos de recurso y los avisos de continuación de una tabla partida. Sin estas líneas, los pies dirían Figure 2.1 y Table 5.1, y bajo la primera parte de la tabla partida se leería Continued. Un tipo de recurso escrito a mano tiene que dar todos sus campos, también los de la numeración, y de eso se encarga counted: {n} con resetOn: 'never' numera de corrido todo el manual (Abbildung 1, Tabelle 1 a 3) en lugar de volver a empezar en cada sección. Solo el tipo de tabla fija captionStyle.position: 'above', así que las tablas llevan el pie encima de la cabecera y el dibujo lo mantiene debajo.

#3 · Teclas, palabras de advertencia y números de pieza como chips

script.js · líneas 63–71en el código 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 rodea con un filete el nombre de una tecla, en Red Hat Mono y sin relleno, como lo lleva impreso el botón. Los chips de las palabras de advertencia van rellenos del color de su recuadro, y el ámbar lleva la palabra en el color del texto, igual que el título de VORSICHT. nr da a los números de pieza un radio mayor que la mitad de su altura, así que salen redondos, como los círculos numerados del dibujo. El tamaño de los chips se da en em del texto que los rodea: una tecla mide 8,6 pt en el texto corrido y 7,5 pt en una celda de tabla (estilos de chip).

#4 · Números de paso sobre la línea base

script.js · líneas 75–80en el código 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 –

Los pasos son una lista numerada con las cifras en Red Hat Display 800 a 15 pt, en verde azulado y sin separador. 1.4.1 centra el número de una lista en su primera línea en vez de apoyarlo en la línea base, así que una cifra de 15 pt junto a texto de 9,3 pt queda cerca de 1 pt por debajo de la línea. numberVerticalOffset: pt(-1), un valor medido en la captura, sube su pie hasta la línea base. itemSpacing separa los pasos 5 pt para dejar sitio a las cifras grandes. Las viñetas anidadas bajo un paso empiezan a 6,5 mm, a la altura del texto del paso; son guiones verde azulado, y los del tercer nivel, grises (listas ordenadas).

#5 · Números de sección en una pestaña invertida

script.js · líneas 84–94en el código 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) },
  ] } } };

Cada sección es un título de primer nivel dentro de la columna, dibujado con una ranura de elementos. La pestaña imprime {number} (lo rellena numberingTemplate: '{1}') en blanco sobre un cuadrado verde azulado, y el título empieza 3 mm a su derecha. Número y título comparten cuerpo e interlineado, así que el mismo relleno superior los asienta en la misma línea base. La pestaña es un cuadrado de 22 pt, 3,6 pt menos que dos líneas de la rejilla de 12,8 pt, de modo que pestaña y título caben en dos líneas de rejilla. La cubierta también es un título de primer nivel, con un estilo de título sin número, así que Sicherheitshinweise es la sección 1.

#6 · Una tabla de averías con celdas combinadas que se parte

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

Cada tabla es un TSV en un archivo de contenido propio. En los datos de averías, una línea con la primera celda vacía pertenece a la avería anterior; mergeCells une esas primeras celdas en una sola celda alta y marca como ocultas las que cubre. parseTSV no fija ninguna fila de cabecera, así que la declara headerRowCount: 1, y esa es la fila que se repite en la página 7. La tabla es más alta que una página, y en 1.4.1 solo se parte una tabla flotante; por eso position: 'top' la convierte en flotante. El corte cae entre dos filas y nunca atraviesa una celda combinada, así que cada avería queda en la misma página que sus causas (tablas más altas que la página). Se cita en la página 5 y encabeza la 6, la primera página tras la cita que puede ocupar un flotante top. fold coloca las dos mitades de una lista una al lado de la otra, en la leyenda y en los datos técnicos, y la leyenda centra sus chips de número con align: 'center'.

La receta completa

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

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

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

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

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

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

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

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

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

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

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

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 102 líneas · content.es.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
Muestra en Markdown · 7 líneas · 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
Muestra en Markdown · 15 líneas · 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
Muestra en Markdown · 7 líneas · 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) { // parseTSV leaves the head row to you: headerRowCount let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [30, 34, 36] }; // weights for (let r = 2, top = 1; r < m.rows.length; r++) { // a rowspan per fault: no cut runs through it if (m.rows[r][0].content) top = r; // mergeCells hides what it covers: merged-cells-hiddenby else m = mergeCells(m, { start: { row: top, col: 0 }, end: { row: r, col: 0 } }); } return m; } const fold = (rows, columnWidths) => ({ columnWidths, rows: rows.slice(0, rows.length / 2) // 2 up .map((row, k) => [...row, ...rows[k + rows.length / 2]]) }); const legend = fold(parseTSV(parts).rows.map(([chip, name]) => [{ ...chip, align: 'center' }, name]), [7, 43, 7, 43]); // each part's number chip centred in its narrow column const HERE = { placement: { position: 'here' } }; // at the resource's ::resource line const table = (id, caption, model, styleId = 'bare', where = HERE) => ({ id, typeId: 'table', kind: 'table', caption, table: { model, styleId }, createdAt: 0, updatedAt: 0, ...where }); const tables = [table('legende', 'Teile des Verra W1', legend), table('daten', 'Kenndaten des VW-170', fold(parseTSV(data).rows, [20, 30, 20, 30])), table('stoerungen', 'Störungen und ihre Behebung', faultTable(faults), null, // the house style, { placement: { position: 'top' } })]; // a float, so it can split (gotcha: here-table-no-split) // #endregion const svgFile = (id, width = 240, height = 240) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0 }); const resources = [ { ...svgFile('teile', 1180, 560), ...HERE, caption: 'Der Verra W1 von links, mit Kanne und ' + 'Sockel', altText: 'Wasserkocher von der Seite; acht Linien zeigen auf seine Teile.' }, ...tables, svgFile('kettle', 1120, 1300), svgFile('triangle-white'), svgFile('triangle-ink'), svgFile('info'), // never cited, so never placed: the cover and the boxes draw them by id ]; // #region art: the kettle, its parts diagram with embedded digits, and the three notice icons const n = (v) => +v.toFixed(2); const svg = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`; const path = (d, fill, stroke = 'none', width = 0, extra = '') => `<path d="${d}" ` + `fill="${fill}" stroke="${stroke}" stroke-width="${width}" stroke-linejoin="round" ` + `stroke-linecap="round"${extra}/>`; const pill = (x, y, w, h, fill, stroke, sw) => `<rect x="${n(x)}" y="${n(y)}" width="${n(w)}" ` + `height="${n(h)}" rx="${n(h / 2)}" fill="${fill}" stroke="${stroke}" stroke-width="${sw}"/>`; // The kettle in profile, spout left, handle right, on a 112 × 128 grid. const K = { body: 'M23 106Q17.6 106 18 101L22.8 41H77.2L82 101Q82.4 106 77 106Z', collar: 'M22.4 34H77.6L77.2 41H22.8Z', lid: 'M24 34C31.5 25.8 68.5 25.8 76 34Z', spout: 'M22.4 35.2L8 29.4Q5.9 28.8 7 30.8Q14.6 42.4 21.9 47.8Z', handle: 'M77.6 35.2H93Q100.5 35.2 100.5 42.7V83.5Q100.5 91 93 91H81.7L81.1 83.6H89Q92.4 83.6' + ' 92.4 80.2V46.2Q92.4 42.8 89 42.8H77.3Z', release: 'M80.5 30.6H90.6Q92.6 30.6 92.6 32.6V35.2H78.6Z', base: 'M14 106.6H86Q90.6 106.6 90.6 111.2V113.8Q90.6 118.4 86 118.4H14Q9.4 118.4 9.4 113.8' + 'V111.2Q9.4 106.6 14 106.6Z', }; function kettle(line, fill, water, sw) { let out = ['body', 'collar', 'lid', 'handle', 'release', 'spout'] .map((k) => path(K[k], fill, line, sw)).join(''); out += path('M63.7 66H68.2L69.8 98H65Z', water) // water in the window + path('M62.5 47H67.3L69.8 98H65Z', 'none', line, sw * 0.8); for (const [y, w] of [[51, 3.2], [61, 1.8], [71, 1.8], [81, 1.8], [91, 3.2]]) { // MAX … MIN out += path(`M${n(60.4 - w)} ${y}H60.4`, 'none', line, sw * 0.7); } out += path('M11.8 32.4L18.9 35.4M13.4 35.6L19.6 38.2', 'none', line, sw * 0.6) // the filter + path('M30 45V98', 'none', water, sw * 1.6) // light on the steel + path(K.base, fill, line, sw) + path('M90.6 114C97.6 114.4 100.6 118.6 100.6 122.4S105 127 111 127', 'none', line, sw); for (const x of [33, 45, 57]) out += pill(x, 110.3, 9, 4.4, water, line, sw * 0.6); // keys return out; } function coverArt() { // a white kettle: its teal outline does not show on the teal wall const steam = [0, 1, 2].map((k) => path(`M${3.5 + k * 4.6} 24q-2.8-4.6 0-9.2t0-9.2`, 'none', palette.paper, 1.2, ' stroke-opacity=".6"')).join(''); return svg(112, 130, `<g transform="translate(0 1)">${kettle(palette.brand, palette.paper, palette.tint, 1.5)}${steam}</g>`); } // Parts diagram, 118 × 56 mm: the kettle at half size, numbered leaders in two columns. const [S, X0, Y0] = [0.5, 30, -8.5]; const PARTS = [ // a point on the kettle grid, then the leader's corners in mm; the disk ends it [[42, 28.6], [[X0 + 21, 2.5], [8, 2.5]]], [[15, 34], [[8, 14]]], [[13, 112.5], [[8, 42]]], [[37.5, 112.5], [[8, 52]]], [[87, 30.6], [[X0 + 43.5, 2.5], [110, 2.5]]], [[100.5, 58], [[110, 22]]], [[68.2, 94], [[110, 38.5]]], [[104, 125], [[110, 52]]]]; function partsArt(face) { // the digits need a face embedded in the SVG (gotcha: svg-no-webfonts) let out = `<g transform="translate(${X0} ${Y0}) scale(${S})">` + kettle(palette.ink, palette.paper, palette.tint, 1.4) + '</g>'; PARTS.forEach(([[x, y], corners], i) => { const pts = [[X0 + x * S, Y0 + y * S], ...corners]; const [dx, dy] = pts.at(-1); out += path(`M${pts.map(([px, py]) => `${n(px)} ${n(py)}`).join('L')}`, 'none', palette.brand, 0.3) + `<circle cx="${n(pts[0][0])}" cy="${n(pts[0][1])}" r="0.65" ` + `fill="${palette.brand}"/><circle cx="${dx}" cy="${dy}" r="2.5" fill="${palette.brand}"/>` + `<text x="${dx}" y="${n(dy + 1.2)}" text-anchor="middle" fill="${palette.paper}">` + `${i + 1}</text>`; }); return svg(118, 56, out, `<style>${face}text{font-family:N;font-size:3.3px}</style>`); } function triangle(fg, mark) { // a rounded safety alert triangle, its '!' in the band's colour return svg(24, 24, path('M12 2.4L22.6 20.6H1.4Z', fg, fg, 2.2) + path('M12 8.6V14.4', 'none', mark, 2.4) + `<circle cx="12" cy="17.6" r="1.35" fill="${mark}"/>`); } function info() { return svg(24, 24, `<circle cx="12" cy="12" r="11" fill="${palette.brand}"/>` + `<circle cx="12" cy="7.2" r="1.6" fill="${palette.paper}"/>` + path('M12 11V17.6', 'none', palette.paper, 2.8)); } async function embeddedFace(family, weight) { // a Fontsource file as a data URL const id = family.toLowerCase().replace(/\s+/g, '-'); const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-` + `${weight}-normal.woff2`); if (!res.ok) throw new Error(`Font not found (${res.status}): ${family} ${weight}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:N;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; } const drawings = async () => ({ 'kettle.svg': coverArt(), 'teile.svg': partsArt(await embeddedFace(DISPLAY, 700)), 'triangle-white.svg': triangle(palette.paper, palette.warning), 'triangle-ink.svg': triangle(palette.ink, palette.caution), 'info.svg': info() }); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Red Hat Text': ['400', '400i', '600'], // text, continuation notes, bold runs 'Red Hat Display': ['500', '600', '700', '800'], // heads, tabs and numbers; chips; the SVG 'Red Hat Mono': ['500', '600'] }; // running heads, keys, table heads (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, parts, faults, data].join('\n'); await Promise.all([loadFonts(FONTS, allText), ...Object.entries(await drawings()).map(([id, markup]) => loadSvg(id, markup))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: 'Verra W1 · Bedienungsanleitung' }); // the sample is German in both
Kit · core, fonts, viewer, images: igual en todas las recetas · 270 líneas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗

Variantes

#Numera figuras y tablas por sección

Con el número de sección en la plantilla y un reinicio en cada sección, el dibujo pasa a ser Abbildung 2.1 y la tabla de averías, Tabelle 5.1.

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

Errores frecuentes

Error frecuente

Traduce Figura y Tabla con defaultResourceTypes(locale)

El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →

Error frecuente

Una tabla 'here' nunca se parte

Solo se parten entre columnas y páginas las tablas flotantes; una tabla colocada 'here' se mueve entera. Deja flotar las tablas largas o mantén cortas las tablas en línea. Tablas que pasan de página →

Error frecuente

Un flotante 'top' nunca cae en la página que lo cita

Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →

Error frecuente

Las celdas combinadas necesitan hiddenBy: usa mergeCells

Las celdas se colocan según su posición en la fila, así que una celda combinada necesita celdas de relleno marcadas con hiddenBy donde se extiende; omitirlas, como en HTML, desplaza todas las columnas siguientes. Combina celdas con mergeCells. Tablas a partir de datos →

Error frecuente

El número de una lista se centra en su primera línea, no se apoya en la línea base

En postext 1.4.1 el número de una lista numerada se pinta con la línea base «middle» del canvas, 0,3 em del texto por encima de la primera línea base del elemento, así que un número en otra fuente o con un numberFontSize mayor crece hacia arriba y hacia abajo desde ahí en vez de apoyarse en la línea: una fuente de rótulo queda alta y un número grande de paso cuelga sobre la primera línea. Colócalo con orderedLists.numberVerticalOffset y revisa el resultado a tamaño real. Listas numeradas →

Error frecuente

El texto dentro de un SVG <img> no puede usar fuentes web

Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →

Error frecuente

Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones

postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →

Error frecuente

El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta

En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta. Viudas, huérfanas y líneas cortas →

  • En 1.4.1, el elemento que sigue a una lista anidada toma el itemSpacing de la lista anidada, no el de su propia lista. Con 5 pt entre pasos y ninguno entre viñetas, un paso que siguiera a una lista de viñetas quedaría 5 pt más cerca que los demás; por eso aquí solo el último paso lleva viñetas.
  • El equilibrado de columnas baja un recuadro que cierra una página hasta la última línea de la rejilla, así que el espacio que sobra en la página se abre encima de él (equilibrado de columnas). El texto de la página 2 está ajustado para llenar la columna sin que sobre ninguna línea. Si borras una viñeta de VORSICHT, el recuadro sigue acabando al pie y la separación entre los dos recuadros pasa de 4,5 a 13,6 mm; con headings: { balancing: { enabled: false } } el recuadro se queda una línea por debajo de WARNUNG y la página termina dos líneas antes del pie.

Créditos

Texto
Texto original, CC BY 4.0
Fuentes
Red Hat Text (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)