باختصار
ورقة عمل في العلوم لأطفال في الثامنة، عن أجزاء النبتة. تحت كل سؤال صندوق للإجابة، وفي بعض الجمل فراغات تُملأ من قائمة كلمات.
ما الذي ستنضده
ورقة العمل 3A من Seedlings Science، وهي سلسلة متخيَّلة لأطفال في الثامنة، على أربع صفحات مقاسها 200 × 260 mm مطبوعة على وجه واحد. شريط بلون أخضر الأوراق يحمل كبسولة صفراء للوحدة، والعنوان، وخانات بيضاء للاسم والتاريخ؛ وبجانبه نبتة بازلاء بالألوان المائية في قرص أبيض، أجزاؤها الستة مرقّمة بالأصفر. الرسم المائي مولَّد بنماذج الانتشار (diffusion models)، ويأتي جاهزًا داخل قرصه، في ملفي JPEG يلتقيان عند حافة الشريط: العلوي في زواياه أخضر الشريط، والسفلي في زواياه أبيض؛ أما الأرقام وخطوط الدلالة فتبقى في الشيفرة، فوق الصورة. كل نشاط عنوان مرقّم فوق بطاقة خضراء فاتحة. تحمل البطاقات صناديق إجابة بيضاء فيها عبارة رمادية صغيرة ومساحة لعدة أسطر، وبنوك كلمات من شارات صفراء، وفراغات محددة الإطار داخل الجمل، وجدولًا لتوصيل الأطعمة بأجزاء النبتة، وقائمة تحقق من دوائر للتلوين. كل صندوق إجابة يقع على بعد 2 mm تحت سؤاله و5 mm فوق ما يليه، وكل بطاقة تنتهي على بعد 9 mm فوق ما يأتي بعدها في صفحتها. النص منضّد بخط Andika، الذي صممته SIL للأطفال الذين يتعلمون القراءة.
تجيب هذه الوصفة عن
- كيف أضع إطارات بعضها داخل بعض، كبطاقة ورقة عمل تضم إطارات إجابات، بتباعد دقيق؟
- كيف أبني صفحة ورقة عمل: أسطر للتعبئة، وإطارات إجابات، وبنوك كلمات، وقوائم تحقق؟
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
- كيف أضع صورًا أو أيقونات داخل خلايا الجدول؟
الجواب المختصر
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
// :::callout{type="card"}
// What is inside the pea pods?
// :::callout{type="answer"}
// Write here ← a prompt first: a box of :::space alone collapses
// :::space{lines=3} (gotcha: space-dropped-box-top)
// :::
// :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
id: 'card', background: col('tint'), borderRadius: mm(4),
padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
// Off the grid, or what follows a card drops to the next grid line. Only the outer card can
// set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
snapToGrid: false,
body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
// A small grey prompt on a full 17 pt line; :::space counts in these body lines.
body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
المكونات
- الميزات
- إطارات متداخلةالخروج عن الشبكة عمدًاالشارات داخل السطرإطارات التنبيهأعمدة داخل الإطارمسافة عمودية صريحةأنماط العناوينالعناوين المرقّمةفصول بلا أرقامصفحات افتتاح مصمَّمةالنصوص والخطوط والمربعات في تصاميم الصفحةالصور في تصاميم الصفحةسمات العنوانفواصل الأسطر في العناوينأنماط الجداول المسمّاةصور في خلايا الجداولأنواع موارد مخصّصةأشكال في هذا الموضع بالضبطالقوائم النقطية وقوائم التحققالقوائم المرقّمة
- الخطوط
- Andika, Baloo 2, Fredoka (SIL OFL 1.1)
- الأصول
plant-1092.jpgplant-foot-1092.jpg- The pea plant on the opener, a watercolour (top of the disc) (Generated With Diffusion Models, أصلي)
- The pea plant’s roots, the foot of the same watercolour below the band (Generated With Diffusion Models, أصلي)
- The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette (Ignacio Ferro, MIT)
طريقة التحضير
#1 · أخرج العناوين من الشبكة أيضًا
headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
balancing: { enabled: false }, // no extra space above headings to fill out a page
levels: [
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
{ level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
advancedDesign: activity },
] },
في الجواب المختصر، تنطوي هوامش كل صندوق مع هوامش جيرانه ويغلب الأكبر. فالـ 5 mm الخاصة ببنك الكلمات تغلب الـ 2 mm فوق القائمة التي تحته، والـ 5 mm الخاصة بآخر صندوق إجابة تقع داخل الحشوة السفلية للبطاقة البالغة 5 mm بدل أن تُضاف إليها. إذا بقي عنوان المستوى الثاني على الشبكة حجز بين 10.8 و14.1 mm بدل الـ 8.6 mm التي لقرصه، لأن ما يليه يعود ليلتصق بالسطر التالي ذي 17 pt؛ ويُبقيه headings.snapToGrid: false عند 8.6 mm. ضبط snapToGrid على الأنماط المتداخلة لا يغيّر شيئًا، لأن البطاقة الخارجية وحدها تستطيع مغادرة الشبكة (الصناديق المتداخلة). موازنة الأعمدة معطّلة لأنها تضيف مساحة فوق العناوين لتملأ الصفحة؛ ففي الصفحة 2 كانت ستضع 33 mm فوق النشاط 3 بدل 9.
#2 · اجعل كل فراغ شارة
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
الشارة صندوق غير قابل للكسر في السطر، وحشوتها تُرسم خارج السطر من غير أن تزيد ارتفاعه. بنك الكلمات صف من الشارات في صندوق bank متداخل. والفراغات والنقاط التي تُوصَل والدوائر التي تُلوَّن شارات أيضًا، لا يحمل كلٌّ منها إلا رابط الكلمات U+2060، لأن الشارة المؤلفة من مسافات تطبع شيفرتها في postext 1.4.1 (الشارات داخل السطر). يحدد paddingX العرض: الفراغ عرضه 34 mm داخل جملة و38 mm وحده في عمود، والدائرة تأخذ نصف ارتفاعها البالغ 1.25 em على كل جانب. في قائمة التحقق تقف الدائرة حيث كانت ستقف النقطة (bulletChar: ''). بارتفاع 19.6 pt يكون الفراغ أطول من السطر البالغ 17 pt، ولهذا لكل فراغ بند قائمة خاص به، على بعد 24 pt من التالي. وفي الإصدار 1.4.1 ما زال Sandbox يعرض التحذير «الشارات تلامس السطر التالي» لـ blank وwide وtick، لأنه يقارن كل شارة بخطوة السطر البالغة 17 pt ويغفل الـ 7 pt التي بين بنود القائمة.
#3 · رقّم الأنشطة بتصميم عنوان
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
placement: { anchor: { to: 'container', edge: 'top-left' },
size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
activityTitle] } } };
عنوان المستوى الثاني ذو advancedDesign يحتفظ بمكانه في العمود، ويُرسم التصميم بدل نصه: قرص يحمل {number}، الذي يطبع numberingTemplate الخاص بالمستوى، و{titleText} على يمينه (الامتداد والتصميم المتقدم). القرص والعنوان ارتفاع كل منهما 8.6 mm، وخارج الشبكة يحجز العنوان هذا المقدار بالضبط، فلا يحتاج التصميم إلى minHeight. ولأن الأرقام تأتي من عدّاد المستوى الثاني، فإن نشاطًا يُدرج في ملف Markdown يعيد ترقيم ما بعده. عنوان التقييم الذاتي يستعمل نمطًا فيه numbered: false، فيُستبعد من العدّ، وتقف نجمة SVG حيث كان القرص سيقف.
#4 · ضع الصور في خلايا الجدول
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
// White rules on the white panel: unseen, but they hide the canvas's seams between cells.
rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
placement: { position: 'here' }, table: { styleId: 'match', model: {
columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
يرسم TableCell.image موردًا داخل خليته، هنا بنسبة 76 % من عرض الخلية، ويكبر الصف ليتسع له. ويضع verticalAlign: 'middle' اسم الطعام والنقاط والشارة في وسط الصورة. العمود الأعرض، 31 % من الجدول، يبقى فارغًا لخط التلميذ. النمط المسمّى يدوّر إطار الجدول ويقصّ تعبئة المتن البيضاء عليه. وخطوط شبكته بيضاء أيضًا: اللوحة (Canvas) تترك خطًّا شعريًا باهتًا بين الخلايا المعبّأة، وخطٌّ أبيض بسماكة 1 pt على اللوح الأبيض يغطيه (أنماط الجداول المسمّاة). نوع موردها له captionPrefix فارغ، وليس للجدول نص تعليق، فلا يُطبع تحته تعليق. ويضع placement: { position: 'here' } الجدول حيث يقف سطر ::resource الخاص به، داخل البطاقة.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════ // https://postext.dev/en/cookbook/worksheet-answer-boxes // Code: MIT · Text: original (CC BY 4.0) · Plant: diffusion models · Icons: drawn in code // Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'worksheet-answer-boxes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines // Design elements read the hex and ignore the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror // #region answer: cards that hold white answer boxes, stacked exactly, off the grid // In the Markdown a box inside a box is a fence inside a fence (each ::: closes one): // :::callout{type="card"} // What is inside the pea pods? // :::callout{type="answer"} // Write here ← a prompt first: a box of :::space alone collapses // :::space{lines=3} (gotcha: space-dropped-box-top) // ::: // ::: const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card const card = { id: 'card', background: col('tint'), borderRadius: mm(4), padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) }, marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows // Off the grid, or what follows a card drops to the next grid line. Only the outer card can // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits). snapToGrid: false, body: { paragraphSpacing: false }, // the boxes' margins do all the spacing lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6), }; // A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the // last box's marginBottom falls inside the card's padding instead of adding to it. const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5), marginTop: mm(ASK), marginBottom: mm(AFTER), ...look }); const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) }, padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) }, // A small grey prompt on a full 17 pt line; :::space counts in these body lines. body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } }); const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }), padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) }, titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'), textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } }); // config() plugs in all three: calloutStyles: [card, answer, bank]. // #endregion // #region chips: words to pick, blanks to fill, dots to join, circles to colour const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill), borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look }); // An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha: // empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle. const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)]; // A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so // the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap). const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4), paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) }); const chipStyles = [ chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }), blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }), chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }), ]; // #endregion // #region activity: an in-column H2 design: the automatic number in a green disc const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}' fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'), box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(DISC_H2), height: mm(DISC_H2) } } }; const activityTitle = { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } }; const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } }; // ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc. const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: { elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement }, activityTitle] } } }; // #endregion const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size const DISC = { x: 108, y: 5, d: 78 }; // mm: the plant's disc on the page const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) }, size }); const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'), align: 'left', overflow: 'wrap', placement, ...extra }); const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) }; const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2, DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center', box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag), { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) }, placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field // Images reserve no height in an opener (gotcha: opener-image-no-reserve), so minHeight does. const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') }, placement: at(0, 0, { height: mm(BAND) }) }, ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]] .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })), ...LABELS.map(dot), words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'), box: { backgroundColor: col('sun'), borderRadius: mm(3), padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }), words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }), { lineHeight: 0.98 }), // two lines: the headings break with \\ words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)), ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58), ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22), ] } }); // #region tables: a rounded panel with a picture in each row and chips to join const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'), bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4), // White rules on the white panel: unseen, but they hide the canvas's seams between cells. rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }]; const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra }); const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' }); const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 }, align: 'center' }), cell(food), dotCell(), cell(''), dotCell(), cell(`:chip[${part}]{style="word"}`)]; const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number // Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist. const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, table: { styleId: 'match', model: { columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } }); // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] }, footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL, 7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] }, page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20), left: mm(SIDE), right: mm(SIDE) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // #region grid: the headings leave the grid too, and nothing stretches the gaps headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid balancing: { enabled: false }, // no extra space above headings to fill out a page levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() }, { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0), advancedDesign: activity }, ] }, // #endregion orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high headingStyles: [review], calloutStyles: [card, answer, bank], paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 119 · content.en.md
title: "Seedlings Science" subtitle: "Year 4" --- # Plants and \\ their parts {unit="Unit 3" sheet="Worksheet 3A"} :::paragraphs{style="lead"} Every part of a plant has a job to do. In this worksheet you will name the parts of a pea plant and find out what each one does and which ones we eat. You will need a pencil and some coloured pencils. ::: ## Look closely :::callout{type="card"} What is inside the pea pods on the plant at the top of the page? :::callout{type="answer"} Write here :::space{lines=4} ::: Which part of the plant grows under the ground? Why does the plant need it? :::callout{type="answer"} Write here :::space{lines=6} ::: ::: ## Name the parts :::callout{type="card"} Six parts of the pea plant on page 1 have a number. Choose a word from the word bank and write it next to the same number. :::callout{type="bank"} :chip[stem]{style="word"} :chip[seeds]{style="word"} :chip[flower]{style="word"} :chip[roots]{style="word"} :chip[leaf]{style="word"} :chip[fruit]{style="word"} ::: :::columns{count=3} 1. :chip[]{style="wide"} 2. :chip[]{style="wide"} 3. :chip[]{style="wide"} 4. :chip[]{style="wide"} 5. :chip[]{style="wide"} 6. :chip[]{style="wide"} ::: ::: ## What does each part do? :::callout{type="card"} Finish each sentence with a word from the word bank. :::callout{type="bank"} :chip[food]{style="word"} :chip[insects]{style="word"} :chip[water]{style="word"} :chip[plant]{style="word"} :chip[upright]{style="word"} :chip[seeds]{style="word"} ::: 1. The **roots** hold the plant in the soil and take in :chip[]{style="blank"}. 2. The **stem** keeps the plant :chip[]{style="blank"} and carries water to the leaves. 3. The **leaves** use sunlight to make :chip[]{style="blank"} for the plant. 4. The **flower** has bright petals that attract :chip[]{style="blank"} such as bees. 5. The **fruit** grows around the :chip[]{style="blank"} and keeps them safe. 6. A **seed** can grow into a new :chip[]{style="blank"}. ::: **Did you know?** A pea plant cannot stand up on its own. It climbs by wrapping thin, curly tendrils around anything it touches. ## Parts we eat :::callout{type="card"} We eat many different plant parts. Draw a line from each food to the part of the plant it comes from. :::space{lines=0.33} ::resource{id="foods"} ::: ## Draw and label :::callout{type="card"} Draw a plant you have seen at home or at school. Label at least four of its parts. :::callout{type="answer"} My plant is called :::space{lines=7} ::: ::: ## Try it at home :::callout{type="card"} Put a dried pea on some wet cotton wool in a clear jar. Keep the cotton wool damp and look at the pea every day for a week. What do you think will come out of the pea first: a root or a shoot? :::callout{type="answer"} I think :::space{lines=3} ::: After a week, draw or write what you saw. :::callout{type="answer"} I saw :::space{lines=6} ::: ::: ## How did I do? {style="review"} :::callout{type="card"} Colour a circle for each thing you can do. - :chip[]{style="tick"} I can name the six parts of a plant. - :chip[]{style="tick"} I can say what each part does. - :chip[]{style="tick"} I can tell which part of a plant a food comes from. ::: :::paragraphs{style="colophon"} Seedlings Science is an invented series made for the Postext Cookbook. Text: CC BY 4.0. Plant: diffusion models. Set in Andika, Baloo 2 and Fredoka (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // The matching table's words. The parts are shuffled so that no food faces its own part. const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans', es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|'); const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower', es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|'); // #region art: the plant's leader lines, the six foods and the star, in the palette's colours // The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's // edge: the top one's corners carry the band's green, the foot's are white. One picture would // resample its green-to-white step and print a pale line along the band's edge. No words in // the drawings: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); the // plant's numbers are design elements set in Baloo 2 over the picture (see the band). // [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts. const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]]; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" ` + 'stroke-linecap="round" stroke-linejoin="round"'); const leafShape = (x, y, len, wid, turn, fill) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ${n(-wid)} ` + `${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}</g>`; function leaders() { // from each dot to its part, in ink on a white halo that clears the soil // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds), // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot. const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4], 5: [38.8, 52], 6: [38.4, 64] }; const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join(''); return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35) + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink, ` stroke="${palette.paper}" stroke-width=".3"`)).join('')); } function star() { // the self-check's badge: a white star on a sun disc const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? 4.4 : 9.4; return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`; }); return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`, palette.paper), 10); } const food = { carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 ' + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2', palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a, palette.leaf)).join(''), asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19` + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} ` + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('') + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun), lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2' + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf) + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>` + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45) + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`; }, broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8], [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr, palette.leaf)).join(''), orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30, palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'), beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=` + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0' + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''), }; const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order const drawings = { leaders, star, ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20, draw())])) }; const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])), plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6', es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las ' + 'raíces numerados del 1 al 6' }), leaders: '', star: t({ en: 'A white star on a yellow disc', es: 'Una estrella blanca en un disco amarillo' }) }; // Each picture is a resource that the opener, a table cell or the review heading names by // id. None is cited, so none is placed as a figure. Drawings in px, 10 to the millimetre. const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 200, ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } })); // The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm. pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet', kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } }))); // #endregion const resources = [...pictures, foods()]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], // (gotcha: fonts-first) Fredoka: ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())), ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });العُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#أعد البطاقات إلى الشبكة
مع القيمة الافتراضية snapToGrid: true، ينزل ما يلي البطاقة إلى خط الشبكة التالي ذي 17 pt، فتصير الـ 9 mm تحت كل بطاقة 14.5 mm تحت النشاط 2، و13.2 mm تحت النشاط 4، و11.2 mm تحت النشاط 6.
- snapToGrid: false,
+ snapToGrid: true,#أعطِ الإجابة مساحة أكبر
يعدّ :::space بأسطر صندوق الإجابة نفسه البالغة 17 pt، وكل سطر يُضاف يعطي التلميذ 6 mm إضافية للكتابة.
Write here
-:::space{lines=4}
+:::space{lines=6}أخطاء شائعة
خطأ شائع
يُحذف :::space في أعلى الإطار أو العمود
يُحذف :::space في أعلى العمود أو الإطار أو مجموعة :::columns، حتى إن كان المحتوى الوحيد للإطار، فينطوي إطار الإجابة المكوَّن من فراغ فقط. افتتح الإطار بعنوان أو بسطر سؤال، ثم أضف الفراغ. مسافة عمودية صريحة →
خطأ شائع
الشارة المكوّنة من مسافات فقط تطبع ترميزها
الشارة التي لا يحتوي نصها إلا مسافات، بما فيها المسافات غير القابلة للكسر، تطبع :chip[ ] حرفيًا. لشارة إجابة فارغة ضع داخلها رابط الكلمات (U+2060). الشارات داخل السطر →
خطأ شائع
الإطار المتداخل يتجاهل span وplacement وsnapToGrid
الإطار المتداخل في إطار آخر يتجاهل span وplacement وsnapToGrid وfloatBarrier الخاصة به: يجري دائمًا داخل الإطار الأم، بالعرض الداخلي للأم. إطارات متداخلة →
خطأ شائع
الشارات الأطول من خطوة السطر تلامس السطر التالي
الشارة التي يزيد ارتفاع إطارها على خطوة السطر تلامس شارات السطر التالي (التحذير chipOverlap). قلّل حشوها العمودي أو حدّها أو مقاسها، أو زِد تباعد الأسطر. الشارات داخل السطر →
خطأ شائع
الجدول أو الشكل داخل الإطار لا يحصل على فراغ حوله
في postext 1.4.1 لا يحصل الجدول أو الشكل الذي يضعه ::resource في الموضع 'here' داخل :::callout على أي من الفراغ الذي يحتفظ به في النص الجاري: يلامس الفقرة التي فوقه والتي تحته. ضع :::space قبل سطر ::resource، وآخر بعده إن تبعه نص؛ وجزء من سطر، مثل lines=0.33، يعطي فراغًا صغيرًا. أشكال في هذا الموضع بالضبط →
خطأ شائع
صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه
في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب
يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا
يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
فحص Sandbox · chipOverlap
الشارات تلامس السطر التالي
السبب. صندوق نمط الشارة أطول من خطوة السطر، فتتلامس الشارات في الأسطر المتتالية.
الحل. قلّل الحشو الرأسي للشارة أو حدّها الخارجي أو حجمها، أو زد تباعد الأسطر. التوثيق →
- في postext 1.4.1 لا يستطيع الصندوق أن يسطّر أسطر الكتابة الخاصة به، لأن أنماط الإطارات ليست لها خلفية مسطّرة. تبقى صناديق الإجابة فارغة وتأخذ ارتفاعها من
:::space، وأسطر الإكمال القصيرة شارات محددة الإطار. - نمط العنوان الذي فيه
numbered: falseيظل يرسم تصميم مستواه، مع ترك{number}فارغًا. من دون تصميم خاص به كان التقييم الذاتي سيبدأ بقرص أخضر فارغ، لذلك ينسخreviewتصميم النشاط ويضع النجمة حيث كان القرص.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- The pea plant on the opener, a watercolour (top of the disc) · Generated With Diffusion Models · أصلي
- The pea plant’s roots, the foot of the same watercolour below the band · Generated With Diffusion Models · أصلي
- The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette · Ignacio Ferro · MIT
- الخطوط
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


