Saltar al contingut principal
Recepta número 48

Receptari · Capítol 6 · Requadres i notes

Llistats de codi i tecles sense blocs de codi

Guia del terminal amb blocs de codi que passen a ser requadres foscos abans de compondre, amb la negreta i la cursiva com a colors i les tecles com a xips.

En aquesta pàgina

pp. 50–51 · 2–3 de 3

  • Mostra en castellà: encara no hi ha edició en català
  • Format 178 × 229 mm
  • Columna i mitja, espai entre columnes de 6 mm
  • Charis SIL 10/14,5
  • JetBrains Mono
  • Sora
  • 3 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 8 ms
  • 180 línies de codi

En poques paraules

Tres pàgines d'una guia per començar amb la línia d'ordres de l'ordinador. Ensenya a convertir els exemples de codi en requadres foscos de colors i a dibuixar tecles com Ctrl com a botonets dins del text.

Què compondràs

Tres pàgines del capítol 4 de La terminal, con calma, una guia de butxaca inventada sobre la línia d'ordres, en format de 178 × 229 mm. El text va justificat en Charis SIL, en una columna de 100 mm, amb una columna lateral exterior per a les notes. Cada llistat és un requadre gairebé negre en JetBrains Mono que entra en aquesta columna, sota una pestanya amb el nom del fitxer o de la sessió. Les paraules clau i les ordres que tecleges surten en ambre, les cadenes en verd i els comentaris en gris. Ctrl, Tab i les altres tecles es dibuixen dins de les línies justificades, i una xuleta de dreceres amb Ctrl, a dues columnes, ocupa el peu de la pàgina 50. El Markdown conserva els blocs de codi de sempre; una funció breu converteix cadascun en un requadre abans de compondre.

Aquesta recepta respon a

  • Com mostro llistats de codi?
  • Com faig xips en línia: tecles, etiquetes, bancs de paraules per a exercicis?
  • Com escric guions de diàleg, anys a l'inici de paràgraf, preus i símbols literals sense que el Markdown els interpreti malament?

La resposta curta

script.js · línies 24–58al codi complet
// Postext sets no fenced code, so the Markdown is rewritten before the build:
// ```bash backup.sh … ``` → :::callout{type="listing" label="backup.sh"} … :::
// Characters Markdown would read as emphasis, a superscript or subscript, code or maths get
// a backslash (gotcha: dollar-math). The parser drops a backslash only before those, so any
// other backslash in the code prints as typed. ']\u2060(' keeps '[a](b)' from becoming a link.
const escape = (text) => text.replace(/[*_^~`$]/g, '\\$&').replace(/\]\(/g, ']\u2060(');
function codeLine(line, lang) {
  // A word joiner (U+2060) opens every line, so a leading '#', '-', '1.' or '>' stays text
  // (gotcha: digit-period-list). Parsing trims leading spaces, no-break ones included;
  // the word joiner in front keeps them.
  const indent = line.match(/^ */)[0].length; // indent listings with spaces, not tabs
  const body = (lang === 'console' ? session : paint)(line.slice(indent));
  const runs = body.replace(/ {2,}/g, (run) => NBSP.repeat(run.length)); // output columns
  return `\u2060${NBSP.repeat(indent)}${runs}`;
}
// The label stops at a double quote, which would close the attribute.
const listings = (markdown) => markdown.replace(/^```(\w*) *([^"\n]*).*\n([\s\S]*?)^```$/gm,
  (_, lang, label, code) => [`:::callout{type="listing" label="${label || lang}"}`,
    // One paragraph per line; a blank line keeps the word joiner alone.
    ...code.replace(/\n$/, '').split('\n').map((line) => codeLine(line, lang)), ':::',
  ].join('\n\n'));
// The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading.
const resume = { id: 'resume', firstLineIndent: ZERO };
const listing = {
  id: 'listing', background: col('night'), span: 'page', // across the text and the margin
  padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
  marginTop: mm(6), marginBottom: mm(2.5),
  label: { fontFamily: MONO, fontSize: pt(7), fontWeight: 700, color: col('phosphor'),
    background: col('night'), height: mm(5), offset: mm(5), paddingX: mm(3), // the tab
    position: 'top-left' },
  body: { fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), textAlign: 'left',
    color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'), // paint()
    // One paragraph per line of code: no space between them, even if bodyText adds some.
    paragraphSpacing: false, firstLineIndent: ZERO },
};

Ingredients

Tipografia
Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Converteix cada bloc en un requadre abans de compondre

El codi d'aquest pas és la resposta curta de més amunt. Postext 1.4.1 llegeix un bloc entre accents greus com a Markdown corrent: les línies es fonen en un sol paràgraf, cada parell de dòlars es converteix en una fórmula, un guió baix obre una cursiva i el comentari # Copia cada carpeta… passa a ser un títol de capítol. listings() reescriu cada bloc com un requadre listing amb un paràgraf per línia i una barra inversa davant de cada caràcter que el Markdown interpretaria. El sagnat el conserva el caràcter d'unió de paraules (U+2060) amb què comença cada línia. Sense aquest caràcter, l'analitzador retalla els espais de no separació, el cos del bucle de copia.sh perd el sagnat i desapareixen les dues línies en blanc del guió. El que segueix el llenguatge a la línia d'obertura (copia.sh, Terminal) s'imprimeix a la pestanya del requadre. El paràgraf que segueix un llistat va en :::paragraphs{style="resume"}, de manera que comença sense sagnat, com després d'un títol.

#2 · Deixa el text estret i que el codi travessi el marge

script.js · línies 154–157al codi complet
  page: { sizePreset: 'custom', width: mm(178), height: mm(229), margins: {
    top: mm(22), bottom: mm(21), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 26, gutterWidth: mm(6),
    sideColumnRole: 'floats', sideColumnSide: 'outer' },

A 10 pt, Charis SIL compon uns 62 caràcters per línia a la columna de 100 mm. La columna lateral d'aquesta disposició a columna i mitja només admet flotants i notes, així que el text no hi entra mai. El span: 'page' de l'estil del llistat (a la resposta curta) estén cada llistat per les dues columnes, 143 mm. Dins del seu marge interior, un requadre admet 73 caràcters de JetBrains Mono a 8,6 pt; la línia més llarga d'aquestes pàgines, la segona de copia.sh, en té 68.

#3 · Acoloreix el codi amb negreta i cursiva

script.js · línies 62–79al codi complet
const KEYWORDS = 'if|then|else|elif|fi|for|in|do|done|while|until|case|esac' // reserved words
  + '|set|echo|cd|export|local|read'; // builtins; programs such as mkdir and rsync stay plain
const TOKEN = new RegExp(`("(?:\\\\.|[^"\\\\])*"|'[^']*')` // a quoted string
  + `|((?:^|(?<=\\s))#.*$)|\\b(${KEYWORDS})\\b`, 'g'); // a comment, a keyword
function paint(line) {
  let out = '';
  let last = 0;
  for (const { 0: token, 1: string, 2: comment, index } of line.matchAll(TOKEN)) {
    out += escape(line.slice(last, index));
    if (string) out += `*${escape(string)}*`;
    else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
    else out += `**${token}**`;
    last = index + token.length;
  }
  return out + escape(line.slice(last));
}
// In a session, what you type after the prompt is bold; the shell's answer stays plain.
const session = (line) => line.startsWith('$ ') ? `\\$ **${escape(line.slice(2))}**` : escape(line);

Postext no acoloreix la sintaxi, però cada requadre té el seu propi boldColor per a la negreta i el seu italicColor per a la cursiva, així que paint() posa en negreta les paraules clau (ambre) i en cursiva les cadenes entre cometes (verd). Els comentaris prenen un tercer color de rem, un estil de xip que imprimeix el text en gris amb la lletra monoespaiada, sense emplenament, contorn, marge interior ni separació. KEYWORDS aplega les paraules reservades de l'intèrpret i algunes ordres internes (set, echo, cd); els programes, com mkdir o rsync, queden en rodona. En un bloc console, session() posa en negreta el que tecleges després de l'indicador i deixa en rodona la resposta de l'intèrpret.

#4 · Compon les tecles i el codi en línia com a xips

script.js · línies 83–97al codi complet
// Chips never break or stretch, so a line with keys puts all its slack in its word spaces;
// the breaker tries other breaks before a space passes 140 % (default 200 %). Inside a chip
// maths stays literal, so '$' needs no backslash there, but ']' does.
const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText
const chipText = (text) => text.replace(/[*_^~`]/g, '\\$&').replace(/]/g, '\\]');
const inlineCode = (markdown) => markdown.replace(/(?<!\\)`([^`\n]+)`/g,
  (_, code) => `:chip[${chipText(code)}]{style="code"}`);
const bare = { backgroundEnabled: false, borderWidth: ZERO, paddingX: ZERO, gap: ZERO };
const chipStyles = [
  { id: 'key', fontFamily: MONO, fontSize: pt(7.8), bold: true, color: col('ink'),
    background: col('code'), borderColor: col('slate'), borderWidth: pt(0.6),
    borderRadius: pt(1.6), paddingX: em(0.45), paddingY: em(0.14), gap: em(0.3) },
  { id: 'code', fontFamily: MONO, fontSize: em(0.88), ...bare }, // `grep` in running text
  { id: 'rem', fontFamily: MONO, color: col('slate'), ...bare }, // a comment in a listing
];

Postext treu els accents greus i compon el codi en línia amb la lletra del text (format en línia), així que inlineCode() converteix cada fragment en un xip code: la monoespaiada a 0,88 em, sense caixa. Les tecles són xips key, amb un emplenament clar i un contorn de 0,6 pt. El seu cos va en punts, de manera que fan 7,8 pt al text de 10 pt, a la nota del marge de 8,6 pt i a la xuleta de 8,4 pt; amb em(0.78), les de la xuleta baixarien a 6,6 pt. Un xip no es parteix mai entre línies ni s'estira (xips en línia), així que en una línia amb tecles tot el buit sobrant va als espais entre paraules. Amb spacing, l'algorisme de partició de línies cerca altres talls abans de deixar que un espai passi del 140 % de la seva amplada normal. Amb el valor per defecte, 200 %, cinc línies d'aquestes pàgines el superen; amb l'ajust, la més oberta arriba al 138 %.

#5 · Numera els passos sobre la retícula

script.js · línies 120–123al codi complet
const orderedLists = { fontFamily: DISPLAY, fontWeight: 800, color: col('ember'),
  gap: em(0.7), separator: '›', separatorGap: em(0.25), separatorFontFamily: MONO,
  separatorFontWeight: 700, separatorColor: col('muted'),
  marginTop: ZERO, marginBottom: ZERO }; // the default 1.5 em opens 5.3 mm above and below

Els números van en Sora 800 amb el color d'accent, i el separador és un › monoespaiat en gris. Com que la seva lletra i el seu color no són els del número, el separador es dibuixa com un fragment propi (llistes ordenades). Els marges de la llista són zero, així que els passos continuen a la retícula de base de 14,5 pt del text que els envolta. Els 1,5 em per defecte obririen 5,3 mm sobre els passos i durien la xuleta a la pàgina 51.

#6 · Porta la xuleta al peu de la pàgina

script.js · línies 101–105al codi complet
const sheet = { ...listing, id: 'sheet', label: undefined, placement: 'bottom',
  columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, gap: mm(3.5),
    color: col('phosphor'), textTransform: 'uppercase', letterSpacing: pt(1.5) },
  body: { ...listing.body, fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13) } };

La xuleta reutilitza l'estil del llistat, amb Sora per a les accions, i placement: 'bottom' la fa flotar fins al peu de la pàgina 50 (requadres flotants). Dins del flux, un requadre que travessa la pàgina necessita lloc per a si mateix i per a dues línies de text a sota. Aquí saltaria a la pàgina 51 amb 58 mm buits sota els passos, i el capítol passaria a quatre pàgines. Al Markdown, :::columns{count=2 breaks="8"} obre la columna dreta al vuitè bloc, el seu títol. L'equilibrat tot sol talla per alçada, i quan una acció ocupa dues línies deixa Órdenes e historial al peu de la columna esquerra. Cada entrada comença pels mateixos dos xips, Ctrl i una lletra, tots dos en la monoespaiada, així que les accions arrenquen a la mateixa distància de la vora esquerra de la seva columna.

La recepta completa

Sandbox
// ═══ Postext Cookbook · Nº 048 · Code listings and keycaps without code blocks ═══
// https://postext.dev/en/cookbook/code-listings-and-keycaps
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Charis SIL, Sora, JetBrains Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'code-listings-and-keycaps';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1b1f24', muted: '#5c636b', ember: '#9a5410', // text, heads, accent
  night: '#0e1116', code: '#d3d9df', amber: '#f2b134', phosphor: '#3ddc84', // the listings
  slate: '#8a939d' }; // comments in a listing, the outline of a key (code is its face)
// Design elements read the hex, not the palette id (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ember })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, MONO] = ['Charis SIL', 'Sora', 'JetBrains Mono'];
const LEAD = 14.5; // pt: the body leading, the page's baseline grid
const [NBSP, ZERO] = ['\u00a0', pt(0)];

// #region answer: a fenced block becomes a dark box with one escaped paragraph per line
// Postext sets no fenced code, so the Markdown is rewritten before the build:
// ```bash backup.sh … ``` → :::callout{type="listing" label="backup.sh"} … :::
// Characters Markdown would read as emphasis, a superscript or subscript, code or maths get
// a backslash (gotcha: dollar-math). The parser drops a backslash only before those, so any
// other backslash in the code prints as typed. ']\u2060(' keeps '[a](b)' from becoming a link.
const escape = (text) => text.replace(/[*_^~`$]/g, '\\$&').replace(/\]\(/g, ']\u2060(');
function codeLine(line, lang) {
  // A word joiner (U+2060) opens every line, so a leading '#', '-', '1.' or '>' stays text
  // (gotcha: digit-period-list). Parsing trims leading spaces, no-break ones included;
  // the word joiner in front keeps them.
  const indent = line.match(/^ */)[0].length; // indent listings with spaces, not tabs
  const body = (lang === 'console' ? session : paint)(line.slice(indent));
  const runs = body.replace(/ {2,}/g, (run) => NBSP.repeat(run.length)); // output columns
  return `\u2060${NBSP.repeat(indent)}${runs}`;
}
// The label stops at a double quote, which would close the attribute.
const listings = (markdown) => markdown.replace(/^```(\w*) *([^"\n]*).*\n([\s\S]*?)^```$/gm,
  (_, lang, label, code) => [`:::callout{type="listing" label="${label || lang}"}`,
    // One paragraph per line; a blank line keeps the word joiner alone.
    ...code.replace(/\n$/, '').split('\n').map((line) => codeLine(line, lang)), ':::',
  ].join('\n\n'));
// The text after a listing goes in :::paragraphs{style="resume"}: flush, as after a heading.
const resume = { id: 'resume', firstLineIndent: ZERO };
const listing = {
  id: 'listing', background: col('night'), span: 'page', // across the text and the margin
  padding: { top: mm(4), right: mm(5), bottom: mm(4), left: mm(5) },
  marginTop: mm(6), marginBottom: mm(2.5),
  label: { fontFamily: MONO, fontSize: pt(7), fontWeight: 700, color: col('phosphor'),
    background: col('night'), height: mm(5), offset: mm(5), paddingX: mm(3), // the tab
    position: 'top-left' },
  body: { fontFamily: MONO, fontSize: pt(8.6), lineHeight: pt(12.4), textAlign: 'left',
    color: col('code'), boldColor: col('amber'), italicColor: col('phosphor'), // paint()
    // One paragraph per line of code: no space between them, even if bodyText adds some.
    paragraphSpacing: false, firstLineIndent: ZERO },
};
// #endregion

// #region paint: keywords bold, strings italic, comments a chip with no box
const KEYWORDS = 'if|then|else|elif|fi|for|in|do|done|while|until|case|esac' // reserved words
  + '|set|echo|cd|export|local|read'; // builtins; programs such as mkdir and rsync stay plain
const TOKEN = new RegExp(`("(?:\\\\.|[^"\\\\])*"|'[^']*')` // a quoted string
  + `|((?:^|(?<=\\s))#.*$)|\\b(${KEYWORDS})\\b`, 'g'); // a comment, a keyword
function paint(line) {
  let out = '';
  let last = 0;
  for (const { 0: token, 1: string, 2: comment, index } of line.matchAll(TOKEN)) {
    out += escape(line.slice(last, index));
    if (string) out += `*${escape(string)}*`;
    else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
    else out += `**${token}**`;
    last = index + token.length;
  }
  return out + escape(line.slice(last));
}
// In a session, what you type after the prompt is bold; the shell's answer stays plain.
const session = (line) => line.startsWith('$ ') ? `\\$ **${escape(line.slice(2))}**` : escape(line);
// #endregion

// #region keycaps: keys, and inline code in the mono face, are chips
// Chips never break or stretch, so a line with keys puts all its slack in its word spaces;
// the breaker tries other breaks before a space passes 140 % (default 200 %). Inside a chip
// maths stays literal, so '$' needs no backslash there, but ']' does.
const spacing = { maxWordSpacing: 1.4 }; // spread into bodyText
const chipText = (text) => text.replace(/[*_^~`]/g, '\\$&').replace(/]/g, '\\]');
const inlineCode = (markdown) => markdown.replace(/(?<!\\)`([^`\n]+)`/g,
  (_, code) => `:chip[${chipText(code)}]{style="code"}`);
const bare = { backgroundEnabled: false, borderWidth: ZERO, paddingX: ZERO, gap: ZERO };
const chipStyles = [
  { id: 'key', fontFamily: MONO, fontSize: pt(7.8), bold: true, color: col('ink'),
    background: col('code'), borderColor: col('slate'), borderWidth: pt(0.6),
    borderRadius: pt(1.6), paddingX: em(0.45), paddingY: em(0.14), gap: em(0.3) },
  { id: 'code', fontFamily: MONO, fontSize: em(0.88), ...bare }, // `grep` in running text
  { id: 'rem', fontFamily: MONO, color: col('slate'), ...bare }, // a comment in a listing
];
// #endregion

// #region sheet: a two-column cheat sheet floated to the foot of its page
const sheet = { ...listing, id: 'sheet', label: undefined, placement: 'bottom',
  columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, gap: mm(3.5),
    color: col('phosphor'), textTransform: 'uppercase', letterSpacing: pt(1.5) },
  body: { ...listing.body, fontFamily: DISPLAY, fontSize: pt(8.4), lineHeight: pt(13) } };
// #endregion

const aside = { id: 'aside', span: 'side', backgroundEnabled: false, // notes in the margin
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('ember') },
  padding: { top: mm(2.2), right: ZERO, bottom: ZERO, left: ZERO },
  titleStyle: { fontFamily: MONO, fontSize: pt(7.5), fontWeight: 700, color: col('ember'),
    textTransform: 'uppercase', letterSpacing: pt(1.2), gap: mm(1.2) },
  body: { fontFamily: TEXT, fontSize: pt(8.6), lineHeight: pt(12.5), textAlign: 'left',
    firstLineIndent: ZERO } };
const colophon = { ...aside, id: 'colophon', stripe: { enabled: false }, body: { ...aside.body,
  fontFamily: MONO, fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'),
  italicColor: col('muted') } };

// #region steps: numbered steps on the grid, a prompt sign for a separator
const orderedLists = { fontFamily: DISPLAY, fontWeight: 800, color: col('ember'),
  gap: em(0.7), separator: '›', separatorGap: em(0.25), separatorFontFamily: MONO,
  separatorFontWeight: 700, separatorColor: col('muted'),
  marginTop: ZERO, marginBottom: ZERO }; // the default 1.5 em opens 5.3 mm above and below
// #endregion

const OUTER = 15; // mm: the outer margin; the running heads align to it
const text = (id, content, family, size, look, placement) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), placement, ...look,
  align: 'left', overflow: 'wrap' }); // design text is centred and cut with '…' by default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { x: ZERO, y: mm(y) }, size: { width } });
const opener = { enabled: true, slot: { elements: [
  text('kicker', '{attr.kicker}', MONO, 8, { fontWeight: 700, letterSpacing: pt(1.6),
    textTransform: 'uppercase', color: col('ember') },
  { anchor: { to: 'container', edge: 'top-left' }, offset: { x: ZERO, y: mm(4) } }),
  // Design lineHeights are multiples (gotcha: design-lineheight-multiple).
  text('title', '{titleText}', DISPLAY, 33, { fontWeight: 800, lineHeight: 1.04 },
    below('kicker', 3.5, mm(118))),
  text('lead', '{attr.lead}', TEXT, 12, { italic: true, lineHeight: 1.36 },
    below('title', 5, 'fill')),
] } };
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: MONO, fontSize: pt(7.5),
  letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...extra,
});
const folio = { fontWeight: 700, color: col('ember') };

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, chipStyles, orderedLists, paragraphStyles: [resume],
  calloutStyles: [listing, sheet, aside, colophon],
  // #region page: a text column and a margin column that only listings and notes enter
  page: { sizePreset: 'custom', width: mm(178), height: mm(229), margins: {
    top: mm(22), bottom: mm(21), left: mm(20), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'oneAndHalf', sideColumnPercent: 26, gutterWidth: mm(6),
    sideColumnRole: 'floats', sideColumnSide: 'outer' },
  // #endregion
  bodyText: { ...spacing, // keycaps
    fontFamily: TEXT, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4.5), indentAfterHeading: false,
    maxRuntTracking: 0, // tracking it cannot paint (gotcha: runt-tracking-unpainted)
  },
  headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 800, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener },
    { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: ZERO },
  ] },
  header: { elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
    head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
    head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
  ] },
  footer: { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
    ...folio, pages: 'opener', // the opener has no running head: its folio drops to the foot
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { x: ZERO, y: mm(9) } } })] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = `---
Mostra en Markdown · 118 línies · content.es.mdtitle: "La terminal, con calma" subtitle: "Guía de bolsillo de la línea de comandos" author: "Tove Ahlberg" --- # Piezas que encajan {kicker="Capítulo 4" lead="Cómo la tubería encadena programas sencillos para hacerle preguntas a una carpeta."} Cada programa de este capítulo hace una sola cosa. \`ls\` muestra los nombres de una carpeta, \`grep\` se queda con las líneas que coinciden con un patrón, \`sort\` las ordena y \`du\` dice cuánto ocupa un archivo en el disco. La tubería, el carácter \`|\`, pasa lo que imprime un programa al siguiente, de modo que puedes encadenarlos en una sola línea y leer la respuesta al final. :::callout{type="aside" title="El indicador"} En un Mac, zsh muestra \`%\` en vez de \`$\`. ::: Pruébalo en una carpeta de fotos. En los listados, las líneas que empiezan por el signo del dólar son las que escribes tú, sin el dólar, y ejecutas con :chip[Intro]{style="key"}. Ese signo es el indicador, que el intérprete de órdenes (la *shell*) muestra cuando te espera. Las de debajo son su respuesta. \`\`\`console Terminal $ cd ~/fotos/2025 $ ls | grep -c 'JPG$' 268 $ ls | grep -v 'JPG$' IMG_0413.MOV IMG_0977.MOV IMG_1502.PNG $ du -sh *.MOV | sort -rh 812M IMG_0977.MOV 455M IMG_0413.MOV \`\`\` :::paragraphs{style="resume"} \`grep -c\` cuenta las líneas que coinciden sin imprimirlas, y \`-v\` se queda con las demás. Las comillas simples entregan el patrón a \`grep\` tal cual; en él, \`$\` marca el final de la línea. El asterisco de la última orden lo expande el intérprete: antes de que \`du\` arranque, \`*.MOV\` ya se ha convertido en la lista de los archivos \`.MOV\`. ::: :::callout{type="aside" title="En un Mac"} :chip[Ctrl]{style="key"} es :chip[control]{style="key"} :chip[Intro]{style="key"} es :chip[retorno]{style="key"} Los atajos usan :chip[control]{style="key"}, no :chip[comando]{style="key"}. ::: ## Cuando una orden no termina Tarde o temprano lanzarás una orden que no acaba. Si escribes \`grep JPG\` sin ningún archivo detrás, \`grep\` se queda esperando a que teclees las líneas en las que debe buscar. Para detenerla, mantén :chip[Ctrl]{style="key"} y pulsa :chip[C]{style="key"}; vuelve el indicador y no ha cambiado nada. Para cerrar la entrada como es debido, pulsa :chip[Ctrl]{style="key"} :chip[D]{style="key"} al principio de una línea vacía; \`grep\` lo entiende como el final de la entrada. :chip[Ctrl]{style="key"} :chip[C]{style="key"} también detiene un \`ping\`, que, si no, escribiría una línea por segundo hasta que cerraras la ventana. El intérprete también te ahorra teclear. Pulsa :chip[Tab]{style="key"} después de las primeras letras del nombre de un archivo o una carpeta y el intérprete completa el resto; si encajan varios nombres, te los enseña (bash espera a un segundo :chip[Tab]{style="key"}). :chip[↑]{style="key"} recupera la última orden, y cada pulsación retrocede una más, así que una tubería con una errata se corrige en vez de volver a escribirse. 1. Escribe \`cd ~/fo\` y pulsa :chip[Tab]{style="key"} para completar el nombre de la carpeta, \`fotos/\`; luego añade \`2025\` y pulsa :chip[Intro]{style="key"}. 2. Pulsa :chip[↑]{style="key"} hasta que vuelva a la línea \`ls | grep -v 'JPG$'\`. 3. Mantén :chip[Ctrl]{style="key"} y pulsa :chip[A]{style="key"} para saltar al principio de la línea; luego :chip[Ctrl]{style="key"} :chip[E]{style="key"} te devuelve al final. 4. Añade \`| sort -r\` y pulsa :chip[Intro]{style="key"}. Los mismos nombres salen en orden inverso. :::callout{type="sheet" title="Chuleta · bash y zsh"} :::columns{count=2 breaks="8"} **En la línea** :chip[Ctrl]{style="key"} :chip[A]{style="key"} al principio de la línea :chip[Ctrl]{style="key"} :chip[E]{style="key"} al final de la línea :chip[Ctrl]{style="key"} :chip[W]{style="key"} corta la palabra anterior :chip[Ctrl]{style="key"} :chip[K]{style="key"} corta hasta el final :chip[Ctrl]{style="key"} :chip[Y]{style="key"} pega lo que cortaste :chip[Ctrl]{style="key"} :chip[T]{style="key"} cambia dos letras de sitio **Órdenes e historial** :chip[Ctrl]{style="key"} :chip[R]{style="key"} busca en órdenes anteriores :chip[Ctrl]{style="key"} :chip[P]{style="key"} la orden anterior :chip[Ctrl]{style="key"} :chip[C]{style="key"} detiene la orden en curso :chip[Ctrl]{style="key"} :chip[Z]{style="key"} la suspende; \`fg\` la reanuda :chip[Ctrl]{style="key"} :chip[L]{style="key"} limpia la pantalla :chip[Ctrl]{style="key"} :chip[D]{style="key"} cierra la sesión (línea vacía) ::: ::: ## Un guion para guardar Las órdenes que escribes cada semana merecen un archivo. Este copia cada carpeta de \`~/trabajo\` en un disco externo, dentro de una carpeta nueva con la fecha del día. Guárdalo como \`copia.sh\` en tu carpeta personal. \`\`\`bash copia.sh #!/usr/bin/env bash # Copia cada carpeta de ~/trabajo en el disco, bajo la fecha de hoy. set -euo pipefail src="$HOME/trabajo" dest="/Volumes/Copias/$(date +%F)" mkdir -p "$dest" for dir in "$src"/*/; do name=$(basename "$dir") rsync -a "$dir" "$dest/$name/" echo "copiada: $name" done \`\`\` :::callout{type="aside" title="Modo archivo"} \`rsync -a\` copia también las subcarpetas y conserva las fechas y los permisos de cada archivo. ::: :::paragraphs{style="resume"} La primera línea, el *shebang*, indica con qué programa se ejecuta el archivo. \`set -euo pipefail\` detiene el guion en la primera orden que falla, así que nunca sigue adelante con media copia. \`$(date +%F)\` ejecuta \`date\` y coloca lo que imprime, por ejemplo 2026-09-26, dentro de la ruta. Las comillas alrededor de cada variable mantienen entera una carpeta llamada Declaración de la renta; sin ellas, el intérprete partiría el nombre por los espacios y \`rsync\` buscaría cuatro carpetas que no existen. ::: Haz el archivo ejecutable con \`chmod +x copia.sh\`, una sola vez, y lánzalo con \`./copia.sh\`. En Linux, un disco externo suele aparecer dentro de \`/media\`, en una carpeta con tu nombre de usuario, así que cambia la línea de \`dest\` para que coincida. :::callout{type="colophon"} *La terminal, con calma* es un libro inventado para el Recetario de Postext. Compuesto en Charis SIL, Sora y JetBrains Mono (SIL OFL). Texto: original, CC BY 4.0. ::: El capítulo 5 lleva \`grep\` a \`/var/log\`, la carpeta de los registros del sistema, donde una tubería de tres órdenes cuenta cuántos errores se anotaron cada día de la última semana.
`; // content.<lang>.md, inlined by the Cookbook const source = inlineCode(listings(markdown)); // fences first: their backticks are escaped const continuation = { pageIndexOffset: 48, pageNumbering: { startAt: 49 } }; // p. 49, a recto // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Charis SIL': ['400', '400i'], Sora: ['400', '700', '800'], 'JetBrains Mono': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const build = () => buildDocument({ markdown: source, continuation }, config()); const doc = await buildWithFonts(build, markdown); showPages(doc, { title: t({ en: 'The Shell, Gently', es: 'La terminal, con calma' }) });
Kit · core, fonts, viewer: igual a totes les receptes · 235 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 ───────────────────────────────────────────────────────────────────────

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

#Penja la pestanya a la dreta

La pestanya passa a la cantonada superior dreta del requadre, que en una pàgina senar queda sobre la columna lateral.

-    position: 'top-left' },
+    position: 'top-right' },

#Dona als comentaris el color de les cadenes

Sense el xip rem, un comentari és una cursiva més i surt en el mateix verd que les cadenes.

-    else if (comment) out += `:chip[${chipText(comment)}]{style="rem"}`;
+    else if (comment) out += `*${escape(comment)}*`;

Errors freqüents

Error freqüent

Un $ sol obre matemàtiques: escriu \$

El signe de dòlar obre matemàtiques en línia, de manera que un preu com $40 comença una fórmula. Escriu \$40. Escapaments i caràcters literals →

Error freqüent

«1998. » o «- » al començament d'un paràgraf obren una llista

Un paràgraf que comença per un número, un punt i un espai, o per un guionet i un espai, es converteix en un element de llista. Posa un unidor de paraules (U+2060) davant del número i escriu els diàlegs amb guió llarg. Escapaments i caràcters literals →

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

:::columns només funciona dins d'un requadre i no es parteix

:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →

Error freqüent

Un requadre lateral comença a l'altura del bloc que segueix la seva tanca

A postext 1.4.1, un requadre amb span: 'side' es col·loca a la columna lateral a l'altura on ha arribat el text a la seva tanca, a la línia següent de la retícula i sota els requadres que ja hi hagi. Posa la tanca d'una glossa just abans del paràgraf que explica: si va després, la glossa comença al costat del paràgraf següent. Un requadre que passaria del peu de la columna puja fins que el seu peu coincideix amb el de la columna, si el requadre de sobre li deixa lloc; si tot i així no hi cap, espera la columna lateral de la pàgina següent. Notes al marge →

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

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

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

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 →

Error freqüent

El lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

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

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

  • Sagna els llistats amb espais. codeLine() converteix els espais inicials i les sèries d'espais en espais de no separació, mentre que un tabulador s'imprimeix com un espai corrent.
  • No passis de 73 caràcters en cap línia de codi. Una línia més llarga es parteix per un espai, i un comentari, que és un xip, no es parteix mai: baixa a una línia pròpia i surt de la vora del requadre.
  • Els escapaments de codeLine() també serveixen a la prosa. Un paràgraf que comença per un any, com 1998. Se inaugura el laboratorio, es converteix en l'element 1998 d'una llista si no porta al davant un caràcter d'unió de paraules, i \$40 impedeix que un preu obri una fórmula. Un diàleg que comença amb guió llarg no necessita escapament; un guionet i un espai obririen una llista.

Crèdits

Text
Text original, CC BY 4.0
Fonts
Charis SIL (SIL OFL 1.1) · Sora (SIL OFL 1.1) · JetBrains Mono (SIL OFL 1.1)
Sandbox