跳到主要内容
食谱编号28

排版食谱 · 第2章 · 字体与文字

实验报告:化学式、上下标和滴定曲线

一份四页A4化学实验报告,正文中的化学式和单位用~和^标记,反应式和方程仍交给TeX。

本页内容
  • 西班牙文样例:尚无中文版本
  • 成品尺寸210 × 297 mm
  • 1栏
  • Inria Serif 10.5/15
  • Inria Sans
  • Sometype Mono
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时19 ms
  • 241行代码

成品一览

三名12年级学生用氢氧化钠滴定苹果醋,测定其中醋酸的含量,并把实验写成化学课的实验4报告。报告共四页A4,单面打印。55 mm的左页边距容纳章节编号,所以每个标题和段落都从同一条边开始。滴定管和表头是石板灰,唯一的颜色是酚酞的粉色:报告头的色带和曲线上指示剂的变色范围用浅粉,章节编号和pH读数用深粉。正文中的化学式和单位用正文字体排,带上下标;反应式和三个行间方程由TeX排。表1和图1的曲线都由pen中的同一组滴定管读数计算得出。

这道食谱解答

  • 不用完整的数学公式,怎样写上标、下标和化学式?
  • 怎样排数学公式(行内、行间、编号公式),并在PDF中保持矢量?
  • 怎样给标题编号(1、1.1、1.1.1),并给每一级设置不同样式?
  • 怎样加一幅带编号题注的图,并在正文里引用它(“见图3.2”)?
  • 怎样做一个有表头行、合并单元格、栏宽和逐格对齐的表?
  • 怎样让“图”和“表”的标签使用文档的语言?
  • 怎样排参考文献或术语表(悬挂缩进、较小字号)?

简短回答

script.js · 第39–54行在完整代码中
// 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.

用料

类型
Inria Serif, Inria Sans, Sometype Mono(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 化学式写成文字,方程交给TeX

代码就是上面的简短回答。~…~和^…^用正文自己的字体,把一段文字排成正文字号的58%,所以CH~3~COOH、OH^−^(其中的负号是U+2212)和cm^3^保持与所在句子相同的字重和颜色。这些标记在题注和表格单元格里也有效,那里不能用TeX。用mhchem的\ce{…}写的反应式和各个方程是放在$$之间的TeX。数学引擎只随?bundle构建提供,所以每个符号都从这个构建导入。math.fontSizeScale保持为1:在1.4.1中,公式的x高度是字号的一半,而Inria Serif的x高度是0.495 em,所以公式和正文的小写字母高度一致。

#2 · 把章节编号悬挂在页边

script.js · 第58–79行在完整代码中
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
];

每一级标题的设计是一个含两个元素的槽位:位于正文边缘的标题,以及以left-of锚定在它左侧4 mm处的{number},放在一个33 mm宽、右对齐的框里。这个框需要固定宽度。自动宽度会被限制在栏左侧剩余的空间内,而栏左侧没有空间,于是框缩到0 mm,标题会多出一到三行。该级的fontSize和lineHeight只决定标题在文字流中占多少空间,即一行15 pt。字体由设计决定:{2}编号的章节用13 pt粉色,{2}.{3}编号的小节用与正文同字号的灰色。

#3 · 用H1绘制报告头

script.js · 第83–117行在完整代码中
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

报告唯一的H1使用report标题样式,它把标题替换成一个绘制的报告头:横跨页面顶部的粉色色带、滴定管、课程名、像章节编号那样悬挂实验编号的标题,以及署名。署名的值来自标题的属性和前置元数据的publishDate。色带是一个框,框会计入标题预留的高度,所以即使没有minHeight,正文也会从色带底部下方、距页面顶端104 mm处开始。minHeight在色带下方8 mm处设了一个下限;加上H1的下外边距和15 pt网格,摘要上方留出15 mm空白,而不是4.7 mm。

#4 · 用报告的语言命名表和图

script.js · 第138–147行在完整代码中
// 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)在西班牙语版中给出Tabla和Figura,仅靠locale的话它们会保持英文。它们的{h1}.{n}模板在这里只印出1,因为报告的H1不编号,空的{h1}会连同它的点一起省略。表的题注放在表格上方,这是报告的惯例。正文用:ref{id="lecturas" style="full"}引用表和图,颜色和字重与周围文字相同。

#5 · 用同一组读数计算表格和曲线

script.js · 第297–315行在完整代码中
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

四对滴定管读数给出表1的每个单元格以及平均值和标准差,平均体积又决定了图1所画pH模型的等当点。第一行是表头(headerRowCount: 1),columnWidths给标签栏分配1.8份宽度。每个单元格带有自己的align,mergeCells让平均值和标准差的标签横跨前三栏,右对齐靠向各自的数值。数字用Sometype Mono排,小数点因此对齐。栏头写成“量/单位”的形式,例如V~b~ used / cm^3^,标记方式与正文相同。

#6 · 以悬挂缩进的参考文献收尾

script.js · 第165–170行在完整代码中
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) },
];

一个:::paragraphs{style="references"}围栏把段落样式应用到每条参考文献:9/12.5 pt,齐左,6 mm悬挂缩进,条目间距4 pt。在1.4.1中,齐左段落从不断词,所以像Fundamentals of Analytical Chemistry这样的长书名只在词与词之间断开。版权页信息有自己的样式:Inria Sans,7.4 pt,上方空一行15 pt。

完整食谱

沙盒
// ═══ 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`---
Markdown样例 · 97行 · 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
工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行// ─── 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 ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗

变化

#给安全须知加底色而不是边框

用终点的粉色填充代替边框,这个框仍然只用一种装饰手段。

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

#按章节给表和图编号

结果部分的表和图变成Table 5.1和Figure 5.1,每节重新计数。

-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id === 'table'
-  ? { ...type, captionStyle: { position: 'above' } } : type)); // 表的题注放在上方
+const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
+  numberingTemplate: '{h2}.{n}', resetOn: 'h2',
+  ...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));

常见问题

易错点

数学公式需要https://esm.sh/postext?bundle和initMathEngine()

从https://esm.sh/postext导入时,公式会画成灰色方框,而且不报错。所有符号都从https://esm.sh/postext?bundle导入,不要混用两个URL,并在首次构建前await initMathEngine()。 数学 →

易错点

相对容器的负偏移什么也渲染不出来

自动宽度的设计文本被限制在容器内,所以相对容器的负偏移会把它推出去,结果什么也不渲染。把这类元素用明确的mm偏移锚定到页面或出血上,或者给它们一个固定宽度。 锚定设计元素 →

易错点

合并单元格需要hiddenBy占位:使用mergeCells

单元格按其在行数组中的位置排布,所以合并单元格延伸到的地方需要标记hiddenBy的占位单元格;像HTML那样省略它们,后面的每一列都会错位。用mergeCells来合并。 由数据生成的表 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →

易错点

不换行空格仍然会断行

在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →

易错点

行内图上方有间距,下方没有

在postext 1.4.1中,::resource以位置'here'排入的图,上方有一个网格行的间距,下方却只有下一行对齐基线网格时剩下的空间:可能是整整一行,也可能几乎没有,于是下一段可能紧贴在题注下面开始。在::resource这一行之后加:::space{lines=1};和其他:::space一样,它在栏顶会被丢弃。 图就放在这里 →

易错点

齐左文字可能让标点与粗体或:ref分开

在postext 1.4.1中,不两端对齐的文字(框内正文、齐左段落)可能在粗体或斜体片段(或:ref)与紧挨着它的标点之间断行:句号可能出现在下一行行首,引用前的'('可能留在上一行行尾。两端对齐的文字从不在这里断行。逐一检查每个版本中的框,遇到这种情况就改写句子,让这个片段落在行中间。 粗体、斜体及其颜色 →

易错点

替换调色板时,设计元素和引用颜色不会跟着变

postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

只有8种语言区域能断词,且须代码完全一致

断词支持en-us、es、fr、de、it、pt、ca和nl,须完全匹配:'es-ES'或其他任何语言都会悄悄退回美式英语。 断词与文档语言 →

易错点

消除孤字时收紧的字距可能根本不绘制

在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →

易错点

frontmatter的每个值都加引号

YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

沙盒检查 · invalidMath

LaTeX无效

原因. MathJax无法解析某个公式,因此印出了一个红色占位符。

解决. 改正TeX,或把不是用作数学公式的美元符号转义为\$。 文档 →

Markdown警告 · unclosedMath

数学分隔符未闭合

原因. 某个$开启了行内公式却没有闭合,通常是因为它属于一个价格。

解决. 字面的美元符号写成\$,或在同一行内闭合公式。 文档 →

  • 行间公式不会和引出它的那一行排在一起,所以一句以“so”结尾的引导语可能落在页末,而方程到了下一页。两个版本的文字篇幅都经过调整,让第1页以[OH⁻]方程及其引导句结束。
  • TeX的字形是MathJax自带的,不是Inria Serif,所以方程看起来比周围文字略细。句子里的化学式用~和^标记,TeX只留给真正需要它的行。
  • 在1.4.1中,保留在栏内的标题设计会在栏的上边缘被裁切。report样式在这份单栏报告中设置span: 'page',原因仅此一个:不设的话,色带和滴定管顶部的24 mm会印成白色。
  • 题注按齐左排,而在1.4.1中,齐左的行可能在单词和它的上标之间断开。表1的题注排成两行时,在mol·dm和−3之间断开了,所以把它删减成一行;检查每个带单位的题注。
  • 网络字体到不了作为图像绘制的SVG,所以曲线的刻度标签和坐标轴标题是MathJax轮廓:tex()向renderMath请求每个标签的路径,并把它们放进SVG。它们在PDF里保持为矢量,所用字形与方程相同。

致谢

文本
原创文字, CC BY 4.0
字体
Inria Serif (SIL OFL 1.1) · Inria Sans (SIL OFL 1.1) · Sometype Mono (SIL OFL 1.1)
沙盒PDF