Saltar al contingut principal
Recepta número 18

Receptari · Capítol 3 · Títols i obertures

Títols de secció fins a set nivells

Seccions de l'1.1 a l'1.12 numerades en una píndola ambre que creix amb el número, quatre nivells més per sota i un setè tret d'un estil de títol.

En aquesta pàgina
  • Mostra en castellà: encara no hi ha edició en català
  • Format 176 × 250 mm
  • 2 columnes, espai entre columnes de 6 mm
  • IBM Plex Serif 9,4/13,2
  • IBM Plex Mono
  • IBM Plex Sans Condensed
  • 3 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 14 ms
  • 180 línies de codi

En poques paraules

Un capítol d'un manual per a colles que tenen cura de senders, sobre com treure l'aigua dels camins. Ensenya a donar set nivells als títols, cadascun amb el seu aspecte, per veure com encaixen les parts.

Què compondràs

Drenaje, capítol 1 del manual de camp d'una brigada de senders, en tres pàgines B5 a dues columnes: IBM Plex Serif per al text, Sans Condensed per a títols i números de secció, i Mono per a etiquetes, folis i números d'apartat. L'obertura posa el número en una gran píndola ambre sobre el perfil del sender, amb dotze punts ambre on aniran nous desviadors. Les seccions 1.1 a 1.12 porten el número en píndoles més petites, que s'eixamplen a l'1.10. Un filet verd i majúscules espaiades marquen els apartats 1.2.1, 1.5.1 i 1.5.2. Els nivells 4 a 6, sense número, canvien de lletra, de color o de caixa, i un setè, en cursiva grisa, anomena les eines. Les normes de seguretat comencen en negreta verda. Tanca el capítol una llista de control sense número, amb un quadrat buit al costat, llistes 1., a) i i. i dues caselles.

Aquesta recepta respon a

  • Com numero els títols (1, 1.1, 1.1.1) i dono a cada nivell un estil diferent?
  • Com faig una insígnia amb el número al costat del títol que s'eixampli quan el número creix (9 → 10)?
  • Què faig si necessito més de sis nivells de títol?
  • Com personalitzo les llistes: pics per nivell, numeració (a)/(i), caselles de tasques i un espaiat que respecti la retícula?

La resposta curta

script.js · línies 34–51al codi complet
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')] } } };

Ingredients

Tipografia
IBM Plex Serif, IBM Plex Sans Condensed, IBM Plex Mono (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Posa el número en una píndola que creix amb ell

El codi és a la resposta curta, més amunt. numberingTemplate: '{1}.{2}' ajunta el comptador del capítol i el de la secció, i així les seccions van de l'1.1 a l'1.12 (configuració per nivell). Si el nivell té un disseny avançat, el número ja no s'imprimeix davant del títol i és el disseny el que col·loca {number}. Aquí va en un element de text amb una caixa (box) de fons ambre i cantonades arrodonides, sense amplada fixa, de manera que la píndola fa el que fa el número més el seu farciment: 10,1 mm a l'1.9 i 12,7 mm a l'1.10. 'right-of' penja el títol de la vora dreta de la píndola i n'alinea les línies a l'esquerra; per això el títol llarg de la 1.5 passa a una segona línia al costat del número i no a sota (posicionament d'elements). El títol necessita a més overflow: 'wrap', perquè, per defecte, el text de disseny que no hi cap es talla amb punts suspensius. A la píndola li falten 3 pt per fer dues línies de la retícula, i com que cada títol de secció comença en una línia de la retícula, aquests 3 pt són el blanc que queda entre la píndola i el text de sota, tant si el títol obre una columna com si segueix un paràgraf.

Pàgina 3: la píndola s'eixampla de l'1.9 a l'1.10 i el títol es desplaça a la dreta el que ocupa la xifra de més.

#2 · Posa filet i espaiat al tercer nivell

script.js · línies 55–67al codi complet
// 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)) },
  ] } } };

A la 1.4.1 els títols no tenen letterSpacing i el text de disseny sí, així que el nivell 3 també és un disseny: un filet verd de 0,75 pt, el número en IBM Plex Mono i, al costat, el títol en majúscules espaiades. El tercer comptador de '{1}.{2}.{3}' torna a començar a cada secció; per això tant l'1.2.1 com l'1.5.1 acaben en 1. DROP baixa només el filet. El número es col·loca LEAD - DROP per sota seu, de manera que número i títol queden una línia de la retícula per sota d'on comença el títol, sigui quin sigui DROP. Com que el filet queda més a prop del número que del paràgraf anterior, es llegeix com a part del títol.

#3 · Baixa del nivell 4 al 6

script.js · línies 96–107al codi complet
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 nivell sense numberingTemplate no imprimeix número, així que a partir del nivell 4 el que distingeix un títol d'un altre és la lletra, el color o la caixa: una cursiva amb gràcies al 4, la lletra dels títols, en verd, al 5 i majúscules seminegres d'amplada fixa al 6. L'interlineat i els marges definits al mateix headings arriben a tots els nivells i posen cada títol sobre la retícula, amb una línia en blanc a sobre i cap a sota. Qualsevol objecte headings anul·la el salt de pàgina que obre cada capítol, i per això el nivell 1 el torna a declarar.

#4 · Fes un setè nivell i una secció sense número amb estils

script.js · línies 111–130al codi complet
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 passa de ######, i un títol perd les marques de negreta i cursiva, així que ###### *Barra de palanca* sortiria com un nivell 6 més. L'estil de títol 'level7' compon els noms de les eines en una cursiva estreta grisa de pes 500, més lleugera que el 600 del nivell 6, i textTransform: 'none' els treu les majúscules que heretarien. Es llegeixen un graó per sota de l'etiqueta d'amplada fixa que els precedeix, tot i que a 8,4 pt són més grans que els 7,8 pt de l'etiqueta (estils d'encapçalament). numbered: false deixa la llista de control fora del compte, de manera que una secció posterior continuaria sent la 1.13. Un {number} buit continuaria pintant la píndola ambre, així que l'estil porta el seu propi disseny, amb un quadrat buit al seu lloc. El verd dels termes que obren les normes de seguretat és el boldColor d'un estil de paràgraf.

#5 · Canvia les marques de la llista amb la profunditat

script.js · línies 134–141al codi complet
// 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 profunditat pren de levels la seva marca, el seu color i el seu separador: la llista de control numera amb 1., a) i i. (sobreescriptures per nivell de les llistes ordenades) i els pics passen del verd al sàlvia (sobreescriptures per nivell de les no ordenades). El nivell 1 conserva el format per defecte, 'arabic'; 'decimal', la paraula que fa servir CSS, imprimiria «undefined». Les dues entrades - [ ] del final de la llista de control porten, en lloc de pic, el taskCheckboxChar, que per defecte és ☐, en el verd dels pics de primer nivell (extensions per a llistes de tasques). Amb els marges de dalt i de baix a zero, totes les llistes continuen sobre la retícula de base.

#6 · Obre el capítol sobre el perfil del sender

script.js · línies 71–92al codi complet
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 és un element d'imatge de l'obertura, ancorat a la pàgina i tan ample com ella. Si fos una figura flotant a tota l'amplada, citada a la pàgina 1 i col·locada a dalt, obriria la pàgina 2 (elements d'imatge). En una obertura, una imatge no reserva alçada, així que minHeight porta el text a la primera línia de la retícula que quedi almenys 6 mm per sota seu. La llegenda sobre el verd és text de disseny, perquè un SVG pintat com a imatge no pot fer servir les fonts de la pàgina. La píndola gran repeteix la lletra i l'ambre de les de secció, i el seu {number} és el número del capítol, que dona numberingTemplate: '{1}'.

La recepta completa

Sandbox
// ═══ 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`---
Mostra en Markdown · 132 línies · content.es.mdtitle: "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 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

#Treu el número del capítol de les píndoles

Treu de la plantilla el comptador del capítol i les píndoles aniran de l'1 al 12, més amples a partir del 10; el nivell 3 continuarà imprimint 1.5.1 mentre la seva pròpia plantilla conservi {1}.

-const h2 = { level: 2, numberingTemplate: '{1}.{2}',
+const h2 = { level: 2, numberingTemplate: '{2}',

#Dona a totes les píndoles la mateixa amplada

Una amplada fixa, la de l'1.10, centra cada número en una píndola igual a les altres i alinea els títols en una sola columna.

-  placement: at('container', 'top-left') }; // plus its padding
+  placement: { ...at('container', 'top-left'), size: { width: mm(12.7) } } };

Errors freqüents

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Un títol perd les marques de negreta i cursiva

A postext 1.4.1 una línia de títol perd les marques en línia: ###### *Barra de palanca* imprimeix Barra de palanca amb la lletra normal del nivell 6, sense asteriscs i sense cursiva. Un setè nivell, o una paraula destacada dins d'un títol, necessiten un estil de títol ({style="…"}) o un disseny avançat. Estils de títol →

Error freqüent

El desbordament del text de disseny és 'ellipsis-end' per defecte

Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →

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

Les imatges d'una obertura no compten per a l'alçada que reserva

A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →

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 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

Llistes 'arabic', recursos 'roman-upper', pàgines 'upper-roman'

Cada opció de numeració escriu els formats a la seva manera: les llistes fan servir numberFormat 'arabic' ('decimal' imprimeix «undefined»), els tipus de recurs counterFormat 'roman-upper' i les pàgines i :::numbering 'upper-roman'. Llistes numerades →

Error freqüent

La majoria dels avisos només existeixen al Sandbox

Els ids, estils i directives desconeguts, les fonts que falten i les línies fluixes els comprova el Sandbox, no el motor: un pen només rep doc.warnings i parseMarkdownWithIssues. Un estil desconegut se substitueix per un altre sense avís i una directiva desconeguda s'imprimeix com a text, així que revisa els teus ids. Avisos i diagnòstic →

Error freqüent

Un espai de no separació continua partint la línia

A postext 1.4.1 l'algorisme de tall tracta U+00A0 com un espai normal, de manera que 0,08 %, 2,006 s o secció 2 poden quedar en dues línies. Ajunta els dos elements (0,08%) o reescriu la frase. Escapaments i caràcters literals →

Error freqüent

Posa entre cometes cada valor del frontmatter

YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →

Error freqüent

Només 8 llengües tenen partició de mots, amb el codi exacte

La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

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 →

Comprovació del Sandbox · headingHierarchy

Salt en la jerarquia de títols

Per què. Un títol se salta un nivell, per exemple un H1 seguit directament d'un H3.

Solució. Fes servir el nivell immediatament inferior, o canvia l'estil del nivell que volies en lloc de saltar-te'l. Documentació →

  • El setè nivell és un títol de nivell 6 amb l'estil level7, així que cap títol de l'exemple no baixa més d'un nivell respecte a l'anterior: La zanja, Herramientas para la zanja, BARRAS DE ACERO i Barra de palanca són dels nivells 4, 5, 6 i 6. El Sandbox avisa d'aquest cas amb «Salt en la jerarquia de títols», i en aquest capítol l'avís no apareix.
  • Una línia que acaba en dos punts només continua unida a la seva llista si el primer element cap en l'espai que queda: a la versió 1.4.1 la regla comprova que hi càpiga una línia, així que un primer element de dues línies que troba una sola línia lliure passa sol a la columna següent i deixa els dos punts aïllats. La secció 1.2 manté el primer element en una línia a les dues edicions.

Crèdits

Text
Text original, CC BY 4.0
Fonts
IBM Plex Serif (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
Sandbox