باختصار
ثلاث صفحات من كتاب قراءة إسباني للأطفال الصغار عن الحرف M. يقف كل مقطع في بطاقة ملونة صغيرة، ولكل كلمة في الشبكة صورتها الخاصة.
ما الذي ستنضده
الوحدة 7 من Letra a letra، كتاب إسباني متخيَّل لتعليم القراءة: ثلاث صفحات عن الحرف M. تعلّم كتب القراءة الإسبانية القراءة مقطعًا مقطعًا، ولذلك العينة بالإسبانية وحدها. تضع صفحة الافتتاح Mm بحرف DynaPuff بحجم 190 pt على شريط بلون القشدة بجوار شيئين يبدأ اسماهما الإسبانيان بحرف m، فراشة (mariposa) وتفاحة (manzana). مقاطع الوحدة شارات بلون حرفها الصوتي: a أحمر، e برتقالي، i أزرق مخضر، o أزرق، u أرجواني. المقاطع التي فيها m مملوءة والباقية محددة الإطار فقط، فيرى الطفل أي نصفَي mesa ينتمي إلى هذه الوحدة. تضع الصفحة 38 ست كلمات مصوّرة في شبكة مستديرة الزوايا، في كل خلية لوحة مائية، ثم ثلاثة أسطر من الحروف للتتبع. اللوحات المائية مولَّدة بنماذج الانتشار؛ أما الشريط والأيقونات وأسطر التتبع فمرسومة بالشيفرة. وفي الصفحة 39 أربع جمل بحجم 24 pt، وصندوق مقاطع للتلوين، وملاحظة للأسرة.
تجيب هذه الوصفة عن
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
- كيف أضع صورًا أو أيقونات داخل خلايا الجدول؟
- كيف أضيف فراغًا رأسيًّا إضافيًّا بين كتلتين، إذا لم تُجدِ الأسطر الفارغة شيئًا؟
- كيف أبني صفحة ورقة عمل: أسطر للتعبئة، وإطارات إجابات، وبنوك كلمات، وقوائم تحقق؟
الجواب المختصر
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
{ ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
{ ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
{ ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, end) => `:chip[${s}]{style="${vowel(s)}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
before + word.split('·').map((s) => chipOf(s, before ? '-frase' : '')).join(''));
المكونات
- الميزات
- الشارات داخل السطرصور في خلايا الجداولصفحات افتتاح مصمَّمةسمات العنوانأنماط العناوينالصور في تصاميم الصفحةالنصوص والخطوط والمربعات في تصاميم الصفحةتثبيت عناصر التصميمالترويسات وأرقام الصفحاتأنماط الجداول المسمّاةأنماط الفقراتمسافة عمودية صريحةإطارات التنبيهلوحة ألوان دلاليةالأشكال والجداول بوصفها مواردأنواع موارد مخصّصةأشكال في هذا الموضع بالضبطصفحات على اللوحة (Canvas)
- تستخدم أيضًا
- موازنة الأعمدةشريط فصل بعرض الصفحة
- الخطوط
- Andika, DynaPuff, Playpen Sans (SIL OFL 1.1)
- الأصول
mano-640.jpgmanzana-480.jpgmapa-640.jpgmariposa-660.jpgmesa-640.jpgmimosa-640.jpgmono-640.jpgmuneca-640.jpg- A watercolour of the butterfly on the opener (Generated With Diffusion Models, أصلي)
- A watercolour of the apple on the opener (Generated With Diffusion Models, أصلي)
- A watercolour of the hand in the picture grid (Generated With Diffusion Models, أصلي)
- A watercolour of the map in the picture grid (Generated With Diffusion Models, أصلي)
- A watercolour of the table in the picture grid (Generated With Diffusion Models, أصلي)
- A watercolour of the mimosa sprig in the picture grid (Generated With Diffusion Models, أصلي)
- A watercolour of the monkey in the picture grid (Generated With Diffusion Models, أصلي)
- A watercolour of the doll in the picture grid (Generated With Diffusion Models, أصلي)
- The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette (Ignacio Ferro, MIT)
طريقة التحضير
#1 · نمط شارة لكل حرف صوتي، ودالة تكتب الشارات
يبني الجواب المختصر 21 نمط شارة بنشر كائن syllable واحد. هناك نمط مملوء لكل حرف صوتي للمقاطع التي فيها m، ونمط محدد الإطار لكل حرف صوتي للبقية، ونظير -frase لكل واحد من هذه العشرة، وpinta، الإطار الحبري الذي يلوّنه الطفل. تحوّل syllables() الصيغة {mi·mo·sa} إلى ثلاثة :chip[…] لا شيء بينها، فتتلاصق مقاطع الكلمة ويرسم الحد الأبيض الوصلة. وعبر مسافة الكلمة تحتفظ الشارة بمقدار gap الخاص به على الأقل، و0.5 em تباعد بين كلمات صف بحجم 24 pt بمقدار 4.2 مم، حيث لا تترك المسافة وحدها إلا 2.3 مم. وداخل الجملة كانت تلك الفجوة ستضاعف تقريبًا المسافة قبل الكلمة الجديدة، فالكلمة التي تأتي بعد كلمة أخرى تأخذ نظائر -frase، التي يترك gap فيها، وقيمته 0، مسافة 2.3 مم كما هي.
#2 · تباعد أسطر يتسع للشارات
// A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
// stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
paragraphStyles: [
{ id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
color: col('muted') }, // the instruction under each activity
{ id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
{ id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
{ id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
{ id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
color: col('muted'), marginTop: pt(GRID) },
],
الحشوة العمودية للشارة وحدّها يُرسمان خارج سطره ولا يضيفان إلى تباعد الأسطر، فالشارة الأطول من خطوة السطر تصطدم بشارات السطر الذي تحته. ارتفاع شارة المقطع بحجم 24 pt هو 10.4 مم؛ ومع تباعد أسطر 30 pt (10.6 مم) كانت شارات جملتين ستقف على بعد 0.1 مم من بعضها. تأخذ الأسطر التي يقرؤها الطفل 45 pt وصفوف المقاطع 60 pt، أي ثلاثة وأربعة أسطر من شبكة التعليمات البالغة 15 pt.
#3 · صورة في كل خلية
const PICTURE_WORDS = ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'];
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
.map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
تضع setCellImage صورة فوق نص الخلية بنسبة من العرض الداخلي للخلية، 0.72 هنا (36.5 مم)، ويطول الصف ليتسع لها. كل صورة ملف JPEG مرسوم على الأبيض ومضروب في لون القشدة الذي للمربعات، فتستقر على المربع بلا صندوق. وتحت كل صورة الكلمة في شارات مقاطع. تقبل خلية الجدول ترميز الشارات كأي فقرة. ويدوّر نمط الجدول dibujos الإطار وتعبئاته بمقدار 5 مم ويفصل مربعات القشدة بخطوط بيضاء بسماكة 4 pt.
#4 · سطر مسافة واحد قبل الصندوق
calloutStyles: [
{ id: 'colorea', background: col('cream'), borderRadius: mm(4),
marginTop: pt(0), // the :::space before the box is the whole gap above it
padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
{ id: 'familia', backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
...tag },
body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
],
يضع Markdown :::space{lines=1} بين الجملة الأخيرة وصندوق التلوين، سطرًا واحدًا بحجم 15 pt (5.3 مم). وmarginTop للصندوق 0، فذلك السطر هو الفجوة كلها؛ ومن دونه كان الصندوق سيبدأ حيث ينتهي سطر الجملة الأخيرة. ويُسقَط :::space الذي يفتتح صندوقًا، فيبدأ الصندوق بسطر الطلب وشارات مفتاح الألوان، ويأتي :::space الخاص به بعدها، بين المفتاح ومهمة التلوين.
#5 · صفحة افتتاح الحرف
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// Images reserve no height in an opener (gotcha: opener-image-no-reserve): by itself the
// opener ends at the foot of the word manzana, 88.7 mm down, and the first title starts on
// the wave's edge. 16 grid lines of minHeight start it on the grid, 11.4 mm below the wave.
const opener = { enabled: true, minHeight: pt(16 * GRID),
slot: { elements: [
picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
box: { backgroundColor: col('ink'), borderRadius: mm(3),
padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
{ anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
picture('butterfly', 'mariposa', at(146, 12, { width: mm(40) })),
picture('apple', 'manzana', at(151, 53, { width: mm(30) })),
...[['mariposa', 45.5], ['manzana', 84.5]].map(([name, y]) => words(name, name, LABEL, 10,
600, 'muted', at(146, y, { width: mm(40) }), { align: 'center' })), // under each picture
] } };
Mm هو نص العنوان ({titleText})؛ ورقم الوحدة والسلسلة واسم الحرف سمات في سطر العنوان نفسه. الشريط والفراشة والتفاحة صور، وصفحة الافتتاح لا تحجز ارتفاعًا للصور. ومن دون minHeight تنتهي صفحة الافتتاح عند أسفل كلمة manzana، على بعد 88.7 مم من أعلى الصفحة، ويبدأ عنوان النشاط الأول عند 99.3 مم، أي فوق أدنى نقطة في الموجة (101.9 مم) بمقدار 2.6 مم. وminHeight بمقدار 16 سطر شبكة يُنهي صفحة الافتتاح عند 102.7 مم، وسطرا الشبكة في marginTop الخاص بالعنوان يبدآنه عند 113.3 مم، على الشبكة وتحت الموجة بمقدار 11.4 مم.
#6 · أيقونات بجوار عناوين الأنشطة
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
size: { width: mm(ICON), height: mm(ICON) } }),
words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
{ verticalAlign: 'middle' }), // centred on the icon
] } } });
يسمّي كل عنوان نشاط نمط عنوانه، كما في ## Lee las frases {style="lee"}، ويرسم النمط أيقونة يتوسط العنوان بجوارها. وتظل العناوين عناوين من المستوى الثاني، فيضبط المستوى H2 تباعدها: سطرا شبكة في lineHeight للعنوان، وسطران آخران في marginTop فوقه.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 060 · Early reader with syllable chips ════════════════ // https://postext.dev/en/cookbook/reading-primer-syllables // Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models · Icons: drawn in code // Fonts: Andika, DynaPuff, Playpen Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, setCellImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'reading-primer-syllables'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#242832', paper: '#ffffff', muted: '#5f6470', // text; instructions cream: '#f6f0e3', rule: '#d3c9b6', // the band, the tiles and the pictures' ground; guides // The vowel code: a syllable takes its vowel's colour. White on these fills measures // 3.2–4.9:1, above WCAG's 3:1 for large text: the vowel chips are bold, 20 pt or more. a: '#d9482b', e: '#dd740c', i: '#1f9a8f', o: '#3f6fd8', u: '#9152cf' }; // 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 are linked to 'main-color': point it at the ink. const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Andika', 'DynaPuff', 'Playpen Sans']; // Andika: one-storey a const [TOP, SIDE, GRID] = [18, 20, 15]; // mm, mm, pt: every activity starts on the 15 pt grid // #region answer: one chip style per vowel, and {ma·má} in the text written out as chips // A word's syllables are chips with nothing between them, so their boxes touch and the white // border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the // words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence // the '-frase' twins keep no gap, so the word space alone parts a chip from the word before. // A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap). const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'), paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) }; const chipStyles = [ ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) }, ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]), { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'), borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in ]; const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u' const chipOf = (s, end) => `:chip[${s}]{style="${vowel(s)}${/m/i.test(s) ? '' : '-borde'}${end}"}`; // {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word // written after another word, as in 'me {mi·ma}.', takes the '-frase' twins. const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) => before + word.split('·').map((s) => chipOf(s, before ? '-frase' : '')).join('')); // #endregion const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const words = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color), align: 'left', overflow: 'wrap', placement, ...extra }); const picture = (id, resourceId, placement) => ({ kind: 'image', id, resourceId, placement }); const tag = { textTransform: 'uppercase', letterSpacing: pt(1.5) }; // #region opener: the letter's page: a cream band, a giant Mm and two things that start with m const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm // Images reserve no height in an opener (gotcha: opener-image-no-reserve): by itself the // opener ends at the foot of the word manzana, 88.7 mm down, and the first title starts on // the wave's edge. 16 grid lines of minHeight start it on the grid, 11.4 mm below the wave. const opener = { enabled: true, minHeight: pt(16 * GRID), slot: { elements: [ picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })), words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag, box: { backgroundColor: col('ink'), borderRadius: mm(3), padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }), words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag), words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }), words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)), picture('butterfly', 'mariposa', at(146, 12, { width: mm(40) })), picture('apple', 'manzana', at(151, 53, { width: mm(30) })), ...[['mariposa', 45.5], ['manzana', 84.5]].map(([name, y]) => words(name, name, LABEL, 10, 600, 'muted', at(146, y, { width: mm(40) }), { align: 'center' })), // under each picture ] } }; // #endregion // #region activities: each activity heading draws its icon beside the title const ICON = 9; // mm const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) }; const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [ picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(ICON), height: mm(ICON) } }), words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon', edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } }, { verticalAlign: 'middle' }), // centred on the icon ] } } }); // #endregion // #region pictures: six picture words in a rounded grid, each picture inside its cell const PICTURE_WORDS = ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca']; const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0) bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles… rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3) .map((word) => ({ content: syllables(`{${word}}`), align: 'center' })); let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] }; PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width const resourceId = word.replaceAll('·', '').replace('ñ', 'n'); grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 }); }); // #endregion // The type of every picture and the table: its empty captionPrefix prints no caption line. const sheet = { id: 'lamina', name: 'Lámina', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; const here = (id, rest) => ({ id, typeId: 'lamina', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, ...rest }); const FOLIO = 9; // mm: the folio's disc const folio = (parity, edge, x, textEdge, textX) => [ words(`n-${parity}`, '{pageNumber}', DISPLAY, 12, 700, 'paper', at(x, -10, { width: mm(FOLIO), height: mm(FOLIO) }, edge), { align: 'center', verticalAlign: 'middle', parity, box: { backgroundColor: col('ink'), borderRadius: mm(FOLIO / 2) } }), words(`s-${parity}`, '{title} · {attr.unit}: {attr.name}', LABEL, 8, 600, 'muted', at(textX, -10, { width: mm(100), height: mm(FOLIO) }, textEdge), { parity, verticalAlign: 'middle', align: textEdge.endsWith('left') ? 'left' : 'right' }), ]; const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, chipStyles, tableStyles, resourceTypes: [sheet], page: { width: mm(210), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20), left: mm(SIDE), right: mm(SIDE) } }, // equal sides: nothing to mirror layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(GRID), color: col('ink'), referenceColor: col('ink'), // the palette never reaches it (gotcha: palette-skips-designs) textAlign: 'left', firstLineIndent: pt(0) }, // ragged: 1.4.1 hyphenates no ragged text headings: { fontFamily: LABEL, // the titles print through designs, but are measured in it balancing: { enabled: false }, // or page 37's spare grid line goes above its first title levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // A unit opens on a recto. span 'page' paints the band into the top margin, where a // design kept in the column is cut off at the column's top edge. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener }, H2] }, headingStyles: [activity('oye', 'oreja'), activity('lee', 'libro'), activity('mira', 'ojo'), activity('repasa', 'lapiz')], // #region reading: the child's lines on multiples of the 15 pt grid, the instructions at 10 pt // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt. paragraphStyles: [ { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') }, // the instruction under each activity { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' }, { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' }, { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) }, { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic color: col('muted'), marginTop: pt(GRID) }, ], // #endregion // #region boxes: an activity on cream, the note for the family under a rule calloutStyles: [ { id: 'colorea', background: col('cream'), borderRadius: mm(4), marginTop: pt(0), // the :::space before the box is the whole gap above it padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) }, titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') }, body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } }, { id: 'familia', backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') }, padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) }, titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'), ...tag }, body: { fontSize: pt(10.5), lineHeight: pt(GRID) } }, ], // #endregion header: { elements: [] }, footer: { elements: [ // the folio on the outer edge, the series beside it ...folio('odd', 'bottom-right', -SIDE, 'bottom-right', -(SIDE + FOLIO + 3)), ...folio('even', 'bottom-left', SIDE, 'bottom-left', SIDE + FOLIO + 3)] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 90 · content.es.md
title: "Letra a letra" --- # Mm {kicker="Letra a letra · Cartilla de lectura 1" unit="Unidad 7" name="la eme"} ## Escucha y repite {style="oye"} :::paragraphs{style="consigna"} Lee las sílabas en voz alta, en orden y después salteadas. Abajo, la eme va al final. ::: :::paragraphs{style="fila"} {ma} {me} {mi} {mo} {mu} {am} {em} {im} {om} {um} ::: ## Lee las palabras {style="lee"} :::paragraphs{style="consigna"} Cada sílaba lleva el color de su vocal. Las que tienen eme van rellenas. ::: :::paragraphs{style="palabras"} {ma·má} {a·mo} {Me·mo} {e·me} {mí·o} {mi·mo} {ma·mi} {mí·a} ::: ## Mira y lee {style="mira"} :::paragraphs{style="consigna"} Di el nombre de cada dibujo y léelo por sílabas. ::: ::resource{id="dibujos"} ## Repasa {style="repasa"} :::paragraphs{style="consigna"} Empieza en el punto y sigue la flecha. Luego repasa las sílabas. ::: ::resource{id="trazos"} ## Lee las frases {style="lee"} :::paragraphs{style="consigna"} Lee despacio. La primera vez que sale una palabra nueva, va partida en sílabas. ::: :::paragraphs{style="lectura"} Mi mamá me {mi·ma}. Mamá me {a·ma}. Yo amo a mi mamá. Memo mima a {Mi·mí}. ::: :::space{lines=1} :::callout{type="colorea" title="Lee y colorea"} Estos son los colores de las vocales: :::paragraphs{style="palabras"} :chip[a]{style="a"} :chip[e]{style="e"} :chip[i]{style="i"} :chip[o]{style="o"} :chip[u]{style="u"} ::: :::space{lines=1} Colorea cada sílaba con el color de su vocal. :::paragraphs{style="palabras"} :chip[mo]{style="pinta"} :chip[ma]{style="pinta"} :chip[mu]{style="pinta"} :chip[me]{style="pinta"} :chip[mi]{style="pinta"} :chip[ma]{style="pinta"} :chip[mi]{style="pinta"} :chip[me]{style="pinta"} :chip[mo]{style="pinta"} :chip[mu]{style="pinta"} :chip[ma]{style="pinta"} :chip[mo]{style="pinta"} ::: ::: :::callout{type="familia" title="Para la familia"} Lean juntos las sílabas de la página 37: primero en orden y después salteadas. Si su hijo o su hija duda entre *ma* y *me*, no le dé la respuesta: señale la vocal y pregúntele de qué color es. Diez minutos al día bastan; es mejor parar cuando todavía tiene ganas de seguir. ::: :::paragraphs{style="colofon"} *Letra a letra* es una cartilla inventada para el Recetario de Postext. Texto: CC BY 4.0. Acuarelas: modelos de difusión. Compuesta en Andika, DynaPuff y Playpen Sans (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the band, the icons and the tracing rows, drawn in the palette's colours // The picture words are watercolours, JPEGs in assets/ painted on white and multiplied by the // cream, so they sit on the band and the tiles without a box. The rest is drawn here, with no // words in it: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts), so the // tracing letters are strokes and every label is set by the page. const P = palette; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const shape = (d, fill) => `<path d="${d}" fill="${fill}"/>`; const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (x, y, r, fill) => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${fill}"/>`; const drawings = { banda: () => svgDoc(210, BAND, shape(`M0 0H210V${BAND - 6}C190 ${BAND - 1} 172 ${BAND - 9} 150 ` + `${BAND - 6}S102 ${BAND + 1} 76 ${BAND - 5} 28 ${BAND - 12} 0 ${BAND - 6}Z`, P.cream)), }; // The activity icons: a white glyph on an ink disc. const icon = (glyph) => svgDoc(20, 20, dot(10, 10, 10, P.ink) + glyph); Object.assign(drawings, { oreja: () => icon(line('M7.5 8.5C7.5 5 10 3.5 12.5 3.5C15.5 3.5 16.5 6 16.5 8C16.5 11 13.5 ' + '11.5 13 14C12.5 16.5 10 17 8.5 15.5M10 8.5C10 7 11 6 12.5 6C13.8 6 14.3 7.2 14 8.3', P.paper, 1.5)), ojo: () => icon(line('M3.5 10Q10 3.5 16.5 10Q10 16.5 3.5 10Z', P.paper, 1.5) + dot(10, 10, 2.4, P.paper)), libro: () => icon(line('M10 6.5C7.5 5 5.5 5 4 5.8V14.8C5.5 14 7.5 14 10 15.5C12.5 14 14.5 14 ' + '16 14.8V5.8C14.5 5 12.5 5 10 6.5ZM10 6.5V15.5', P.paper, 1.3)), lapiz: () => icon(line('M5.5 14.5L6.5 11L13.5 4L16 6.5L9 13.5ZM12 5.5L14.5 8', P.paper, 1.3)), }); // Tracing: rows on writing guides, a model letter in ink, then dashed ones to go over. const TRACE = { w: 170, first: 26, pitch: 20.5 }; // mm const guides = (b) => line(`M0 ${b - 16}H${TRACE.w}M0 ${b}H${TRACE.w}`, P.rule, 0.4) + line(`M0 ${b - 8}H${TRACE.w}`, P.rule, 0.35, ' stroke-dasharray="1 1"'); // Letters as strokes: x-height 8, capitals 16, from the baseline b; [path, width]. const glyphs = { m: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} ` + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}M${x + 5.3} ${b - 5.4}` + `C${x + 5.3} ${b - 7.4} ${x + 6.7} ${b - 8} ${x + 8} ${b - 8}S${x + 10.6} ${b - 7.2} ` + `${x + 10.6} ${b - 5.4}V${b}`, 10.6], M: (x, b) => [`M${x} ${b}V${b - 16}L${x + 6} ${b - 6}L${x + 12} ${b - 16}V${b}`, 12], a: (x, b) => [`M${x + 6.4} ${b - 4}A3.2 4 0 0 0 ${x} ${b - 4}A3.2 4 0 0 0 ${x + 6.4} ${b - 4}` + `M${x + 6.4} ${b - 8}V${b}`, 6.4], e: (x, b) => [`M${x + 0.2} ${b - 4}H${x + 6.8}C${x + 6.8} ${b - 6.4} ${x + 5.2} ${b - 8} ` + `${x + 3.5} ${b - 8}S${x + 0.2} ${b - 6.2} ${x + 0.2} ${b - 4}S${x + 1.8} ${b} ${x + 3.7} ` + `${b}C${x + 5} ${b} ${x + 6} ${b - 0.5} ${x + 6.7} ${b - 1.3}`, 6.8], i: (x, b) => [`M${x + 0.6} ${b - 8}V${b}M${x + 0.6} ${b - 11.4}V${b - 11}`, 1.2], o: (x, b) => [`M${x + 6.8} ${b - 4}A3.4 4 0 0 0 ${x} ${b - 4}A3.4 4 0 0 0 ${x + 6.8} ${b - 4}`, 6.8], u: (x, b) => [`M${x} ${b - 8}V${b - 2.8}C${x} ${b - 1} ${x + 1.3} ${b} ${x + 3} ${b}S${x + 6.2} ` + `${b - 1} ${x + 6.2} ${b - 2.8}M${x + 6.2} ${b - 8}V${b}`, 6.2], }; const KERN = 1.8; // mm between letters const write = (word, x, b) => word.split('').reduce(([d, at], ch) => { const [path, w] = glyphs[ch](at, b); return [d + path, at + w + KERN]; }, ['', x]); // [path, x after the last letter + KERN] const dashed = ([d]) => line(d, P.muted, 1, ' stroke-dasharray="1.1 1"'); // The model: ink strokes, a teal dot where the pencil starts and an arrow beside the stem // pointing the way the first stroke goes (dir 1 down, −1 up). const model = (letter, b, [x, y], dir) => line(write(letter, 4, b)[0], P.ink, 1.3) + dot(x, y, 1.2, P.i) + line(`M${x - 2.4} ${y + dir * 1.5}V${y + dir * 6}`, P.i, 0.6) + shape(`M${x - 3.5} ${y + dir * 5.2}H${x - 1.3}L${x - 2.4} ${y + dir * 7.2}Z`, P.i); const repeat = (letter, b) => Array.from({ length: 7 }, (_, k) => dashed(write(letter, TRACE.first + k * TRACE.pitch, b))).join(''); // The five syllables, each centred in a fifth of the row. const syllableRow = (b) => ['ma', 'me', 'mi', 'mo', 'mu'].map((syll, k) => { const w = write(syll, 0, b)[1] - KERN; return dashed(write(syll, (k + 0.5) * (TRACE.w / 5) - w / 2, b)); }).join(''); drawings.trazos = () => svgDoc(TRACE.w, 67, guides(17) + model('m', 17, [4, 9], 1) + repeat('m', 17) + guides(41) + model('M', 41, [4, 41], -1) + repeat('M', 41) + guides(65) + syllableRow(65)); // The watercolours, by resource id: [file, width, height] in px. const PAINTINGS = { mariposa: ['mariposa-660.jpg', 660, 540], manzana: ['manzana-480.jpg', 480, 480], ...Object.fromEntries(['mano', 'mapa', 'mesa', 'mimosa', 'mono', 'muneca'] .map((id) => [id, [`${id}-640.jpg`, 640, 480]])) }; const ALT = { banda: 'Franja de color crema con el borde ondulado', manzana: 'Una manzana roja con una hoja', mariposa: 'Una mariposa morada con manchas amarillas', mano: 'Una mano abierta', mapa: 'Un mapa del tesoro con un río y un camino hasta una cruz', mesa: 'Una mesa con una manzana y una taza', mono: 'Un mono sentado', mimosa: 'Una rama de mimosa con flores amarillas y hojas plumosas', muneca: 'Una muñeca de trapo con coletas y vestido morado', oreja: 'Una oreja', ojo: 'Un ojo', libro: 'Un libro abierto', lapiz: 'Un lápiz', trazos: 'Tres pautas: la eme minúscula y la mayúscula, cada una con un modelo y siete de puntos ' + 'para repasar, y las sílabas ma, me, mi, mo y mu de puntos' }; // Each picture is a resource that the opener, a heading style, a table cell or the text names // by id. Drawings in px, 10 to the millimetre (the viewBox's own). const size = (svg) => svg.match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number); const common = (id) => ({ id, typeId: 'lamina', altText: ALT[id], createdAt: 0, updatedAt: 0 }); const artwork = [...Object.entries(drawings).map(([id, draw]) => { const [width, height] = size(draw()); return { ...common(id), kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, ...(id === 'trazos' && here(id)) }; }), ...Object.entries(PAINTINGS).map(([id, [fileId, w, h]]) => ({ ...common(id), kind: 'bitmap', bitmap: { fileId, format: 'jpeg', width: w, height: h } }))]; // #endregion const resources = [...artwork, here('dibujos', { kind: 'table', table: { styleId: 'dibujos', model: grid } })]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], DynaPuff: ['700'], 'Playpen Sans': ['400', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const text = syllables(markdown); await Promise.all([loadFonts(FONTS, text), // every face before the build (gotcha: fonts-first) ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())), ...Object.values(PAINTINGS).map(([file]) => loadImage(file, asset(file)))]); const continuation = { pageIndexOffset: 36, pageNumbering: { startAt: 37 } }; // 37: a recto const doc = await buildWithFonts(() => buildDocument({ markdown: text, resources, continuation }, config()), text); showPages(doc, { title: 'Letra a letra · La eme' });العُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#دوّر المقاطع إلى أقراص
نصف القطر الذي يتجاوز نصف ارتفاع الشارة يُحصر في ذلك النصف، فالقيمة الكبيرة ترسم أطرافًا مستديرة. يخرج المقطع ذو الحرف الواحد قرصًا شبه دائري، وتصير الوصلات داخل الكلمة حزوزًا.
- paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
+ paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5), borderRadius: em(1) };أخطاء شائعة
خطأ شائع
الشارات الأطول من خطوة السطر تلامس السطر التالي
الشارة التي يزيد ارتفاع إطارها على خطوة السطر تلامس شارات السطر التالي (التحذير chipOverlap). قلّل حشوها العمودي أو حدّها أو مقاسها، أو زِد تباعد الأسطر. الشارات داخل السطر →
خطأ شائع
النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب
يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →
خطأ شائع
يُحذف :::space في أعلى الإطار أو العمود
يُحذف :::space في أعلى العمود أو الإطار أو مجموعة :::columns، حتى إن كان المحتوى الوحيد للإطار، فينطوي إطار الإجابة المكوَّن من فراغ فقط. افتتح الإطار بعنوان أو بسطر سؤال، ثم أضف الفراغ. مسافة عمودية صريحة →
خطأ شائع
صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه
في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
فحص Sandbox · chipOverlap
الشارات تلامس السطر التالي
السبب. صندوق نمط الشارة أطول من خطوة السطر، فتتلامس الشارات في الأسطر المتتالية.
الحل. قلّل الحشو الرأسي للشارة أو حدّها الخارجي أو حجمها، أو زد تباعد الأسطر. التوثيق →
أعطِ minHeight أسطر شبكة كاملة. عند 85 مم، أي 0.33 مم بعد السطر السادس عشر، بدأ العنوان الأول خارج الشبكة بمقدار 0.33 مم. فامتدت كتلة عنوانه إلى خط الشبكة التالي، بارتفاع 15.5 مم بدلًا من 10.6، ونزل كل ما تحتها في الصفحة 37 بمقدار 5.3 مم.
مع تفعيل موازنة الأعمدة، ترك minHeight بمقدار 16 سطرًا أنشطة الصفحة 37 حيث كانت 17 سطرًا ستضعها: كان في الصفحة سطر شبكة فائض، فوضعته الموازنة فوق عنوان النشاط الأول. تعطّل الإعدادات الموازنة (headings.balancing)، فيبقى السطر الفائض في أسفل الصفحة.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- A watercolour of the butterfly on the opener · Generated With Diffusion Models · أصلي
- A watercolour of the apple on the opener · Generated With Diffusion Models · أصلي
- A watercolour of the hand in the picture grid · Generated With Diffusion Models · أصلي
- A watercolour of the map in the picture grid · Generated With Diffusion Models · أصلي
- A watercolour of the table in the picture grid · Generated With Diffusion Models · أصلي
- A watercolour of the mimosa sprig in the picture grid · Generated With Diffusion Models · أصلي
- A watercolour of the monkey in the picture grid · Generated With Diffusion Models · أصلي
- A watercolour of the doll in the picture grid · Generated With Diffusion Models · أصلي
- The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette · Ignacio Ferro · MIT
- الخطوط
- Andika (SIL OFL 1.1) · DynaPuff (SIL OFL 1.1) · Playpen Sans (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


