Lo que vas a componer
Drenaje, capítulo 1 del manual de campo de una brigada de senderos, en tres páginas B5 a dos columnas: IBM Plex Serif para el texto, Sans Condensed para títulos y números de sección, y Mono para etiquetas, folios y números de apartado. La apertura pone el número en una gran píldora ámbar sobre el perfil del sendero, con doce puntos ámbar donde irán nuevos desviadores. Las secciones 1.1 a 1.12 llevan el número en píldoras menores, que se ensanchan en el 1.10. Un filete verde y mayúsculas espaciadas marcan los apartados 1.2.1, 1.5.1 y 1.5.2. Los niveles 4 a 6, sin número, cambian de letra, de color o de caja, y un séptimo, en cursiva gris, nombra las herramientas. Las normas de seguridad empiezan en negrita verde. Cierra el capítulo una lista de control sin número, con un cuadrado hueco al lado, listas 1., a) e i. y dos casillas.
Esta receta responde a
- ¿Cómo numero los títulos (1, 1.1, 1.1.1) y doy a cada nivel un estilo distinto?
- ¿Cómo hago una insignia con el número junto al título que se ensanche cuando el número crece (9 → 10)?
- ¿Qué hago si necesito más de seis niveles de título?
- ¿Cómo personalizo las listas: viñetas por nivel, numeración (a)/(i), casillas de tareas y un espaciado que respete la rejilla?
La respuesta corta
const H2 = 13.5; // pt: the number and the title share one size and one line height,
const LH = 1.2; // so, under the same top padding, they share one baseline
// Every section head starts on a grid line, so the 3 pt the pill falls short of two lines
// is the gap the grid snap leaves between the pill and the text under it.
const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt
const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH };
const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'),
box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its
padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number
placement: at('container', 'top-left') }; // plus its padding
// 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long
// title wraps beside the number, never under it (gotcha: overflow-ellipsis-default).
const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face,
color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } },
placement: at(`#${from}`, 'right-of', mm(2.2)) });
// The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels.
const h2 = { level: 2, numberingTemplate: '{1}.{2}',
advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } };
Ingredientes
- Funciones
- Títulos numeradosTextos, filetes y cajas en los diseños de páginaAnclaje de elementos de diseñoNiveles de títuloEstilos de títuloCapítulos sin númeroAperturas diseñadasImágenes en los diseños de páginaAtributos de títuloEstilos de párrafoNegrita, cursiva y sus coloresListas de viñetas y de comprobaciónListas numeradasRejilla baseViudas, huérfanas y líneas cortasCabeceras y foliosCabeceras según el tipo de páginaMárgenes simétricosPaleta de color semánticaFiguras y tablas como recursos
- También usa
- Banda de capítulo a todo el ancho
- Tipografía
- IBM Plex Serif, IBM Plex Sans Condensed, IBM Plex Mono (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Pon el número en una píldora que crece con él
El código está en la respuesta corta, más arriba. numberingTemplate: '{1}.{2}' junta el contador del capítulo y el de la sección, y así las secciones van del 1.1 al 1.12 (configuración por nivel). Si el nivel tiene un diseño avanzado, el número ya no se imprime delante del título y es el diseño el que coloca {number}. Aquí va en un elemento de texto con una caja (box) de fondo ámbar y esquinas redondeadas, sin ancho fijo, de modo que la píldora mide lo que el número más su relleno: 10,1 mm en el 1.9 y 12,7 mm en el 1.10. 'right-of' cuelga el título del borde derecho de la píldora y alinea sus líneas a la izquierda; por eso el título largo de la 1.5 pasa a una segunda línea junto al número y no debajo (posicionamiento de elementos). El título necesita además overflow: 'wrap', porque, por defecto, el texto de diseño que no cabe se corta con puntos suspensivos. A la píldora le faltan 3 pt para medir dos líneas de la rejilla, y como cada título de sección empieza en una línea de la rejilla, esos 3 pt son el blanco que queda entre la píldora y el texto de debajo, tanto si el título abre una columna como si sigue a un párrafo.

#2 · Pon filete y espaciado al tercer nivel
// Headings have no letterSpacing of their own; design text has, so this head is a design.
const small = { fontSize: pt(8.4), lineHeight: LH };
const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line
const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'),
placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } },
{ kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small,
color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600,
...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'),
overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) },
] } } };
En la 1.4.1 los títulos no tienen letterSpacing y el texto de diseño sí, así que el nivel 3 también es un diseño: un filete verde de 0,75 pt, el número en IBM Plex Mono y, a su lado, el título en mayúsculas espaciadas. El tercer contador de '{1}.{2}.{3}' vuelve a empezar en cada sección; por eso tanto el 1.2.1 como el 1.5.1 terminan en 1. DROP baja solo el filete. El número se coloca LEAD - DROP por debajo de él, de modo que número y título quedan una línea de la rejilla por debajo de donde empieza el título, sea cual sea DROP. Como el filete queda más cerca del número que del párrafo anterior, se lee como parte del título.
#3 · Baja del nivel 4 al 6
const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid,
lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below
levels: [
// Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break).
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
numberingTemplate: '{1}', advancedDesign: opener },
h2, h3,
// No template below level 3, so no number: each level changes face, colour or case.
{ level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) },
{ level: 5, fontSize: pt(9.4), color: col('band') },
{ level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' },
] };
Un nivel sin numberingTemplate no imprime número, así que a partir del nivel 4 lo que distingue un título de otro es la letra, el color o la caja: una cursiva con remates en el 4, la letra de los títulos, en verde, en el 5 y mayúsculas seminegras de paso fijo en el 6. El interlineado y los márgenes definidos en el propio headings llegan a todos los niveles y ponen cada título sobre la rejilla, con una línea en blanco encima y ninguna debajo. Cualquier objeto headings anula el salto de página que abre cada capítulo, y por eso el nivel 1 lo declara de nuevo.
#4 · Haz un séptimo nivel y una sección sin número con estilos
const headingStyles = [
// Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped):
// '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey.
{ id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4),
textTransform: 'none', color: col('muted') },
// numbered: false: no number, and the H2 counter does not move. An empty {number} would
// still paint the amber pill, so the style draws a hollow square in its place.
{ id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8),
borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'),
size: { width: pt(PILL_H), height: pt(PILL_H) } } },
sectionTitle('box'),
] } } },
];
const paragraphStyles = [
// Run-in heads: the bold term opening each rule prints in the accent, not in body ink.
{ id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) },
{ id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9),
color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
Markdown no pasa de ######, y un título pierde sus marcas de negrita y cursiva, así que ###### *Barra de palanca* saldría como un nivel 6 más. El estilo de título 'level7' compone los nombres de las herramientas en una cursiva estrecha gris de peso 500, más ligera que el 600 del nivel 6, y textTransform: 'none' les quita las mayúsculas que heredarían. Se leen un escalón por debajo de la etiqueta de paso fijo que los precede, aunque a 8,4 pt son más grandes que los 7,8 pt de la etiqueta (estilos de encabezado). numbered: false deja la lista de control fuera de la cuenta, de modo que una sección posterior seguiría siendo la 1.13. Un {number} vacío seguiría pintando la píldora ámbar, así que el estilo trae su propio diseño, con un cuadrado hueco en su lugar. El verde de los términos que abren las normas de seguridad es el boldColor de un estilo de párrafo.
#5 · Cambia las marcas de la lista con la profundidad
// Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'.
const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'),
levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1
// Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies).
const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6),
marginTop: pt(0), marginBottom: pt(0), levels: [
{ level: 2, numberFormat: 'lower-alpha', separator: ')' },
{ level: 3, numberFormat: 'lower-roman', color: col('muted') }] };
Cada profundidad toma de levels su marca, su color y su separador: la lista de control numera con 1., a) e i. (sobrescrituras por nivel de las listas ordenadas) y las viñetas pasan del verde al salvia (sobrescrituras por nivel de las no ordenadas). El nivel 1 conserva el formato por defecto, 'arabic'; 'decimal', la palabra que usa CSS, imprimiría «undefined». Las dos entradas - [ ] del final de la lista de control llevan, en lugar de viñeta, el taskCheckboxChar, que por defecto es ☐, en el verde de las viñetas de primer nivel (extensiones para listas de tareas). Con los márgenes de arriba y de abajo a cero, todas las listas siguen sobre la rejilla base.
#6 · Abre el capítulo sobre el perfil del sendero
const DEPTH = 96; // mm: the profile's foot, measured from the top of the page
const CLEAR = 6; // mm: the least room between the profile's foot and the text under it
const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot
const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') };
// A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight
// reaches past the profile: the text starts on the first grid line CLEAR mm or more under it.
const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [
{ kind: 'image', id: 'profile', resourceId: 'profile',
placement: { ...at('page', 'top-left'), size: { width: 'fill' } } },
{ kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'),
borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } },
placement: at('container', 'top-left') },
{ kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } },
placement: at('#num', 'right-of', mm(4)) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true,
fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } },
// Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts).
{ kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500,
fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER),
mm(DEPTH - LEGEND)) },
] } };
El perfil es un elemento de imagen de la apertura, anclado a la página y tan ancho como ella. Si fuera una figura flotante a todo el ancho, citada en la página 1 y colocada arriba, abriría la página 2 (elementos de imagen). En una apertura, una imagen no reserva altura, así que minHeight lleva el texto a la primera línea de la rejilla que quede al menos 6 mm por debajo de ella. La leyenda sobre el verde es texto de diseño, porque un SVG pintado como imagen no puede usar las fuentes de la página. La píldora grande repite la letra y el ámbar de las de sección, y su {number} es el número del capítulo, que da numberingTemplate: '{1}'.
La receta completa
// ═══ Postext Cookbook · Nº 018 · Section heads seven levels deep ═════════════════ // https://postext.dev/en/cookbook/section-heads-field-manual // Code: MIT · Text: original (CC BY 4.0) · Picture: drawn in code (MIT) // Fonts: IBM Plex Serif, Sans Condensed, Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'section-heads-field-manual'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // forest green for structure, a signal amber for numbers ink: '#1d2320', // text: a green-black band: '#2f6b3f', // the accent: rules, run-in terms, bullets, numbers, folios (6.4:1) signal: '#e0a526', // the number pills, with ink on them (7.3:1) sage: '#7a9e80', // the second bullet and the profile's upper contours tint: '#e9f0e6', // the opener's sky; the legend on the green (5.5:1) muted: '#5f6a62', // running heads, level 7, roman list numbers, the colophon (5.6:1) }; // The hex as well as the id: design slots read only the hex (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // Defaults this config does not restate link to 'main-color', so it points at the accent. const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TRIM = { width: 176, height: 250 }; // mm: ISO B5, a common size for field manuals const [TOP, INNER, OUTER] = [22, 16, 14]; // mm: margins; the running heads align to OUTER const LEAD = 13.2; // pt: the body leading, the pitch of the baseline grid const LINES = 44; // grid lines in the text block, so every full column ends on one baseline const [DISPLAY, LABEL] = ['IBM Plex Sans Condensed', 'IBM Plex Mono']; // with the serif text const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x, y } }); // #region answer: section numbers 1.1 … 1.12 in an amber pill that widens with the number const H2 = 13.5; // pt: the number and the title share one size and one line height, const LH = 1.2; // so, under the same top padding, they share one baseline // Every section head starts on a grid line, so the 3 pt the pill falls short of two lines // is the gap the grid snap leaves between the pill and the text under it. const PILL_H = 2 * LEAD - 3, PAD = (PILL_H - H2 * LH) / 2; // pt const face = { fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(H2), lineHeight: LH }; const pill = { kind: 'text', id: 'pill', content: '{number}', ...face, color: col('ink'), box: { backgroundColor: col('signal'), borderRadius: mm(3), // no width: the pill is its padding: { top: pt(PAD), bottom: pt(PAD), left: mm(1.8), right: mm(1.8) } }, // number placement: at('container', 'top-left') }; // plus its padding // 'right-of' hangs the title on the pill's right edge and aligns its lines left, so a long // title wraps beside the number, never under it (gotcha: overflow-ellipsis-default). const sectionTitle = (from) => ({ kind: 'text', id: 'title', content: '{titleText}', ...face, color: col('ink'), overflow: 'wrap', box: { padding: { top: pt(PAD) } }, placement: at(`#${from}`, 'right-of', mm(2.2)) }); // The H1 counter, a point, the H2 counter: 1.1 … 1.12 in the pill. h2 joins headings.levels. const h2 = { level: 2, numberingTemplate: '{1}.{2}', advancedDesign: { enabled: true, slot: { elements: [pill, sectionTitle('pill')] } } }; // #endregion // #region ruled: level 3, a green rule over the number and a tracked capital title // Headings have no letterSpacing of their own; design text has, so this head is a design. const small = { fontSize: pt(8.4), lineHeight: LH }; const DROP = 6; // pt: the rule drops this far toward the number, which keeps its grid line const h3 = { level: 3, numberingTemplate: '{1}.{2}.{3}', // 1.5.1: restarts under every H2 advancedDesign: { enabled: true, slot: { elements: [ { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('band'), placement: { ...at('container', 'top-left', mm(0), pt(DROP)), size: { width: 'fill' } } }, { kind: 'text', id: 'num', content: '{number}', fontFamily: LABEL, fontWeight: 500, ...small, color: col('band'), placement: at('#rule', 'below', mm(0), pt(LEAD - DROP)) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 600, ...small, letterSpacing: pt(1.35), textTransform: 'uppercase', color: col('ink'), overflow: 'wrap', placement: at('#num', 'right-of', mm(2)) }, ] } } }; // #endregion // #region opener: the chapter number in the section pill, scaled up, over the trail's profile const DEPTH = 96; // mm: the profile's foot, measured from the top of the page const CLEAR = 6; // mm: the least room between the profile's foot and the text under it const LEGEND = 7; // mm: how far the legend's top sits above the profile's foot const big = { ...face, fontSize: pt(54), lineHeight: 1, color: col('ink') }; // A picture reserves no height in an opener (gotcha: opener-image-no-reserve), so minHeight // reaches past the profile: the text starts on the first grid line CLEAR mm or more under it. const opener = { enabled: true, minHeight: mm(DEPTH - TOP + CLEAR), slot: { elements: [ { kind: 'image', id: 'profile', resourceId: 'profile', placement: { ...at('page', 'top-left'), size: { width: 'fill' } } }, { kind: 'text', id: 'num', content: '{number}', ...big, box: { backgroundColor: col('signal'), borderRadius: mm(4), padding: { top: pt(4), bottom: pt(4), left: mm(4), right: mm(4) } }, placement: at('container', 'top-left') }, { kind: 'text', id: 'title', content: '{titleText}', ...big, box: { padding: { top: pt(4) } }, placement: at('#num', 'right-of', mm(4)) }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'IBM Plex Serif', italic: true, fontSize: pt(11.5), lineHeight: 1.3, color: col('ink'), align: 'left', overflow: 'wrap', placement: { ...at('#num', 'below', mm(0), mm(5)), size: { width: mm(100) } } }, // Design text: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). { kind: 'text', id: 'legend', content: '{attr.profile}', fontFamily: LABEL, fontWeight: 500, fontSize: pt(7), color: col('tint'), placement: at('page', 'top-right', mm(-OUTER), mm(DEPTH - LEGEND)) }, ] } }; // #endregion // #region levels: numbers down to 1.1.1, then italic, bold and label faces for 4 to 6 const headings = { fontFamily: DISPLAY, color: col('ink'), // every head sits on the grid, lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // a line above, none below levels: [ // Any headings object drops the H1 page break: restated (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, numberingTemplate: '{1}', advancedDesign: opener }, h2, h3, // No template below level 3, so no number: each level changes face, colour or case. { level: 4, fontFamily: 'IBM Plex Serif', fontWeight: 400, italic: true, fontSize: pt(11) }, { level: 5, fontSize: pt(9.4), color: col('band') }, { level: 6, fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.8), textTransform: 'uppercase' }, ] }; // #endregion // #region styles: a seventh level and an unnumbered section as heading styles; run-in terms const headingStyles = [ // Markdown stops at ######, and a heading drops *marks* (gotcha: heading-marks-dropped): // '###### Rock bar {style="level7"}' stays level 6, set in lower case, lighter and grey. { id: 'level7', fontFamily: DISPLAY, fontWeight: 500, italic: true, fontSize: pt(8.4), textTransform: 'none', color: col('muted') }, // numbered: false: no number, and the H2 counter does not move. An empty {number} would // still paint the amber pill, so the style draws a hollow square in its place. { id: 'checklist', numbered: false, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'box', style: { borderColor: col('signal'), borderWidth: pt(1.8), borderRadius: mm(1.5) }, placement: { ...at('container', 'top-left'), size: { width: pt(PILL_H), height: pt(PILL_H) } } }, sectionTitle('box'), ] } } }, ]; const paragraphStyles = [ // Run-in heads: the bold term opening each rule prints in the accent, not in body ink. { id: 'rules', boldColor: col('band'), firstLineIndent: pt(0) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; // #endregion // #region lists: bullets that fade with depth; numbers 1. then a) then i.; task boxes // Zero margins keep lists on the grid; a '- [ ]' item's bullet becomes taskCheckboxChar, '☐'. const unorderedLists = { gap: mm(2), marginTop: pt(0), marginBottom: pt(0), color: col('band'), levels: [{ level: 2, bulletChar: '–', color: col('sage') }] }; // '•' stays at level 1 // Level 1 keeps the defaults: 'arabic', never CSS's 'decimal' (gotcha: numbering-vocabularies). const orderedLists = { fontFamily: DISPLAY, color: col('band'), gap: mm(1.6), marginTop: pt(0), marginBottom: pt(0), levels: [ { level: 2, numberFormat: 'lower-alpha', separator: ')' }, { level: 3, numberFormat: 'lower-roman', color: col('muted') }] }; // #endregion // Running heads, HEAD mm from the trim: folio and book on versos, chapter and folio on rectos. const HEAD = 12; // mm; an opener keeps only a drop folio, HEAD mm above its foot const FOLIO_GAP = 9; // mm from a folio to the title beside it const runHead = { fontFamily: DISPLAY, fontWeight: 600, fontSize: pt(7.8), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted') }; const folio = { ...runHead, fontFamily: LABEL, color: col('band') }; const head = (id, content, parity, edge, x, style = runHead) => ({ kind: 'text', id, content, parity, pages: 'body', ...style, placement: at('page', edge, mm(x), mm(HEAD)) }); const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { fontFamily: 'IBM Plex Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, minWordSpacing: 0.85, maxWordSpacing: 1.4, // a narrow band: an even grey, line to line maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted) headings, headingStyles, paragraphStyles, unorderedLists, orderedLists, header: { elements: [head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('v-book', '{title}', 'even', 'top-left', OUTER + FOLIO_GAP), head('r-chapter', t({ en: 'Chapter {chapterNumber} · {chapterTitle}', es: 'Capítulo {chapterNumber} · {chapterTitle}' }), 'odd', 'top-right', -(OUTER + FOLIO_GAP)), head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }, footer: { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...folio, placement: at('page', 'bottom', mm(0), mm(-HEAD)) }] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 132 líneas · content.es.md
title: "Manual de campo de la brigada de senderos" subtitle: "Mantenimiento con herramientas de mano" author: "Postext Cookbook" --- # Drenaje {lead="Dónde y cómo construir los drenajes de un sendero, de la plataforma inclinada a la alcantarilla." profile="Sendero de la Loma del Mirador, km 0 a 4,2 · doce puntos balizados para nuevos desviadores"} Las botas apisonan la plataforma de un sendero hasta que despide la lluvia como un tejado de cinc, y el agua baja por ella cada vez más deprisa y más cargada de tierra. Este capítulo explica cómo sacarla del sendero antes de que abra un surco. ## El agua, el enemigo Cuanto más deprisa corre el agua, más tierra se lleva, y corre más deprisa cuanto mayor es la pendiente y más largo el tramo. Una lámina que apenas se mueve en el llano se vuelve una corriente que corta en una rampa larga. En cuanto aparece un surco, los excursionistas pisan a su lado, la plataforma se ensancha y cada tormenta lo ahonda. El drenaje trocea el recorrido para que el agua no se embale. ## Leer el terreno Recorre el tramo con lluvia si puedes, o justo después: el agua enseña por dónde quiere ir. Busca estas señales: - abanicos de limo al pie de una rampa - charcos rodeados por una senda nueva - un surco por el centro de la plataforma - si no pasa de una suela: se perfila - más hondo: hace falta un desviador - raíces y piedras que asoman del suelo ### Marcar antes de cavar Señala cada punto con cinta de balizar antes de que llegue la brigada y anota la estación en el cuaderno: su distancia al inicio del sendero, la pendiente y la obra que propones. Con el tramo recorrido y balizado, el jefe de brigada organiza la jornada en minutos. ## Inclinar la plataforma El desagüe más barato es inclinar la plataforma. Dale una caída de un 5 % hacia el borde de abajo, 3 cm en una plataforma de 60 cm, y el agua la cruzará en una lámina fina en lugar de correr a lo largo. Retira el caballón de tierra suelta que se forma en el borde exterior: convierte la plataforma en una cuneta. Comprueba la caída con un nivel corto puesto de través: una inclinación imperceptible ya desagua, y una mayor solo tuerce tobillos. ## Badenes Un badén invierte la pendiente a lo largo de unos metros: el sendero baja, vuelve a subir y sigue su ascenso, y el agua se va por el punto bajo. En un sendero nuevo, los badenes casi no se notan y piden poco mantenimiento. En uno viejo, a menudo se puede tallar uno con la azada donde la pendiente se suaviza. ## Desviadores: sacar el agua de las rampas Donde la pendiente es demasiado fuerte para un badén, el desviador conduce el agua de un lado a otro de la plataforma. Es una hilera de piedras o un tronco que se entierra en diagonal, con la parte de arriba un poco por encima del suelo, y termina, en su extremo bajo, en una salida protegida. ### Replanteo del desviador Sesga el desviador entre 30 y 45 grados respecto a la perpendicular, para que el agua lleve velocidad suficiente para arrastrar el limo; uno puesto a escuadra se llena de sedimento en la primera tormenta. Júntalos más cuanto más empinado sea el tramo: en suelo suelto y al 10 %, uno cada 25 o 30 metros. #### Elegir el sitio Coloca el desviador donde el agua pueda salir con facilidad, en un punto bajo natural del lado de abajo. Que nunca desagüe sobre una curva de herradura ni sobre un cortado, donde la salida socavaría la ladera. ### Construir en piedra Abre una zanja a través de la plataforma con el ángulo elegido, tan honda como dos tercios de tu piedra más alta. Asienta las piedras de canto, bien juntas y con al menos dos tercios enterrados, y empotra el extremo alto 30 cm en el talud para que el agua no lo rodee. #### La zanja Deja las paredes a plomo y el fondo sobre un suelo mineral firme. Tira la tierra ladera abajo, fuera de la plataforma, y reserva la mejor para el relleno. Si la tierra está suelta, abre la zanja más ancha y forra su lado de abajo con piedras menores, para que el desviador descanse sobre algo firme. Guarda aparte la tierra vegetal: al final cubrirá el relleno. ##### Herramientas para la zanja La azada y la pala la abren; dos barras de acero colocan las piedras. ###### Barras de acero Las dos miden cerca de 1,5 m, y la más pesada equivale a una mochila de excursión llena. Déjalas siempre tumbadas en el suelo, nunca de pie contra un árbol. ###### Barra de palanca {style="level7"} La pesada: una palanca con la que se despegan las piedras y se hacen bascular hasta su sitio. Aparta los dedos de la piedra de apoyo y levanta con las piernas. ###### Pisón {style="level7"} Una barra más ligera, con un pie plano en la punta. Rellena por capas no más gruesas que una mano y apisona bien cada una: la primera escorrentía se lleva el relleno suelto. ## Rebajes En tramos llanos u ondulados, donde se forman charcos, el rebaje drena el agua sin obra ninguna. Es una media luna poco honda, de unos 3 metros, excavada en la plataforma de modo que su borde exterior quede un palmo por debajo del resto. Para tallarlo no hace falta más que una azada. ## Escalones de retención Cuando el sendero sube por una torrentera y el agua no se puede desviar, hay que frenarla. Los escalones de retención son peldaños bajos de piedra o madera que cruzan la plataforma y retienen cada uno un lecho de tierra nivelado, y el agua pierde velocidad en cada rellano. Deja tabicas de 15 a 20 cm, que con mochila son un paso cómodo, y empotra bien los escalones. ## Zanjas de desagüe El agua que sale del sendero ha de ir a alguna parte. Una zanja de desagüe la lleva desde un desviador o un badén hasta donde pueda extenderse sin daño. Ábrela al menos tan ancha como la salida, con una caída uniforme, y termínala donde la vegetación retenga el limo. ## Alcantarillas Si un manantial o un arroyo cruza el sendero, pasa su agua bajo la plataforma. La alcantarilla abierta, dos hileras de lajas separadas por un hueco, se limpia con facilidad. La cerrada, con losas encima, se pisa mejor, pero hay que despejar su boca tras cada tormenta. ## Proteger las salidas Todo punto por donde el agua deja el sendero puede abrir una cárcava propia. Reviste la salida de cada desviador, badén y alcantarilla con un abanico de piedras del tamaño de un puño, hincadas en el suelo, y prolonga la protección hasta la vegetación o la roca. ## Trabajo seguro El jefe de brigada revisa las herramientas antes de salir. Cuatro normas rigen todo el día. :::paragraphs{style="rules"} **Transporte.** Las herramientas con filo se llevan en la mano, pegadas al costado, con el filo hacia abajo y protegido, del lado de abajo del sendero. Nunca al hombro. **Distancia.** Guarda dos largos de herramienta con los demás y avisa antes de cada golpe. **Piedras.** Mueve las piedras con barras y con la gravedad, no con la espalda. Nadie se queda ladera abajo de una piedra que se mueve. **Protección.** Casco, guantes, gafas y botas de suela rígida para toda la brigada, desde la salida hasta la vuelta. ::: ## Registrar el trabajo Anota en el cuaderno cada obra que construyas o limpies, con su estación, tipo, material y estado. Las notas de una temporada muestran qué drenajes fallan antes: esos hay que rediseñarlos, no repararlos. ## Lista de control {style="checklist"} Después de cada tormenta fuerte, recorre el tramo con azada y barra y sigue esta lista: 1. Desviadores 1. Limpia el sedimento del canal. 2. Revisa la protección de la salida. 1. Recoloca las piedras movidas. 2. Prolóngala hasta la vegetación. 2. Badenes y rebajes 1. Recupera la inclinación. 2. Limpia las zanjas de desagüe. 3. Alcantarillas 1. Despeja la entrada y la salida. 2. Rehaz las boquillas que hayan cedido. - [ ] Baliza lo que no puedas reparar hoy. - [ ] Anota cada reparación. :::paragraphs{style="colophon"} Texto: CC BY 4.0, escrito para el Recetario de Postext · Compuesto en IBM Plex Serif, IBM Plex Sans Condensed e IBM Plex Mono (SIL OFL) :::`; // content.<lang>.md, inlined by the Cookbook // The profile is a resource that no :ref cites: only the opener's image element draws it. const resources = [{ id: 'profile', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'profile.svg', width: TRIM.width * 10, height: DEPTH * 10 }, altText: t({ en: 'A 376 m climb in 4.2 km; amber dots mark twelve sites flagged for water bars.', es: 'Subida de 376 m en 4,2 km; puntos ámbar en doce sitios balizados para desviadores.' }) }]; // #region art: the trail's elevation profile, drawn in code with a seeded PRNG function profileSvg() { // Survey points, distance (km) and elevation (m): an easy valley, then the climb. const KM = 4.2; const pts = [[0, 1180], [0.8, 1190], [1.5, 1204], [2.1, 1226], [2.6, 1262], [3.0, 1330], [3.35, 1412], [3.7, 1486], [4.0, 1535], [4.2, 1556]]; const Y0 = DEPTH - 12; // mm: where 1180 m sits in the picture const K = 50 / 376; // mm of picture per metre of climb const elev = (d) => { // smoothstep between survey points: monotone, no overshoot const next = pts.findIndex(([x]) => x > d); const i = next < 0 ? pts.length - 2 : Math.max(0, next - 1); const [[x0, e0], [x1, e1]] = [pts[i], pts[i + 1]]; const u = Math.min(1, (d - x0) / (x1 - x0)); return e0 + (e1 - e0) * u * u * (3 - 2 * u); }; let seed = 18; // Mulberry32: the same wobble on every run const rand = () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const N = 220; const crest = Array.from({ length: N + 1 }, (_, i) => [(TRIM.width * i) / N, Y0 - (elev((KM * i) / N) - 1180) * K + (rand() - 0.5) * 0.5]); const xy = (list) => list.map(([x, y]) => `${x.toFixed(2)} ${y.toFixed(2)}`).join('L'); // Contour bands every 50 m, from the band green in the valley to sage on the ridge: each // band is the profile clipped between two contours. const mix = (a, b, u) => '#' + [1, 3, 5].map((i) => Math.round(parseInt(a.slice(i, i + 2), 16) * (1 - u) + parseInt(b.slice(i, i + 2), 16) * u).toString(16).padStart(2, '0')).join(''); const bands = Array.from({ length: 8 }, (_, k) => { const floor = Y0 - k * 50 * K; const top = crest.map(([x, y]) => [x, Math.min(floor, Math.max(y, floor - 50 * K))]); return `<path d="M0 ${floor}L${xy(top)}L${TRIM.width} ${floor}Z" ` + `fill="${mix(palette.band, palette.sage, k / 7)}"/>`; }).join(''); // The twelve flagged sites, placed one per 32 m of climb: they crowd where it steepens. const dots = Array.from({ length: 12 }, (_, k) => { const target = 1180 + 32 * (k + 0.5); let [lo, hi] = [0, KM]; for (let it = 0; it < 40; it++) { const mid = (lo + hi) / 2; if (elev(mid) < target) lo = mid; else hi = mid; } return `<circle cx="${((TRIM.width * lo) / KM).toFixed(2)}" ` + `cy="${(Y0 - (target - 1180) * K).toFixed(2)}" r="1.9" fill="${palette.signal}" ` + `stroke="${palette.ink}" stroke-width="0.35"/>`; }).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM.width * 10}" ` + `height="${DEPTH * 10}" viewBox="0 0 ${TRIM.width} ${DEPTH}">` + `<rect width="${TRIM.width}" height="${DEPTH}" fill="${palette.tint}"/>` + `<path d="M0 ${DEPTH}L${xy(crest)}L${TRIM.width} ${DEPTH}Z" fill="${palette.band}"/>` + `${bands}<path d="M${xy(crest)}" fill="none" stroke="${palette.ink}" ` + `stroke-width="0.7" stroke-linejoin="round"/>${dots}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'IBM Plex Serif': ['400', '400i', '700'], 'IBM Plex Sans Condensed': ['500i', '600', '700'], 'IBM Plex Mono': ['400', '500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('profile.svg', profileSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Section heads seven levels deep', es: 'Títulos de sección hasta siete niveles' }) });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
#Quita el número del capítulo de las píldoras
Quita de la plantilla el contador del capítulo y las píldoras irán del 1 al 12, más anchas a partir del 10; el nivel 3 seguirá imprimiendo 1.5.1 mientras su propia plantilla conserve {1}.
-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',#Da a todas las píldoras el mismo ancho
Un ancho fijo, el del 1.10, centra cada número en una píldora igual a las demás y alinea los títulos en una sola columna.
- placement: at('container', 'top-left') }; // plus its padding
+ placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };Errores frecuentes
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Un título pierde sus marcas de negrita y cursiva
En postext 1.4.1 una línea de título pierde sus marcas en línea: ###### *Barra de palanca* imprime Barra de palanca con la letra normal del nivel 6, sin asteriscos y sin cursiva. Un séptimo nivel, o una palabra destacada dentro de un título, necesitan un estilo de título ({style="…"}) o un diseño avanzado. Estilos de título →
Error frecuente
El desbordamiento del texto de diseño es 'ellipsis-end' por defecto
Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →
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
Las imágenes de una apertura no cuentan para la altura que reserva
En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →
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
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
Listas 'arabic', recursos 'roman-upper', páginas 'upper-roman'
Cada ajuste de numeración escribe sus formatos a su manera: las listas usan numberFormat 'arabic' ('decimal' imprime «undefined»), los tipos de recurso counterFormat 'roman-upper' y las páginas y :::numbering 'upper-roman'. Listas numeradas →
Error frecuente
La mayoría de los avisos solo existen en el Sandbox
Los ids, estilos y directivas desconocidos, las fuentes que faltan y las líneas flojas los comprueba el Sandbox, no el motor: un pen solo recibe doc.warnings y parseMarkdownWithIssues. Un estilo desconocido se sustituye sin aviso por otro y una directiva desconocida se imprime como texto, así que revisa tus ids. Avisos y diagnóstico →
Error frecuente
Un espacio de no separación sigue partiendo la línea
En postext 1.4.1 el algoritmo de corte trata U+00A0 como un espacio normal, así que 0,08 %, 2,006 s o sección 2 pueden quedar en dos líneas. Junta los dos elementos (0,08%) o reescribe la frase. Escapes y caracteres literales →
Error frecuente
Entrecomilla cada valor del frontmatter
YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
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 →
Comprobación del Sandbox · headingHierarchy
Salto en la jerarquía de títulos
Por qué. Un título se salta un nivel, por ejemplo un H1 seguido directamente de un H3.
Solución. Usa el nivel inmediatamente inferior, o cambia el estilo del nivel que querías en lugar de saltártelo. Documentación →
- El séptimo nivel es un título de nivel 6 con el estilo
level7, así que ningún título del ejemplo baja más de un nivel respecto al anterior: La zanja, Herramientas para la zanja, BARRAS DE ACERO y Barra de palanca son de los niveles 4, 5, 6 y 6. El Sandbox avisa de ese caso con «Salto en la jerarquía de títulos», y en este capítulo el aviso no aparece. - Una línea que termina en dos puntos solo sigue unida a su lista si el primer elemento cabe en el espacio que queda: en la versión 1.4.1 la regla comprueba que quepa una línea, así que un primer elemento de dos líneas que encuentra una sola línea libre pasa solo a la columna siguiente y deja los dos puntos aislados. La sección 1.2 mantiene su primer elemento en una línea en las dos ediciones.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- IBM Plex Serif (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
- Código
- MIT, como Postext


