باختصار
مطوية مربعة لمهرجان موسيقي معروضة بأربع مجموعات ألوان. تبيّن كيف تغيّر كل ألوان المستند دفعة واحدة باختيار مجموعة ألوان جديدة.
ما الذي ستنضده
برنامج Riverside Music Week، مهرجان متخيَّل في بلدة نهرية، مطبوعًا مطويةً مربعة قياسها 200 mm. للصفحة الأمامية حقل لوني يمتد على نصفها العلوي، فيه العنوان بخط Syne 800 بحجم 46 pt وموجة صوتية من أعمدة شاحبة مستديرة الأطراف؛ وتحته عمودان بحافة حرّة يسردان الأسبوع يومًا بيوم، ولكل فعالية وقت بالخط العريض وشارة للسعر. يرسم الظهر الأسبوع شبكةً من مربعات ملوّنة تحت شريط تعليق، ثم يسرد الأيام من الثلاثاء إلى الجمعة وينتهي بإطار للتذاكر تحت شريط ملوّن وبيانات الطبع. فوق الصفحات يعرض المثال أربع بروفات: بالأحمر والفيروزي والبنفسجي والرملي. تأتي ألوانها من سبعة مداخل في لوحة الألوان مسمّاة بحسب وظيفتها، إضافةً إلى main-color المضبوط على لون الحقل. انقر على بروفة فيُبنى المستند كله من جديد بتلك اللوحة.
تجيب هذه الوصفة عن
- كيف أمنع العناوين والكلمات العريضة والنقاط من الظهور باللون الأزرق؟
- كيف ألوّن المصطلحات الرئيسية (العريضة أو المائلة) في المتن أو داخل الإطارات؟
- كيف أضيف عيّنات لونية دليلية إلى النص أو التعليقات أو ملاحظات الجداول؟
الجواب المختصر
function retint(way) {
const palette = { ...NEUTRALS, ...COLOURWAYS[way] };
// 1.4.1 applies colorPalette to text, lists, boxes, chips, captions and tables, and resolves
// swatches and cell fills against it; design elements and referenceColor print the hex
// written beside their id, so relink() rewrites that hex (gotcha: palette-skips-designs).
const relink = (v) => (Array.isArray(v) ? v.map(relink) : !v || typeof v !== 'object' ? v
: Object.hasOwn(palette, v.paletteId ?? '') ? { ...v, hex: palette[v.paletteId] }
: Object.fromEntries(Object.entries(v).map(([k, x]) => [k, relink(x)])));
return { // a new object on every call: resolved configs are cached per object
...relink(config()), // (gotcha: config-cache-identity)
colorPalette: entries({ ...palette, 'main-color': palette.band }), // the defaults take the band
};
}
المكونات
- الميزات
- لوحة ألوان دلاليةالغامق والمائل وألوانهماعيّنات الألوانالشارات داخل السطرتعبئة الخلايانمط التعليقصفحات افتتاح مصمَّمةشريط فصل بعرض الصفحةالنصوص والخطوط والمربعات في تصاميم الصفحةالقوائم النقطية وقوائم التحققإطارات التنبيهالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةسمات العنوانالأشكال والجداول بوصفها مواردصفحات على اللوحة (Canvas)
- الخطوط
- Syne, Plus Jakarta Sans (SIL OFL 1.1)
- الأصول
- لا شيء: كل صورة مرسومة بالكود
طريقة التحضير
#1 · سمِّ الألوان بحسب وظيفتها
const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' };
const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper
red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' },
teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' },
violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' },
sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' },
}; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink
const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the hex config() writes beside each id
const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id });
const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id,
value: { hex, model: 'hex' } })); // the shape of config.colorPalette
يملأ band الحقول اللونية، ويلوّن onBand الحروف المنضَّدة عليها، وdeep هو لون الإبراز للحروف على الورق الأبيض، وtint هو التعبئة الفاتحة. تضبط كل مجموعة ألوان هذه الأربعة وتتشارك الألوان المحايدة الثلاثة. على الأبيض يبلغ تباين الحقل الفيروزي 3.3:1 والحقل الرملي 2.1:1، وهي نسبة أدنى مما يلزم لحروف بيضاء على الحقل أو لحروف بلون الحقل على الورق. تضبط هاتان اللوحتان onBand على لون الحبر، وتنضّد اللوحات الأربع الخط العريض والعناوين بلون deep، وهو درجة أغمق يتراوح تباينها على الأبيض بين 5.99:1 (الرملي) و8.40:1 (البنفسجي).
#2 · اربط كل ما كان المحرّك سيطبعه بالأزرق
const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD),
color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited)
boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
{ level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) },
] };
const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4),
marginTop: pt(0), marginBottom: pt(0) };
const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6),
paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) };
const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf
{ id: 'ticket', background: col('band'), color: col('onBand'), ...chip },
{ id: 'free', background: col('tint'), color: col('deep'), ...chip,
borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white
];
const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } });
const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content,
parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] });
const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head
{ ...caps(7.5), color: col('deep') });
const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS,
fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'),
borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9),
left: mm(2.4) } } });
أزرق المحرّك مدخل في لوحة الألوان، main-color (#295AA3)، وقيمه الافتراضية توجّه إليه العناوين والخط العريض والمائل والإحالات ونقاط القوائم والترويسة. هنا يحمل كل واحد من هذه معرّفًا من لوحة الألوان: الأوقات والمصطلحات الأساسية بالعريض بلون deep، والمائل بلون الحبر، ونقاط القوائم بلون band، وفي الصفحة 2 ترويسة بلون deep ورقم الصفحة بلون onBand على كبسولة بلون band. توجّه retint() أيضًا main-color إلى لون الحقل؛ ولأن هذه الإعدادات تضبط كل لون، لا يتغير شيء في هذه الصفحات، لكن أي نمط يُضاف لاحقًا من غير لون يأخذ لون الحقل بدل الأزرق. لا يضبط إطار التذاكر لونًا خاصًا للخط العريض، فيرث bodyText.boldColor مع معرّفه في لوحة الألوان، ويتغير خطه العريض مع مجموعة الألوان.
#3 · ارسم العمل الفني بصناديق التصميم
const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left',
overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' },
offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style });
const opener = { enabled: true,
minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone)
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('band') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
...waveform(), // boxes filled with col('tint'), which relink() rewrites like the rest
text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)),
text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY,
fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), // a multiple
text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10),
lineHeight: 1.36 }), // (gotcha: design-lineheight-multiple)
] },
};
صفحة الافتتاح خانة تصميم تحوي صندوقًا ينزف خارج الحافة العليا، و29 عمودًا مستدير الأطراف للموجة الصوتية، وثلاثة عناصر نصية: نص العنوان نفسه عنوانًا، والسطر التمهيدي والمقدمة من سماته. الأعمدة صناديق لا SVG، فتعبئتها رابط آخر إلى tint تعيد relink() كتابته؛ أما SVG فكان سيلزم رسمه وتسجيله من جديد لكل مجموعة ألوان. لو تُرك صندوق الحقل وحده لبدأ النص على سطر الشبكة الواقع 2.4 mm تحت الحقل؛ أما minHeight، أي 18 سطرًا أو 85.1 mm تحت الهامش العلوي، فيحجز مساحة أكبر وينقل السطر الأول إلى 7 mm تحته.
#4 · دع لوحة الألوان تملأ الجدول
const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'],
es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] });
const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday
[t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'],
[t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'],
[t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'],
[t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'],
[t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'],
[t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'],
];
const fill = { t: col('band'), f: col('tint') }; // resources are not in the config: no relink
const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2
caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }),
note: t({ en: ':swatch{color="band"} ticketed concert :swatch{color="tint"} free event',
es: ':swatch{color="band"} concierto con entrada :swatch{color="tint"} acto gratuito' }),
altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.',
es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }),
table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [
[{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({
content: day, isHeader: true, align: 'center' }))],
...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) =>
(m === '.' ? { content: '' } : { content: '', background: fill[m] }))]),
] } } }];
الشبكة مورد من نوع جدول، خارج الإعدادات، فلا تمسّها relink() أبدًا. ومع ذلك تحمل تعبئات خلاياها معرّفات من لوحة الألوان، ويحلّها المحرّك من لوحة ألوان المستند حين يُخرج الجدول؛ وكذلك تُحلّ :swatch{color="band"} و:swatch{color="tint"} في مفتاح ملحوظة الجدول. تستعمل الشارات في القوائم المعرّفين نفسيهما، فيتطابق المربع المملوء والشارة بجانب الوقت وعيّنة المفتاح في كل مجموعة ألوان.
#5 · ابنِ إعدادات جديدة مع كل نقرة
const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' },
es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } });
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions">
<div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs',
es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live"
role="img"></canvas><div class="buttons" role="group"></div></div></section>`);
const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas,
{ scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width });
const buttons = Object.keys(COLOURWAYS).map((way) => {
const button = document.querySelector('#editions .buttons')
.appendChild(Object.assign(document.createElement('button'), { type: 'button' }));
button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint']
.map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`;
paint(button.firstChild, docs[way]);
// A fresh config on every click (retint() calls config()); the fonts are loaded by now.
button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way)));
return [way, button];
});
const live = document.getElementById('live');
let shown; // the document on the live page
function show(way, doc) {
paint(live, (shown = doc));
live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`;
for (const [id, button] of buttons) button.ariaPressed = String(id === way);
showPages(doc, { title: `${TITLE} · ${NAMES[way]}` });
}
show('red', docs.red);
new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size
paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way]));
}).observe(live);
يستدعي كل زر retint()، التي تستدعي config()، فيحصل كل بناء على كائن جديد لا يقف خلفه أي حلّ مخزَّن. يحلّ المحرّك الإعدادات مرة واحدة ويخزّن النتيجة مقترنةً بذلك الكائن. إن أبقيت كائن إعدادات واحدًا وغيّرت مداخل لوحة ألوانه في مكانها، أعاد البناء التالي استعمال الحلّ القديم: في اختبار على 1.4.1 احتفظت العناوين ونقاط القوائم والشارات وشريط التعليق بلونها الأول، بينما أخذت عيّنات الألوان وتعبئات الخلايا، التي تقرأ لوحة الألوان أثناء الإخراج، اللون الجديد. وإسناد مصفوفة colorPalette جديدة إلى الكائن نفسه لم يغيّر شيئًا. أما structuredClone() للإعدادات المعدَّلة فيحصل فعلًا على حلّ جديد، لكن خانات التصميم ولون الإحالات فيه ما زالت تحمل قيم hex القديمة، فبقي الحقل والموجة الصوتية وعناصر الصفحة الثابتة و«Table 1» بالأحمر؛ أما retint() فتعيد كتابة قيم hex تلك وتُرجع كائنًا جديدًا.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 054 · Retint a whole document from one palette ═══════════ // https://postext.dev/en/cookbook/live-palette-retint // Code: MIT · Text: original (CC BY 4.0) · Artwork: design boxes generated in code (CC BY 4.0) // Fonts: Syne, Plus Jakarta Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'live-palette-retint'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: three neutrals every edition shares, four entries each colourway sets const NEUTRALS = { ink: '#1d1d1f', muted: '#5f5f66', paper: '#ffffff' }; const COLOURWAYS = { // band: colour fields · onBand: type on them · deep: accent type on paper red: { band: '#d7263d', onBand: '#ffffff', deep: '#b3122a', tint: '#fcdfe3' }, teal: { band: '#2a9d8f', onBand: '#1d1d1f', deep: '#17695f', tint: '#d8eeeb' }, violet: { band: '#6a4c93', onBand: '#ffffff', deep: '#5b3f86', tint: '#e7dff0' }, sand: { band: '#f4a261', onBand: '#1d1d1f', deep: '#a14a16', tint: '#fde4cf' }, }; // white on teal is 3.3:1 and on sand 2.1:1, so those two set their band type in ink const HOUSE = { ...NEUTRALS, ...COLOURWAYS.red }; // the hex config() writes beside each id const col = (id) => ({ hex: HOUSE[id], model: 'hex', paletteId: id }); const entries = (hexes) => Object.entries(hexes).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // the shape of config.colorPalette // #endregion // #region answer: a colourway is a palette; retint() builds a fresh config linked to it function retint(way) { const palette = { ...NEUTRALS, ...COLOURWAYS[way] }; // 1.4.1 applies colorPalette to text, lists, boxes, chips, captions and tables, and resolves // swatches and cell fills against it; design elements and referenceColor print the hex // written beside their id, so relink() rewrites that hex (gotcha: palette-skips-designs). const relink = (v) => (Array.isArray(v) ? v.map(relink) : !v || typeof v !== 'object' ? v : Object.hasOwn(palette, v.paletteId ?? '') ? { ...v, hex: palette[v.paletteId] } : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, relink(x)]))); return { // a new object on every call: resolved configs are cached per object ...relink(config()), // (gotcha: config-cache-identity) colorPalette: entries({ ...palette, 'main-color': palette.band }), // the defaults take the band }; } // #endregion const [DISPLAY, SANS, BODY, LEAD] = ['Syne', 'Plus Jakarta Sans', 9.4, 13.4]; // pt: 33 lines const [TRIM, TOP, BOTTOM, INNER, OUTER, GUTTER] = [200, 22, 22, 17, 15, 6]; // mm: square, mirrored const BAND = 100; // mm, trim top to the colour field's foot ('bleed' is the trim: no cut lines) const lines = (n) => pt(n * LEAD); const caps = (size) => ({ fontFamily: SANS, fontSize: pt(size), fontWeight: 700, letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); // #region opener: the colour field, a waveform of palette-linked boxes, the title on top const text = (id, content, x, y, width, style) => ({ kind: 'text', id, content, align: 'left', overflow: 'wrap', color: col('onBand'), placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(width) } }, ...style }); const opener = { enabled: true, minHeight: lines(18), // 85.1 mm: text 7 mm under the band (2.4 mm with the band box alone) slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, ...waveform(), // boxes filled with col('tint'), which relink() rewrites like the rest text('kicker', '{attr.kicker}', INNER, 14, 150, caps(8)), text('title', '{titleText}', INNER, 20, TRIM - INNER - OUTER, { fontFamily: DISPLAY, fontSize: pt(46), fontWeight: 800, lineHeight: 0.92 }), // a multiple text('standfirst', '{attr.standfirst}', INNER, 71, 92, { fontFamily: SANS, fontSize: pt(10), lineHeight: 1.36 }), // (gotcha: design-lineheight-multiple) ] }, }; // #endregion // #region links: every colour the engine would print in its blue, linked to a palette id const bodyText = { fontFamily: SANS, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // bold: the times, key terms, boxes (inherited) boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) }; const headings = { fontFamily: DISPLAY, color: col('deep'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12.5), lineHeight: lines(1), marginTop: lines(1), marginBottom: pt(0) }, ] }; const unorderedLists = { color: col('band'), indent: mm(3.6), gap: mm(1.4), marginTop: pt(0), marginBottom: pt(0) }; const chip = { fontSize: pt(7.5), bold: true, borderWidth: pt(0), borderRadius: mm(1.6), paddingX: mm(1.3), paddingY: pt(0.9), gap: mm(1) }; const chipStyles = [ // band = a ticket, tint = free: the same code as the grid overleaf { id: 'ticket', background: col('band'), color: col('onBand'), ...chip }, { id: 'free', background: col('tint'), color: col('deep'), ...chip, borderWidth: pt(0.6), borderColor: col('band') }, // the outline keeps it off the white ]; const corner = (edge, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(OUTER), y: mm(y) } }); const furniture = (id, content, edge, y, style) => ({ elements: [{ kind: 'text', id, content, parity: 'even', pages: 'body', overflow: 'wrap', placement: corner(edge, y), ...style }] }); const header = furniture('head', '{title} · {subtitle}', 'top-left', 12, // page 2's head { ...caps(7.5), color: col('deep') }); const footer = furniture('folio', '{pageNumber}', 'bottom-left', -12, { fontFamily: SANS, fontSize: pt(8), fontWeight: 700, color: col('onBand'), box: { backgroundColor: col('band'), borderRadius: mm(2.4), padding: { top: mm(0.9), right: mm(2.4), bottom: mm(0.9), left: mm(2.4) } } }); // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object // "Tabla" in Spanish (gotcha: resource-types-locale); one table: "Table 1", not "1.1" resourceTypes: defaultResourceTypes(LANG).map((r) => ({ ...r, numberingTemplate: '{n}' })), colorPalette: entries(HOUSE), // the red edition; retint() replaces it page: { width: mm(TRIM), height: mm(TRIM), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText, headings, unorderedLists, chipStyles, calloutStyles: [{ id: 'tickets', backgroundEnabled: false, // no fill, a stripe on top stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('band') }, padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { fontFamily: DISPLAY, fontSize: pt(11), color: col('deep'), gap: mm(1.2) } }], tableStyle: { borderColor: col('paper'), borderWidth: pt(1.6), // white rules cut the tiles headerBackground: col('ink'), headerColor: col('paper'), headerFontSize: pt(7.5), bodyFontSize: pt(8.2), bodyColor: col('ink'), cellPadding: mm(1) }, captionStyle: { fontSize: pt(8), color: col('onBand'), labelColor: col('onBand'), position: 'above', backgroundEnabled: true, background: col('band'), padding: mm(1.2), gap: mm(1.2), note: { fontSize: pt(7.5), color: col('muted') } }, paragraphStyles: [{ id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted') }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 51 · content.en.md
title: "Riverside Music Week" subtitle: "Programme" author: "Almarén Music Society" --- # Riverside \\ Music Week {kicker="Almarén · 12–18 June 2027" standfirst="Fourteen events on both banks of the Almar, from the Quay Stage to the Boathouse. Eight of them are free; the rest cost €9 to €14."} The ninth Riverside Music Week keeps close to the water. Every venue is within ten minutes’ walk of the **Quay Stage**, and the **Iron Bridge** joins the two banks. :ref{id="week" style="full"} overleaf maps the week by venue and day. Free events need no ticket, but the Market Hall seats only 300, so come early. Concerts on the Quay go ahead in light rain. ## Saturday 12 June - **18.00** :chip[Free]{style="free"} The Almar Brass Band leads the opening parade from the Iron Bridge steps to the Quay Stage. - **21.30** :chip[€12]{style="ticket"} The Almar Chamber Orchestra plays Handel’s *Water Music* from two barges moored off the Quay. ## Sunday 13 June - **11.00** :chip[Free]{style="free"} Children’s percussion in the Market Hall. Bring a saucepan and a wooden spoon from home. - **17.00** :chip[Free]{style="free"} The Rowing Club choir sings shanties on the Boathouse slipway. - **20.00** :chip[€14]{style="ticket"} In St Clare’s Cloister, Cuarteto Ribera plays Haydn’s *Lark* quartet and Ravel’s Quartet in F. ## Monday 14 June - **19.00** :chip[Free]{style="free"} Fado on the Quay: Marta Loureiro sings with Portuguese and classical guitar. - **22.00** :chip[€9]{style="ticket"} The Almar Jazz Trio plays standards and its own tunes at the Boathouse. ## Tuesday 15 June - **21.30** :chip[€10]{style="ticket"} Murnau’s silent film *Sunrise* (1927) on a screen in the Tannery Yard, with a new score played live by the Tannery Ensemble. The yard opens at 21.00. ## Wednesday 16 June - **18.30** :chip[Free]{style="free"} Folk dance on the Quay. A caller walks everyone through each dance before the band starts it. ## Thursday 17 June - **18.00** :chip[Free]{style="free"} Open rehearsal: the Almar Chamber Orchestra works through *Vltava* on the Quay Stage. Sit on the steps and listen. - **20.00** :chip[Free]{style="free"} Six Almarén composers, one river song each, sung by the Community Choir in the Market Hall. - **22.30** :chip[€9]{style="ticket"} Cumbia night at the Boathouse with Los Remeros. ## Friday 18 June - **21.00** :chip[€12]{style="ticket"} Closing concert on the Quay Stage: the Almar Chamber Orchestra plays Smetana’s *Vltava*, which follows a river from its two springs to Prague. - **23.45** :chip[Free]{style="free"} The Community Choir sings at midnight on the Iron Bridge steps. Candles are handed out from 23.15. :::callout{type="tickets" title="Tickets"} A €48 **week pass** covers all six ticketed concerts. Single tickets are sold at the Market Hall box office from Monday 7 June, 10.00 to 14.00, and at the door 45 minutes before the start. **Under-16s** go free with a paying adult. ::: :::paragraphs{style="colophon"} Riverside Music Week is run by the Almarén Music Society. Programme correct on 3 May 2027; changes are posted at the box office. Set in Syne and Plus Jakarta Sans (SIL OFL) · Text CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region grid: the week at a glance, filled from palette ids the engine resolves itself const DAYS = t({ en: ['Sat 12', 'Sun 13', 'Mon 14', 'Tue 15', 'Wed 16', 'Thu 17', 'Fri 18'], es: ['Sáb 12', 'Dom 13', 'Lun 14', 'Mar 15', 'Mié 16', 'Jue 17', 'Vie 18'] }); const WEEK = [ // t: a ticketed concert, f: a free event, one mark a day from Saturday [t({ en: 'Quay Stage', es: 'Escenario del Muelle' }), 't.f.fft'], [t({ en: 'Iron Bridge steps', es: 'Escalinata del Puente' }), 'f.....f'], [t({ en: 'Market Hall', es: 'Mercado de Abastos' }), '.f...f.'], [t({ en: 'St Clare’s Cloister', es: 'Claustro de Santa Clara' }), '.t.....'], [t({ en: 'Tannery Yard', es: 'Patio de la Curtiduría' }), '...t...'], [t({ en: 'Boathouse', es: 'Casa de las Barcas' }), '.ft..t.'], ]; const fill = { t: col('band'), f: col('tint') }; // resources are not in the config: no relink const resources = [{ id: 'week', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top', span: 'page' }, // cited on page 1, it heads page 2 caption: t({ en: 'The week at a glance', es: 'La semana de un vistazo' }), note: t({ en: ':swatch{color="band"} ticketed concert :swatch{color="tint"} free event', es: ':swatch{color="band"} concierto con entrada :swatch{color="tint"} acto gratuito' }), altText: t({ en: 'A grid of six venues by seven days; filled squares mark the events.', es: 'Una cuadrícula de seis escenarios por siete días; los cuadros rellenos son los actos.' }), table: { model: { headerRowCount: 1, columnWidths: [2.6, 1, 1, 1, 1, 1, 1, 1], rows: [ [{ content: '', isHeader: true, background: col('paper') }, ...DAYS.map((day) => ({ content: day, isHeader: true, align: 'center' }))], ...WEEK.map(([venue, marks]) => [{ content: venue, align: 'right' }, ...[...marks].map((m) => (m === '.' ? { content: '' } : { content: '', background: fill[m] }))]), ] } } }]; // #endregion // #region art: a waveform over the river, 29 rounded bars from a seeded generator function waveform() { let seed = 0x5eed; // Mulberry32: the same bars on every run const random = () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; const [x0, axis, n, width, gap] = [114, 84, 29, 2.1, 1.35]; // mm; axis: the waterline return Array.from({ length: n }, (_, i) => { const envelope = Math.sin(((i + 0.5) / n) * Math.PI) ** 0.8; const up = 3 + 26 * envelope * (0.35 + 0.65 * random()); // mm above the waterline const down = up * 0.42; // and its reflection below it return { kind: 'box', id: `bar-${i}`, style: { backgroundColor: col('tint'), borderRadius: mm(width / 2) }, placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x0 + i * (width + gap)), y: mm(axis - up) }, size: { width: mm(width), height: mm(up + down) } } }; }); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Plus Jakarta Sans': ['400', '400i', '700'], Syne: ['700', '800'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const TITLE = t({ en: 'One programme, four palettes', es: 'Un programa, cuatro paletas' }); const build = (way) => buildWithFonts(() => buildDocument({ markdown, resources }, retint(way)), markdown); const docs = {}; // red is built last: the capture shows the last build on its pages for (const way of ['sand', 'violet', 'teal', 'red']) docs[way] = await build(way); // #region live: four buttons, each a fresh build of the whole programme in one colourway const NAMES = t({ en: { red: 'Red', teal: 'Teal', violet: 'Violet', sand: 'Sand' }, es: { red: 'Rojo', teal: 'Verde azulado', violet: 'Violeta', sand: 'Arena' } }); document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="editions"> <div class="desk"><header><p class="kicker">${t({ en: 'Riverside Music Week · proofs', es: 'Música en la Ribera · pruebas' })}</p><h2>${TITLE}</h2></header><canvas id="live" role="img"></canvas><div class="buttons" role="group"></div></div></section>`); const paint = (canvas, doc) => renderPageToCanvas(doc.pages[0], doc, canvas, { scale: (canvas.clientWidth * Math.min(devicePixelRatio, 2)) / doc.pages[0].width }); const buttons = Object.keys(COLOURWAYS).map((way) => { const button = document.querySelector('#editions .buttons') .appendChild(Object.assign(document.createElement('button'), { type: 'button' })); button.innerHTML = `<canvas></canvas><span>${NAMES[way]}<i>${['band', 'deep', 'tint'] .map((id) => `<b style="background:${COLOURWAYS[way][id]}"></b>`).join('')}</i></span>`; paint(button.firstChild, docs[way]); // A fresh config on every click (retint() calls config()); the fonts are loaded by now. button.onclick = () => show(way, buildDocument({ markdown, resources }, retint(way))); return [way, button]; }); const live = document.getElementById('live'); let shown; // the document on the live page function show(way, doc) { paint(live, (shown = doc)); live.ariaLabel = `${NAMES[way]}, ${t({ en: 'page 1', es: 'página 1' })}`; for (const [id, button] of buttons) button.ariaPressed = String(id === way); showPages(doc, { title: `${TITLE} · ${NAMES[way]}` }); } show('red', docs.red); new ResizeObserver(() => { // canvases are bitmaps: repaint them at the desk's new size paint(live, shown); buttons.forEach(([way, button]) => paint(button.firstChild, docs[way])); }).observe(live); // #endregionالعُدّة · core, fonts, viewer: نفسها في كل وصفة · أسطر: 235
// ─── 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 ───────────────────────────────────────────────────────────────────────
/* The proof desk above the pages: the live page, and the four colourways as buttons.
Sizes are in container units, so the desk keeps its 4:3 shape at any width up to 1120 px. */
#editions {
container-type: inline-size; max-width: 1120px; margin: 0 auto; background: #16181d;
color: #b9bcc4; font: 400 13px/1.4 "Plus Jakarta Sans", system-ui, sans-serif;
}
#editions .desk {
box-sizing: border-box; display: grid; aspect-ratio: 4 / 3; padding: 4cqw;
grid-template: 'page head' auto 'page ways' minmax(0, 1fr) / 67cqw minmax(0, 1fr);
gap: 2.6cqw 3.5cqw;
}
#editions header { grid-area: head; }
#editions .kicker {
margin: 0 0 0.8cqw; font-size: max(10px, 1cqw); font-weight: 700; letter-spacing: .18em;
text-transform: uppercase; color: #8d9098;
}
#editions h2 { margin: 0; font: 800 2.2cqw/1.1 Syne, sans-serif; color: #f4f1ea; }
#live {
grid-area: page; display: block; width: 67cqw; height: 67cqw; background: #fff;
box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 2.4cqw 4.8cqw -1.8cqw rgb(0 0 0 / .85);
}
#editions .buttons {
grid-area: ways; display: grid; grid-template-rows: repeat(4, minmax(0, 1fr)); gap: 1.3cqw;
}
#editions button {
display: flex; align-items: center; gap: 1.6cqw; padding: 0; border: 0; background: none;
color: #f4f1ea; font: 700 1.7cqw/1.15 Syne, sans-serif; text-align: left; cursor: pointer;
}
#editions button canvas {
flex: none; width: 11cqw; height: 11cqw; background: #fff;
outline: 2px solid transparent; outline-offset: 0.45cqw; transition: outline-color .15s;
}
#editions button[aria-pressed="true"] canvas { outline-color: #f4f1ea; }
#editions button:hover canvas, #editions button:focus-visible canvas { outline-color: #8d9098; }
#editions button:focus-visible { outline: none; }
#editions button i { display: flex; gap: 0.5cqw; margin-top: 0.8cqw; }
#editions button b {
width: 1.4cqw; height: 1.4cqw; border-radius: 50%;
box-shadow: inset 0 0 0 1px rgb(255 255 255 / .14);
}
@container (max-width: 640px) {
#editions .desk {
aspect-ratio: auto; padding: 24px 16px; gap: 18px;
grid-template: 'head' auto 'page' auto 'ways' auto / minmax(0, 1fr);
}
#editions h2 { font-size: 24px; }
#live { width: 100%; height: auto; aspect-ratio: 1; }
#editions .buttons { grid-template: auto / repeat(4, minmax(0, 1fr)); gap: 10px; }
#editions button { flex-direction: column; align-items: stretch; gap: 6px; font-size: 13px; }
#editions button canvas { width: 100%; height: auto; aspect-ratio: 1; }
#editions button i { gap: 4px; margin-top: 4px; }
#editions button b { width: 10px; height: 10px; }
}
يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#أبقِ الخط العريض بلون الحبر
اترك لون الإبراز للعناوين ونقاط القوائم والشارات، ونضّد الأوقات والمصطلحات الأساسية بلون النص.
- boldColor: col('deep'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };
+ boldColor: col('ink'), referenceColor: col('deep'), textAlign: 'left', firstLineIndent: pt(0) };#أعطِ كل جزء من الكتاب لونه الخاص
للألوان التي تتغير داخل مستند واحد جزءًا بعد جزء، انظر أجزاء ملوّنة من سمة واحدة.
أخطاء شائعة
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
النص غير المضبوط قد يترك علامة الترقيم منفصلة عن العريض أو عن :ref
في postext 1.4.1 قد يكسر النص غير المضبوط (متون الإطارات، الفقرات ذات الحافة الحرّة) السطرَ بين مقطع عريض أو مائل، أو :ref، وعلامة الترقيم الملاصقة له: قد تفتتح النقطة السطر التالي، وقد ينتهي السطر السابق بـ '(' التي قبل الإحالة. النص المضبوط لا ينكسر هناك أبدًا. اقرأ إطارات كل نسخة وأعد صياغة كل جملة يحدث فيها ذلك، ليقع المقطع في وسط السطر. الغامق والمائل وألوانهما →
خطأ شائع
النص غير المضبوط لا يُفحص أبدًا بحثًا عن الكلمات المعزولة
تعمل optimalLineBreaking وavoidRunts وruntPenalty وruntMinCharacters على كاسر الأسطر Knuth–Plass، الذي لا يشغّله postext 1.4.1 إلا للنص المضبوط. الفقرة غير المضبوطة تُكسر سطرًا سطرًا وقد تنتهي بكلمة قصيرة واحدة أيًّا كانت تلك الإعدادات. اقرأ الأسطر الأخيرة من النص غير المضبوط وأعد صياغة الفقرة التي تنتهي بكلمة معزولة. الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
ترجم Figure/Table بـ defaultResourceTypes(locale)
يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes. «شكل» و«جدول» بلغتك →
خطأ شائع
lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا
في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
- احذف
relink()وابنِ النسخة الفيروزية لترى ما يتركه postext 1.4.1 خلفه: الحقل والموجة الصوتية والعنوان الأبيض والترويسة وكبسولة رقم الصفحة والإحالة «Table 1» تحتفظ بألوان النسخة الحمراء، بينما يتبع النصُّ والقوائم والشارات وشريط التعليق والجدول وإطار التذاكر لوحةَ الألوان. - أبقِ
bodyText.boldColorمضبوطًا حين يحوي صندوقٌ خطًا عريضًا. ينسخه الصندوق ومعه معرّف لوحة الألوان؛ وإن تركته غير مضبوط نسخ 1.4.1 القيمة الافتراضية بدلًا منه، فيأخذ الخط العريض خارج الصندوق في النسخة الفيروزية لون الحقل بينما تُطبع «week pass» داخل الصندوق بلون #295AA3.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الخطوط
- Syne (SIL OFL 1.1) · Plus Jakarta Sans (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


