Saltar al contingut principal
Recepta número 28

Receptari · Capítol 2 · Text i tipografia

Informe de laboratori: fórmules, subíndexs i corba de pH

Un informe de química de quatre pàgines A4 que marca amb ~ i ^ les fórmules i unitats del text i deixa TeX per a la reacció i les equacions.

En aquesta pàgina
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 297 mm
  • 1 columna
  • Inria Serif 10,5/15
  • Inria Sans
  • Sometype Mono
  • 4 pàgines
  • Nivell
  • Postext 1.4.1
  • Compost en 19 ms
  • 241 línies de codi

En poques paraules

Un informe escolar de química de quatre pàgines sobre l'anàlisi d'un vinagre. Ensenya a escriure fórmules químiques i unitats amb números petits a dalt i a baix, a més d'algunes equacions completes.

Què compondràs

A la pràctica 4 de Química, tres alumnes de 2n de Batxillerat valoren un vinagre de poma amb hidròxid de sodi per saber quant àcid acètic conté. Són quatre pàgines A4 impreses per una cara. Els números de secció pengen en un marge esquerre de 55 mm, de manera que títols i paràgrafs comencen al mateix límit. La bureta i la capçalera de la taula van en gris pissarra, i l'únic color és el rosa de la fenolftaleïna, pàl·lid a la banda de l'obertura i a l'interval de viratge, intens als números de secció i a les lectures del pH-metre. Les fórmules i unitats del text van en la mateixa lletra, amb subíndexs i superíndexs; TeX compon la reacció i tres equacions destacades. La taula 1 i la corba de la figura 1 surten d'un únic array de lectures al codi.

Aquesta recepta respon a

  • Com escric superíndexs, subíndexs i fórmules químiques sense escriure LaTeX?
  • Com componc matemàtiques (en línia, destacades, equacions) i les mantinc vectorials al PDF?
  • Com numero els títols (1, 1.1, 1.1.1) i dono a cada nivell un estil diferent?
  • Com afegeixo una figura amb peu numerat i la cito en el text («vegeu la fig. 3.2»)?
  • Com faig una taula amb files de capçalera, cel·les combinades, amplades de columna i alineació per cel·la?
  • Com aconsegueixo les etiquetes «Figura» i «Taula» en la llengua del meu document?
  • Com componc una bibliografia o un glossari (sagnat francès, cos més petit)?

La resposta curta

script.js · línies 39–54al codi complet
// In the Markdown, ~…~ lowers a run and ^…^ raises it, in the text's own face at 58 % of
// its size: CH~3~COOH, OH^−^ (the minus is U+2212), 25,0 cm^3^, 0,100 mol·dm^−3^, p*K*~a~.
// The marks also work where TeX cannot go (gap: math-in-captions): captions and the table
// cells this file writes, such as the column heads, each a quantity over its unit:
const units = { cm3: 'cm^3^', conc: 'mol·dm^−3^' }; // the dot keeps a unit one word
const heads = t({ es: ['Valoración', 'V~inicial~', 'V~final~', 'V~b~ gastado'],
  en: ['Titration', 'V~initial~', 'V~final~', 'V~b~ used'] })
  .map((head, i) => (i ? `${head} / ${units.cm3}` : head)); // 'V~b~ gastado / cm^3^'
// Between $$ and $$ is TeX, set by MathJax as vector paths: the reaction, with mhchem's
// \ce{…} (it lowers the 3 of CH3COOH by itself), and the equations. MathJax comes only with
// the ?bundle build, which every postext symbol here is imported from: the plain URL makes
// initMathEngine() throw, and a build that starts before it resolves prints grey boxes
// (gotcha: math-bundle).
await initMathEngine();
// No math.fontSizeScale: 1.4.1 draws formulas with an x-height of half the type size, and
// Inria Serif's is 0.495 em, so at the default scale their lowercase matches the text's.

Ingredients

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

Elaboració

#1 · Fórmules com a text, TeX per a les equacions

El codi és la resposta curta de més amunt. ~…~ i ^…^ componen un tram al 58 % del cos en la lletra del text, de manera que CH~3~COOH, OH^−^ (el seu signe menys és U+2212) i cm^3^ conserven el pes i el color de la frase que els envolta. Les marques serveixen també als peus i a les cel·les de taula, on no hi cap TeX. La reacció, escrita amb \ce{…} de mhchem, i les equacions van en TeX entre $$. El motor de fórmules només ve a la versió ?bundle, així que tots els símbols s'importen d'aquesta. math.fontSizeScale es deixa en 1: a la 1.4.1 l'alçada de la x d'una fórmula és mig cos i la d'Inria Serif és de 0,495 em, de manera que les minúscules de fórmula i text queden a la mateixa alçada.

#2 · Números de secció penjats al marge

script.js · línies 58–79al codi complet
const face = (size, extra) => ({ fontFamily: SANS, fontWeight: 700, fontSize: pt(size),
  lineHeight: 1.1, align: 'left', overflow: 'wrap', ...extra });
// The title starts on the text edge; the number hangs off its left side ('left-of') in a box
// of fixed width, set right. An auto width is clamped to the room the column leaves on that
// side, which is none: the box shrinks to 0 mm, the number wraps a character to a line and
// the heads grow by one to three lines (gotcha: negative-offsets).
const hang = (id, content, size, color, extra) => ({ kind: 'text', id, content,
  ...face(size, extra), color: col(color), align: 'right',
  placement: { ...at('#title', 'left-of', -GAP), size: { width: mm(HANG) } } });
const hung = (size, color) => ({ enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', ...face(size), color: col('ink'),
    placement: at('container', 'top-left') },
  hang('number', '{number}', size, color),
] } });
const levels = [ // a headings object drops the H1 break (gotcha: headings-drop-h1-break)
  { level: 1, breakBefore: { enabled: true, parity: 'any' } },
  // One grid line per head: the design sets the type, the level's size and leading the flow.
  { level: 2, numberingTemplate: '{2}', fontSize: pt(13), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(13, 'phenol') }, // 5
  { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(BODY), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(BODY, 'muted') }, // 5.1
];

El disseny de cada nivell de títol és una ranura amb dos elements: el títol, al límit del text, i el {number}, ancorat a la seva esquerra (left-of) a 4 mm, en una caixa de 33 mm alineada a la dreta. La caixa necessita una amplada fixa. L'amplada automàtica es limita a l'espai que la columna deixa a la seva esquerra, i com que no en deixa gens, la caixa es queda en 0 mm i els títols creixen d'una a tres línies. El cos i l'interlineat del nivell només compten per al flux, on cada títol ocupa una línia de 15 pt. La lletra la posa el disseny: els apartats, numerats {2}, en rosa a 13 pt, i els subapartats, {2}.{3}, en gris i al cos del text.

#3 · L'obertura de l'informe surt de l'H1

script.js · línies 83–117al codi complet
const BAND = 104; // mm from the top of the page to the band's foot, where the flask stands
// The byline: values from the H1's attributes and the frontmatter, one per line, each
// with its label hung in the margin like a section number.
const byline = [['{attr.authors}', t({ es: 'Autores', en: 'Authors' })],
  ['{attr.group}', t({ es: 'Grupo', en: 'Class' })],
  ['{attr.teacher}', t({ es: 'Profesora', en: 'Teacher' })],
  ['{publishDate}', t({ es: 'Fecha', en: 'Date' })]].flatMap(([content, label], i) => [
  { kind: 'text', id: `value${i}`, content, fontFamily: SERIF, fontSize: pt(10), align: 'left',
    color: col('ink'), overflow: 'clip', placement: at('#title', 'below', 0, 7 + 4.6 * i) },
  { kind: 'text', id: `label${i}`, content: label, fontFamily: MONO, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('ink'),
    align: 'right', overflow: 'clip',
    placement: { ...at(`#value${i}`, 'left-of', -GAP, 0.7), size: { width: mm(HANG) } } },
]);
// span: 'page' changes nothing in this one-column layout but where the design is painted:
// a heading design kept in the column is clipped at the column's top edge, 24 mm down, so
// the top of the band and the burette would print white.
const report = { id: 'report', numbered: false, span: 'page', advancedDesign: { enabled: true,
  // The band is a box, and boxes count towards the height a head reserves, so the text
  // would start below its foot at 104 mm (where the burette ends too). minHeight sets a floor
  // 8 mm lower, which the H1's bottom margin and the 15 pt grid round up to 15 mm.
  minHeight: mm(BAND + 8 - TOP), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('blush') },
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'image', id: 'burette', resourceId: 'burette', // its stand 12 mm into the margin
      placement: { ...at('page', 'top-right', 12 - RIGHT), size: { width: mm(34) } } },
    { kind: 'text', id: 'course', content: '{attr.course}', fontFamily: MONO, fontWeight: 600,
      fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase',
      color: col('phenol'), overflow: 'clip', placement: at('container', 'top-left', 0, 2) },
    { kind: 'text', id: 'title', content: '{titleText}', ...face(27, { lineHeight: 1.06 }),
      color: col('ink'), placement: { ...at('#course', 'below', 0, 5), size: { width: mm(96) } } },
    hang('practice', '{attr.practice}', 27, 'phenol', { lineHeight: 1.06 }), // as a section's
    ...byline,
  ] } } };
const back = { id: 'back', numbered: false, advancedDesign: hung(13, 'phenol') }; // no number

L'únic H1 de l'informe porta l'estil de títol report, que el substitueix per una obertura dibuixada: una banda rosa a dalt de la pàgina, la bureta, la línia del curs, el títol amb el número de la pràctica penjat com un número de secció i les dades d'autoria. Aquestes dades surten dels atributs del títol i del publishDate del frontmatter. La banda és una caixa, i les caixes compten per a l'alçada que reserva el títol, així que el text començaria sota el seu peu, a 104 mm del límit superior de la pàgina, fins i tot sense minHeight. minHeight fixa una alçada mínima que arriba 8 mm per sota de la banda; amb el marge inferior de l'H1 i la retícula de 15 pt queden 15 mm de blanc sobre el resum, en lloc de 4,7 mm.

#4 · Taula i Figura en la llengua de l'informe

script.js · línies 138–147al codi complet
// defaultResourceTypes(LANG) names them in Spanish (gotcha: resource-types-locale). Their
// '{h1}.{n}' prints a plain 1: the H1 is unnumbered, and an empty {h1} drops out with its dot.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
  ? { ...type, captionStyle: { position: 'above' } } : type)); // a table's caption goes on top
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('slate'), headerColor: col('paper'), headerFontFamily: SANS,
  headerFontSize: pt(8.5), bodyFontFamily: MONO, bodyFontSize: pt(8.8), bodyColor: col('ink'),
  cellPadding: mm(1.5) }; // figures in a monospace face, so the decimal separators line up
const captionStyle = { fontFamily: SANS, fontSize: pt(8.8), color: col('ink'), gap: mm(2.5),
  labelColor: col('phenol'), note: { fontFamily: SANS, fontSize: pt(7.4), color: col('muted') } };

defaultResourceTypes(LANG) dona Tabla i Figura a l'edició espanyola; el locale tot sol les deixaria en anglès. La plantilla {h1}.{n} imprimeix aquí un 1 a seques, perquè l'H1 de l'informe no porta número i un {h1} buit desapareix amb el seu punt. El peu de la taula va a sobre, com és costum als informes. El text cita taula i figura amb :ref{id="lecturas" style="full" case="lower"}, que escriu «tabla 1» en minúscula, amb la tinta i el pes del text.

#5 · Una sola sèrie de lectures per a la taula i la corba

script.js · línies 297–315al codi complet
const READINGS = [[0.00, 21.30], [0.40, 21.25], [1.10, 22.00], [0.25, 21.10]]; // cm³: synthetic
const titres = READINGS.map(([from, to]) => to - from); // the first is the rough titration
const fair = titres.slice(1);
const mean = fair.reduce((a, b) => a + b) / fair.length; // 20.87 cm³
const sd = Math.sqrt(fair.reduce((s, v) => s + (v - mean) ** 2, 0) / (fair.length - 1)); // 0.03
const num = (x) => x.toFixed(2).replace('.', t({ es: ',', en: '.' })); // the decimal comma
const cell = (content, align = 'right') => ({ content, align }); // figures set right
const label = (es, en) => cell(t({ es, en }), 'left');
// A summary row: its label set right, against its value, and two cells for the merge to cover.
const total = (es, en, x) => [cell(t({ es, en })), cell(''), cell(''), cell(num(x))];
const rows = [heads.map((head, i) => ({ ...cell(head, i ? 'right' : 'left'), isHeader: true })),
  ...READINGS.map(([from, to], i) => [i ? cell(String(i), 'left') : label('Orientativa', 'Rough'),
    cell(num(from)), cell(num(to)), cell(num(titres[i]))]),
  total('Media de 1–3', 'Mean of 1–3', mean), total('Desviación típica', 'Standard deviation', sd)];
// The last two labels span the first three columns. The cells a merge covers stay in the
// row, marked hiddenBy, which mergeCells writes (gotcha: merged-cells-hiddenby).
const table = [rows.length - 2, rows.length - 1].reduce((model, row) => mergeCells(model,
  { start: { row, col: 0 }, end: { row, col: 2 } }), { headerRowCount: 1, rows,
  columnWidths: [1.8, 1, 1, 1.15] }); // relative: the first column holds the longest labels

Les quatre parelles de lectures de la bureta donen totes les cel·les de la taula 1, la mitjana i la desviació típica, i el volum mitjà fixa el punt d'equivalència del model de pH que dibuixa la figura 1. La primera fila és la capçalera (headerRowCount: 1), i columnWidths dona a la columna de rètols 1,8 parts de l'amplada. Cada cel·la porta el seu propi align, i mergeCells estén els rètols de la mitjana i la desviació sobre les tres primeres columnes, alineats a la dreta al costat del seu valor. Les xifres van en Sometype Mono, de manera que les comes decimals queden en columna. Les capçaleres són magnituds sobre unitats, com V~b~ gastado / cm^3^, amb les mateixes marques que el text.

#6 · Referències amb sagnat francès

script.js · línies 165–170al codi complet
const paragraphStyles = [
  { id: 'references', fontSize: pt(9), lineHeight: pt(12.5), textAlign: 'left',
    hangingIndent: mm(6), spaceBetween: pt(4) }, // ragged, so never hyphenated
  { id: 'colophon', fontFamily: SANS, fontSize: pt(7.4), lineHeight: pt(10), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];

Un contenidor :::paragraphs{style="references"} aplica l'estil de paràgraf a cada referència: 9 pt sobre 12,5 pt, en bandera, amb 6 mm de sagnat francès i 4 pt entre entrades. A la 1.4.1 el text en bandera no té partició de mots, així que un títol llarg com Fundamentos de química analítica només es talla entre paraules. El colofó porta un estil propi, Inria Sans a 7,4 pt, amb una línia de 15 pt de blanc a sobre.

La recepta completa

Sandbox
// ═══ Postext Cookbook · Nº 028 · Lab report: formulas, subscripts and a titration curve ═══
// https://postext.dev/en/cookbook/lab-report-formulas
// Code: MIT · Text: original (CC BY 4.0) · Figures: drawn in code (CC BY 4.0)
// Fonts: Inria Serif, Inria Sans, Sometype Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes, initMathEngine, renderMath, mergeCells,
} from 'https://esm.sh/postext?bundle';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'lab-report-formulas';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // white paper and phenolphthalein: pale at the end point, deep past it
  ink: '#1d2126', // text: a blue-black
  phenol: '#ad2c5f', // the accent: numbers, kickers, stripes, the flask's liquid (6.4:1)
  blush: '#f6d5e2', // the end-point pink: the head's band, the indicator's range on the curve
  slate: '#5b6b7a', // the burette's steel, the table head
  rule: '#cfd4da', // hairlines
  muted: '#626a73', // running heads, notes (5.5:1)
  paper: '#ffffff',
};
// 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 });
// 'main-color' as well: the engine's defaults are linked to it, so any left over turn pink.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.phenol })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [SERIF, SANS, MONO] = ['Inria Serif', 'Inria Sans', 'Sometype Mono'];
const [BODY, LEAD] = [10.5, 15]; // pt: text size and leading
// mm: A4, printed on one side, so not mirrored; the wide left margin holds the numbers
const [TRIM_W, TRIM_H, TOP, BOTTOM, LEFT, RIGHT] = [210, 297, 24, 22, 55, 31];
const MEASURE = TRIM_W - LEFT - RIGHT; // mm: 124, about 74 characters of Inria Serif
const GAP = 4; // mm between a hanging number and the text edge
const HANG = LEFT - GAP - 18; // mm: the number column, 18 mm clear of the trim
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: formulas and units in the text face; TeX for the reaction and equations
// In the Markdown, ~…~ lowers a run and ^…^ raises it, in the text's own face at 58 % of
// its size: CH~3~COOH, OH^−^ (the minus is U+2212), 25,0 cm^3^, 0,100 mol·dm^−3^, p*K*~a~.
// The marks also work where TeX cannot go (gap: math-in-captions): captions and the table
// cells this file writes, such as the column heads, each a quantity over its unit:
const units = { cm3: 'cm^3^', conc: 'mol·dm^−3^' }; // the dot keeps a unit one word
const heads = t({ es: ['Valoración', 'V~inicial~', 'V~final~', 'V~b~ gastado'],
  en: ['Titration', 'V~initial~', 'V~final~', 'V~b~ used'] })
  .map((head, i) => (i ? `${head} / ${units.cm3}` : head)); // 'V~b~ gastado / cm^3^'
// Between $$ and $$ is TeX, set by MathJax as vector paths: the reaction, with mhchem's
// \ce{…} (it lowers the 3 of CH3COOH by itself), and the equations. MathJax comes only with
// the ?bundle build, which every postext symbol here is imported from: the plain URL makes
// initMathEngine() throw, and a build that starts before it resolves prints grey boxes
// (gotcha: math-bundle).
await initMathEngine();
// No math.fontSizeScale: 1.4.1 draws formulas with an x-height of half the type size, and
// Inria Serif's is 0.495 em, so at the default scale their lowercase matches the text's.
// #endregion

// #region hanging: section numbers hung in the margin, titles on the text edge
const face = (size, extra) => ({ fontFamily: SANS, fontWeight: 700, fontSize: pt(size),
  lineHeight: 1.1, align: 'left', overflow: 'wrap', ...extra });
// The title starts on the text edge; the number hangs off its left side ('left-of') in a box
// of fixed width, set right. An auto width is clamped to the room the column leaves on that
// side, which is none: the box shrinks to 0 mm, the number wraps a character to a line and
// the heads grow by one to three lines (gotcha: negative-offsets).
const hang = (id, content, size, color, extra) => ({ kind: 'text', id, content,
  ...face(size, extra), color: col(color), align: 'right',
  placement: { ...at('#title', 'left-of', -GAP), size: { width: mm(HANG) } } });
const hung = (size, color) => ({ enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', ...face(size), color: col('ink'),
    placement: at('container', 'top-left') },
  hang('number', '{number}', size, color),
] } });
const levels = [ // a headings object drops the H1 break (gotcha: headings-drop-h1-break)
  { level: 1, breakBefore: { enabled: true, parity: 'any' } },
  // One grid line per head: the design sets the type, the level's size and leading the flow.
  { level: 2, numberingTemplate: '{2}', fontSize: pt(13), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(13, 'phenol') }, // 5
  { level: 3, numberingTemplate: '{2}.{3}', fontSize: pt(BODY), lineHeight: pt(LEAD),
    marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: hung(BODY, 'muted') }, // 5.1
];
// #endregion

// #region head: the report head: a pink band, the burette, the H1 and its attributes
const BAND = 104; // mm from the top of the page to the band's foot, where the flask stands
// The byline: values from the H1's attributes and the frontmatter, one per line, each
// with its label hung in the margin like a section number.
const byline = [['{attr.authors}', t({ es: 'Autores', en: 'Authors' })],
  ['{attr.group}', t({ es: 'Grupo', en: 'Class' })],
  ['{attr.teacher}', t({ es: 'Profesora', en: 'Teacher' })],
  ['{publishDate}', t({ es: 'Fecha', en: 'Date' })]].flatMap(([content, label], i) => [
  { kind: 'text', id: `value${i}`, content, fontFamily: SERIF, fontSize: pt(10), align: 'left',
    color: col('ink'), overflow: 'clip', placement: at('#title', 'below', 0, 7 + 4.6 * i) },
  { kind: 'text', id: `label${i}`, content: label, fontFamily: MONO, fontWeight: 500,
    fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('ink'),
    align: 'right', overflow: 'clip',
    placement: { ...at(`#value${i}`, 'left-of', -GAP, 0.7), size: { width: mm(HANG) } } },
]);
// span: 'page' changes nothing in this one-column layout but where the design is painted:
// a heading design kept in the column is clipped at the column's top edge, 24 mm down, so
// the top of the band and the burette would print white.
const report = { id: 'report', numbered: false, span: 'page', advancedDesign: { enabled: true,
  // The band is a box, and boxes count towards the height a head reserves, so the text
  // would start below its foot at 104 mm (where the burette ends too). minHeight sets a floor
  // 8 mm lower, which the H1's bottom margin and the 15 pt grid round up to 15 mm.
  minHeight: mm(BAND + 8 - TOP), slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('blush') },
      placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'image', id: 'burette', resourceId: 'burette', // its stand 12 mm into the margin
      placement: { ...at('page', 'top-right', 12 - RIGHT), size: { width: mm(34) } } },
    { kind: 'text', id: 'course', content: '{attr.course}', fontFamily: MONO, fontWeight: 600,
      fontSize: pt(7.5), letterSpacing: pt(1.2), textTransform: 'uppercase',
      color: col('phenol'), overflow: 'clip', placement: at('container', 'top-left', 0, 2) },
    { kind: 'text', id: 'title', content: '{titleText}', ...face(27, { lineHeight: 1.06 }),
      color: col('ink'), placement: { ...at('#course', 'below', 0, 5), size: { width: mm(96) } } },
    hang('practice', '{attr.practice}', 27, 'phenol', { lineHeight: 1.06 }), // as a section's
    ...byline,
  ] } } };
const back = { id: 'back', numbered: false, advancedDesign: hung(13, 'phenol') }; // no number
// #endregion

// Running heads on the body pages; the folio hangs in the number column, at the foot of p. 1.
const small = { fontFamily: MONO, fontWeight: 500, fontSize: pt(7.5), letterSpacing: pt(0.6),
  textTransform: 'uppercase', color: col('muted'), overflow: 'clip', pages: 'body' };
const folio = (edge, y, pages) => ({ kind: 'text', id: 'folio', ...small, pages, fontWeight: 700,
  content: '{pageNumber} / {totalPages}', color: col('phenol'), align: 'right',
  placement: { ...at('page', edge, LEFT - GAP - HANG, y), size: { width: mm(HANG) } } });
const header = { elements: [folio('top-left', 13, 'body'),
  { kind: 'text', id: 'course', content: '{attr.course}', ...small,
    placement: at('page', 'top-left', LEFT, 13) },
  { kind: 'text', id: 'authors', content: '{attr.short}', ...small, align: 'right',
    placement: at('page', 'top-right', -RIGHT, 13) },
  { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
    pages: 'body', placement: { ...at('page', 'top-left', LEFT, 17),
      size: { width: mm(MEASURE) } } },
] };
const footer = { elements: [folio('bottom-left', -12, 'opener')] };

// #region labels: Tabla and Figura in the report's language, and how their captions look
// defaultResourceTypes(LANG) names them in Spanish (gotcha: resource-types-locale). Their
// '{h1}.{n}' prints a plain 1: the H1 is unnumbered, and an empty {h1} drops out with its dot.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
  ? { ...type, captionStyle: { position: 'above' } } : type)); // a table's caption goes on top
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
  headerBackground: col('slate'), headerColor: col('paper'), headerFontFamily: SANS,
  headerFontSize: pt(8.5), bodyFontFamily: MONO, bodyFontSize: pt(8.8), bodyColor: col('ink'),
  cellPadding: mm(1.5) }; // figures in a monospace face, so the decimal separators line up
const captionStyle = { fontFamily: SANS, fontSize: pt(8.8), color: col('ink'), gap: mm(2.5),
  labelColor: col('phenol'), note: { fontFamily: SANS, fontSize: pt(7.4), color: col('muted') } };
// #endregion

const boxTitle = { fontFamily: MONO, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.2),
  textTransform: 'uppercase', color: col('phenol'), gap: mm(1.2) };
const box = (id, title, extra) => ({ id, title, backgroundEnabled: false, titleStyle: boxTitle,
  body: { fontSize: pt(9.8), lineHeight: pt(14), firstLineIndent: pt(0) }, ...extra });
const calloutStyles = [ // one device each: a stripe for the abstract, a frame for safety
  box('abstract', t({ es: 'Resumen', en: 'Abstract' }), { marginTop: pt(0), marginBottom: pt(0),
    stripe: { enabled: true, width: pt(3), color: col('phenol') }, // on the left
    padding: { top: mm(0.5), right: mm(0), bottom: mm(0.5), left: mm(5) } }),
  box('safety', t({ es: 'Seguridad', en: 'Safety' }), {
    border: { enabled: true, color: col('phenol'), width: pt(0.75) },
    marginTop: mm(4.5), // air over the frame; the grid rounds the space under it to 8 mm
    padding: { top: mm(3), right: mm(4), bottom: mm(3), left: mm(4) } }),
];

// #region references: a reference list with hanging indents, and the colophon
const paragraphStyles = [
  { id: 'references', fontSize: pt(9), lineHeight: pt(12.5), textAlign: 'left',
    hangingIndent: mm(6), spaceBetween: pt(4) }, // ragged, so never hyphenated
  { id: 'colophon', fontFamily: SANS, fontSize: pt(7.4), lineHeight: pt(10), color: col('muted'),
    textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ es: 'es', en: 'en-us' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes, colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    referenceBold: false, // 'la tabla 1' reads as part of the sentence
    firstLineIndent: mm(5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.5,
    maxRuntTracking: 0 }, // runt fixes tighten spaces only (gotcha: runt-tracking-unpainted)
  headings: { fontFamily: SANS, color: col('ink'), levels },
  // A display's marginBottom is a minimum that the 15 pt grid rounds up: at the default
  // 0.8 em a fraction got a line more air under it than over it.
  math: { marginBottom: em(0.3) },
  headingStyles: [report, back],
  unorderedLists: { bulletChar: '–', color: col('phenol'), marginTop: pt(0), marginBottom: pt(0) },
  orderedLists: { fontFamily: SANS, fontWeight: 700, color: col('phenol'), marginTop: pt(0),
    marginBottom: pt(0) },
  calloutStyles, paragraphStyles, tableStyle, captionStyle, header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Mostra en Markdown · 97 línies · content.es.mdtitle: "Valoración ácido-base: la acidez de un vinagre" author: "Lucía Varela, Daniel Okafor y Marta Ibarra" publishDate: "12 de marzo de 2026" --- # Valoración ácido-base: la acidez de un vinagre {style="report" course="Química · 2.º de Bachillerato · Práctica 4" practice="4" authors="Lucía Varela · Daniel Okafor · Marta Ibarra" group="2.º B · mesa 3" teacher="Elena Soto" short="Varela, Okafor e Ibarra"} :::callout{type="abstract"} Hemos medido el ácido acético de un vinagre de manzana valorándolo con hidróxido de sodio 0,100 mol·dm^−3^ y fenolftaleína. Tres valoraciones concordantes gastaron de media 20,87 cm^3^ de NaOH, así que el vinagre contiene 0,835 mol·dm^−3^ de CH~3~COOH, es decir, 5,01 ± 0,04 g por cada 100 cm^3^, de acuerdo con el 5 % que declara la etiqueta. Otra valoración, seguida con un pH-metro, dio la curva de valoración y un p*K*~a~ de 4,75. ::: ## Objetivo Determinar el contenido de ácido acético (CH~3~COOH) de un vinagre por valoración con una base fuerte y comprobar la acidez que declara la etiqueta; además, registrar la curva de pH y estimar con ella la *K*~a~ del ácido. ## Fundamento El vinagre es una disolución acuosa diluida de ácido acético, un ácido débil, y los iones OH^−^ del hidróxido de sodio lo neutralizan mol a mol: $$\ce{CH3COOH(aq) + NaOH(aq) -> CH3COONa(aq) + H2O(l)}$$ En el punto de equivalencia se ha añadido tanta base como ácido había, y como la cantidad de sustancia es *n* = *cV*, la concentración del ácido se deduce del volumen de base gastado. La sosa sólida absorbe agua y CO~2~ del aire, así que pesarla no fija la concentración: la disolución se valoró el día anterior con hidrogenoftalato de potasio (KHC~8~H~4~O~4~), un patrón primario que no absorbe agua. En la equivalencia el matraz no contiene ácido ni base, sino acetato de sodio, y el ion acetato, CH~3~COO^−^, es una base débil: *K*~b~ = *K*~w~/*K*~a~ = 5,7 × 10^−10^. En ese punto su concentración *c* ronda los 0,045 mol·dm^−3^, así que $$[\mathrm{OH^-}] = \sqrt{K_\mathrm{b}\,c} = 5{,}1\times10^{-6}\ \mathrm{mol{\cdot}dm^{-3}}, \qquad \mathrm{pH} = 14 - \mathrm{pOH} = 8{,}7$$ El pH en la equivalencia no es, por tanto, 7. Por eso el indicador es la fenolftaleína, incolora por debajo de pH 8,2 y rosa por encima, que vira dentro del salto brusco de pH que rodea la equivalencia. Antes del salto, la mezcla de ácido y acetato es una disolución reguladora que cumple la ecuación de Henderson-Hasselbalch: $$\mathrm{pH} = \mathrm{p}K_\mathrm{a} + \log\frac{[\mathrm{CH_3COO^-}]}{[\mathrm{CH_3COOH}]}$$ Con la mitad de la base añadida, las dos concentraciones se igualan y el pH coincide con el p*K*~a~ del ácido, que la bibliografía da como 4,76 a 25 °C. ## Materiales y reactivos - Bureta de 50 cm^3^ (±0,05 cm^3^), pipetas aforadas de 10 y 25 cm^3^ con pera de succión, matraz aforado de 100 cm^3^ y tres erlenmeyers de 250 cm^3^. - pH-metro calibrado con disoluciones tampón de pH 4,00 y 7,00, agitador magnético y una baldosa blanca. - Disolución de NaOH 0,100 mol·dm^−3^, valorada, y disolución de fenolftaleína al 0,5 % en etanol como indicador. - Un embudo pequeño para llenar la bureta y un frasco lavador. - Vinagre de manzana con una acidez declarada del 5 %, y agua destilada. :::callout{type="safety"} La disolución de NaOH, aunque diluida, irrita los ojos y la piel, así que llevamos gafas durante toda la práctica. Las pipetas se llenaron siempre con la pera, nunca con la boca, y la bureta, por debajo de la altura de los ojos y con un embudo que retirábamos antes de leer. La fenolftaleína está disuelta en etanol, que es inflamable: en la mesa no hubo ninguna llama. Si la sosa salpicaba la piel, la lavábamos enseguida con agua abundante. ::: ## Procedimiento 1. Pasamos 10 cm^3^ de vinagre con la pipeta al matraz aforado de 100 cm^3^, enrasamos con agua destilada y agitamos el matraz: el vinagre quedó diluido diez veces. 2. Enjuagamos la bureta con un poco de la disolución de NaOH, la llenamos, eliminamos la burbuja de la punta y anotamos la lectura inicial, con los ojos a la altura del menisco. 3. Pasamos 25,0 cm^3^ del vinagre diluido a un erlenmeyer, que pusimos sobre la baldosa blanca, y añadimos tres gotas de fenolftaleína. 4. Añadimos la sosa agitando sin parar. Cuando el rosa empezó a tardar en desaparecer, seguimos gota a gota hasta que un rosa pálido se mantuvo medio minuto, y anotamos la lectura final. 5. Tras una valoración orientativa, repetimos hasta tener tres volúmenes que no difirieran en más de 0,10 cm^3^. 6. Para la curva valoramos otra alícuota con el electrodo del pH-metro sumergido. Añadimos la sosa de 2 en 2 cm^3^, y de 0,5 en 0,5 cm^3^ entre 18 y 24 cm^3^, y anotamos el pH tras cada adición. ## Resultados ### Valoraciones con indicador Las lecturas de la bureta están en la :ref{id="lecturas" style="full" case="lower"}. La valoración orientativa se pasó del punto final y queda fuera de la media; las otras tres no difieren en más de 0,05 cm^3^. El volumen medio de NaOH es *V*~b~ = 20,87 cm^3^, con una desviación típica de 0,03 cm^3^. ::resource{id="lecturas"} :::space{lines=1} Como cada alícuota era de *V*~a~ = 25,0 cm^3^ de vinagre diluido diez veces, con un factor *f* = 10, la concentración de ácido acético en el vinagre es $$c_\mathrm{vinagre} = f\,\frac{c_\mathrm{b}\,V_\mathrm{b}}{V_\mathrm{a}} = 10\cdot\frac{0{,}100 \cdot 20{,}87}{25{,}0}\ \mathrm{mol{\cdot}dm^{-3}} = 0{,}835\ \mathrm{mol{\cdot}dm^{-3}}$$ Con la masa molar del ácido acético, 60,05 g·mol^−1^, son 50,1 g·dm^−3^, es decir, 5,01 g por cada 100 cm^3^ de vinagre. Al combinar las tolerancias del material con la de la concentración de la sosa, la incertidumbre relativa resulta del 0,7 %. El resultado es, pues, de 5,01 ± 0,04 g de ácido acético por cada 100 cm^3^ de vinagre. ### Curva de pH La :ref{id="curva" style="full" case="lower"} muestra el pH frente al volumen de sosa añadido. El pH parte de 2,9 en el vinagre diluido y sube despacio hasta unos 18 cm^3^, porque la mezcla de ácido y acetato amortigua los cambios; entre 20 y 22 cm^3^ salta más de cinco unidades. El salto más brusco, entre 20,5 y 21 cm^3^, contiene el punto de equivalencia, que el cálculo sitúa en 20,87 cm^3^ y pH 8,7, dentro del intervalo de viraje de la fenolftaleína. Interpolando entre las lecturas de 10 y 12 cm^3^, el pH a la mitad de ese volumen es 4,75, que tomamos como p*K*~a~ del ácido acético. ## Discusión La dispersión de las valoraciones concordantes, 0,03 cm^3^, es menor que la tolerancia de la bureta, así que el error aleatorio es pequeño; el más probable es sistemático. La sosa capta CO~2~ del aire: si lo hubiera hecho desde que se valoró, parte de ella sería ya carbonato, y el volumen gastado con fenolftaleína saldría mayor que el real. Valorar la sosa el mismo día, o protegerla con un tubo de cal sodada, lo evitaría. La curva confirma la elección del indicador: el primer rosa aparece a pH 8,2, cuando falta menos de una gota para la equivalencia. Con naranja de metilo, que vira entre pH 3,1 y 4,4, el color habría cambiado antes de neutralizar un tercio del ácido. El p*K*~a~ obtenido, 4,75, solo difiere en 0,01 del que da la bibliografía. ## Conclusiones El vinagre de manzana analizado contiene 5,01 ± 0,04 g de ácido acético en cada 100 cm^3^, es decir, 0,835 mol·dm^−3^, así que la acidez del 5 % que declara la etiqueta es correcta dentro de nuestra incertidumbre. Según la curva de pH, el p*K*~a~ del ácido acético es 4,75, lo que da una *K*~a~ de 1,8 × 10^−5^. ## Referencias {style="back"} :::paragraphs{style="references"} Flowers, P., Theopold, K., Langley, R. y Robinson, W. R. (2019). *Chemistry 2e*, cap. 14: «Acid-Base Equilibria». OpenStax. Harris, D. C. (2020). *Quantitative Chemical Analysis* (10.ª ed.). W. H. Freeman. Skoog, D. A., West, D. M., Holler, F. J. y Crouch, S. R. (2015). *Fundamentos de química analítica* (9.ª ed.). Cengage Learning. ::: :::paragraphs{style="colophon"} Compuesto con Postext en Inria Serif, Inria Sans y Sometype Mono (SIL OFL). Texto y figuras son originales, con licencia CC BY 4.0. Las lecturas de la bureta y del pH-metro son sintéticas, generadas para este ejemplo. :::
`; // content.<lang>.md, inlined by the Cookbook // #region data: the burette readings, which Table 1 and the curve of Figure 1 are computed from const READINGS = [[0.00, 21.30], [0.40, 21.25], [1.10, 22.00], [0.25, 21.10]]; // cm³: synthetic const titres = READINGS.map(([from, to]) => to - from); // the first is the rough titration const fair = titres.slice(1); const mean = fair.reduce((a, b) => a + b) / fair.length; // 20.87 cm³ const sd = Math.sqrt(fair.reduce((s, v) => s + (v - mean) ** 2, 0) / (fair.length - 1)); // 0.03 const num = (x) => x.toFixed(2).replace('.', t({ es: ',', en: '.' })); // the decimal comma const cell = (content, align = 'right') => ({ content, align }); // figures set right const label = (es, en) => cell(t({ es, en }), 'left'); // A summary row: its label set right, against its value, and two cells for the merge to cover. const total = (es, en, x) => [cell(t({ es, en })), cell(''), cell(''), cell(num(x))]; const rows = [heads.map((head, i) => ({ ...cell(head, i ? 'right' : 'left'), isHeader: true })), ...READINGS.map(([from, to], i) => [i ? cell(String(i), 'left') : label('Orientativa', 'Rough'), cell(num(from)), cell(num(to)), cell(num(titres[i]))]), total('Media de 1–3', 'Mean of 1–3', mean), total('Desviación típica', 'Standard deviation', sd)]; // The last two labels span the first three columns. The cells a merge covers stay in the // row, marked hiddenBy, which mergeCells writes (gotcha: merged-cells-hiddenby). const table = [rows.length - 2, rows.length - 1].reduce((model, row) => mergeCells(model, { start: { row, col: 0 }, end: { row, col: 2 } }), { headerRowCount: 1, rows, columnWidths: [1.8, 1, 1, 1.15] }); // relative: the first column holds the longest labels // #endregion // #region art: the burette, the pH model of the curve and its drawing, labels set by MathJax // The curve's model: the charge balance of acetic acid and NaOH, solved for [H⁺] by bisection; // its 'readings' are the model at each volume plus a seeded ±0.02 of noise. function pH(v) { const [CB, VA, KA] = [0.100, 25.0, 1.75e-5]; // NaOH mol/dm³, the aliquot in cm³, acid's Ka const [a, b] = [(CB * mean) / (VA + v), (CB * v) / (VA + v)]; // acid and Na⁺, mol/dm³ let [lo, hi] = [0, 14]; for (let i = 0; i < 60; i++) { const mid = (lo + hi) / 2; const h = 10 ** -mid; if (h + b - 1e-14 / h - (a * KA) / (KA + h) > 0) lo = mid; else hi = mid; // + : too acid } return (lo + hi) / 2; } let seed = 28; // Mulberry32: the same noise on every run function 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 VOLUMES = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 18.5, 19, 19.5, 20, 20.5, 21, 21.5, 22, 22.5, 23, 23.5, 24, 26, 28, 30]; // cm³: every 2, and every 0.5 from 18 to 24 const measured = VOLUMES.map((v) => [v, Math.round((pH(v) + (rand() - 0.5) * 0.04) * 100) / 100]); const R = (x) => Math.round(x * 100) / 100; const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; // in mm, 10 px to the mm const line = (x1, y1, x2, y2, color, width, extra = '') => `<line x1="${R(x1)}" y1="${R(y1)}" ` + `x2="${R(x2)}" y2="${R(y2)}" stroke="${palette[color]}" stroke-width="${width}" ${extra}/>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${R(x)}" cy="${R(y)}" r="${r}" ` + `fill="${fill}" ${extra}/>`; // An SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts): the labels are // MathJax paths, vector in the PDF like the formulas in the text. renderMath needs // initMathEngine() resolved: called before, it returns no paths and the labels go missing. function tex(markup, x, y, size, anchor = 0, color = 'muted') { // anchor 0 left, .5 mid, 1 right const r = renderMath(markup, false, 100); // paths in MathJax units, 1000 to the em const k = size / 1000; return `<g transform="translate(${R(x - anchor * r.viewBox.width * k)} ${R(y)}) scale(${k})" ` + `fill="${palette[color]}">${r.paths.map((p) => `<path d="${p.d}"/>`).join('')}</g>`; } function burette() { // 34 mm × BAND: stand, clamp, burette, stopcock, a drop, the flask const [cx, rod, foot] = [14, 30, BAND - 3]; // tube axis, stand rod, top of the base plate const stroke = `stroke="${palette.slate}" stroke-width="0.45"`; let out = `<rect x="0" y="${foot}" width="34" height="3" fill="${palette.slate}"/>` + `<rect x="${rod - 0.6}" y="0" width="1.2" height="${foot}" fill="${palette.slate}"/>` + `<rect x="${cx + 3.4}" y="39.4" width="${rod - cx - 3.4}" height="1.2" ` + `fill="${palette.slate}"/><rect x="${cx - 4.2}" y="37.5" width="8.4" height="5" rx="0.8" ` + `fill="none" ${stroke}/>` + `<rect x="${cx - 3.2}" y="-1" width="6.4" height="65" fill="${palette.paper}" ${stroke}/>` + `<rect x="${cx - 2.75}" y="14" width="5.5" height="49.6" fill="${palette.rule}"/>`; for (let y = 4; y <= 62; y += 2) { // a tick every 2 mm, a long one every 10 out += line(cx - 3.2, y, cx - (y % 10 ? 1.6 : 0.2), y, 'slate', 0.25); } out += `<path d="M${cx - 1.5} 64v4l1 8h1l1 -8v-4z" fill="${palette.slate}"/>` + `<rect x="${cx - 6}" y="65.3" width="12" height="2.4" rx="1.2" fill="${palette.slate}"/>` + `<path d="M${cx} 78q1.4 2.2 0 3.4q-1.4 -1.2 0 -3.4z" fill="${palette.slate}"/>`; // The flask: a neck, then a cone down to the plate, pale pink below the LEVEL line (the // end point), with the deep pink a drop makes where it lands, before swirling clears it. const [NECK, SHOULDER, LEVEL, R_NECK, R_BASE] = [84, 88.5, 92.5, 3.6, 13.2]; const base = `L${cx - R_BASE} ${foot - 1.4}q-.7 1.4 .9 1.4h${2 * R_BASE - 1.8}q1.6 0 .9 -1.4`; const r = R_NECK + ((LEVEL - SHOULDER) / (foot - 1.4 - SHOULDER)) * (R_BASE - R_NECK); const glass = `M${cx - R_NECK} ${NECK}V${SHOULDER}${base}L${cx + R_NECK} ${SHOULDER}V${NECK}`; const pink = (d, alpha) => `<path d="${d}" fill="${palette.phenol}" fill-opacity="${alpha}"/>`; const bell = (w, h) => `M${cx - w / 2} ${LEVEL}h${w}q-.1 ${R(h * 0.8)} ${-w / 2} ${h}` + `q${0.1 - w / 2} ${R(-h * 0.2)} ${-w / 2} ${-h}z`; // a cloud hanging from the surface return svg(34, BAND, out + `<path d="${glass}z" fill="${palette.paper}"/>` + pink(`M${R(cx - r)} ${LEVEL}${base}L${R(cx + r)} ${LEVEL}z`, 0.35) // a shade over the band + pink(bell(10, 6), 0.5) + pink(bell(5, 4), 1) + `<path d="${glass}" fill="none" ${stroke}/>`); } const BURETTE = { id: 'burette', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'burette.svg', width: 34 * 10, height: BAND * 10 }, altText: t({ es: 'Una bureta gotea sobre un erlenmeyer de líquido rosa pálido.', en: 'A burette drips into a conical flask of pale pink liquid.' }) }; function curve() { // MEASURE × 92 mm: pH 2–13 against 0–30 cm³ const [x0, y0, w, h] = [12, 4, MEASURE - 16, 74]; const X = (v) => x0 + (v / 30) * w; const Y = (p) => y0 + h - ((p - 2) / 11) * h; const half = mean / 2; const [p10, p12] = [10, 12].map((v) => measured.find(([x]) => x === v)[1]); const pka = p10 + ((p12 - p10) * (half - 10)) / 2; // read at half the equivalence volume let out = `<rect x="${x0}" y="${R(Y(10))}" width="${w}" height="${R(Y(8.2) - Y(10))}" ` + `fill="${palette.blush}"/>`; for (let p = 2; p <= 13; p += 1) { out += line(x0, Y(p), x0 + w, Y(p), 'rule', p % 2 ? 0.12 : 0.25); if (p % 2 === 0) out += tex(String(p), x0 - 2, Y(p) + 1.2, 3.4, 1); } for (let v = 0; v <= 30; v += 5) { out += line(X(v), y0 + h, X(v), y0 + h + 1.2, 'muted', 0.25) + tex(String(v), X(v), y0 + h + 5.2, 3.4, 0.5); } out += line(x0, y0 + h, x0 + w, y0 + h, 'muted', 0.35); const model = Array.from({ length: 301 }, (_, i) => [X(i / 10), Y(pH(i / 10))]); out += `<path d="${model.map(([x, y], i) => `${i ? 'L' : 'M'}${R(x)} ${R(y)}`).join('')}" ` + `fill="none" stroke="${palette.ink}" stroke-width="0.45"/>`; out += line(X(half), Y(2), X(half), Y(pka), 'slate', 0.3, 'stroke-dasharray="1 0.8"') + line(x0, Y(pka), X(half), Y(pka), 'slate', 0.3, 'stroke-dasharray="1 0.8"') + tex('\\mathrm{p}K_\\mathrm{a}', x0 + 1.5, Y(pka) - 1.6, 3.6, 0, 'slate'); out += measured.map(([v, p]) => dot(X(v), Y(p), 0.75, palette.phenol)).join(''); out += dot(X(mean), Y(pH(mean)), 2.2, 'none', `stroke="${palette.phenol}" stroke-width="0.4"`) + tex(t({ es: '\\text{equivalencia}', en: '\\text{equivalence}' }), X(mean) + 3.2, Y(pH(mean)) + 1, 3.4, 0, 'phenol'); return svg(MEASURE, 92, out + tex('\\mathrm{pH}', x0 - 2, y0 - 1, 3.8, 1, 'ink') + tex('V_\\mathrm{NaOH}\\,/\\,\\mathrm{cm^3}', x0 + w, y0 + h + 11, 3.8, 1, 'ink')); } // #endregion const resources = [BURETTE, // the head's picture: uncited, so never placed in the text { id: 'lecturas', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, table: { model: table }, placement: { position: 'here' }, // set where ::resource{id="lecturas"} stands caption: t({ es: `Lecturas de la bureta: 25,0 ${units.cm3} de vinagre diluido frente a NaOH ` + `0,100 ${units.conc}.`, en: `Burette readings, titrating 25.0 ${units.cm3} of diluted ` + `vinegar with 0.100 ${units.conc} NaOH.` }), // one line: at two, the English one broke // between 'dm' and its '−3' (gotcha: ragged-run-punctuation) note: t({ es: `Lecturas sintéticas, con la apreciación de la bureta: 0,05 ${units.cm3}.`, en: `Synthetic readings, taken to the nearest 0.05 ${units.cm3}, as a burette is read.` }) }, { id: 'curva', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'curve.svg', width: MEASURE * 10, height: 92 * 10 }, // 10 px to the mm caption: t({ es: `Curva de valoración de 25,0 ${units.cm3} de vinagre diluido con NaOH ` + `0,100 ${units.conc}: lecturas del pH-metro (puntos) y el cálculo con *K*~a~ = ` + `1,75 × 10^−5^ (línea). La banda rosa es el viraje de la fenolftaleína, de pH 8,2 a 10,0.`, en: `Titration curve of 25.0 ${units.cm3} of diluted vinegar with 0.100 ${units.conc} NaOH: ` + `pH meter readings (dots) and the model with *K*~a~ = 1.75 × 10^−5^ (line). The pink band ` + 'is the range over which phenolphthalein turns, pH 8.2 to 10.0.' }), note: t({ es: 'Lecturas sintéticas.', en: 'Synthetic readings.' }), altText: t({ es: 'El pH sube despacio hasta unos 18 cm³ y salta de 6 a 11 cerca de 21 cm³.', en: 'The pH rises slowly to about 18 cm³, then leaps from 6 to 11 near 21 cm³.' }) }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages paint, loaded before the first build (gotcha: fonts-first) 'Inria Serif': ['400', '400i', '700'], 'Inria Sans': ['400', '400i', '700'], // 400i: the K of K~a~ in Figure 1's caption 'Sometype Mono': ['400', '500', '600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('burette.svg', burette()); // for the canvas, and kept as bytes for the PDF await loadSvg('curve.svg', curve()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ es: 'Informe de laboratorio', en: 'Lab report' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // text in the Fontsource faces; formulas and figures as vector paths
Kit · core, fonts, viewer, pdf, images: igual a totes les receptes · 310 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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

#Acoloreix la nota de seguretat en lloc d'emmarcar-la

Un fons amb el rosa del viratge substitueix el marc, i el requadre continua amb un sol recurs gràfic.

   box('safety', t({ es: 'Seguridad', en: 'Safety' }), {
-    border: { enabled: true, color: col('phenol'), width: pt(0.75) },
+    backgroundEnabled: true, background: col('blush'),

#Numera taules i figures per apartat

Els resultats passen a ser la taula 5.1 i la figura 5.1, amb el compte reiniciat a cada apartat.

-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
-  ? { ...type, captionStyle: { position: 'above' } } : type)); // a table's caption goes on top
+const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
+  numberingTemplate: '{h2}.{n}', resetOn: 'h2',
+  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));

Errors freqüents

Error freqüent

Les matemàtiques necessiten postext?bundle i initMathEngine()

Les fórmules carregades des de https://esm.sh/postext es pinten com a caixes grises sense cap error. Importa tots els símbols des de https://esm.sh/postext?bundle, sense barrejar mai les dues URL, i espera initMathEngine() abans de la primera composició. Matemàtiques →

Error freqüent

Els desplaçaments negatius relatius al contenidor no pinten res

El text de disseny d'amplada automàtica es limita al seu contenidor, de manera que un desplaçament negatiu respecte al contenidor el treu fora i no es pinta res. Ancora aquests elements a la pàgina o a la sang amb desplaçaments explícits en mm, o dona'ls una amplada fixa. Ancoratge d'elements de disseny →

Error freqüent

Les cel·les combinades necessiten hiddenBy: fes servir mergeCells

Les cel·les es col·loquen segons la seva posició a la fila, així que una cel·la combinada necessita cel·les de farciment marcades amb hiddenBy on s'estén; ometre-les, com en HTML, desplaça totes les columnes següents. Combina cel·les amb mergeCells. Taules a partir de dades →

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

Tradueix Figura i Taula amb defaultResourceTypes(locale)

El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →

Error freqüent

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

Una figura en línia porta aire a sobre, però no a sota

A postext 1.4.1, una figura que ::resource col·loca amb la posició 'here' porta una línia de la retícula de base d'aire a sobre, però a sota només el que sobra quan la línia següent s'ajusta a la retícula: des d'una línia sencera fins a gairebé res, de manera que el paràgraf següent pot començar enganxat al peu. Posa :::space{lines=1} després de la línia ::resource; com tot :::space, es descarta al capdamunt d'una columna. Figures just aquí →

Error freqüent

El text en bandera pot deixar sola la puntuació al costat d'una negreta o un :ref

A postext 1.4.1, el text que no va justificat (cossos de requadre, paràgrafs en bandera) pot partir la línia entre una negreta, una cursiva o un :ref i el signe de puntuació que hi va enganxat: un punt pot obrir la línia següent i el «(» d'una remissió pot tancar l'anterior. El text justificat no es parteix mai en aquest punt. Revisa els requadres de cada edició i reescriu la frase afectada perquè aquest tram quedi a mitja línia. Negreta, cursiva i els seus colors →

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

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

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 →

Comprovació del Sandbox · invalidMath

LaTeX no vàlid

Per què. MathJax no ha pogut interpretar una fórmula, de manera que en el seu lloc s'imprimeix un marcador vermell.

Solució. Corregeix el TeX, o escriu \$ on el signe de dòlar no obre una fórmula. Documentació →

Avís del Markdown · unclosedMath

Delimitador matemàtic sense tancar

Per què. Un $ obre una fórmula en línia que no es tanca mai; sol ser el d'un preu.

Solució. Escriu \$ per a un dòlar literal, o tanca la fórmula a la mateixa línia. Documentació →

  • Una equació destacada no queda unida a la frase que la presenta, i una frase que acaba en «así que» pot tancar una pàgina amb l'equació ja a la següent. El text de les dues edicions està ajustat perquè l'equació de [OH⁻] tanqui la pàgina 1 sota la seva frase.
  • Els glifs de TeX són els de MathJax, no els d'Inria Serif, i una equació pesa una mica menys que el text que l'envolta. Deixa les fórmules que van dins d'una frase com a marques ~ i ^, i reserva TeX per a les línies que el necessiten.
  • A la 1.4.1, un disseny de títol que es queda a la columna es retalla pel límit superior de la columna. L'estil report porta span: 'page' en aquest informe d'una sola columna només per això: sense aquesta opció, els 24 mm de dalt de la banda i de la bureta surten en blanc.
  • Els peus es componen en bandera, i a la 1.4.1 una línia en bandera es pot partir entre una paraula i el seu superíndex. Quan ocupava dues línies, el peu de la taula 1 de l'edició anglesa es tallava entre mol·dm i −3; ara cap en una línia a les dues edicions. Revisa cada peu que porti una unitat.
  • Les fonts web no arriben a un SVG dibuixat com a imatge, així que les xifres dels eixos i els seus rètols a la corba són contorns de MathJax: tex() demana a renderMath els traçats de cada rètol i els col·loca a l'SVG. Al PDF continuen sent vectorials i tenen els mateixos glifs que les equacions.

Crèdits

Text
Text original, CC BY 4.0
Fonts
Inria Serif (SIL OFL 1.1) · Inria Sans (SIL OFL 1.1) · Sometype Mono (SIL OFL 1.1)
SandboxPDF