انتقل إلى المحتوى الرئيسي
الوصفة رقم 28

دليل الوصفات · الفصل 2 · الحروف والنص

تقرير مختبر: صيغ وأحرف سفلية وعلوية ومنحنى معايرة

تقرير كيمياء من أربع صفحات على A4 يعلّم الصيغ والوحدات داخل النص بـ ~ و^ ويحتفظ بـ TeX للتفاعل والمعادلات.

في هذه الصفحة
المُخرَج
Canvas · PDF
المستوى
متقدم
Postext
اختُبرت مع Postext 1.4.1
تتطلب ≥ 1.4.1 · postext-pdf ≥ 1.4.1
الترخيص
حُدّثت في 26 سبتمبر 2026
الكود MIT · النص CC BY 4.0
  • نموذج باللغة الإسبانية: لا توجد طبعة عربية بعد
  • مقاس القص 210 × 297 مم
  • عمود واحد
  • Inria Serif 10.5/15
  • Inria Sans
  • Sometype Mono
  • 4 صفحات
  • المستوى
  • Postext 1.4.1
  • أُخرجت في 19 ms
  • أسطر الكود: 241

باختصار

تقرير مدرسي في الكيمياء من أربع صفحات عن فحص الخل. يبيّن كيف تُكتب الصيغ الكيميائية والوحدات بأرقام صغيرة مرفوعة ومنخفضة، مع بضع معادلات كاملة.

ما الذي ستنضده

ثلاثة طلاب في الصف الثاني عشر يعايرون خل التفاح بهيدروكسيد الصوديوم ليعرفوا كم يحتوي من حمض الأسيتيك، ويكتبون العمل بوصفه التجربة العملية 4 في مقرر الكيمياء. يقع التقرير في أربع صفحات A4 مطبوعة على وجه واحد. هامشه الأيسر البالغ 55 مم يحمل أرقام الأقسام، فيبدأ كل عنوان وكل فقرة على الحافة نفسها. السحاحة ورأس الجدول بلون رمادي أردوازي، واللون الوحيد هو وردي الفينولفثالين، فاتحًا لشريط رأس التقرير ولنطاق الكاشف على المنحنى، وداكنًا لأرقام الأقسام وقراءات الأس الهيدروجيني. الصيغ والوحدات في النص الجاري منضّدة بخط النص بأحرف سفلية وعلوية؛ ويُنضّد TeX التفاعل وثلاث معادلات معروضة. ويُحسب الجدول 1 ومنحنى الشكل 1 كلاهما من مصفوفة واحدة لقراءات السحاحة في المثال.

تجيب هذه الوصفة عن

  • كيف أكتب الأرقام العلوية والسفلية والصيغ الكيميائية من دون محرّك الرياضيات الكامل؟
  • كيف أنضد الرياضيات (مضمّنة، ومعروضة، ومعادلات) وأبقيها متجهية في ملف 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. أما التفاعل، المكتوب بـ \ce{…} من mhchem، والمعادلات فهي TeX بين $$. لا يُشحن محرّك الرياضيات إلا في بناء ?bundle، لذا يُستورد كل رمز من ذلك البناء. ويبقى math.fontSizeScale عند 1: في 1.4.1 ارتفاع الحرف x في الصيغة نصف حجم الخط، وارتفاع x في Inria Serif يبلغ 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
];

تصميم كل مستوى عنوان خانة فيها عنصران: العنوان على حافة النص، و{number} مثبّتًا left-of العنوان، على بعد 4 مم، في إطار عرضه 33 مم محاذى إلى اليمين. يحتاج الإطار إلى عرض ثابت. فالعرض التلقائي يُقيَّد بالمساحة التي يتركها العمود على يساره، وهو لا يترك شيئًا، فينكمش الإطار إلى 0 مم ويزيد كل عنوان من سطر إلى ثلاثة أسطر. لا يضبط fontSize وlineHeight في المستوى إلا المساحة التي يشغلها العنوان في التدفق، سطرًا واحدًا من 15 نقطة. أما التصميم فيحدد الحروف: الأقسام مرقّمة {2} بالوردي بمقاس 13 نقطة، والأقسام الفرعية {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 في البيانات التمهيدية (frontmatter). الشريط إطار، والإطارات تُحتسب ضمن الارتفاع الذي يحجزه العنوان، لذا كان النص سيبدأ تحت أسفل الشريط حتى من دون minHeight، على بعد 104 مم من أعلى الصفحة. تضع minHeight حدًّا أدنى على بعد 8 مم تحت الشريط؛ ومع الهامش السفلي لعنوان H1 وشبكة 15 نقطة يبقى 15 مم من البياض فوق الملخص بدلًا من 4.7 مم.

#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 في الطبعة الإسبانية، وكانت اللغة المحلية وحدها ستتركهما بالإنجليزية. ويطبع قالبهما {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. الصف الأول هو الرأس (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 نقطة، بحافة حرّة، مع إزاحة معلّقة 6 مم و4 نقاط بين المداخل. في 1.4.1 لا تُقسَّم الكلمات بالواصلة في الفقرات غير المضبوطة أبدًا، لذا لا ينكسر عنوان طويل مثل Fundamentals of Analytical Chemistry إلا بين الكلمات. ولبيانات الطبع نمط خاص بها، Inria Sans بمقاس 7.4 نقطة، مع سطر واحد من 15 نقطة فوقه.

الوصفة كاملة

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`---
نموذج 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 المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على 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)); // 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' } }) }));

أخطاء شائعة

خطأ شائع

الرياضيات تحتاج إلى https://esm.sh/postext?bundle وinitMathEngine()

الصيغ المستوردة من https://esm.sh/postext ترسم مربعات رمادية دون أي خطأ. استورد كل الرموز من https://esm.sh/postext?bundle، دون أن تخلط بين العنوانين أبدًا، وانتظر initMathEngine() قبل البناء الأول. الرياضيات →

خطأ شائع

الإزاحات السالبة نسبةً إلى الحاوية لا ترسم شيئًا

يُقيَّد نص التصميم ذو العرض التلقائي بحاويته، فالإزاحة السالبة عن الحاوية تدفعه خارجها فلا يُرسَم شيء. ثبّت هذه العناصر على الصفحة أو على النزف بإزاحات صريحة بالمليمتر، أو أعطها عرضًا ثابتًا. تثبيت عناصر التصميم →

خطأ شائع

الخلايا المدمجة تحتاج إلى خلايا نائبة hiddenBy: استخدم mergeCells

تُوزَّع الخلايا بحسب موضعها في مصفوفة الصف، فالخلية المدمجة تحتاج إلى خلايا نائبة معلَّمة بـ hiddenBy حيث تمتد؛ وحذفها، كما يفعل HTML، يزيح كل الأعمدة التالية. ابنِ الدمج بـ mergeCells. جداول من البيانات →

خطأ شائع

النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب

يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →

خطأ شائع

ترجم Figure/Table بـ defaultResourceTypes(locale)

يضبط 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() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →

فحص Sandbox · invalidMath

LaTeX غير صالح

السبب. لم يتمكن MathJax من تحليل صيغة، فطُبع بدلها عنصر نائب أحمر.

الحل. صحّح شيفرة TeX، أو اهرب علامة دولار لم تُقصد بها الرياضيات بكتابة \$. التوثيق →

تحذير Markdown · unclosedMath

محدِّد رياضيات غير مغلق

السبب. علامة $ تفتح رياضيات مضمّنة لا تُغلق أبدًا، وعادةً لأنها جزء من سعر.

الحل. اكتب \$ لعلامة دولار حرفية، أو أغلق الصيغة في السطر نفسه. التوثيق →

  • لا تبقى الصيغة المعروضة مع السطر الذي يمهّد لها، فقد تختم جملةٌ تمهيدية تنتهي بكلمة «so» صفحةً وتأتي معادلتها في الصفحة التالية. نصّ الطبعتين مُعدّ بحيث تنتهي الصفحة 1 بمعادلة [OH⁻] تحت جملتها.
  • رموز TeX هي رموز MathJax نفسها، لا رموز Inria Serif، لذا تبدو المعادلة أخف قليلًا من النص حولها. أبقِ الصيغ داخل الجمل علامات ~ و^، واحتفظ بـ TeX للأسطر التي تحتاج إليه.
  • في 1.4.1 يُقصّ تصميم العنوان الذي يبقى داخل العمود عند الحافة العليا للعمود. ولهذا السبب وحده يضبط النمط report القيمة span: 'page' في هذا التقرير ذي العمود الواحد: من دونها يُطبع الجزء العلوي من الشريط والسحاحة، بارتفاع 24 مم، أبيض.
  • يُنضَّد التعليق بحافة حرّة، وفي 1.4.1 قد ينكسر السطر غير المضبوط بين كلمة وحرفها العلوي. حين كان تعليق الجدول 1 في سطرين انكسر بين mol·dm و−3، لذا اختُصر إلى سطر واحد؛ راجع كل تعليق يحمل وحدة.
  • لا تصل خطوط الويب إلى SVG مرسوم صورةً، لذا فتسميات تدريجات المنحنى وعناوين محوريه مخططات MathJax: تطلب tex() من renderMath مسارات كل تسمية وتضعها في SVG. وتبقى متجهية في PDF وتستخدم الرموز نفسها التي تستخدمها المعادلات.

الحقوق

الوصفة
Ignacio Ferro
النص
نص أصلي, CC BY 4.0
الخطوط
Inria Serif (SIL OFL 1.1) · Inria Sans (SIL OFL 1.1) · Sometype Mono (SIL OFL 1.1)
SandboxPDF