باختصار
فصل من كتاب مدرسي عن الجبال، بعمودين وسبع صور مرقّمة. يبيّن كيف تنتقل كل صورة إلى أول مكان شاغر بعد أن يذكرها النص أول مرة.
ما الذي ستنضده
الفصل الثاني من Mountain Landforms، كتاب مدرسي في علم أشكال سطح الأرض على صفحة بقياس 200 × 250 مم. يتدلى شريط أزرق يحمل رقم الفصل من رأس صفحة الافتتاح بجوار لوح العنوان الأزرق الجليدي؛ ويليه عمودان مضبوطان بخط Faustina. الأشكال السبعة، لوحات مولّدة بنماذج الانتشار ومسمّاة بالشيفرة، مرقّمة بترتيب أول ذكر لها. ستة منها تطفو إلى أول موضع شاغر يسمح به موضعها، عدًّا من الفقرة التي تستشهد بها أول مرة. الشكل 2.1، auto، يقع في أسفل صفحة الافتتاح؛ و2.3 يفتتح العمود الأيمن من الصفحة 28 و2.2 يملأ أسفل تلك الصفحة؛ و2.4 و2.5 يفتتحان عمودي الصفحة 29. ويُنضَّد الشكل 2.6 حيث يضمّنه النص. أما الشكل 2.7، عنصر عائم top مستشهَد به في الصفحة 30، فكان سينتظر الصفحة 31، لكن العنصر العائم لا يغادر فصله، فيذهب إلى أسفل الصفحة 30.
تجيب هذه الوصفة عن
- كيف أضيف شكلًا بتعليق مرقّم وأحيل إليه في النص («انظر الشكل 3.2»)؟
- كيف أتحكم في موضع الشكل: أعلى الصفحة، أو بعرض العمودين، أو هنا تمامًا، أو في الهامش؟
- كيف أحصل على تسميتي «شكل» و«جدول» بلغة مستندي؟
- كيف أضيف صورًا وجداول من الشيفرة (موارد) بدل ![]() في Markdown؟
الجواب المختصر
// In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1.
// Captions, credits and alt texts come from content.figures.<lang>.md.
const figure = (id, height, placement) => {
if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`);
const [caption, note, altText] = TEXTS[id];
// An SVG fills the width of its slot (a column or the text block, or a fraction of
// either), so its width and height only give its shape.
const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1);
return { id, typeId: 'figure', kind: 'svg', caption, note, altText,
svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 };
};
// In any order: the first mention of each one in the text, a :ref or a ::resource line,
// decides its number.
const resources = [
// Cited on the opener page: 'auto' may take that page's foot band, where 'top'
// could only open the next page (gotcha: top-float-next-page).
figure('valleys', 56, { position: 'auto', span: 'page' }),
// Across both columns, but only in a foot band: the page it is cited on, if both
// columns still have room there, else the foot of the next page.
figure('profile', 60, { position: 'bottom', span: 'page' }),
// A column figure that takes only a column head: the next one still empty after its
// citation, here the right column of the same page, above the text that follows it.
figure('cirque', 48, { position: 'top' }),
// Cited in the same sentence, the two take the next two column heads, side by side.
figure('abrasion', 48, { position: 'top' }),
figure('plucking', 48, { position: 'top' }),
// No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline
// figure gets a grid line above it but only the grid snap below, so the Markdown follows
// it with :::space{lines=1} (gotcha: here-figure-no-space-after).
figure('roche', 42, { position: 'here' }),
// A band of its own, half the text width and centred. It is cited on the chapter's last
// page, where a 'top' float would wait for the next page; a float cannot leave its
// chapter, so this one goes to the foot of the last page. A float is queued where its
// citing paragraph starts, so that paragraph starts on the last page
// (gotcha: float-queues-at-paragraph).
figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }),
];
المكونات
- الميزات
- استشهادات تضع الأشكالموضع الأشكالتعليقات مرقّمةأشكال في هذا الموضع بالضبطحواجز العناصر العائمة«شكل» و«جدول» بلغتكنمط التعليقسطور المصدر والحقوقالأشكال والجداول بوصفها مواردأنواع موارد مخصّصةصفحات افتتاح مصمَّمةشريط فصل بعرض الصفحةسمات العنوانالعناوين المرقّمةتقسيم الكلمات ولغة المستندالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةلوحة ألوان دلاليةمسافة عمودية صريحةموازنة الأعمدة
- تستخدم أيضًا
- أنماط الفقرات
- الخطوط
- Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1)
- الأصول
abrasion-1080.jpgcirque-1080.jpgmoraines-1080.jpgplucking-1080.jpgprofile-1400.jpgroche-1080.jpgvalleys-1536.jpg- Figure 2.1: a V-shaped river valley and a U-shaped glacial valley (Generated With Diffusion Models, أصلي)
- Figure 2.2: long profile of a valley glacier (Generated With Diffusion Models, أصلي)
- Figure 2.3: a glacial cirque in section (Generated With Diffusion Models, أصلي)
- Figure 2.4: abrasion at the base of the ice (Generated With Diffusion Models, أصلي)
- Figure 2.5: plucking on a rock step (Generated With Diffusion Models, أصلي)
- Figure 2.6: a roche moutonnée (Generated With Diffusion Models, أصلي)
- Figure 2.7: lateral, medial and terminal moraines in plan (Generated With Diffusion Models, أصلي)
طريقة التحضير
#1 · لوحة ألوان واحدة للصفحات والأشكال
const palette = {
ink: '#1b2227', // text: a cold near-black
glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water
ice: '#e3f1f8', // the opener slab
rock: '#5b5a57', // bedrock in the drawings
moss: '#7d8f4e', // valley floors and pines
rule: '#c6d3db', // the hairline under the running heads
muted: '#5d6a72', // running heads, credit notes, the colophon
paper: '#ffffff',
};
// A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read
// the hex, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps
// that second blue off the page.
{ id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } },
];
كل لون في الإعداد مربوط بأحد هذه المداخل، وقد وُلّدت اللوحات بالرمادي الأردوازي والأزرق الجليدي والطحلبي نفسها، فتجلس الأشكال في ألوان الصفحة. لكن اللوحة تحتفظ بألوانها: قيمة جديدة لـglacier تغيّر الشريط واللوح والتسميات، لا الجليد في الأشكال. واللون المميِّز داكن بما يكفي للحروف الصغيرة: 5.2:1 على الأبيض لتسميات التعليقات والاستشهادات، و4.5:1 على لوح الجليد للعنوان التمهيدي. كل شكل ملف SVG يضمّن لوحته عنوان بيانات JPEG وينضّد تسمياته فوقها نصًّا بهالة بيضاء رفيعة، فتبقى حادة وتتبع لغة الطبعة. والتسميات بخط IBM Plex Sans Condensed، مضمَّن في كل SVG، لأن ملف SVG المحمَّل صورةً لا يصل إلى خطوط الويب في الصفحة.
#2 · سمِّ الأشكال بلغة القارئ
const captions = () => ({
// config.locale sets hyphenation, not captions (gotcha: resource-types-locale):
// 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter.
resourceTypes: defaultResourceTypes(LANG),
captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),
labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default
note: { color: col('muted'), gap: mm(0.6) }, // the credit line
},
});
يسمّي defaultResourceTypes(LANG) الأنواع بلغة النموذج: فالتعليقات تقرأ Figure 2.3 هنا وFigura 2.3 في الطبعة الإسبانية، حيث كان locale: 'es' وحده سيقسّم كلمات النص بالواصلة ويترك كل تعليق بالإنجليزية. ثم يأخذ كل استشهاد الصيغة التي تحتاجها جملته: case="lower" لـ*(fig. 2.1)، ومع style="full" لـ(figure 2.2)*؛ وstyle="number" بعد صيغة الجمع (figures 2.4 and 2.5)؛ وtext="…" لعبارة مثل the chapter’s first figure، التي لا تطبع رقمًا. تلك العبارة تحيل إلى شكل وُضع من قبل؛ ولو كانت أول ذكر له لظلت ترقّمه وتضعه.
#3 · شريط ولوح من جليد لصفحة الافتتاح
const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge },
offset: { x: mm(x), y: mm(y) },
...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
align: 'left', ...extra });
const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase',
letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em
// Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default).
const wrap = { overflow: 'wrap' };
const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length
const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon
const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst
const opener = {
enabled: true,
// At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines,
// so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab.
minHeight: mm(SLAB + 5),
slot: { elements: [
{ kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge
placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) },
{ kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head
placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) },
text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall:
at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot
{ fontWeight: 800, lineHeight: 1, align: 'center' }),
text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}',
es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier',
at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }),
text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W),
{ fontWeight: 800, lineHeight: 1.06, ...wrap }),
text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W),
{ italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }),
] },
};
const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads
const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier',
at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [ // outer corners, over a hairline; never on the opener
folio('verso-folio', 'even', 'top-left', OUTER),
head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
{ kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'),
thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE),
size: { width: 'fill', height: 'auto' } } },
] };
const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text
text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9),
{ fontWeight: 800, align: 'center', pages: 'opener' })] };
صفحة الافتتاح إطاران وأربعة نصوص: شريط يتدلى من الرأس وفي أسفله رقم الفصل، ولوح جليد يمتد إلى ما وراء الحافة الخارجية، والعنوان التمهيدي من السمة topic للعنوان، ثم العنوان والمقدمة متسلسلين تحته. يحجز minHeight اللوح و5 مم على الأقل تحته، مقرّبةً صعودًا إلى أسطر شبكة كاملة (نحو 7 مم هنا)، فيظل الشكل 2.1 يتسع في الشريط السفلي من الصفحة نفسها. وتجلس الترويسات في الزوايا الخارجية فوق خط رفيع بلون rule، ويُبعدها pages: 'body' عن صفحة الافتتاح، التي تأخذ بدلًا منها رقم صفحة في الذيل.
#4 · ارفض المعرّفات المجهولة قبل البناء
// An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1
// warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the
// mentions exactly as numbering and placement read them; an embed needs double quotes
// (gotcha: resource-double-quotes).
function checkFigures() {
const [named, embedded] = [[], new Set()];
for (const block of parseMarkdown(markdown)) {
if (block.type === 'resourceBlock' && block.resourceId) {
named.push(block.resourceId);
embedded.add(block.resourceId);
}
for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId);
}
const ids = resources.map((r) => r.id);
const types = new Set(captions().resourceTypes.map((type) => type.id));
const problems = [
...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`),
...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`),
...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`),
...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id))
.map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`),
...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`),
];
if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`);
}
يطبع :ref إلى معرّف لا يحمله أي مورد علامة «?»، والشكل الذي لا يذكره أحد لا يوضع أبدًا؛ ينبّه Sandbox إلى الأولى، لكن postext 1.4.1 لا يعطي المثال تحذيرًا عن أيٍّ منهما. يقرأ الفحص Markdown بـparseMarkdown، محلّل المحرّك نفسه، فيجد كل :ref و::resource سيقرؤهما الترقيم والتوضيع. ويحوّل معرّفًا مجهولًا أو مكرّرًا، أو شكلًا غير مستشهَد به، أو شكلًا here بلا سطر ::resource، أو نوعًا ناقصًا، إلى خطأ واحد قبل إخراج الصفحة الأولى.
#5 · ابدأ العدّ من حيث وصل الكتاب
const face = await labelFace();
for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId
const art = await dataUrl(PAINTINGS[id]);
await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" `
+ `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`));
}
// One chapter came before: figures number 2.1, 2.2… and the folios start at 27.
const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1
headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources, continuation }, config()), words);
showPages(doc, { title: t({ en: 'Figures that float to where you cite them',
es: 'Figuras que flotan hasta donde las citas' }) });
هذا هو الفصل الثاني من كتاب أطول، فيسجّل الاستمرار فصلًا واحدًا قبله: الأشكال المرقّمة {h1}.{n} تبدأ من 2.1، وأرقام الصفحات من 27. في الصفحة 28 يقف الشكل 2.3 فوق الشكل 2.2، الذي يحمل الرقم الأصغر لأن النص يستشهد به أولًا. وعلامة ![]() في Markdown تُحذف، فكل شكل مورد يُسجَّل ملف SVG الخاص به تحت svg.fileId الخاص به قبل البناء. وكل موضع ينتج عن مكان الاستشهاد بالشكل: auto للشكل 2.1، لأن اللوح يملأ رأس صفحة الافتتاح؛ وbottom للمقطع الطولي، الذي ما زال يستطيع أخذ أسفل الصفحة التي تستشهد به؛ وtop لأشكال الأعمدة، التي تفتتح رؤوس الأعمدة الشاغرة التالية؛ وtop للشكل 2.7 أيضًا، الذي ينتهي في أسفل الصفحة 30 لأن العنصر العائم يبقى داخل فصله.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 009 · Figures that float to where you cite them ═════════ // https://postext.dev/en/cookbook/figures-float-where-cited // Code: MIT · Text: original (CC BY 4.0) · Figures: diffusion models, labels in code // Fonts: Faustina, Montserrat, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // // Chapter 2 of a geomorphology textbook. Six of its seven figures float, each to the first // free slot its placement allows, counting from the paragraph that first cites it. Figure 2.6 // is set where ::resource embeds it. The figures are numbered in order of first mention. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, parseMarkdown, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en') const RECIPE = 'figures-float-where-cited'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: eight named colours; the paintings were made to match them const palette = { ink: '#1b2227', // text: a cold near-black glacier: '#34729a', // the accent: kicker, ribbon, caption labels, references, folios, water ice: '#e3f1f8', // the opener slab rock: '#5b5a57', // bedrock in the drawings moss: '#7d8f4e', // valley floors and pines rule: '#c6d3db', // the hairline under the running heads muted: '#5d6a72', // running heads, credit notes, the colophon paper: '#ffffff', }; // A linked colour carries its hex too: postext 1.4.1 design slots and referenceColor read // the hex, not the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color' (#295aa3): pointing it at the accent keeps // that second blue off the page. { id: 'main-color', name: 'glacier (defaults)', value: { hex: palette.glacier, model: 'hex' } }, ]; // #endregion const TEXT = 'Faustina'; // one family each for text, display and labels const DISPLAY = 'Montserrat'; const LABEL = 'IBM Plex Sans Condensed'; const LEAD = 13.4; // body leading in pt: the grid every float band snaps to const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 250, 22, 20, 18, 14, 6]; // mm const MEASURE = PAGE_W - INNER - OUTER; // 168 mm: the text block, and a page-wide figure const COLUMN = (MEASURE - GUTTER) / 2; // 81 mm: a column, and a column figure // #region captions: the type name in the document's language; bold label, italic description const captions = () => ({ // config.locale sets hyphenation, not captions (gotcha: resource-types-locale): // 'Figura 2.3' and 'Fig. 2.3' come from the localised types, numbered {h1}.{n} per chapter. resourceTypes: defaultResourceTypes(LANG), captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2), labelColor: col('glacier'), descriptionItalic: true, // the label is bold by default note: { color: col('muted'), gap: mm(0.6) }, // the credit line }, }); // #endregion // #region furniture: an ice slab off the fore-edge, a ribbon from the head, running heads const at = (to, edge, x, y, width, height) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width), height: height ? mm(height) : 'auto' } }) }); const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, align: 'left', ...extra }); const caps = (size) => ({ fontWeight: 600, textTransform: 'uppercase', letterSpacing: pt(size * 0.18) }); // capitals tracked 0.18 em // Opener texts break onto more lines instead of ending in '…' (gotcha: overflow-ellipsis-default). const wrap = { overflow: 'wrap' }; const [SLAB, RIBBON, RIBBON_END] = [64, 30, 70]; // mm: slab height; ribbon width and length const [TEXT_X, KICKER_Y] = [RIBBON + 8, 10]; // mm: the opener texts start 8 mm right of the ribbon const [TITLE_W, LEAD_W] = [118, 112]; // mm: the title's measure, and a shorter standfirst const opener = { enabled: true, // At least 5 mm under the slab; the reserve then rounds up to whole 13.4 pt grid lines, // so here 69 mm becomes 15 lines (70.9 mm) and the text starts about 7 mm under the slab. minHeight: mm(SLAB + 5), slot: { elements: [ { kind: 'box', id: 'slab', style: { backgroundColor: col('ice') }, // runs off the fore-edge placement: at('container', 'top-left', 0, 0, MEASURE + OUTER, SLAB) }, { kind: 'box', id: 'ribbon', style: { backgroundColor: col('glacier') }, // hangs from the head placement: at('page', 'top-left', INNER, 0, RIBBON, RIBBON_END) }, text('numeral', '{chapterNumber}', DISPLAY, 80, 'paper', // an 80 pt line box is 28 mm tall: at('page', 'top-left', INNER, RIBBON_END - 31, RIBBON), // it ends 3 mm above the foot { fontWeight: 800, lineHeight: 1, align: 'center' }), text('kicker', t({ en: 'Chapter {chapterNumber} · {attr.topic}', es: 'Capítulo {chapterNumber} · {attr.topic}' }), LABEL, 8.5, 'glacier', at('container', 'top-left', TEXT_X, KICKER_Y), { ...caps(8.5), ...wrap }), text('title', '{titleText}', DISPLAY, 27, 'ink', at('#kicker', 'below', 0, 2.6, TITLE_W), { fontWeight: 800, lineHeight: 1.06, ...wrap }), text('lead', '{attr.lead}', TEXT, 10.6, 'ink', at('#title', 'below', 0, 4.2, LEAD_W), { italic: true, lineHeight: 1.38, hyphenate: true, ...wrap }), ] }, }; const HAIRLINE = TOP - 5; // mm from the top edge: the rule under the running heads const HEAD_Y = HAIRLINE - 4.4; // the running heads' line box, 4.4 mm above the hairline const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted', at('page', edge, x, HEAD_Y), { ...caps(7.6), parity, pages: 'body', ...extra }); const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'glacier', at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra }); const header = { elements: [ // outer corners, over a hairline; never on the opener folio('verso-folio', 'even', 'top-left', OUTER), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }), folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }), { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', color: col('rule'), thickness: pt(0.5), placement: { ...at('container', 'top-left', 0, HAIRLINE), size: { width: 'fill', height: 'auto' } } }, ] }; const footer = { elements: [ // the drop folio: on the opener only, centred 9 mm under the text text('drop-folio', '{pageNumber}', DISPLAY, 8.5, 'glacier', at('container', 'top', 0, 9), { fontWeight: 800, align: 'center', pages: 'opener' })] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) ...captions(), colorPalette, page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150, // a compact textbook trim margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText: { // justified serif; first lines indented 4 mm, except after a heading fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('glacier'), // citations in the accent, like the caption labels they name firstLineIndent: mm(4), indentAfterHeading: false }, headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), // Columns end flush by adding grid lines above the H2s. Beside a float band a column can // come up several lines short; one line per heading (the default is 4) keeps a section // head from floating in a gap, and the balancer's other levers take what is left. balancing: { maxLinesPerHeading: 1 }, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontSize: pt(27), span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', marginTop: pt(LEAD), marginBottom: pt(0) }, // one grid line above, none below ], }, unorderedLists: { color: col('glacier'), marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 67 · content.es.md
title: "Relieves de montaña" subtitle: "Introducción a la geomorfología" --- # Cómo esculpen los glaciares el paisaje {topic="Geomorfología glaciar" lead="El hielo baja por el valle unas decenas de metros al año, demasiado despacio para verlo, pero en unas pocas glaciaciones convierte la estrecha V de un río en una ancha U. La roca guarda cada etapa, del circo a las morrenas."} Quien sube a un valle de alta montaña después de haber recorrido otro excavado solo por un río nota enseguida la diferencia. El valle fluvial es estrecho y tiene forma de V: el río ahonda su cauce y las laderas se desmoronan tras él. El valle por el que pasó un glaciar es ancho, de fondo plano y paredes casi verticales, con la forma de una U (:ref{id="valleys" case="lower"}). El hielo llena el valle de pared a pared y lo lima a la vez por el fondo y por los lados. Hace unos veinte mil años, en el momento de máxima extensión de la última glaciación, el hielo cubría buena parte del norte de Europa y bajaba por los valles del Pirineo hasta cotas inferiores a los mil metros. También había glaciares en los Picos de Europa, en Gredos y en Sierra Nevada. Casi todos han desaparecido, pero el relieve conserva sus huellas con tanta nitidez que se puede reconstruir el tamaño de un glaciar que se fundió hace milenios. Este capítulo explica cuáles son esas huellas y cómo se leen. ## El hielo que fluye Un glaciar nace donde cae más nieve de la que se funde. Año tras año, cada capa queda enterrada bajo la siguiente y su peso expulsa el aire de entre los copos. La nieve recién caída pesa unos cien kilogramos por metro cúbico; al compactarse se convierte en neviza, un material granuloso, y después en hielo compacto y azulado, que supera los ochocientos. En los Alpes la transformación dura unas décadas; en la Antártida, donde nieva tan poco que cada año suma apenas unos centímetros, puede llevar siglos. Todo glaciar tiene dos mitades (:ref{id="profile" style="full" case="lower"}). En la parte alta, la zona de acumulación, cada invierno deja más nieve de la que el verano consigue fundir. En la parte baja, la zona de ablación, ocurre lo contrario: el hielo se pierde y el glaciar solo se mantiene porque le llega hielo de arriba. La frontera entre ambas, la línea de equilibrio, se reconoce a finales del verano como el límite de la nieve del año sobre el hielo desnudo. Si el clima se enfría, la línea baja y el frente avanza; si se calienta, la línea sube y el frente retrocede. Cuando el hielo del circo de cabecera (:ref{id="cirque" case="lower"}) alcanza unas decenas de metros de espesor, empieza a fluir bajo su propio peso. El hielo se deforma despacio y sin romperse, como lo haría una masa de brea, mientras los treinta metros de arriba, con demasiado poco peso encima para fluir, se rompen en grietas. Donde el lecho está húmedo, el glaciar además resbala sobre una película de agua de fusión. Así avanzan los glaciares de valle, entre unas decenas y unos cientos de metros al año, más deprisa en el centro que junto a las paredes, donde el rozamiento los frena. Louis Agassiz lo comprobó en la década de 1840 con una hilera de estacas clavada de un borde a otro del glaciar del Unteraar, en Suiza: con los años, la hilera se curvó valle abajo por el centro. Incluso un glaciar en retirada sigue fluyendo hacia abajo. Si su frente retrocede es porque cada verano se funde allí más hielo del que llega. ## Donde nacen los glaciares Un circo es una hondonada con forma de sillón excavada en la cabecera del valle. El hielo se acumula en el fondo, gira pendiente abajo como en una cuchara y rebaja el suelo por debajo del borde; cuando el glaciar se funde, la cubeta se llena de agua y nace un lago. Para ahondarla, el hielo trabaja con dos herramientas complementarias, las de las figuras :ref{id="abrasion" style="number"} y :ref{id="plucking" style="number"}. La pared del fondo, mientras tanto, retrocede: dos circos que crecen espalda con espalda afilan entre ellos una arista, y tres o más que atacan una misma cumbre la dejan convertida en un pico piramidal, como el Cervino. En el Pirineo, la mayoría de los circos miran al norte o al este, donde la nieve dura más, y muchos guardan uno de esos lagos, que en Aragón llaman ibones. ## Las herramientas del hielo El hielo es más blando que casi cualquier roca y por sí solo apenas la rayaría; erosiona su lecho con dos herramientas. La primera herramienta es la abrasión. Los cantos incrustados en la base del glaciar rayan el lecho como una lija y dejan estrías paralelas que señalan, milenios después, la dirección en que se movía el hielo; el polvo que producen, la harina de roca, da a los lagos glaciares su color turquesa lechoso. La segunda es el arranque: el agua de fusión se cuela en las grietas del lecho, vuelve a helarse y suelda los bloques al hielo, que se los lleva al avanzar. Las dos herramientas actúan a la vez sobre cualquier resalte del lecho, y el resultado es una de las formas más características del paisaje glaciar, la roca aborregada: ::resource{id="roche"} :::space{lines=1} Su cara de aguas arriba, pulida por abrasión, es suave y tendida; la de aguas abajo, donde el glaciar arrancó bloques, es abrupta y rugosa. Basta mirar hacia dónde apunta la cara áspera para saber hacia dónde iba el hielo. El nombre francés, *roche moutonnée*, lo acuñó el naturalista ginebrino Horace-Bénédict de Saussure a finales del siglo XVIII. ## Valles en artesa El trabajo de esas herramientas, sumado durante decenas de miles de años, transforma el valle entero. El glaciar endereza el valle sinuoso del río y trunca los espolones que separaban sus curvas. Ensancha y ahonda el fondo hasta darle el perfil en U de :ref{id="valleys" text="la primera figura del capítulo"}, escalonado en cubetas y umbrales, y el valle de Ordesa, en el Pirineo aragonés, es una artesa de manual. Como un glaciar grueso excava más que uno delgado, el valle principal se hunde más que los de sus afluentes: cuando el hielo desaparece, los valles laterales quedan colgados a cientos de metros sobre el principal y sus arroyos saltan en cascada, como en el valle de Yosemite, en California. Donde el mar ha invadido una artesa se forma un fiordo; el de Sogn, en Noruega, penetra más de doscientos kilómetros tierra adentro y supera los mil trescientos metros de profundidad. ## Lo que deja el glaciar Todo lo que el glaciar arranca acaba depositado en algún sitio. Los derrubios que caen de las laderas viajan sobre los bordes del hielo y forman morrenas laterales; donde dos glaciares se unen, sus morrenas laterales se funden en una morrena central que recorre el hielo como una franja oscura. En el frente, el glaciar suelta su carga como una cinta transportadora y levanta un arco de derrubios, la morrena frontal, que señala hasta dónde llegó la lengua de hielo en su máximo avance. Las morrenas son mezclas caóticas de arcilla, arena, cantos y bloques de todos los tamaños, sin la clasificación que el agua impone a sus sedimentos. Algunos bloques, los erráticos, viajaron decenas de kilómetros y descansan hoy sobre rocas de naturaleza muy distinta. Muchas morrenas frontales retienen lagos (:ref{id="moraines" case="lower"}). El de Sanabria, en Zamora, el mayor lago de origen glaciar de la península, está represado por las morrenas del glaciar que bajaba de la sierra Segundera. ## Cómo leer un paisaje glaciar Bastan cuatro huellas para reconocer el paso de un glaciar por un valle que hoy no tiene hielo: - **El perfil.** Una artesa en U, de fondo plano y paredes abruptas, como la de la :ref{id="valleys" style="full" case="lower"}. - **Los valles colgados.** Afluentes que terminan a media ladera y cuyos arroyos caen en cascada. - **La roca.** Superficies pulidas y estriadas y rocas aborregadas, con la cara abrupta vuelta valle abajo (:ref{id="roche" case="lower"}). - **Los depósitos.** Morrenas sin clasificar, bloques erráticos y los lagos que represan. Ninguna de esas huellas basta por sí sola: un río también pule los cantos, y un desprendimiento deja derrubios caóticos al pie de una ladera. Juntas, y repetidas valle tras valle, prueban que allí hubo un glaciar. ## Glaciares en retirada Los glaciares que quedan en la península son pequeños y están todos en el Pirineo, en las caras norte de sus cumbres más altas: el Aneto, la Maladeta, el Monte Perdido. Han perdido la mayor parte de su superficie desde mediados del siglo XIX, cuando terminó la Pequeña Edad de Hielo, y varios se han reducido a heleros, masas de hielo que ya no fluyen. En muchos veranos, la línea de equilibrio sube hoy por encima de sus cumbres: el glaciar entero queda en la zona de ablación de la :ref{id="profile" style="full" case="lower"} y el hielo que pierde ya no se repone. Lo que queda se refugia a la sombra de las paredes norte, alimentado tanto por los aludes y la nieve que arrastra el viento como por la que cae. Los glaciólogos siguen ese retroceso con los métodos de Agassiz y con otros nuevos: estacas de ablación que cada verano asoman un poco más, fotografías repetidas desde los mismos puntos y modelos del terreno levantados con láser y con drones, que comparados año tras año dan el volumen de hielo perdido. Cuando desaparezca el último, el macizo de la Maladeta se parecerá a la sierra de Gredos, que perdió sus glaciares hace más de diez mil años y conserva lagunas en los circos y morrenas que cierran los valles. :::paragraphs{style="colophon"} Compuesto en Faustina, Montserrat e IBM Plex Sans Condensed (SIL Open Font License) · Texto: CC BY 4.0 · Figuras: modelos de difusión :::`; // content.<lang>.md, inlined by the Cookbook // Caption, credit note ('-' for none) and alt text of each figure, one block per figure. const figureTexts = String.raw`valleysنموذج Markdown · أسطر: 33 · content.figures.es.md
Un río abre un valle en V; un glaciar lo ensancha en U. A trazos, la V que borró el hielo. Secciones esquemáticas, sin escala. Dos secciones de valle: a la izquierda, un valle fluvial en V con un río en el fondo; a la derecha, un valle glaciar en U lleno de hielo, con el antiguo perfil en V a trazos. profile Perfil de un glaciar: el hielo nacido sobre la línea de equilibrio baja a fundirse bajo ella. Exageración vertical ×2. Sección longitudinal de un glaciar desde el circo hasta el frente, con la zona de acumulación nevada, la línea de equilibrio, flechas de flujo y la morrena frontal. cirque Circo glaciar en sección. El hielo gira en la cubeta y la ahonda por debajo del umbral. - Sección de un circo: pared abrupta, rimaya, hielo que gira en una cubeta y umbral rocoso aguas abajo. abrasion Abrasión: los cantos presos en la base del hielo rayan el lecho. - Detalle de la base de un glaciar: cantos incrustados en el hielo rayan la roca y dejan estrías y harina de roca. plucking Arranque: el agua se hiela en las diaclasas y el hielo se lleva los bloques. - Detalle del lado de aguas abajo de un resalte rocoso: agua helada en las diaclasas y un bloque que el hielo arranca. roche Roca aborregada. El hielo pulió la cara tendida y arrancó bloques de la abrupta. El hielo iba de izquierda a derecha. Perfil de una roca aborregada: una cara suave y tendida a la izquierda y otra escalonada y abrupta a la derecha. moraines Dos glaciares se unen: sus morrenas laterales forman la central, y la frontal represa un lago. Vista en planta, sin escala. Plano de dos lenguas de hielo que confluyen, con morrenas laterales y central; bajo el frente, el arco de la morrena frontal retiene un lago que solo cruza su arroyo de desagüe.`; const TEXTS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/) .map((block) => block.split('\n').map((line) => line.trim())) .map(([id, caption, note, alt]) => [id, [caption, note === '-' ? undefined : note, alt]])); // #region answer: six figures float to the first slot their placement allows; one stays put // In the Markdown, :ref{id="valleys" case="lower"} prints 'fig. 2.1' and places Figure 2.1. // Captions, credits and alt texts come from content.figures.<lang>.md. const figure = (id, height, placement) => { if (!TEXTS[id]) throw new Error(`content.figures has no caption block for "${id}"`); const [caption, note, altText] = TEXTS[id]; // An SVG fills the width of its slot (a column or the text block, or a fraction of // either), so its width and height only give its shape. const width = (placement.span === 'page' ? MEASURE : COLUMN) * (placement.width ?? 1); return { id, typeId: 'figure', kind: 'svg', caption, note, altText, svg: { fileId: `${id}.svg`, width, height }, placement, createdAt: 0, updatedAt: 0 }; }; // In any order: the first mention of each one in the text, a :ref or a ::resource line, // decides its number. const resources = [ // Cited on the opener page: 'auto' may take that page's foot band, where 'top' // could only open the next page (gotcha: top-float-next-page). figure('valleys', 56, { position: 'auto', span: 'page' }), // Across both columns, but only in a foot band: the page it is cited on, if both // columns still have room there, else the foot of the next page. figure('profile', 60, { position: 'bottom', span: 'page' }), // A column figure that takes only a column head: the next one still empty after its // citation, here the right column of the same page, above the text that follows it. figure('cirque', 48, { position: 'top' }), // Cited in the same sentence, the two take the next two column heads, side by side. figure('abrasion', 48, { position: 'top' }), figure('plucking', 48, { position: 'top' }), // No float: set exactly where ::resource{id="roche"} stands. In postext 1.4.1 an inline // figure gets a grid line above it but only the grid snap below, so the Markdown follows // it with :::space{lines=1} (gotcha: here-figure-no-space-after). figure('roche', 42, { position: 'here' }), // A band of its own, half the text width and centred. It is cited on the chapter's last // page, where a 'top' float would wait for the next page; a float cannot leave its // chapter, so this one goes to the foot of the last page. A float is queued where its // citing paragraph starts, so that paragraph starts on the last page // (gotcha: float-queues-at-paragraph). figure('moraines', 50, { position: 'top', span: 'page', width: 0.5, align: 'center' }), ]; // #endregion // #region check: every cited id exists and every figure gets placed, before the build // An unknown :ref prints '?' and a figure nobody names is never placed, and postext 1.4.1 // warns about neither (gotcha: unknown-ref-silent). The engine's own parser lists the // mentions exactly as numbering and placement read them; an embed needs double quotes // (gotcha: resource-double-quotes). function checkFigures() { const [named, embedded] = [[], new Set()]; for (const block of parseMarkdown(markdown)) { if (block.type === 'resourceBlock' && block.resourceId) { named.push(block.resourceId); embedded.add(block.resourceId); } for (const span of block.spans) if (span.ref?.resourceId) named.push(span.ref.resourceId); } const ids = resources.map((r) => r.id); const types = new Set(captions().resourceTypes.map((type) => type.id)); const problems = [ ...[...new Set(named)].filter((id) => !ids.includes(id)).map((id) => `unknown id "${id}"`), ...ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `"${id}" is defined twice`), ...ids.filter((id) => !named.includes(id)).map((id) => `"${id}" is never cited`), ...resources.filter((r) => r.placement.position === 'here' && !embedded.has(r.id)) .map((r) => `"${r.id}" is placed 'here' but no ::resource line embeds it`), ...resources.filter((r) => !types.has(r.typeId)).map((r) => `"${r.id}": no type ${r.typeId}`), ]; if (problems.length) throw new Error(`Figures: ${problems.join('; ')}`); } // #endregion // #region art: seven paintings (JPEGs in assets/) under vector labels in the book's language // Each figure is an SVG: the painting, embedded as a data URL at the figure's printed size in // mm, then its labels as text, so they stay sharp and follow the edition's language. const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')).join('')}`; const C = { ink: palette.ink, flow: mix(palette.glacier, palette.ink, 0.4), snow: palette.paper }; const n2 = (v) => +v.toFixed(2); // A hairline, drawn over a wider white one so it reads on rock and ice alike. const hairline = ([x1, y1], [x2, y2]) => [['#ffffff', 0.55], [C.ink, 0.18]].map(([c, w]) => `<path d="M${n2(x1)} ${n2(y1)}L${n2(x2)} ${n2(y2)}" stroke="${c}" stroke-width="${w}" ` + 'stroke-linecap="round"/>').join(''); // A label with a thin white halo, and an optional leader to the point it names. function label(x, y, words, { anchor = 'start', to, bold = false, color = C.ink } = {}) { const halo = color === C.snow ? C.ink : '#ffffff'; const leader = to ? hairline([to[0], to[1]], [to[2] ?? x, to[3] ?? y - 0.9]) : ''; return `${leader}<text x="${n2(x)}" y="${n2(y)}" text-anchor="${anchor}" fill="${color}"` + ` stroke="${halo}" stroke-width="0.5" stroke-linejoin="round" paint-order="stroke"` + `${bold ? ' font-weight="600"' : ''}>${words}</text>`; } const L = (en, es) => t({ en, es }); // An SVG loaded as an <img> has no access to the page's web fonts (gotcha: svg-no-webfonts), // so each drawing embeds the two weights its labels use. The latin subsets cover the English // and Spanish labels. const LABEL_MM = 2.45; // the label size in the drawings' millimetres: about 7 pt in print async function labelFace() { const id = fontsourceId(LABEL); const faces = await Promise.all(['400', '600'].map(async (weight) => { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}-` + 'normal.woff2'; const res = await fetch(url); if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;font-weight:${weight};` + `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; })); return `${faces.join('')}text{font-family:L;font-size:${LABEL_MM}px}`; } // The viewBox is the figure's printed size in mm; the SVG's own size is set in mm too. const svg = (w, h, face, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${n2(w)}mm" ` + `height="${n2(h)}mm" viewBox="0 0 ${n2(w)} ${n2(h)}"><style>${face}</style>${body}</svg>`; // The labels of each figure, in its millimetres, placed on its painting. const DRAWINGS = { valleys: () => label(14, 5, L('River valley', 'Valle fluvial'), { bold: true }) + label(100, 5, L('Glacial valley', 'Valle glaciar'), { bold: true }) + label(47, 50.4, L('river', 'río'), { to: [43.4, 46.6, 46.8, 49.4] }) + label(126, 21, L('ice', 'hielo'), { anchor: 'middle', bold: true }) + label(125.5, 53.2, L('earlier V-shaped valley', 'antiguo valle en V'), { anchor: 'middle', to: [125.5, 48, 125.5, 51.3] }), profile: () => label(48, 8, L('accumulation zone', 'zona de acumulación'), { anchor: 'middle', bold: true, color: C.flow }) + label(100, 20, L('ablation zone', 'zona de ablación'), { anchor: 'middle', bold: true, color: C.flow }) + label(57, 19.6, L('equilibrium line', 'línea de equilibrio'), { to: [52.8, 27, 56.6, 20.2] }) + label(108, 43.3, L('ice flow', 'flujo del hielo'), { bold: true, color: C.flow }) + label(166, 41.6, L('terminal moraine', 'morrena frontal'), { anchor: 'end', to: [146, 45.5, 150, 42.2] }) + label(4, 57.4, L('bedrock', 'lecho rocoso')), cirque: () => label(2.5, 30, L('back wall', 'pared')) + label(26, 6.6, L('bergschrund', 'rimaya'), { to: [20, 12.5, 25.6, 7.2] }) + label(30, 22.6, L('rotation', 'rotación'), { bold: true, color: C.flow }) + label(33, 40, L('basin', 'cubeta')) + label(66, 17.4, L('rock lip', 'umbral'), { anchor: 'middle', to: [60.5, 22.8, 64.4, 18.3] }), abrasion: () => label(55.5, 8.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow }) + label(24, 16.6, L('stones in the ice', 'cantos presos en el hielo'), { to: [38.5, 22.6, 38, 17.4] }) + label(20, 37, L('striations', 'estrías'), { anchor: 'end', to: [24, 29.5, 18, 35.8] }) + label(40.5, 37, L('rock flour', 'harina de roca'), { to: [32, 26, 40.5, 35.8] }), plucking: () => label(30, 9.3, L('ice moves', 'el hielo avanza'), { bold: true, color: C.flow }) + label(63.5, 18.5, L('plucked block', 'bloque arrancado'), { to: [60, 21, 63.3, 19.2] }) + label(4, 43, L('ice in the joints', 'hielo en las diaclasas'), { to: [25.2, 32, 14, 41.3] }), roche: () => label(4, 5, L('ice, long gone', 'el hielo, hoy fundido'), { bold: true, color: C.flow }) + label(25, 16.5, L('abrasion: smooth', 'abrasión: pulida'), { anchor: 'end', to: [30, 18.8, 25.5, 16.9] }) + label(63, 11.2, L('plucking: rough', 'arranque: rugosa'), { to: [57.5, 15.5, 63.2, 11.9] }), // Placed on a 100.8 × 60 mm drawing; k scales the positions, not the type, to the figure. moraines: (w) => { const k = w / 100.8; const at = (x, y, words, o = {}) => label(x * k, y * k, words, { ...o, ...(o.to && { to: o.to.map((v) => v * k) }) }); return at(4, 25.5, L('lateral moraine', 'morrena lateral'), { to: [37, 16, 21, 24.3] }) + at(62, 33, L('medial moraine', 'morrena central'), { to: [50.5, 33, 61.5, 32.3] }) + at(50, 45, L('lake', 'lago'), { anchor: 'middle', bold: true, color: C.snow }) + at(97.5, 55, L('terminal moraine', 'morrena frontal'), { anchor: 'end', to: [60, 49, 78, 54] }); }, }; // A painting as a data URL: an SVG drawn as an image cannot fetch anything itself. async function dataUrl(url) { const res = await fetch(url); if (!res.ok) throw new Error(`Painting not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `data:image/jpeg;base64,${btoa(bin)}`; } const PAINTINGS = { // each figure's painting, a file in assets/, named by its width in pixels valleys: asset('valleys-1536.jpg'), profile: asset('profile-1400.jpg'), cirque: asset('cirque-1080.jpg'), abrasion: asset('abrasion-1080.jpg'), plucking: asset('plucking-1080.jpg'), roche: asset('roche-1080.jpg'), moraines: asset('moraines-1080.jpg') }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first) Faustina: ['400', '400i', '700'], // text Montserrat: ['800'], // display: title, section heads, numeral, folios 'IBM Plex Sans Condensed': ['400', '400i', '600', '700'], // labels: kicker, heads, captions }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const words = `${markdown}\n${figureTexts}`; // captions too: their letters decide the subsets await loadFonts(FONTS, words); checkFigures(); // a wrong id stops here, and the viewer's bar says why // #region build: register the drawings, then set chapter 2 of a longer book const face = await labelFace(); for (const { id, svg: { fileId, width, height } } of resources) { // each under its svg.fileId const art = await dataUrl(PAINTINGS[id]); await loadSvg(fileId, svg(width, height, face, `<image href="${art}" width="${n2(width)}" ` + `height="${n2(height)}" preserveAspectRatio="none"/>${DRAWINGS[id](width, height)}`)); } // One chapter came before: figures number 2.1, 2.2… and the folios start at 27. const continuation = { pageNumbering: { startAt: 27 }, // odd, to match the recto of page 1 headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), words); showPages(doc, { title: t({ en: 'Figures that float to where you cite them', es: 'Figuras que flotan hasta donde las citas' }) }); // #endregionالعُدّة · core, fonts, viewer, images: نفسها في كل وصفة · أسطر: 270
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#رقّم الأشكال بلا رقم الفصل
احذف الفصل من الأرقام فتُطبع الأشكال من 1 إلى 7؛ ولعدٍّ واحد عبر كتاب كامل، أخرج الفصول بـbuildBundle أو مرّر إلى كل فصل continuationAfter() الخاص بالفصل الذي قبله.
- resourceTypes: defaultResourceTypes(LANG),
+ resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type,
+ numberingTemplate: '{n}', resetOn: 'never' })),#ضع التعليقات فوق الشكل على شريط
ينتقل التعليق فوق الشكل إلى شريط بالأزرق الجليدي من لوحة الألوان، ويبقى سطر المصدر تحت الشكل؛ أعد ضبط النص، لأن الطبعتين كلتيهما تمتدان عندئذٍ إلى صفحة خامسة.
captionStyle: { // the text colour follows bodyText; the note is 0.85 × the caption size
+ position: 'above', backgroundEnabled: true, background: col('ice'),
fontFamily: LABEL, fontSize: pt(8.3), gap: mm(2.2),#ضع شكلًا في الهامش
صفحة العمود والنصف تعطي الأشكال وتعليقاتها قناة خارجية لا يدخلها النص أبدًا: انظر الكتاب المدرسي بعمود في الهامش.
#عوّم الجداول بالطريقة نفسها
الجداول موارد أيضًا، يُستشهد بها وتوضع بالقواعد نفسها، والطويل منها ينقسم عبر الصفحات: انظر نشرة المواصفات الفنية.
أخطاء شائعة
خطأ شائع
العنصر العائم 'top' لا يقع أبدًا في صفحة الإحالة إليه
لا يصعد العنصر العائم أبدًا فوق الإحالة إليه، فالعنصر العائم 'top' الممتد بعرض الصفحة والمُحال إليه في الصفحة N يفتتح الصفحة N+1. أحِل إليه في موضع أبكر، أو استخدم الموضع 'auto' أو 'bottom' الذي يمكنه أن يشغل أسفل صفحة الإحالة. موضع الأشكال →
خطأ شائع
يدخل الشكل في الانتظار حيث تبدأ الفقرة التي تحيل إليه
يدخل الشكل في الانتظار عند بداية الفقرة التي تحيل إليه، لا عند سطر :ref الخاص به. فإذا بدأت تلك الفقرة في أسفل صفحة ووقعت الإحالة في الصفحة التالية، قد يفتتح شكلٌ 'top' تلك الصفحة التالية فوق الجملة التي تحيل إليه. ضع :ref في أول فقرته، أو افتتح به فقرة جديدة. استشهادات تضع الأشكال →
خطأ شائع
الشكل داخل النص يحصل على فراغ فوقه لا تحته
في postext 1.4.1 يحصل الشكل الذي يضعه ::resource في الموضع 'here' على سطر شبكة واحد من الفراغ فوقه، أما تحته فلا يحصل إلا على ما يتبقى حين يلتصق السطر التالي بشبكة خطوط الأساس: من سطر كامل إلى لا شيء تقريبًا، فقد تبدأ الفقرة التالية تحت التعليق مباشرةً. أتبِع سطر ::resource بـ :::space{lines=1}؛ وهو، مثل أي :::space، يُحذف في أعلى العمود. أشكال في هذا الموضع بالضبط →
خطأ شائع
معرّف :ref المجهول يطبع '?' دون تحذير من المحرّك
الإحالة :ref إلى معرّف لا يملكه أي مورد تطبع "?" ولا تضع شيئًا، ولا ينبّه إليها إلا Sandbox. تحقّق من وجود كل معرّف تحيل إليه. استشهادات تضع الأشكال →
خطأ شائع
ترجم Figure/Table بـ defaultResourceTypes(locale)
يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes. «شكل» و«جدول» بلغتك →
خطأ شائع
::resource{id="…"} لا يقبل إلا علامتي الاقتباس المزدوجتين
لا يُتعرَّف على التضمين الكتلي إلا بالصيغة ::resource{id="…"} بعلامتي اقتباس مزدوجتين؛ وأي صيغة أخرى تبقى في النص سطرًا ظاهرًا. أشكال في هذا الموضع بالضبط →
خطأ شائع
معظم التحذيرات موجودة في Sandbox وحده
المعرّفات والأنماط والتوجيهات المجهولة، والخطوط المفقودة، والأسطر المتخلخلة يفحصها Sandbox لا المحرّك: مثال CodePen لا يحصل إلا على doc.warnings وparseMarkdownWithIssues. النمط المجهول يعود إلى البديل دون تنبيه، والتوجيه المجهول يُطبع نصًا، فتحقّق من معرّفاتك. التحذيرات والتشخيص →
خطأ شائع
النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب
يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →
خطأ شائع
لا <marker> ولا مرشِّحات في رسوم SVG (وإلا البديل النقطي)
لا يبقى شكل SVG متّجهيًا في PDF إلا إذا خلا من <marker> والمرشِّحات والأقنعة؛ وإلا عاد إلى صورة نقطية، والمرشِّحات المتداخلة بعمق قد تجعله فارغًا في Chrome. ارسم رؤوس الأسهم مسارات. الأشكال والجداول بوصفها موارد →
خطأ شائع
8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا
يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
فيض نص التصميم افتراضيًا 'ellipsis-end'
عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
فحص Sandbox · unknownResourceId
مورد غير معروف
السبب. :ref أو ::resource يسمّي معرّفًا لا يملكه أي مورد؛ فتطبع الإحالة "?" ولا يوضع شيء.
الحل. صحّح المعرّف (بعلامات اقتباس مزدوجة فقط) أو أضف المورد. التوثيق →
فحص Sandbox · danglingTypeRef
نوع مورد غير معروف
السبب. قيمة typeId لمورد تسمّي نوعًا لم تعد resourceTypes تعرّفه، فيُستعمل نوع افتراضي.
الحل. عرّف النوع، أو اجعل المورد يشير إلى نوع موجود. التوثيق →
- في الصفحة 30 تستشهد الفقرة عن البحيرات بالشكل 2.7 في جملتها الأولى، لأن الشكل يُصفّ حيث تبدأ الفقرة التي تستشهد به. ولو كانت تلك الجملة خاتمةَ الفقرة السابقة، التي تبدأ في الصفحة 29، لافتتح الشكل الصفحة 30، فوق السطر الذي يستشهد به.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- Figure 2.1: a V-shaped river valley and a U-shaped glacial valley · Generated With Diffusion Models · أصلي
- Figure 2.2: long profile of a valley glacier · Generated With Diffusion Models · أصلي
- Figure 2.3: a glacial cirque in section · Generated With Diffusion Models · أصلي
- Figure 2.4: abrasion at the base of the ice · Generated With Diffusion Models · أصلي
- Figure 2.5: plucking on a rock step · Generated With Diffusion Models · أصلي
- Figure 2.6: a roche moutonnée · Generated With Diffusion Models · أصلي
- Figure 2.7: lateral, medial and terminal moraines in plan · Generated With Diffusion Models · أصلي
- الخطوط
- Faustina (SIL OFL 1.1) · Montserrat (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


