En poques paraules
Un manual d'instruccions de set pàgines, en alemany, per a un bullidor elèctric. Mostra com crear requadres de seguretat amb el triangle d'advertiment, en vermell o ambre segons el nivell de perill.
Què compondràs
Un manual de set pàgines per al Verra W1, un bullidor inventat, en alemany i en A5. A la pàgina de seguretat, els dos nivells de perill van en requadres: a l'esquerra baixa una franja del color d'avís amb el triangle d'advertiment, i un marc fi del mateix color tanca el requadre, vermell a WARNUNG i ambre a VORSICHT. HINWEIS, que avisa de danys a l'aparell, canvia franja i triangle per un fons verd blavós de cantonades arrodonides. Abans dels requadres, un paràgraf presenta les tres paraules d'advertiment en xips plens. A la pàgina 3, un dibuix amb línies de referència numerades va sobre la seva llegenda, i a la 4 les tecles són xips perfilats al costat de números de pas grans. La taula d'avaries ocupa les pàgines 6 i 7. Els peus diuen Abbildung i Tabelle, i la taula partida, Fortsetzung.
Aquesta recepta respon a
- Com faig requadres de nota, consell o advertiment amb icona, franja de color i cantonades arrodonides?
- Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
- Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?
- Com afegeixo una figura amb peu numerat i la cito en el text («vegeu la fig. 3.2»)?
- Com faig una taula amb files de capçalera, cel·les combinades, amplades de columna i alineació per cel·la?
- Com parteixo una taula llarga entre pàgines amb la capçalera repetida i un avís de «continua»?
- Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
La resposta curta
const BAND = 8.5; // mm: a stripe on the side is the icon's column; the icon is centred on it
const [TITLE, TITLE_GAP] = [8.6, 2.4]; // pt; 1.4.1 sets a box title 1.2 times its size
const PAD_Y = (2 * LEAD - 1.2 * TITLE - TITLE_GAP) / 2; // pt: title and padding fill two lines
const notice = (id, hue, ink, icon) => ({ id, backgroundEnabled: false,
stripe: { enabled: true, side: 'left', width: mm(BAND), color: col(hue) },
border: { enabled: true, color: col(hue), width: pt(0.75) }, // closes the band into a frame
icon: { kind: 'resource', resourceId: icon, size: mm(5.6), align: 'top' },
padding: { top: pt(PAD_Y), right: mm(3.2), bottom: pt(PAD_Y), left: mm(3.2) },
titleStyle: { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(TITLE), color: col(ink),
textTransform: 'uppercase', letterSpacing: pt(1.3), gap: pt(TITLE_GAP) },
body: { fontSize: pt(8.8), lineHeight: pt(LEAD) }, // on the grid: a box is whole lines tall
lists: { color: col(ink), gap: mm(2) }, marginTop: pt(LEAD), marginBottom: pt(0) });
const calloutStyles = [
notice('warnung', 'warning', 'warning', 'triangle-white'), // white triangle, red '!'
notice('vorsicht', 'caution', 'ink', 'triangle-ink'), // amber type would fail contrast
{ ...notice('hinweis', 'brand', 'brand'), stripe: { enabled: false }, border: { enabled: false },
backgroundEnabled: true, background: col('tint'), borderRadius: mm(2), // property damage:
icon: { kind: 'resource', resourceId: 'info', size: mm(4.6) } }, // no band, an icon column
];
Ingredients
- Funcions
- Icones i distintius de cantonadaRequadresTipus de recurs propisChips en líniaCites que col·loquen les figuresFigures just aquíCol·locació de figuresTaules a partir de dadesEstil de taulesTaules que passen de pàginaTítols numeratsLlistes numeradesLlistes de pics i de comprovacióPartició de mots i llengua del documentFigura i Taula en la teva llenguaObertures dissenyadesEstils de títolCobertes, portades i colofonsCapçaleres i folisTextos, filets i caixes als dissenys de pàgina
- També fa servir
- Atributs de títolSalts de pàgina i de columnaCapçaleres segons el tipus de pàginaEstils de paràgrafFigures i taules com a recursosEstils de taula amb nomCapítols sense número
- Tipografia
- Red Hat Text, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · Una franja que porta el triangle
El codi és el de la resposta curta, més amunt. stripe pinta la franja per la vora esquerra. Quan un requadre porta franja lateral, 1.4.1 centra la icona sobre la franja en lloc de donar-li una columna pròpia (estils d'avís), i align: 'top' alinea la vora superior de la icona amb la del títol. El border del mateix color converteix la franja en el costat gruixut d'un marc; sense ell, el text del requadre quedaria solt a la pàgina, al costat d'una barra de color. PAD_Y està calculat perquè el títol, la seva separació i els farciments superior i inferior sumin dues línies de la retícula de 12,8 pt. El cos manté l'interlineat de la retícula, així que cada requadre mesura un nombre enter de línies (10 el de WARNUNG, 8 el de VORSICHT), i marginTop: pt(LEAD) deixa exactament una línia entre els dos requadres de la pàgina 2. A VORSICHT, l'ambre es queda a la franja i el marc, i el títol i les vinyetes van en el color del text: l'ambre sobre blanc dona un contrast de 2,0:1, i la lletra petita necessita 4,5:1. HINWEIS parteix de la mateixa base (...notice(…)) i desactiva la franja i el marc, de manera que la seva icona d'informació ocupa una columna pròpia i borderRadius arrodoneix un fons llis.
#2 · Noms alemanys per a figures i taules
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' };
Amb locale: 'de', la partició de mots és l'alemanya, però 1.4.1 només porta en anglès i en castellà els noms dels tipus de recurs i els avisos de continuació d'una taula partida. Sense aquestes línies, els peus dirien Figure 2.1 i Table 5.1, i sota la primera part de la taula partida es llegiria Continued. Un tipus de recurs escrit a mà ha de donar tots els seus camps, també els de la numeració, i d'això se n'encarrega counted: {n} amb resetOn: 'never' numera correlativament tot el manual (Abbildung 1, Tabelle 1 a 3) en lloc de tornar a començar a cada secció. Només el tipus de taula fixa captionStyle.position: 'above', així que les taules porten el peu damunt de la capçalera i el dibuix el manté a sota.
#3 · Tecles, paraules d'advertiment i números de peça com a xips
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 envolta amb un filet el nom d'una tecla, en Red Hat Mono i sense farciment, tal com el porta imprès el botó. Els xips de les paraules d'advertiment van plens del color del seu requadre, i l'ambre porta la paraula en el color del text, igual que el títol de VORSICHT. nr dona als números de peça un radi més gran que la meitat de la seva alçada, així que surten rodons, com els cercles numerats del dibuix. La mida dels xips es dona en em del text que els envolta: una tecla mesura 8,6 pt al text corregut i 7,5 pt en una cel·la de taula (estils de xip).
#4 · Números de pas sobre la línia de base
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 –
Els passos són una llista numerada amb les xifres en Red Hat Display 800 a 15 pt, en verd blavós i sense separador. 1.4.1 centra el número d'una llista a la seva primera línia en comptes de recolzar-lo a la línia de base, així que una xifra de 15 pt al costat de text de 9,3 pt queda prop d'1 pt per sota de la línia. numberVerticalOffset: pt(-1), un valor mesurat a la captura, puja el seu peu fins a la línia de base. itemSpacing separa els passos 5 pt per deixar lloc a les xifres grans. Les vinyetes niades sota un pas comencen a 6,5 mm, a l'alçada del text del pas; són guions verd blavós, i els del tercer nivell, grisos (llistes ordenades).
#5 · Números de secció en una pestanya invertida
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ó és un títol de primer nivell dins de la columna, dibuixat amb una ranura d'elements. La pestanya imprimeix {number} (l'omple numberingTemplate: '{1}') en blanc sobre un quadrat verd blavós, i el títol comença 3 mm a la seva dreta. Número i títol comparteixen cos i interlineat, així que el mateix farciment superior els assenta a la mateixa línia de base. La pestanya és un quadrat de 22 pt, 3,6 pt menys que dues línies de la retícula de 12,8 pt, de manera que pestanya i títol caben en dues línies de retícula. La coberta també és un títol de primer nivell, amb un estil de títol sense número, així que Sicherheitshinweise és la secció 1.
#6 · Una taula d'avaries amb cel·les combinades que es parteix
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 taula és un TSV en un fitxer de contingut propi. A les dades d'avaries, una línia amb la primera cel·la buida pertany a l'avaria anterior; mergeCells uneix aquestes primeres cel·les en una sola cel·la alta i marca com a ocultes les que cobreix. parseTSV no fixa cap fila de capçalera, així que la declara headerRowCount: 1, i aquesta és la fila que es repeteix a la pàgina 7. La taula és més alta que una pàgina, i a 1.4.1 només es parteix una taula flotant; per això position: 'top' la converteix en flotant. El tall cau entre dues files i no travessa mai una cel·la combinada, així que cada avaria queda a la mateixa pàgina que les seves causes (taules més altes que la pàgina). Es cita a la pàgina 5 i encapçala la 6, la primera pàgina després de la cita que pot ocupar un flotant top. fold col·loca les dues meitats d'una llista l'una al costat de l'altra, a la llegenda i a les dades tècniques, i la llegenda centra els seus xips de número amb align: 'center'.
La recepta 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`---Mostra en Markdown · 102 línies · content.es.md
title: "Verra W1 · Bedienungsanleitung" author: "Verra Haushaltsgeräte" --- # Verra W1 {style="cover" product="Wasserkocher" model="Modell VW-170 · 1,7 l · 220–240 V · 2200 W"} :::pagebreak # Sicherheitshinweise Lesen Sie diese Anleitung vor dem ersten Gebrauch ganz durch und bewahren Sie sie auf; wer das Gerät nach Ihnen benutzt, braucht sie auch. Der Verra W1 ist nur zum Erhitzen von Trinkwasser bestimmt. Milch, Instantgetränke und Suppen brennen am Heizboden an und schäumen über. Kinder ab 8 Jahren und Personen mit eingeschränkten körperlichen, sensorischen oder geistigen Fähigkeiten dürfen das Gerät unter Aufsicht benutzen, oder wenn sie in seinen sicheren Gebrauch eingewiesen wurden und die Gefahren verstehen. Kinder dürfen nicht mit dem Gerät spielen; reinigen dürfen sie es erst ab 8 Jahren und unter Aufsicht. Die Warnhinweise sind nach der Schwere der Gefahr gestuft: :chip[WARNUNG]{style="warnung"} warnt vor Lebensgefahr und schweren Verletzungen, :chip[VORSICHT]{style="vorsicht"} vor leichten Verletzungen und :chip[HINWEIS]{style="hinweis"} vor Sachschäden. Die Hinweise finden Sie bei den Arbeitsschritten, für die sie gelten. :::callout{type="warnung" title="Warnung · Stromschlag"} - Schließen Sie das Gerät nur an eine ordnungsgemäß installierte Schutzkontakt-Steckdose mit 220 bis 240 Volt an. - Tauchen Sie Kanne, Sockel und Netzkabel nie in Wasser, und ziehen Sie vor dem Reinigen den Netzstecker. - Stellen Sie den Sockel nicht neben die Spüle oder unter den Wasserhahn. Er muss trocken bleiben. - Benutzen Sie das Gerät nicht, wenn Netzkabel, Stecker oder Kanne beschädigt sind. Ein beschädigtes Kabel ersetzt nur der Kundendienst. ::: :::callout{type="vorsicht" title="Vorsicht · Verbrühungsgefahr"} - Füllen Sie höchstens bis zur Marke MAX. Aus einer zu vollen Kanne spritzt kochendes Wasser. - Öffnen Sie den Deckel nicht, solange das Wasser kocht, und fassen Sie die Kanne nur am Griff an. Die Wand aus Edelstahl wird beim Kochen heiß. - Stellen Sie das Gerät auf eine feste, ebene Fläche, und lassen Sie das Netzkabel nicht über die Tischkante hängen. ::: # Gerät im Überblick :ref{id="teile" style="full"} zeigt den Verra W1 von der Seite, :ref{id="legende" style="full"} nennt seine Teile. Die Nummern gelten in der ganzen Anleitung. ::resource{id="teile"} ::resource{id="legende"} Überschüssiges Netzkabel wickeln Sie unter dem Sockel auf und führen es durch eine der beiden Kerben nach außen, damit der Sockel eben steht. Prüfen Sie nach dem Auspacken, ob alle Teile vorhanden und unbeschädigt sind: - [ ] Kanne mit Deckel und Kalkfilter - [ ] Sockel mit Netzkabel - [ ] diese Bedienungsanleitung # Bedienung ## Vor dem ersten Gebrauch Entfernen Sie alle Aufkleber und Verpackungsreste. Kochen Sie zweimal eine volle Kanne Wasser auf und gießen Sie es jedes Mal weg; so spülen Sie Rückstände aus der Fertigung heraus. ## Wasser kochen 1. Heben Sie die Kanne ab und öffnen Sie den Deckel (1) mit der Taste (5). 2. Füllen Sie frisches, kaltes Leitungswasser ein, mindestens bis MIN (0,5 Liter) und höchstens bis MAX (1,7 Liter). Drücken Sie den Deckel zu, bis er einrastet. 3. Setzen Sie die Kanne auf den Sockel; sie passt in jeder Richtung. 4. Wählen Sie mit :chip[°C]{style="taste"} die Temperatur. Jeder Druck senkt sie um 10 Grad, eine Leuchte am Bedienfeld (4) zeigt die Wahl. 5. Drücken Sie :chip[EIN/AUS]{style="taste"}. Ist die Temperatur erreicht, ertönt ein Signal, und das Gerät schaltet sich ab. - Ein zweiter Druck auf :chip[EIN/AUS]{style="taste"} bricht vorher ab. - Mit :chip[WARM]{style="taste"} hält das Gerät die Temperatur danach 30 Minuten lang. - Ohne Kanne auf dem Sockel endet das Warmhalten nach 2 Minuten. :::callout{type="hinweis" title="Hinweis"} Schalten Sie das Gerät nie leer ein. Läuft es trocken, schaltet der Überhitzungsschutz ab, und die Leuchte blinkt rot. Lassen Sie es dann 10 Minuten abkühlen, bevor Sie Wasser einfüllen. ::: ## Die richtige Temperatur - 100 °C für schwarzen Tee und Kräutertee - 90 °C für Filterkaffee - 80 °C für weißen Tee und Oolong - 70 °C für grünen Tee # Reinigung und Entkalken Ziehen Sie vor dem Reinigen den Netzstecker und lassen Sie das Gerät abkühlen. Wischen Sie Kanne und Sockel außen mit einem feuchten Tuch ab. Den Kalkfilter im Ausgießer (2) ziehen Sie nach oben heraus und spülen ihn unter fließendem Wasser ab. Innen genügt es, die Kanne nach Gebrauch zu leeren und mit offenem Deckel trocknen zu lassen. Wie oft Sie entkalken, hängt von der Härte Ihres Wassers ab: bei hartem Wasser über 14 °dH jeden Monat, bei weichem Wasser unter 8,4 °dH etwa alle drei Monate. Den Härtegrad nennt Ihnen Ihr Wasserversorger. Spätestens wenn der Heizboden eine weiße Schicht zeigt oder das Gerät beim Aufheizen lauter wird, ist es Zeit. 1. Füllen Sie 1 Liter kaltes Wasser ein und lösen Sie 2 Esslöffel Zitronensäure (etwa 30 g) darin auf. 2. Lassen Sie die Lösung eine Stunde einwirken, ohne das Gerät einzuschalten. Erhitzte Zitronensäure bildet mit dem Kalk schwer lösliches Calciumcitrat. 3. Gießen Sie die Lösung weg und spülen Sie die Kanne zweimal gründlich aus. Kochen Sie einmal frisches Wasser auf und gießen Sie es ebenfalls weg. :::callout{type="hinweis" title="Hinweis"} Scheuermittel und Stahlwolle zerkratzen den Edelstahl. Essigessenz greift die Dichtung des Deckels an und darf nicht in die Kanne. Kanne und Sockel gehören nicht in die Spülmaschine. ::: # Störungen beheben Viele Störungen können Sie selbst beheben. Suchen Sie in :ref{id="stoerungen" style="full"} die Beschreibung, die zu Ihrem Fall passt, und prüfen Sie die Ursachen der Reihe nach; die häufigste steht jeweils oben. Hilft keine der Lösungen, wenden Sie sich an den Kundendienst und öffnen Sie das Gerät nicht selbst. Halten Sie dafür die Modellbezeichnung VW-170 bereit; sie steht auf dem Typenschild unter dem Sockel. Die Anschrift des Kundendienstes finden Sie auf der beiliegenden Garantiekarte. # Technische Daten ::resource{id="daten"} # Entsorgung Elektrogeräte gehören nicht in den Hausmüll. Geben Sie den ausgedienten Wasserkocher bei einer Sammelstelle für Elektroaltgeräte ab, etwa beim Wertstoffhof Ihrer Gemeinde. Auch Händler, die auf mindestens 400 m² Elektrogeräte verkaufen, nehmen Altgeräte kostenlos zurück. Die Verpackung besteht aus Pappe und gehört ins Altpapier. :::paragraphs{style="colophon"} Verra W1 · Bedienungsanleitung DE · Ausgabe 09/2026. Verra ist eine erfundene Marke; Gerät und Anleitung sind ein Beispiel aus dem Postext Cookbook. Gesetzt in Red Hat Text, Red Hat Display und Red Hat Mono (SIL OFL). Text und Zeichnungen: CC BY 4.0. :::`; // content.<lang>.md: the manual, in German const parts = String.raw`:chip[1]{style="nr"} DeckelMostra en Markdown · 7 línies · 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 AbhilfeMostra en Markdown · 15 línies · 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-170Mostra en Markdown · 7 línies · 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 bothKit · core, fonts, viewer, images: igual a totes les receptes · 270 línies
// ─── 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 ───────────────────────────────────────────────────────────────────────
L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)
Variants
#Numera figures i taules per secció
Amb el número de secció a la plantilla i un reinici a cada secció, el dibuix passa a ser Abbildung 2.1 i la taula d'avaries, Tabelle 5.1.
-const counted = { numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // 1, 2…
+const counted = { numberingTemplate: '{h1}.{n}', resetOn: 'h1', counterFormat: 'decimal' };Errors freqüents
Error freqüent
Tradueix Figura i Taula amb defaultResourceTypes(locale)
El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →
Error freqüent
Una taula 'here' no es parteix mai
Només es parteixen entre columnes i pàgines les taules flotants; una taula col·locada 'here' es mou sencera. Deixa flotar les taules llargues o mantén curtes les taules en línia. Taules que passen de pàgina →
Error freqüent
Un flotant 'top' no cau mai a la pàgina que el cita
Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita. Col·locació de figures →
Error freqüent
El número d'una llista es centra a la primera línia, no es recolza en la línia de base
A postext 1.4.1 el número d'una llista numerada es pinta amb la línia de base «middle» del canvas, 0,3 em del text per sobre de la primera línia de base de l'element, així que un número en una altra font o amb un numberFontSize més gran creix cap amunt i cap avall des d'allà en lloc de recolzar-se en la línia: una font de retolació queda alta i un número de pas gran penja sobre la primera línia. Col·loca'l amb orderedLists.numberVerticalOffset i revisa el resultat a mida real. Llistes numerades →
Error freqüent
El text dins d'un SVG <img> no pot fer servir fonts web
Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →
Error freqüent
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
La correcció de les línies curtes pot estrènyer un espaiat entre lletres que no es pinta mai
A postext 1.4.1, quan un paràgraf acaba en una línia curta, el motor el compon amb una línia menys: primer estreny l'espai entre paraules i després aplica fins a maxRuntTracking mil·lèsimes d'em d'espaiat negatiu entre lletres. Els renderitzadors de canvas i PDF només pinten l'espaiat entre lletres més gran que zero, així que el paràgraf s'imprimeix sense aquest espaiat: les seves línies justificades perden aquesta diferència en els espais entre paraules, que surten aixafats, i l'última línia pot passar-se de la mesura i quedar tallada a la vora de la columna. Posa bodyText.maxRuntTracking: 0, que conserva la correcció amb l'espai entre paraules, i reescriu els paràgrafs que tornin a acabar en una línia curta. Vídues, òrfenes i línies curtes →
- A 1.4.1, l'element que segueix una llista niada pren l'
itemSpacingde la llista niada, no el de la seva pròpia llista. Amb 5 pt entre passos i cap entre vinyetes, un pas que seguís una llista de vinyetes quedaria 5 pt més a prop que els altres; per això aquí només l'últim pas porta vinyetes. - L'equilibri de columnes baixa un requadre que tanca una pàgina fins a l'última línia de la retícula, així que l'espai que sobra a la pàgina s'obre damunt seu (equilibri de columnes). El text de la pàgina 2 està ajustat per omplir la columna sense que sobri cap línia. Si esborres una vinyeta de VORSICHT, el requadre continua acabant al peu i la separació entre els dos requadres passa de 4,5 a 13,6 mm; amb
headings: { balancing: { enabled: false } }el requadre es queda una línia per sota de WARNUNG i la pàgina acaba dues línies abans del peu.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Fonts
- Red Hat Text (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


