باختصار
وحدة من كتاب مدرسي عن نهر، في تسع صفحات. كل موضوع صفحتان متقابلتان على أرضية ملوّنة، بلسان للعنوان ونص وصور وإطارات. لا كلمة على الخريطة، وأسماؤها كلها من النص. وتتفاوت اللغات الثلاث في الطول وتتسع لها القوالب نفسها.
ما الذي ستنضده
تسع صفحات من Our river، الوحدة 4 من كتاب مُتخيَّل للعلوم الاجتماعية للصف الثالث، على صفحة 210 × 280 مم، بالإسبانية والكتالونية والإنجليزية. يفتح الوحدةَ وادٍ مرسوم، وتسرد صفحة الفهرس جزأين، ثم تأتي ثلاثة موضوعات صفحتين متقابلتين، لكل منها أرضيتها: زرقة الماء، وخضرة الحقل، ورمل الشمس. في كل موضوع لسان فيه الرقم والعنوان، ونص في عمودين، وإطارات بألسنة تطول مع نصها. الموضوع الثاني خريطة مرسومة واحدة تمتد على الصفحتين، وتُسمّى عليها النهر والقرى والمجاري الثلاثة من Markdown. تختلف النصوص الثلاثة في الطول بخُمس أو أكثر وتتسع لها القوالب نفسها: يأخذ الإطار الارتفاع الذي تطلبه كلماته ويدفع الذي يليه إلى الأسفل، وتصغر الخريطة قليلًا قبل أي نص.
تجيب هذه الوصفة عن
- كيف أنضّد وحدة من كتاب مدرسي بثلاث لغات بقوالب الصفحة نفسها، وطول النص يختلف من لغة إلى أخرى؟
- كيف أجعل إطارات الصفحة تطول مع نصها وتدفع الإطارات التي تحتها إلى الأسفل؟
- كيف أضع أسماء الخريطة من النص، بحيث تغيّر الترجمة الكلمات ولا تمسّ الرسم؟
الجواب المختصر
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
box: { backgroundColor: col('paper'), borderRadius: mm(6) },
placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
placement: at('container', 'top-left', TAB) };
const base = {
id: 'tema', spread: true, gutter: mm(12),
decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
fit: { minScale: 0.9 } }],
groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
{ id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
{ id: 'number', color: col(f) }] },
...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
height: mm(LINE * lines), minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
placement: at(`#${name}`, 'top-right', -5, 8) });
المكونات
- الميزات
- الصفحات المركّبةإطارات تكبر داخل مجموعةألسنة الإطارات المرقّمةتسميات على الصورةجدول المحتوياتقوالب الصفحتين المتقابلتينإطارات التنبيهالنصوص والخطوط والمربعات في تصاميم الصفحةالصور في تصاميم الصفحةتثبيت عناصر التصميمزخرفة مثبّتة بالإطاراتهوامش متناظرةالترويسات وأرقام الصفحاتأنماط العناوينلوحة ألوان دلاليةالأشكال والجداول بوصفها مواردصفحات على اللوحة (Canvas)التصدير إلى PDF
- تستخدم أيضًا
- إطارات تزيح النص بصندوقها أو بمحيطهاسمات العنوانصفحات افتتاح مصمَّمةصفحات فواصل الأجزاءالخطوط المضمَّنة في PDFأنواع موارد مخصّصةفصول بلا أرقام
- الخطوط
- Nunito, Fredoka (SIL OFL 1.1)
- الأصول
arroyo-720.jpgcascada-720.jpglimpieza-720.jpgmapa-1900.jpgmolino-720.jpgnacimiento-720.jpgpiraguas-720.jpgportada-1400.jpgriego-720.jpg- The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water. (Generated With Diffusion Models, أصلي)
- The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown. (Generated With Diffusion Models, أصلي)
- A spring between mossy rocks. (Generated With Diffusion Models, أصلي)
- A mountain stream over round stones. (Generated With Diffusion Models, أصلي)
- A small waterfall into a green pool. (Generated With Diffusion Models, أصلي)
- A farmer opening the gate of an irrigation canal. (Generated With Diffusion Models, أصلي)
- An old stone watermill with its wheel. (Generated With Diffusion Models, أصلي)
- Children paddling canoes on a calm river. (Generated With Diffusion Models, أصلي)
- Children and a teacher picking up litter on a river bank. (Generated With Diffusion Models, أصلي)
طريقة التحضير
#1 · ابنِ الإطارات بـ extends
// Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind.
const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'),
fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2),
offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } });
const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left',
paragraphSpacing: true };
const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding
const calloutStyles = [
{ id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3),
border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) },
body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } },
...Object.keys(FAMILIES).flatMap((f) => [
{ id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } },
{ id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) },
{ id: `palabras-${f}`, extends: `caja-${f}`,
border: { enabled: true, color: col(f), width: pt(1.4) } },
{ id: `actividad-${f}`, extends: `caja-${f}`,
stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } },
]),
// Plain frames: the title (its tab is decoration), a picture's card and an answer space.
{ id: 'titulo', extends: 'caja', backgroundEnabled: false,
padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } },
{ id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } },
{ id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4),
padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } },
{ id: 'foto', extends: 'caja', borderRadius: mm(2),
padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } },
{ id: 'respuesta', extends: 'caja', padding: none },
{ id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none,
body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } },
];
يُبنى نمط الإطار في ثلاث خطوات. يحمل caja الشكل والخط وهندسة اللسان؛ ثم يضيف caja-agua وcaja-campo وcaja-sol لون العائلة إلى اللسان والنقاط؛ ثم تحدّد dato- وpalabras- وactividad- النوع: إطار ملوّن الخلفية، أو ذو حدّ، أو ذو شريط. والعائلة الجديدة ثلاثة ألوان في FAMILIES، ولا يُكتب نمط مرتين.
#2 · اجعل كل موضوع يرث قالبًا أساسيًا واحدًا
// Ground, title tab and number are decoration anchored to the title frame, so the tab is as
// tall as the title needs. A topic extends the base and changes its colours and adds frames.
const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col(`${f}-ground`) } });
const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin
size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } },
style: { backgroundColor: col(f), borderRadius: mm(4) } });
const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY,
fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center',
box: { backgroundColor: col('paper'), borderRadius: mm(6) },
placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4),
size: { width: mm(12), height: mm(12) } } });
const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame
const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB),
placement: at('container', 'top-left', TAB) };
const base = {
id: 'tema', spread: true, gutter: mm(12),
decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] },
frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
fit: { minScale: 0.9 } }],
groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }],
};
const topic = (id, f, more) => ({ id, extends: 'tema',
decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } },
{ id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } },
{ id: 'number', color: col(f) }] },
...more });
const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF),
minHeight: mm(minHeight), ...more });
const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF),
height: mm(LINE * lines), minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A picture that sits at the corner of a box and pushes its text aside.
const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push',
placement: at(`#${name}`, 'top-right', -5, 8) });
القالب الأساسي صفحتان متقابلتان: أرضية حتى منطقة النزف، ولسان عنوان فيه الرقم، ونص في عمودين. يرثه كل موضوع بـ extends: 'tema'، ويغيّر ألوان ثلاثة عناصر زخرفية بحسب id، ويضيف إطاراته. اللسان والرقم زخرفة مثبّتة على إطار العنوان، فيكون اللسان بارتفاع العنوان الذي تطلبه اللغة، ويقف الرقم يسار الإطار حيث لا يغطيه شيء.
#3 · دع المجموعات تتقاسم المكان
لكل موضوع مجموعتان. الصفحة اليسرى رصّة: العنوان فالنص ثم شريط الصور أو الخريطة. واليمنى رصّة أخرى من الإطارات، أو صف من ثلاثة إطارات تتقاسم العرض. الإطار بارتفاع كلماته ولا ينزل عن minHeight، فيدفع النص الإسباني كل ما تحته إلى الأسفل ولا يتغيّر موضع في القالب. وحين يضيق المكان على صفحة يسترد سلّم الملاءمة مساحة، ثم يصغّر الخريطة والصور، ويترك النص أخيرًا حتى 90 %. ويعيد pnpm cookbook bench تنضيد الإصدارات الثلاثة والنص أطول بنسبة 30 %.
#4 · ضع أسماء الخريطة من النص
// An anchor is a point of the painting (fractions of its width and height) and a label style;
// every language's ::label lines use the same anchors.
const halo = { color: col('paper'), width: pt(1.8) };
const labelStyles = [
{ id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'),
textTransform: 'uppercase', letterSpacing: 0.14, halo },
{ id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic',
color: { hex: '#0b3a5c', model: 'hex' }, halo },
{ id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic',
color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } },
{ id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo },
{ id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'),
textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'),
padding: mm(1.6) },
];
const ANCHORS = [
{ id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' },
{ id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' },
{ id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' },
{ id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 },
{ id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' },
{ id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' },
{ id: 'mar', x: 0.965, y: 0.28, style: 'mar' },
{ id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 },
{ id: 'alto', x: 0.17, y: 0.45, style: 'curso' },
{ id: 'medio', x: 0.4, y: 0.77, style: 'curso' },
{ id: 'bajo', x: 0.74, y: 0.865, style: 'curso' },
];
الخريطة لوحة بلا حرف واحد. كل مكان مرساة في الصورة، نقطة بكسور من العرض والارتفاع مع نمط تسمية، وكل إصدار يكتب أسطر ::label الخاصة به عند هذه المراسي. تقف Penalta Reservoir وPantà de Penyalta وEmbalse de Peñalta في المكان نفسه، والاسم الأطول ينكسر داخل width المرساة.
#5 · أبقِ كل جزء من الفهرس متماسكًا
// The heading style paints the title tab; the entries come from the headings and the parts.
const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [
{ kind: 'box', id: 'tab', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } },
style: { backgroundColor: col('agua'), borderRadius: mm(4) } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28),
fontWeight: 600, color: col('paper'), align: 'left',
placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } },
] } };
const toc = {
levels: [
{ level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500,
color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9),
marginTop: pt(4) },
{ level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9),
color: col('ink') },
],
pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') },
leader: { char: '.', gap: mm(1.5) },
parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [
{ kind: 'box', id: 'band', decorative: true,
placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } },
style: { backgroundColor: col('paper'), borderRadius: mm(3) } },
{ kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16),
fontWeight: 700, color: col('paper'), align: 'center',
box: { backgroundColor: col('agua'), borderRadius: mm(4) },
placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } },
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13),
fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) },
] } },
};
يسرد :::toc{keep="part" levels="1-2"} الموضوعات وفقراتها ويُبقي صف كل جزء مع مداخله. وسور :::part مع parts.page: false ليس إلا صفًّا في الفهرس وقسمًا للرؤوس الجارية، فلا يضيف صفحة فاصلة. والفهرس صفحة نص متصل لا إطار، لأن :::toc داخل إطار يفقد صفوف الأجزاء.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 156 · A children's textbook unit in three languages ═══ // https://postext.dev/en/cookbook/children-textbook-pages // Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models // Fonts: Nunito, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.40.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en' | 'ca') const RECIPE = 'children-textbook-pages'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one ink and three families (water, field, sun), each with an accent, a tint // for its boxes and a ground for its pages const FAMILIES = { agua: { main: '#1b6aa0', tint: '#e1f0fa', ground: '#d3e8f6' }, campo: { main: '#336f27', tint: '#e5f1dc', ground: '#d9ebcb' }, sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' }, }; const palette = { ink: '#1d2f3f', muted: '#4f6272', rule: '#7e98ab', paper: '#ffffff' }; for (const [f, c] of Object.entries(FAMILIES)) { Object.assign(palette, { [f]: c.main, [`${f}-tint`]: c.tint, [`${f}-ground`]: c.ground }); } 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' } })), { id: 'main-color', name: 'water (defaults)', value: { hex: palette.agua, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY] = ['Nunito', 'Fredoka']; const PAGE = { w: 210, h: 280, top: 18, bottom: 18, inner: 14, outer: 12 }; // mm, mirrored const HALF = PAGE.w - PAGE.inner - PAGE.outer; // one page's type area: 184 mm const SHEET = 2 * HALF + 2 * PAGE.inner; // a spread's content area: 396 mm const RIGHT = HALF + 2 * PAGE.inner; // where the right page's type area starts on the sheet const LINE = 8; // mm between two answer lines const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const onRight = at('container', 'top-left', RIGHT); // every frame of the right page's stack // #region boxes: callout styles that extend each other // Three steps of `extends`: the box (shape, type, tab), a family's colours on it, then the kind. const tab = (f) => ({ position: 'top-left', background: col(f), color: col('paper'), fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, height: mm(7), paddingX: mm(3.2), offset: mm(7), inset: mm(5), rule: { enabled: false, color: col(f) } }); const boxText = { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0), textAlign: 'left', paragraphSpacing: true }; const none = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }; // no padding const calloutStyles = [ { id: 'caja', backgroundEnabled: true, background: col('paper'), borderRadius: mm(3), border: { enabled: false }, padding: { top: mm(3.4), right: mm(5), bottom: mm(4), left: mm(5) }, body: boxText, lists: { color: col('ink'), gap: mm(2), itemSpacing: mm(1.6) } }, ...Object.keys(FAMILIES).flatMap((f) => [ { id: `caja-${f}`, extends: 'caja', label: tab(f), lists: { color: col(f) } }, { id: `dato-${f}`, extends: `caja-${f}`, background: col(`${f}-tint`) }, { id: `palabras-${f}`, extends: `caja-${f}`, border: { enabled: true, color: col(f), width: pt(1.4) } }, { id: `actividad-${f}`, extends: `caja-${f}`, stripe: { enabled: true, side: 'left', width: pt(4), color: col(f) } }, ]), // Plain frames: the title (its tab is decoration), a picture's card and an answer space. { id: 'titulo', extends: 'caja', backgroundEnabled: false, padding: { top: mm(2.6), right: mm(6), bottom: mm(3.2), left: mm(0) } }, { id: 'titulo-plano', extends: 'titulo', padding: { left: mm(8) } }, { id: 'portada', extends: 'caja-agua', background: col('agua'), borderRadius: mm(4), padding: { top: mm(2.6), right: mm(8), bottom: mm(3.6), left: mm(8) } }, { id: 'foto', extends: 'caja', borderRadius: mm(2), padding: { top: mm(1.8), right: mm(1.8), bottom: mm(2.2), left: mm(1.8) } }, { id: 'respuesta', extends: 'caja', padding: none }, { id: 'colofon', extends: 'caja', backgroundEnabled: false, padding: none, body: { ...boxText, fontSize: pt(8), lineHeight: pt(11), color: col('muted') } }, ]; // #endregion // #region answer: one base template and a family of topics that extend it // Ground, title tab and number are decoration anchored to the title frame, so the tab is as // tall as the title needs. A topic extends the base and changes its colours and adds frames. const ground = (f) => ({ kind: 'box', id: 'ground', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } }, style: { backgroundColor: col(`${f}-ground`) } }); const titleTab = (f, x = TAB) => ({ kind: 'box', id: 'tab', decorative: true, placement: { ...at('#titulo', 'top-left', -x), // the tab reaches out to the margin size: { width: mm(HALF), height: { to: '#titulo', edge: 'bottom' } } }, style: { backgroundColor: col(f), borderRadius: mm(4) } }); const badge = (f) => ({ kind: 'text', id: 'number', content: '{attr.numero}', fontFamily: DISPLAY, fontSize: pt(22), fontWeight: 700, color: col(f), align: 'center', box: { backgroundColor: col('paper'), borderRadius: mm(6) }, placement: { ...at('#titulo', 'top-left', 5 - TAB, 2.4), size: { width: mm(12), height: mm(12) } } }); const TAB = 22; // mm of the tab left of the title: the badge stands there, outside the frame const title = { name: 'titulo', takes: 'h1', style: 'titulo', width: mm(HALF - TAB), placement: at('container', 'top-left', TAB) }; const base = { id: 'tema', spread: true, gutter: mm(12), decoration: { elements: [ground('agua'), titleTab('agua'), badge('agua')] }, frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF), fit: { minScale: 0.9 } }], groups: [{ name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'texto'] }], }; const topic = (id, f, more) => ({ id, extends: 'tema', decoration: { elements: [{ id: 'ground', style: { backgroundColor: col(`${f}-ground`) } }, { id: 'tab', style: { backgroundColor: col(f), borderRadius: mm(4) } }, { id: 'number', color: col(f) }] }, ...more }); const box = (name, style, minHeight, more) => ({ name, style, width: mm(HALF), minHeight: mm(minHeight), ...more }); const answer = (name, lines) => ({ name, optional: true, style: 'respuesta', width: mm(HALF), height: mm(LINE * lines), minHeight: mm(LINE * lines), rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } }); // A picture that sits at the corner of a box and pushes its text aside. const corner = (name) => ({ name: `${name}-foto`, layer: 2, width: mm(56), exclude: 'push', placement: at(`#${name}`, 'top-right', -5, 8) }); // #endregion const questions = [1, 2, 3].flatMap((n) => [box(`pregunta-${n}`, 'dato-agua', 14), answer(`respuesta-${n}`, 3)]); const pageTemplates = [ base, // Water: text, a strip of three pictures and a box; three boxes grow down the right page. topic('tema-agua', 'agua', { frames: [ ...[1, 2, 3].map((n) => ({ name: `foto-${n}`, style: 'foto', width: mm((HALF - 10) / 3), ...(n === 1 && { placement: at('#texto', 'below', 0, 8) }) })), box('probar', 'palabras-agua', 46, { placement: at('#foto-1', 'below', 0, 10) }), { ...box('sabias', 'dato-agua', 40), placement: onRight }, { ...box('palabras', 'palabras-agua', 62), placement: onRight }, { ...box('actividades', 'actividad-agua', 58), placement: onRight }, ], groups: [ { name: 'fotos', kind: 'row', gap: mm(5), frames: ['foto-1', 'foto-2', 'foto-3'] }, { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['sabias', 'palabras', 'actividades'] }, ], }), // Field: one map across the spread; under it a text, and three boxes that share a width. topic('tema-campo', 'campo', { frames: [ { name: 'mapa', crossGutter: true, width: mm(SHEET) }, box('actividad', 'actividad-campo', 24), ...[1, 2, 3].map((n) => ({ name: `curso-${n}`, style: 'dato-campo', width: mm(56), minHeight: mm(46), shrink: 1, ...(n === 1 && { placement: at('#mapa', 'below', RIGHT, 6) }) })), ], groups: [ { name: 'izquierda', kind: 'stack', gap: mm(6), frames: ['titulo', 'mapa', 'texto', 'actividad'] }, { name: 'cursos', kind: 'row', gap: mm(6), frames: ['curso-1', 'curso-2', 'curso-3'] }, ], }), // Sun: boxes with a picture at their corner that pushes the text aside. topic('tema-sol', 'sol', { frames: [ box('cuidar', 'palabras-sol', 64, { padding: { right: mm(64) } }), corner('cuidar'), box('turno', 'actividad-sol', 40), ...[1, 2, 3].flatMap((n) => [ box(`uso-${n}`, 'dato-sol', 66, { padding: { right: mm(64) }, placement: onRight }), corner(`uso-${n}`), ]), ], groups: [ { name: 'izquierda', kind: 'stack', gap: mm(12), frames: ['titulo', 'texto', 'cuidar', 'turno'] }, { name: 'derecha', kind: 'stack', gap: mm(12), frames: ['uso-1', 'uso-2', 'uso-3'] }, ], }), // Before the unit: questions, each with room to write. { id: 'saberes', header: false, decoration: { elements: [titleTab('agua', 0)] }, frames: [{ ...title, style: 'titulo-plano', width: mm(HALF), placement: at('container', 'top-left') }, { name: 'texto', flow: true, width: mm(HALF) }, ...questions], groups: [{ name: 'pagina', kind: 'stack', gap: mm(7), frames: ['titulo', 'texto', ...questions.map((q) => q.name)] }] }, // The opener: a painting to the bleed, the title over its foot. { id: 'unidad', header: false, footer: false, decoration: { elements: [ { kind: 'image', id: 'portada', resourceId: 'portada', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(157.5) } } }, { kind: 'text', id: 'kicker', content: '{attr.unidad}', fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, letterSpacing: pt(1), textTransform: 'uppercase', color: col('agua'), box: { backgroundColor: col('paper'), borderRadius: mm(2) }, placement: { ...at('#titulo', 'top-left', 0, -9), size: { height: mm(7) } } }, ] }, frames: [ { name: 'titulo', takes: 'h1', style: 'portada', width: mm(120), placement: at('container', 'top-left', 0, 104) }, { name: 'texto', flow: true, width: mm(160) }, box('aprenderas', 'dato-agua', 36), { name: 'colofon', style: 'colofon', width: mm(HALF), placement: at('container', 'bottom-left') }, ], groups: [{ name: 'pagina', kind: 'stack', gap: mm(7), frames: ['titulo', 'texto', 'aprenderas'] }] }, ]; const head = (id, content, parity, edge, x) => ({ kind: 'text', id, content, parity, fontFamily: TEXT, fontSize: pt(8), fontWeight: 700, letterSpacing: pt(1), color: col('muted'), textTransform: 'uppercase', placement: at('page', edge, x, 9) }); const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 600, color: col('paper'), align: 'center', box: { backgroundColor: col('agua'), borderRadius: mm(4) }, placement: { ...at('page', edge, x, -8), size: { width: mm(8), height: mm(8) } } }); // #region contents: a flow page, because a :::toc cannot be laid out inside a frame yet // The heading style paints the title tab; the entries come from the headings and the parts. const indiceOpener = { enabled: true, minHeight: mm(26), slot: { elements: [ { kind: 'box', id: 'tab', decorative: true, placement: { ...at('container', 'top-left'), size: { width: mm(HALF), height: mm(18) } }, style: { backgroundColor: col('agua'), borderRadius: mm(4) } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(28), fontWeight: 600, color: col('paper'), align: 'left', placement: { ...at('container', 'top-left', 8, 3.2), size: { width: mm(HALF - 12) } } }, ] } }; const toc = { levels: [ { level: 1, fontFamily: DISPLAY, fontSize: pt(15), lineHeight: pt(22), fontWeight: 500, color: col('ink'), numberFontWeight: 600, numberColor: col('agua'), numberWidth: mm(9), marginTop: pt(4) }, { level: 2, fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(18), indent: mm(9), color: col('ink') }, ], pageNumber: { fontFamily: DISPLAY, fontWeight: 500, width: mm(9), color: col('agua') }, leader: { char: '.', gap: mm(1.5) }, parts: { height: pt(34), marginTop: pt(12), marginBottom: pt(4), design: { elements: [ { kind: 'box', id: 'band', decorative: true, placement: { ...at('container', 'top-left'), size: { width: 'fill', height: 'fill' } }, style: { backgroundColor: col('paper'), borderRadius: mm(3) } }, { kind: 'text', id: 'letter', content: '{number}', fontFamily: DISPLAY, fontSize: pt(16), fontWeight: 700, color: col('paper'), align: 'center', box: { backgroundColor: col('agua'), borderRadius: mm(4) }, placement: { ...at('container', 'left', 2, 0), size: { width: mm(8), height: mm(8) } } }, { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('agua'), placement: at('container', 'left', 14, 0) }, ] } }, }; // #endregion // #region art-labels: the map's label styles and anchors; the words are in the Markdown // An anchor is a point of the painting (fractions of its width and height) and a label style; // every language's ::label lines use the same anchors. const halo = { color: col('paper'), width: pt(1.8) }; const labelStyles = [ { id: 'sierra', fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 600, color: col('ink'), textTransform: 'uppercase', letterSpacing: 0.14, halo }, { id: 'agua', fontFamily: TEXT, fontSize: pt(11), fontWeight: 700, fontStyle: 'italic', color: { hex: '#0b3a5c', model: 'hex' }, halo }, { id: 'mar', fontFamily: TEXT, fontSize: pt(12), fontWeight: 700, fontStyle: 'italic', color: col('paper'), halo: { color: { hex: '#0b3a5c', model: 'hex' }, width: pt(1.8) } }, { id: 'pueblo', fontFamily: DISPLAY, fontSize: pt(12), fontWeight: 600, color: col('ink'), halo }, { id: 'curso', fontFamily: DISPLAY, fontSize: pt(10.5), fontWeight: 600, color: col('paper'), textTransform: 'uppercase', letterSpacing: 0.08, background: col('campo'), padding: mm(1.6) }, ]; const ANCHORS = [ { id: 'sierra', x: 0.21, y: 0.055, style: 'sierra' }, { id: 'nacimiento', x: 0.065, y: 0.115, style: 'agua', align: 'start' }, { id: 'fontalba', x: 0.135, y: 0.255, style: 'pueblo', verticalAlign: 'top' }, { id: 'embalse', x: 0.31, y: 0.155, style: 'agua', width: 0.12 }, { id: 'molinar', x: 0.505, y: 0.35, style: 'pueblo', verticalAlign: 'bottom' }, { id: 'aldamar', x: 0.845, y: 0.505, style: 'pueblo', verticalAlign: 'bottom' }, { id: 'mar', x: 0.965, y: 0.28, style: 'mar' }, { id: 'rio', x: 0.61, y: 0.715, style: 'agua', rotate: -3 }, { id: 'alto', x: 0.17, y: 0.45, style: 'curso' }, { id: 'medio', x: 0.4, y: 0.77, style: 'curso' }, { id: 'bajo', x: 0.74, y: 0.865, style: 'curso' }, ]; // #endregion const config = { locale: t({ es: 'es', en: 'en-gb', ca: 'ca' }), resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Picture', ca: 'Dibuix' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, margins: { top: mm(PAGE.top), bottom: mm(PAGE.bottom), left: mm(PAGE.inner), right: mm(PAGE.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17.5), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [ { level: 1, fontSize: pt(28), lineHeight: pt(32), color: col('paper'), marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: false } }, { level: 2, fontSize: pt(15), lineHeight: pt(19), marginTop: pt(9), marginBottom: pt(3) }, ] }, // Headings that are not entries of the unit: no number, no row in the contents. headingStyles: [ { id: 'portada', numbered: false, toc: false, fontSize: pt(46), lineHeight: pt(48) }, { id: 'previo', numbered: false, toc: false }, { id: 'indice', numbered: false, toc: false, fontSize: pt(28), advancedDesign: indiceOpener }, ], toc, parts: { page: false }, // a part is a row of the contents, with no divider page unorderedLists: { bulletChar: '•', color: col('agua'), fontWeight: 700, itemSpacing: pt(3) }, orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 600, color: col('agua'), itemSpacing: pt(4) }, labelStyles, captionStyle: { fontFamily: TEXT, fontSize: pt(9), color: col('ink'), descriptionItalic: true, gap: pt(4) }, calloutStyles, header: { elements: [ head('materia', '{subtitle}', 'even', 'top-left', PAGE.outer), head('unidad', '{title}', 'odd', 'top-right', -PAGE.outer), ] }, footer: { elements: [folio('folio-par', 'even', 'bottom-left', PAGE.outer), folio('folio-impar', 'odd', 'bottom-right', -PAGE.outer)] }, pageTemplates, }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 188 · content.es.md
title: "Nuestro río" subtitle: "Ciencias sociales · 3.º de Primaria" --- ::::page{template="unidad" unidad="Unidad 4 · Ciencias sociales"} # Nuestro río {style="portada"} Todos los pueblos que conoces están cerca del agua. Esta unidad sigue a un río, el Aldara, desde la nieve donde nace hasta el mar donde termina. Vas a descubrir dónde nace un río, cómo cambia en su camino hacia abajo y cómo viven junto a él las personas de Fontalba, Molinar y Aldamar. :::frame{slot="aprenderas" label="Vas a aprender"} - Cómo un manantial se convierte en arroyo, y un arroyo en río. - Qué son el curso alto, el curso medio y el curso bajo de un río. - Cómo usan las personas el agua del río y cómo se cuida. ::: :::frame{slot="colofon"} Compuesto en Nunito y Fredoka (SIL OFL) · Texto original (CC BY 4.0) · Ilustraciones generadas con modelos de difusión ::: :::: # En esta unidad {style="indice"} :::toc{keep="part" levels="1-2"} :::figure{resource="rio"} ::alt[Mapa pintado del valle del río Aldara, desde las montañas nevadas hasta el mar.] ::caption[El río Aldara, desde su nacimiento hasta el mar. El mapa está en las páginas 6 y 7.] ::: ::::page{template="saberes"} # ¿Qué sabes de los ríos? {style="previo"} Antes de empezar, piensa en los ríos que has visto. Escribe tus ideas en las líneas. Al final de la unidad vuelve a leerlas y mira qué ha cambiado. :::frame{slot="pregunta-1" label="Piensa 1"} ¿Dónde crees que empieza un río? ::: :::frame{slot="pregunta-2" label="Piensa 2"} ¿Para qué usan las personas un río? ::: :::frame{slot="pregunta-3" label="Piensa 3"} ¿Qué animales y plantas viven en un río o junto a él? ::: :::: :::part{number="A" title="Un río de la montaña al mar"} ::: ::::page{template="tema-agua" spread numero="1"} # Dónde nace un río Llueve y nieva en las montañas. Parte del agua baja por las laderas y parte se cuela en la tierra. Bajo tierra avanza despacio, hasta que choca con una roca que no puede atravesar. Entonces sale al aire libre. El lugar donde el agua brota de la tierra se llama **manantial**. ## Del manantial al arroyo Un manantial da solo un hilo de agua. El hilo baja por la ladera, se encuentra con otros hilos y entre todos forman un **arroyo**. En una pendiente fuerte, el arroyo corre deprisa. Arrastra guijarros y los deja lisos de tanto rodarlos. Cuando se juntan varios arroyos, forman uno mayor. El río Aldara empieza así, entre la nieve de la sierra Blanca, a 1850 metros sobre el nivel del mar. :::figure{resource="nacimiento" slot="foto-1"} ::alt[Agua clara que brota entre rocas cubiertas de musgo.] ::caption[Un manantial. El agua ha pasado meses bajo tierra.] ::: :::figure{resource="arroyo" slot="foto-2"} ::alt[Un arroyo que salta sobre piedras redondas.] ::caption[Un arroyo, empinado y rápido, con guijarros pulidos.] ::: :::figure{resource="cascada" slot="foto-3"} ::alt[Una pequeña cascada que cae en una poza verde.] ::caption[Una cascada, donde el arroyo cae por un escalón de roca.] ::: :::frame{slot="probar" label="Pruébalo"} Sostén una esponja mojada sobre una bandeja y apriétala con suavidad. ¿Por dónde sale el agua? Mira cómo gotea y luego corre. Así alimenta un manantial a un arroyo. ::: :::frame{slot="sabias" label="¿Sabías que…?"} En su nacimiento, el Aldara es un hilo de agua que se puede saltar. Después de 94 kilómetros es tan ancho como largo es un campo de fútbol. ::: :::frame{slot="palabras" label="Palabras para aprender"} **Manantial:** lugar donde el agua brota de la tierra. **Arroyo:** río pequeño que baja rápido por una pendiente. **Afluente:** arroyo o río que desemboca en otro mayor. **Orilla:** la tierra que hay a los lados de un río. ::: :::frame{slot="actividades" label="Actividades"} 1. Mira las tres imágenes. ¿Cuál muestra un manantial? ¿Cómo lo sabes? 2. ¿Por qué un arroyo corre más rápido en la montaña que en el valle? 3. ¿Cuál de las cuatro palabras del recuadro puedes ver en las imágenes? ::: :::: ::::page{template="tema-campo" spread numero="2"} # De la montaña al mar El río Aldara mide 94 kilómetros. Nace en la sierra Blanca, cruza un valle y una llanura y llega al mar en Aldamar. Los geógrafos dividen un río en tres partes. Cada parte se llama **curso** y cada una tiene un aspecto distinto. :::figure{resource="mapa" slot="mapa"} ::alt[Un mapa pintado del valle: montañas nevadas y un nacimiento a la izquierda, el pueblo de Fontalba, un embalse con su presa, la ciudad de Molinar con su puente y su molino, campos de cultivo, el río que se curva por una llanura y el puerto de Aldamar junto al mar, a la derecha.] ::label[Sierra Blanca]{at="sierra"} ::label[Nacimiento]{at="nacimiento"} ::label[Fontalba]{at="fontalba"} ::label[Embalse de Peñalta]{at="embalse"} ::label[Molinar]{at="molinar"} ::label[Aldamar]{at="aldamar"} ::label[Mar]{at="mar"} ::label[Río Aldara]{at="rio"} ::label[Curso alto]{at="alto"} ::label[Curso medio]{at="medio"} ::label[Curso bajo]{at="bajo"} ::: :::frame{slot="actividad" label="Mira el mapa"} Sigue el río con el dedo desde el nacimiento hasta el mar. ¿Qué población está más cerca del mar? ::: :::frame{slot="curso-1" label="Curso alto"} La pendiente es fuerte. El agua es fría, clara y rápida, y excava la roca. Aquí está Fontalba. ::: :::frame{slot="curso-2" label="Curso medio"} El valle es más ancho y el río va más despacio. Los agricultores riegan sus campos, y Molinar creció junto a un puente y un molino. ::: :::frame{slot="curso-3" label="Curso bajo"} La tierra es casi llana. El río es ancho y lento, y dibuja grandes curvas. En Aldamar desemboca en el mar. ::: :::: :::part{number="B" title="Las personas y el río"} ::: ::::page{template="tema-sol" spread numero="3"} # Las personas y el río Las personas siempre se han instalado cerca de los ríos. Necesitan agua para beber, para lavarse y para cultivar alimentos. Un río también da peces, un camino para las barcas y la fuerza que hace girar una rueda. ## Cuidamos el río Un río solo se mantiene limpio si lo cuidamos. La basura, el aceite y el agua sucia lo enferman, y los peces y las plantas que viven en él lo sufren. Todo lo que cae por un desagüe o por la calle acaba en un río o en el mar. :::frame{slot="cuidar" label="Cuidamos nuestro río"} - Tiramos la basura a la papelera, nunca al agua. - Gastamos el agua con cuidado y cerramos el grifo. - Vamos por los caminos y no molestamos a plantas ni animales. - Ayudamos a limpiar la orilla cuando lo organiza el colegio. ::: :::frame{slot="turno" label="Te toca a ti"} 1. Dibuja un cartel con tres normas para mantener limpio nuestro río. 2. Pregunta en casa: ¿de dónde viene el agua de tu grifo? ::: :::figure{resource="limpieza" slot="cuidar-foto"} ::alt[Niños con guantes y bolsas de papel recogiendo basura en la orilla de un río.] ::: :::frame{slot="uso-1" label="1 · Agua para los campos"} En el curso medio, los agricultores abren pequeñas acequias para que el agua del río corra entre los surcos. Sin ella, los tomates y las lechugas no crecerían en verano. Una sola acequia puede llevar agua a muchos kilómetros de distancia. ::: :::figure{resource="riego" slot="uso-1-foto"} ::alt[Un agricultor levanta una compuerta de una acequia en un campo de hortalizas.] ::: :::frame{slot="uso-2" label="2 · Molinos"} Hace mucho tiempo, el agua al caer movía la rueda del molino de Molinar. La rueda hacía girar las piedras que molían el trigo para hacer harina para todo el valle. Hoy el viejo molino es un pequeño museo. ::: :::figure{resource="molino" slot="uso-2-foto"} ::alt[Un viejo molino de piedra con una gran rueda de madera junto a un río.] ::: :::frame{slot="uso-3" label="3 · Deporte y juegos"} Ahora el curso bajo, tranquilo, es un lugar para las piraguas. En las mañanas de verano, los niños de Aldamar aprenden a remar con un monitor, y todos llevan chaleco salvavidas. ::: :::figure{resource="piraguas" slot="uso-3-foto"} ::alt[Niños con chalecos salvavidas reman en dos piraguas por un río tranquilo.] ::: ::::`; // content.<lang>.md, inlined by the Cookbook // #region art: nine paintings (JPEGs in assets/), the pictures of the unit // Painted with a diffusion model in a coloured-pencil and watercolour manner, cut to the shape // of the frames that show them and declared with their pixels. Nothing is written on them: the // names on the map are ::label lines of the Markdown, standing at the anchors below. const ART = { portada: ['portada-1400.jpg', 1400, 1021, 165], mapa: ['mapa-1900.jpg', 1900, 730, 122], ...Object.fromEntries(['nacimiento', 'arroyo', 'cascada', 'riego', 'molino', 'piraguas', 'limpieza'].map((id) => [id, [`${id}-720.jpg`, 720, 540, 330]])) }; const resources = await Promise.all(Object.entries(ART).map(async ([id, [fileId, w, h, ppi]]) => { await loadImage(fileId, asset(fileId)); return { id, typeId: 'dibujo', kind: 'bitmap', caption: '', altText: '', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: 'jpeg', width: w, height: h, resolution: ppi }, ...(id === 'mapa' && { anchors: ANCHORS }) }; })); // The map again with no anchors, small, under the contents. resources.push({ ...resources.find((r) => r.id === 'mapa'), id: 'rio', anchors: [] }); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Nunito: ['400', '400i', '700'], Fredoka: ['500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'Nuestro río', en: 'Our river', ca: 'El nostre riu' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);العُدّة · core, fonts, viewer, pdf, images: نفسها في كل وصفة · أسطر: 315
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { 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 text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ↗ (يفتح في تبويب جديد)
تنويعات
#أضف عائلة رابعة
موضوع عن البحر مدخل رابع في FAMILIES وقالب يُصنع بـ topic('tema-mar', 'mar', { frames }). وتكتب الحلقة على FAMILIES أنماطه، caja-mar وdato-mar وبقيتها.
- sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+ sol: { main: '#9a5209', tint: '#fcefd2', ground: '#f8e6b8' },
+ mar: { main: '#0f5a6b', tint: '#dcf0f2', ground: '#cbe6ea' },#أبقِ النص بحجمه
الإطار الذي لا يجوز تصغير نصه يأخذ fit: { shrinkText: false }. فيضغط النص الأطول بقوة أكبر على الإطارات المجاورة ويبلغ السلّم الصور أبكر.
- frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
- fit: { minScale: 0.9 } }],
+ frames: [title, { name: 'texto', flow: true, columns: 2, columnGap: mm(7), width: mm(HALF),
+ fit: { shrinkText: false } }],أخطاء شائعة
خطأ شائع
لا يمكن تنضيد فهرس المحتويات داخل إطار من صفحة مركّبة
يُنضَّد :::toc داخل :::frame من صفحة مركّبة كفقرات الإطار: لا تُرسم صفوف الأجزاء المعرّفة في toc.parts.design وتترك سطرًا فارغًا، ويتبع كل مدخل تباعدُ الفقرات في المستند، ويظهر في القائمة عنوان الفهرس نفسه. نضّد الفهرس صفحةً من النص المتصل: نمط عنوان (numbered: false وtoc: false) يرسم advancedDesign فيه العنوان، ثم :::toc{keep="part"} في النص، حيث تعمل صفوف الأجزاء والنقاط الإرشادية وقاعدة keep. ويتوقف تصميمه عند هامش العمود، فلون خلفية الصفحة ينتظر أن ينضّد المحرّك الفهرس صفحةً مركّبة. جدول المحتويات →
- اسم مرسوم داخل صورة لا يتغيّر مع اللغة: لا كلمة على الخريطة، وكل اسم تسمية.
- عنصر زخرفة تحت إطار لا يمكن النقر عليه في Sandbox، ولهذا يقف رقم كل موضوع يسار إطار العنوان لا داخله.
- شكلان يضبطان المورد نفسه يأخذان كلمات الأول: الخريطة الصغيرة في الفهرس هي الملف نفسه تحت مورد آخر،
rio، بلا مراسٍ.
الحقوق
- الوصفة
- Your Name
- النص
- Our river, a children's textbook unit written for this recipe in Spanish, Catalan and English · Postext Cookbook · CC BY 4.0
- الصور
- The valley of the River Aldara, painted from the mountains to the sea, with two children watching the water. · Generated With Diffusion Models · أصلي
- The painted map of the valley, from the spring to the port, with no lettering: the names are labels set from the Markdown. · Generated With Diffusion Models · أصلي
- A spring between mossy rocks. · Generated With Diffusion Models · أصلي
- A mountain stream over round stones. · Generated With Diffusion Models · أصلي
- A small waterfall into a green pool. · Generated With Diffusion Models · أصلي
- A farmer opening the gate of an irrigation canal. · Generated With Diffusion Models · أصلي
- An old stone watermill with its wheel. · Generated With Diffusion Models · أصلي
- Children paddling canoes on a calm river. · Generated With Diffusion Models · أصلي
- Children and a teacher picking up litter on a river bank. · Generated With Diffusion Models · أصلي
- الخطوط
- Nunito (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


