باختصار
نشرة من صفحتين لنادٍ للبستنة. القصة الرئيسية تملأ عمودًا عريضًا وتكمل في الصفحة 2، والأخبار القصيرة تملأ عمودًا ضيقًا بجانبها.
ما الذي ستنضده
عدد الربيع من The Allotment Gazette، نشرة جمعية متخيَّلة لحدائق صغيرة مؤجَّرة، مطبوعة على وجهَي ورقة A4 واحدة. يحمل شريط بلون القش الاسمَ بخط Titan One على سطرين، وعلى حافته السفلى صف من الشتلات، ولسانًا بلون الطماطم فيه رقم العدد والموسم. تحته تنقسم الصفحة إلى عمود عرضه 113 mm وآخر عرضه 57 mm. القصة الرئيسية، عن ثلاث حجيرات سماد جديدة، تملأ العمود العريض تحت مخطط للموقع مرسوم بالشيفرة، وتتوقف عند وسم «Continued on page 2». ويضم العمود الضيق خمسة أخبار قصيرة، يبدأ كل منها بوسوم على شكل كبسولة. في الصفحة 2 تكمل القصة تحت عنوان تتمة، ويتوسط العمودَ رسمٌ بياني للحرارة. وفي الأسفل صندوق من عمودين يضع رسم حوض الزراعة بجانب الفقرة التي تتحدث عنه. وتملأ ثمانيةُ إعلانات العمود الضيق.
تجيب هذه الوصفة عن
- كيف أصمم تخطيط عمود ونصف، فيه عمود نص عريض وعمود جانبي ضيق؟
- هل يستطيع Postext تنضيد ثلاثة أعمدة نصية أو أكثر، أو التفاف النص حول صورة؟
- كيف أضع عمودين داخل إطار (عمود نص بجانب عمود شكل)؟
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
الجواب المختصر
const layout = {
layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
// defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
sideColumnPercent: 32, // 57 mm of the 178 mm measure
gutterWidth: mm(8), // the wide column keeps the other 113 mm
columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
// ## Shared compost bays open by the top gate ← the lead, in the wide column
// … :chip[Continued on page 2]{style="jump"}
// :::columnbreak ← the briefs open the narrow column
// ## In brief {style="rail"}
// …
// :::columnbreak ← from the last column: next page
// :chip[Continued from page 1]{style="jump"} ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
المكونات
- الميزات
- عمود ونصففواصل الصفحات والأعمدةالشارات داخل السطرأعمدة داخل الإطارأنماط العناوينموضع الأشكالإطارات التنبيهأشكال في هذا الموضع بالضبطالخط الفاصل بين الأعمدةالبيانات الوصفية للمستندصفحات افتتاح مصمَّمةسمات العنوانالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةأنماط الفقراتأنواع موارد مخصّصةسطور المصدر والحقوقلوحة ألوان دلاليةصفحات على اللوحة (Canvas)الأشكال والجداول بوصفها موارد
- تستخدم أيضًا
- شريط فصل بعرض الصفحةلون الورق
- الخطوط
- Work Sans, Titan One, Courier Prime (SIL OFL 1.1)
- الأصول
- The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
طريقة التحضير
#1 · دع Markdown يملأ كل عمود
إعدادات الإخراج في الجواب المختصر أعلاه. في إخراج oneAndHalf يأخذ العمود الضيق نصًّا افتراضيًا، فيجري النص في العمود العريض، ثم الضيق، ثم العمود العريض في الصفحة التالية (أنواع الإخراج). ينهي :::columnbreak العمود حيث يقف (التوجيهات): بعد وسم «Continued on page 2» في القصة الرئيسية يفتح العمود الضيق للأخبار القصيرة، وبعد آخر خبر، في آخر عمود من الصفحة، يفتح الصفحة 2. في هذا العدد ضُبط عمودا الصفحة 1 لينتهيا على خط الشبكة نفسه، على بعد 275 mm من الحافة العليا، فإذا حذفت أيًّا من الفاصلين بقيت الصفحات كما هي. تظهر قيمة الفاصلين حين يتغير النص. احذف الفقرة الثانية من القصة الرئيسية، ومن دون الفاصل الأول يبدأ «In brief» على بعد 247 mm في العمود العريض، تحت الوسم، ويفيض الخبر الأول إلى العمود الضيق، حيث يحتفظ نصفه الثاني بعرض السطر 113 mm الذي بدأ به ويُقصّ عند عرض العمود البالغ 57 mm.
#2 · ابنِ ترويسة النشرة من البيانات التمهيدية
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
slot: { elements: [
// The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
// area on this page, behind the masthead, however deep the masthead is.
{ kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
{ kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
{ kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
// {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
// (gotcha: quote-frontmatter): an unquoted date prints nothing here.
{ kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
align: 'right', box: { backgroundColor: col('tomato'),
padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
placement: at('page', 'top-right', -SIDE, 44) },
] } };
يأخذ H1 نمط عنوان يرسم تصميمُه ترويسةَ النشرة، وspan: 'page' يضع هذا التصميم فوق العمودين. ينكسر {titleText} بقياس 60 pt إلى سطرين في صندوقه البالغ 178 mm. ويجمع اللسان بين {attr.issue}، المكتوب على سطر H1، و{publishDate} من البيانات التمهيدية (frontmatter)، ويجب أن يكون نصًّا بين علامتي تنصيص (عناصر النص). من دون minHeight تحجز الترويسة مساحة حتى أسفل الشريط، على بعد 62 mm من الحافة العليا، ويبدأ النص على خط الشبكة التالي، عند 62.5 mm، ملامسًا الشريط. وminHeight بقيمة عشرة أسطر من الشبكة يبدأ النص عند 67 mm.
#3 · وسّم الأخبار القصيرة بالشارات
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
chip('free', { background: col('marigold'), color: col('ink') }),
chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
color: col('leaf') }),
chip('jump', { background: col('straw'), color: col('ink') }),
];
التصريح بـ chipStyles يحل محل الشارة المدمجة الزرقاء الشاحبة، والشارة التي ليس لها style تأخذ النمط الأول (أنماط الشارات). نصف قطر أكبر من نصف ارتفاع الصندوق يرسم كبسولة. الشارة لا تنكسر من داخلها أبدًا، فلا ينكسر السطر إلا عند المسافة التي قبلها أو بعدها، ويبقى تاريخ مثل «Sat 11 April» على سطر واحد. كُتبت الوسوم بالحروف الكبيرة في Markdown لأن أنماط الشارات ليس فيها إعداد لحالة الحروف. والحشوة العمودية تُرسم خارج السطر، فتبقى الشبكة البالغة 13.4 pt سليمة.
#4 · نضّد النص بجانب الرسم داخل صندوق
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
// :::callout{type="bed" title="Sixteen squares by the gate"}
// :::columns{count=2 breaks="2"}
// The demonstration bed by the gate is 1.2 metres square … ← block 1
// ::resource{id="bed"} ← block 2
// :::
// ::: (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
لا يلفّ Postext النص حول الصورة: الشكل يأخذ شريطًا كاملًا من عموده. لكن داخل الإطار (callout) تضع مجموعة :::columns الكتل جنبًا إلى جنب (:::columns). يبدأ breaks="2" العمود الثاني البالغ 50 mm عند الرسم، فتبقى الفقرة كاملة في الأول. في هذا العدد الفقرة (65.6 mm) أقصر من الرسم وتعليقه (71.7 mm)، وحذف breaks="2" لا يغير شيئًا. إنما وُضع لفقرة أطول: من دونه تُوازن المجموعة، ومع فقرة من عشرين سطرًا يذهب آخر سطرين إلى رأس العمود الثاني، فوق الرسم.
#5 · ضع رسمًا بيانيًا ضيقًا في وسط شريطه
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
يجعل width: 0.62 عرض الرسم البياني 70.1 mm في العمود البالغ 113 mm، ويترك align: 'center' 21.5 mm من الورق على كل جانب (التموضع). النص فوقه وتحته يحتفظ بعرض السطر كاملًا ولا يجري أبدًا في ذلك البياض. الرسوم تنتمي إلى نوع مورد قيمة captionPrefix فيه فارغة، فيبدأ كل تعليق بكلماته الخاصة بالأسود العريض، من غير «Figure 1» قبلها.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 042 · Community newsletter: lead story and briefs ═════ // https://postext.dev/en/cookbook/community-newsletter // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Work Sans, Titan One, Courier Prime (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'community-newsletter'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: garden colours, every one linked by id // ink: text · leaf: nameplate and headlines · tomato, the accent: tags, tab, column heads · // straw: the band · marigold: second tag · tint: the box · rule: hairlines · muted: notes const palette = { ink: '#1d211c', leaf: '#2f6b3a', tomato: '#c43f2a', straw: '#f2d492', marigold: '#e2b33c', tint: '#eef4e8', rule: '#cfd3c6', muted: '#5f6659', paper: '#fbfaf5' }; // The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ // defaults link to 'main-color': point it at the leaf, never blue ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Work Sans', 'Titan One', 'Courier Prime']; const SIDE = 16; // mm: the side margins of one A4 sheet, printed on both sides const LEAD = 13.4; // body leading in pt: the baseline grid const caps = (size, colour) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(size * 0.12), color: col(colour) }); const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region answer: a wide column for the lead, a narrow one for the briefs, one flow const layout = { layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it) sideColumnPercent: 32, // 57 mm of the 178 mm measure gutterWidth: mm(8), // the wide column keeps the other 113 mm columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter }; // The text runs down the wide column, then the narrow one, then the next page's wide // column. A :::columnbreak sends the next block on to the following column, so the // Markdown decides what each column holds: // ## Shared compost bays open by the top gate ← the lead, in the wide column // … :chip[Continued on page 2]{style="jump"} // :::columnbreak ← the briefs open the narrow column // ## In brief {style="rail"} // … // :::columnbreak ← from the last column: next page // :chip[Continued from page 1]{style="jump"} ← the lead goes on in the wide column // Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that // runs over into the other column keeps the measure it started with (gotcha: // split-paragraph-measure). // #endregion // #region masthead: the H1 is the nameplate; the issue tab reads the frontmatter const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85) const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text const masthead = { enabled: true, minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band slot: { elements: [ // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text // area on this page, behind the masthead, however deep the masthead is. { kind: 'box', id: 'band', style: { backgroundColor: col('straw') }, placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } }, { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } }, { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left', placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60), lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple) color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } }, // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter // (gotcha: quote-frontmatter): an unquoted date prints nothing here. { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'), align: 'right', box: { backgroundColor: col('tomato'), padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } }, placement: at('page', 'top-right', -SIDE, 44) }, ] } }; // #endregion // #region tags: four chip styles replace the built-in pale-blue chip const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true, fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size const chipStyles = [ chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default chip('free', { background: col('marigold'), color: col('ink') }), chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'), color: col('leaf') }), chip('jump', { background: col('straw'), color: col('ink') }), ]; // #endregion // #region box: two columns inside a box set a paragraph beside its drawing // Text never runs round a picture in a column (text wrap is a gap), but a :::columns group // inside a box sets blocks side by side; breaks="2" opens column two at the second block: // :::callout{type="bed" title="Sixteen squares by the gate"} // :::columns{count=2 breaks="2"} // The demonstration bed by the gate is 1.2 metres square … ← block 1 // ::resource{id="bed"} ← block 2 // ::: // ::: (gotcha: callout-columns) const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5), titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) }, body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } }; // #endregion // #region band: a narrower figure still takes the whole band of its column const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg', svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2], createdAt: 0, updatedAt: 0 }); // width is a fraction of the column and align where the figure sits in it: the text above // and below never moves into the white either side. const HEAT = { width: 0.62, align: 'center' }; const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT), drawing('bed', 50, 50), DRILL]; // Newsletter drawings carry no "Figure 1": an empty prefix prints no label. const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing', captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }]; // #endregion const head = (id, content, edge, x, y, look) => ({ kind: 'text', id, content, pages: 'body', align: edge.slice(4), placement: at('page', edge, x, y), ...look }); const folio = { elements: [ // page 2's head: title and date over a hairline, the folio right head('name', '{title} · {publishDate}', 'top-left', SIDE, 11, caps(7.5, 'leaf')), head('folio', '{pageNumber}', 'top-right', -SIDE, 9.6, { fontFamily: DISPLAY, fontSize: pt(12), color: col('tomato') }), { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), pages: 'body', placement: { ...at('page', 'top-left', SIDE, 15.5), size: { width: mm(210 - 2 * SIDE) } } }, ] }; const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), ...look }); const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, resourceTypes, chipStyles, layout, calloutStyles: [bedBox], page: { width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(20), bottom: mm(18), left: mm(SIDE), right: mm(SIDE) } }, bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), referenceColor: col('leaf'), // for a :ref label: the palette reaches bold but not this // colour, which would stay the default blue (gotcha: palette-skips-designs) // Ragged: no hyphens, no runt check (gotchas: ragged-no-hyphenation, ragged-runts) textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('leaf'), levels: [ // Restated (gotcha: headings-drop-h1-break); span: 'page' sets the masthead over both columns { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginTop: pt(0), marginBottom: pt(LEAD / 2) }, { level: 3, fontFamily: TEXT, fontWeight: 700, fontSize: pt(10.2), lineHeight: pt(LEAD), color: col('ink'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a brief's name { level: 4, ...caps(8.5, 'tomato'), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, // a notice's name ] }, headingStyles: [ // marginBottom 0: the level's 0.5 em is added under minHeight and would drop the text a line { id: 'masthead', marginBottom: pt(0), advancedDesign: masthead }, { id: 'rail', fontSize: pt(17), lineHeight: pt(2 * LEAD), color: col('tomato') }, { id: 'jump', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD / 2) }, ], paragraphStyles: [ { id: 'standfirst', fontSize: pt(12), lineHeight: pt(16) }, { id: 'byline', ...label(8, { color: col('muted'), marginBottom: pt(LEAD / 2) }) }, { id: 'jump', ...label(8, { textAlign: 'right' }) }, // 'Continued on page 2' { id: 'from', ...label(8) }, // 'Continued from page 1' { id: 'colophon', ...label(7, { lineHeight: pt(9.4), color: col('muted'), marginTop: pt(LEAD) }) }, ], captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), gap: mm(1.6), note: { fontFamily: LABEL, fontSize: pt(6.8), color: col('muted') } }, header: folio, footer: { elements: [] }, // the folio is at the head of page 2; page 1 has the masthead }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 116 · content.en.md
title: "The Allotment Gazette" subtitle: "Newsletter of the Wren Lane Allotment Society" publishDate: "Spring 2026" --- # The Allotment Gazette {style="masthead" issue="No. 47"} :chip[SITE NEWS]{style="tag"} ## Shared compost bays open by the top gate :::paragraphs{style="standfirst"} Sixty plots now feed three timber bays instead of a heap each, and bay one reached 63 °C in five days. Its compost goes back to the plots in September. ::: :::paragraphs{style="byline"} Maureen Adeyemi, site secretary ::: ::resource{id="plan"} On the first Saturday of March eleven of us put up three bays of reclaimed scaffold boards by the top gate. Each is a metre and a half square and a metre high, with loose boards at the front that lift out, so a heap can be forked into the next bay. Hallam’s yard on Station Road let us have the boards for the cost of the van, and Ron Ellis brought his drill. Until now each plot has kept its own heap, and most of those heaps never get warm. A heap needs about a cubic metre of mixed material before it holds its heat, and few of us fill that much in a season. In one bay, with the stable manure the riding school leaves at the gate, the same waste heats up within days. :::paragraphs{style="jump"} :chip[Continued on page 2]{style="jump"} ::: :::columnbreak ## In brief {style="rail"} ### Path day :chip[WORK PARTY]{style="tag"} :chip[Sun 12 April]{style="when"} The grass paths between the plots need edging, and the main path a fresh load of gravel. Andy Price brings his edging iron; there are spades and barrows. Meet at the hut at nine. ### Seed swap :chip[EVENT]{style="tag"} :chip[Sat 11 April]{style="when"} Bring spare seed in labelled envelopes to the hut between ten and twelve. Seed saved from open-pollinated plants is welcome; seed from F1 hybrids will not come true. Tea and flapjack, 50p. ### Stable manure :chip[FREE]{style="free"} Whitecross Riding School leaves a trailer-load at the top gate every other Tuesday. Take what your plot needs, but top up bay one first, and keep the gateway clear for the tractor. ### Jam jars and a gazebo :chip[WANTED]{style="tag"} Clean jars with lids for the jam stall at the summer show, and a gazebo to borrow on Saturday 18 July. Leave jars in the crate by the hut door; Sue Marsh collects them on Fridays. ### New neighbours :chip[WELCOME]{style="free"} Four plots changed hands this winter. Welcome to the Okafors on plot 9, Priya and Tom on 23a, Mr Hensley on 31, and the Year 5 class at St Anne’s, who share plot 40 with Mrs Dunn. :::columnbreak :::paragraphs{style="from"} :chip[Continued from page 1]{style="jump"} ::: ## What goes into the bays {style="jump"} Bay one was filled on 7 March. On the 12th the thermometer at its centre read 63 °C, and the heap stayed at 55 °C or more for six days, long enough to kill most weed seeds. The morning readings are chalked on the board by the gate. Fill one bay at a time, with green and brown in equal barrow-loads: grass cuttings, weeds without seed heads, spent crops and peelings with torn cardboard, straw and manure. Chop anything thicker than a thumb. Keep out clubroot-infected brassica roots, bindweed and couch grass, and cooked food, which brings rats. ::resource{id="heat"} Turning lets air into the middle of the heap, and the temperature climbs again: bay one, turned on 18 March, was back above 60 °C two days later. The rota on the hut door puts two plots in charge of the fork each week. In September every plot can take away two barrow-loads of finished compost, and whatever is left goes on the demonstration bed by the gate. :::callout{type="bed" title="Sixteen squares by the gate"} :::columns{count=2 breaks="2"} The demonstration bed by the gate is 1.2 metres square, edged with the same scaffold boards as the bays. This year it holds compost from the council depot, 15 centimetres deep; next spring it gets our own. Strings divide the bed into sixteen squares, 30 centimetres a side, each planted with one, four, nine or sixteen plants according to the size of the crop. Year 5 from St Anne’s planted it on 20 March and will keep its diary. ::resource{id="bed"} ::: ::: :::columnbreak ## Notices {style="rail"} #### Rents Rents for 2026 are £52 for a full plot and £28 for a half, due by the end of April. Pay Dev Sharma on plot 18, or by bank transfer: the details are on the hut door. #### Water The troughs are filled from 1 April to the end of October. Use a hose to fill cans only, and never leave one running on a bed. #### Bonfires None from April to September, and none at any time when the wind blows towards the houses on Wren Lane. #### Sheds A new shed over two metres tall needs the committee’s approval. Send a sketch with its size to the secretary before you buy. #### Waiting list Thirty-one names are on the list. We expect to offer eight plots this year, most of them half plots. #### Bees On sunny Saturdays in April, Jan inspects the two hives on plot 52. Keep to the main path while her flag is up. #### Lost A green ten-litre watering can with a brass rose. Please bring it back to plot 7. #### Next issue Please leave copy for the summer issue in the letterbox on the hut door by 15 June. :::paragraphs{style="colophon"} The Allotment Gazette is written and put together by members of the Wren Lane Allotment Society. Set in Work Sans, Titan One and Courier Prime (SIL Open Font License) · Text: original, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the site plan, the heap's temperature and the square-metre bed function mulberry32(seed) { // a seeded PRNG: the same plots on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k const f = (v) => +v.toFixed(2); const P = palette; const SOIL = mix(P.tomato, P.ink, 0.62); const EARTH = mix(SOIL, P.straw, 0.28); const GRASS = mix(P.tint, P.leaf, 0.3); const DEEP = mix(P.leaf, P.ink, 0.35); const svgOf = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`; const dot = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${f(x)}" y="${f(y)}" width="${f(w)}" ` + `height="${f(h)}" fill="${fill}"${extra}/>`; const line = (x1, y1, x2, y2, stroke, width) => `<path d="M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}" ` + `stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"/>`; // One plot seen from above: rows of crops across it, sometimes a shed or a greenhouse. function plot(x, y, w, h, rand, extra) { let out = rect(x, y, w, h, EARTH); const strips = 2 + Math.floor(rand() * 3); let top = y + 0.8; for (let s = 0; s < strips; s++) { const room = y + h - 0.8 - top; if (room < 2) break; let sh = s === strips - 1 ? room : Math.max(4, (h - 1.6) * (0.2 + rand() * 0.3)); if (room - sh < 4) sh = room; // no strip thinner than 4 mm const kind = Math.floor(rand() * 6); const [x0, x1] = [x + 0.9, x + w - 0.9]; if (kind === 0) { // rows of lettuce and onions for (let yy = top + 1; yy < top + sh - 0.6; yy += 1.7) { for (let xx = x0 + 0.6; xx < x1; xx += 1.5) out += dot(xx, yy, 0.55, P.leaf); } } else if (kind === 1) { // peas and beans up canes for (let yy = top + 1.2; yy < top + sh - 0.8; yy += 2.6) out += line(x0, yy, x1, yy, DEEP, 1); } else if (kind === 2) { // brassicas under netting out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, mix(P.tint, P.paper, 0.4)); for (let yy = top + 1.6; yy < top + sh - 1.2; yy += 3) { for (let xx = x0 + 1.4; xx < x1 - 0.8; xx += 3) { out += dot(xx, yy, 1.1, mix(P.leaf, P.rule, 0.45)); } } } else if (kind === 3) { // rhubarb and squashes for (let xx = x0 + 2; xx < x1 - 1; xx += 4 + rand() * 2) { const yy = top + sh / 2 + (rand() - 0.5) * (sh - 3); out += dot(xx, yy, 1.8, P.leaf) + dot(xx + 0.6, yy - 0.5, 0.45, P.marigold); } } else if (kind === 4) { // dug over and raked for (let yy = top + 0.7; yy < top + sh - 0.3; yy += 0.9) { out += line(x0, yy, x1, yy, SOIL, 0.25); } } else { // straw round the strawberries out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, P.straw); for (let xx = x0 + 1; xx < x1; xx += 2.2) { out += dot(xx, top + sh / 2 - 0.2, 0.8, P.leaf) + dot(xx + 0.4, top + sh / 2 + 0.3, 0.3, P.tomato); } } top += sh; } if (extra === 'shed') { // a shed with its felt roof in tomato, and a water butt const sx = rand() < 0.5 ? x + 0.6 : x + w - 5.1; out += rect(sx, y + 0.6, 4.5, 3.4, P.tomato) + line(sx, y + 2.3, sx + 4.5, y + 2.3, SOIL, 0.3) + dot(sx + (sx > x + 1 ? -1.3 : 5.8), y + 1.6, 0.9, P.ink); } else if (extra === 'glass') { // a greenhouse: glass over the whole end of the plot out += rect(x + 0.8, y + h - 7.2, w - 1.6, 6.4, mix(P.paper, P.tint, 0.6)) + [0.25, 0.5, 0.75].map((k) => line(x + 0.8 + k * (w - 1.6), y + h - 7.2, x + 0.8 + k * (w - 1.6), y + h - 0.8, P.rule, 0.3)).join(''); } return out; } function planSvg(face) { // 114 × 56.5 mm: the top of the Wren Lane site, north up const rand = mulberry32(1921); // the year the society took the lease const [W, H, PATH] = [114, 56.5, 26.5]; const label = (lx, ly, s, fill, anchor = 'start') => `<text x="${lx}" y="${ly}" ` + `font-size="2.3" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(0, 0, W, H, GRASS) + rect(0, PATH, W, 4.5, P.straw); // the main path, gravel out += label(W - 2, PATH + 3, t({ en: 'MAIN PATH', es: 'CAMINO' }), SOIL, 'end'); // The top gate at the end of the path, and the corner behind it. out += rect(0, PATH - 0.8, 1.2, 1.2, P.ink) + rect(0, PATH + 4.1, 1.2, 1.2, P.ink) + label(2.2, PATH + 3, t({ en: 'TOP GATE', es: 'PUERTA DE ARRIBA' }), SOIL); out += rect(2, 2.4, 11, 8, P.tomato) + rect(2, 6.4, 11, 4, mix(P.tomato, P.ink, 0.22)) // the hut + label(7.5, 7.2, t({ en: 'HUT', es: 'CASETA' }), P.paper, 'middle'); out += rect(15.5, 3.2, 11.5, 3.6, mix(P.rule, P.ink, 0.3)) // the trough + rect(16.1, 3.8, 10.3, 2.4, mix(P.tint, P.rule, 0.4)); for (let i = 0; i < 3; i++) { // the three bays in fresh boards: full, half full, empty const bx = 2 + i * 8.6; out += rect(bx, 13.5, 7.8, 7.8, P.marigold) + rect(bx + 0.8, 14.3, 6.2, 6.2, SOIL); for (let k = 0; k < 16 - i * 7; k++) { out += dot(bx + 1.5 + rand() * 4.8, 15 + rand() * 4.8, 0.5, i === 0 ? DEEP : EARTH); } out += label(bx + 3.9, 24.6, i + 1, P.ink, 'middle'); } out += dot(25.5, 10.5, 3.6, DEEP); // the old pear // Plots: four above the path, five below; one split in halves, grass paths between. const row = (x0, x1, y, h, n, extras) => { const w = (x1 - x0 - (n - 1) * 1.4) / n; for (let i = 0; i < n; i++) { const px = x0 + i * (w + 1.4); if (extras[i] === 'halves') { const half = (h - 1.4) / 2; out += plot(px, y, w, half, rand) + plot(px, y + half + 1.4, w, half, rand, 'shed'); } else out += plot(px, y, w, h, rand, extras[i]); } }; row(31, W - 2, 2, PATH - 3.5, 4, ['shed', 'halves', '', 'shed']); row(2, W - 2, PATH + 6.5, H - PATH - 8.5, 5, ['', 'shed', 'glass', '', 'shed']); return svgOf(W, H, out, face); } // An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so the // chart carries its label face inline, as a data URL of the Fontsource file. async function inlineFace(family, weight) { const id = family.toLowerCase().replace(/\s+/g, '-'); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}` + '-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return `<style>@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:L;font-weight:700}</style>`; } const HEAT_LOG = [12, 24, 38, 49, 57, 63, 64, 62, 59, 55, 51, 47, 56, 61, 62, 60, 57, 54, 51, 48, 46, 44]; // °C at the heap's centre, 7 to 28 March; turned after the reading on the 18th function heatSvg(face) { // 68 × 40 mm; the plot runs 7–28 March and 0–70 °C const [W, H, X0, X1, Y0, Y1] = [68, 40, 9, 66, 3, 33]; const x = (day) => X0 + ((day - 7) / 21) * (X1 - X0); const y = (deg) => Y1 - (deg / 70) * (Y1 - Y0); const text = (tx, ty, s, fill, anchor = 'end', size = 2.5) => `<text x="${f(tx)}" y="${f(ty)}" ` + `font-size="${size}" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(X0, y(65), X1 - X0, y(55) - y(65), P.straw); // where weed seeds die for (const deg of [20, 40, 60]) { out += line(X0, y(deg), X1, y(deg), P.rule, 0.2) + text(X0 - 1.2, y(deg) + 0.9, deg, P.muted); } out += text(X0 - 1.2, y(70) + 0.9, '°C', P.muted); // the unit, over the scale for (const day of [7, 14, 21, 28]) { out += line(x(day), Y1, x(day), Y1 + 1, P.ink, 0.25) + text(x(day), Y1 + 3.8, day === 28 ? t({ en: '28 March', es: '28 marzo' }) : day, P.muted, day === 28 ? 'end' : 'middle'); } out += line(x(18.5), y(70), x(18.5), Y1, P.leaf, 0.35) + text(x(18.5) + 1, y(70) + 2.2, t({ en: 'turned', es: 'volteo' }), P.leaf, 'start'); const pts = HEAT_LOG.map((deg, i) => [x(7 + i), y(deg)]); out += `<path d="M${pts.map(([px, py]) => `${f(px)} ${f(py)}`).join('L')}" fill="none" ` + `stroke="${P.tomato}" stroke-width="0.55" stroke-linejoin="round"/>`; out += pts.map(([px, py]) => dot(px, py, 0.6, P.tomato)).join(''); out += line(X0, Y1, X1, Y1, P.ink, 0.3); return svgOf(W, H, out, face); } // The square-metre bed: sixteen squares of 30 cm, one to sixteen plants in each. const CROPS = { // plants per square, size and colour of each plant seen from above cabbage: [1, 4.2, mix(P.leaf, P.rule, 0.35)], lettuce: [4, 2.1, mix(P.leaf, P.straw, 0.35)], chard: [4, 1.9, mix(P.leaf, P.tomato, 0.3)], marigold: [4, 1.5, P.marigold], beetroot: [9, 1.1, mix(P.tomato, P.ink, 0.35)], beans: [9, 1.2, P.leaf], onion: [9, 0.8, mix(P.straw, P.paper, 0.3)], radish: [16, 0.6, P.tomato], carrot: [16, 0.55, mix(P.marigold, P.tomato, 0.35)], }; const BED = [['cabbage', 'lettuce', 'lettuce', 'marigold'], ['beetroot', 'radish', 'carrot', 'onion'], ['chard', 'beans', 'beetroot', 'radish'], ['marigold', 'onion', 'carrot', 'lettuce']]; function bedSvg() { // 50 × 50 mm: 1.2 m of bed inside its scaffold boards const [S, BOARD] = [50, 2.6]; const cell = (S - 2 * BOARD) / 4; let out = rect(0, 0, S, S, P.marigold) + rect(BOARD, BOARD, S - 2 * BOARD, S - 2 * BOARD, SOIL); BED.forEach((cropRow, r) => cropRow.forEach((crop, c) => { const [n, size, fill] = CROPS[crop]; const k = Math.sqrt(n); for (let i = 0; i < n; i++) { const cx = BOARD + c * cell + ((i % k) + 0.5) * (cell / k); const cy = BOARD + r * cell + (Math.floor(i / k) + 0.5) * (cell / k); out += dot(cx, cy, size, fill); if (crop === 'marigold') out += dot(cx, cy, size * 0.4, P.tomato); } })); for (let i = 1; i < 4; i++) { // the strings const at = BOARD + i * cell; out += line(at, BOARD, at, S - BOARD, P.paper, 0.25) + line(BOARD, at, S - BOARD, at, P.paper, 0.25); } return svgOf(S, S, out); } // A drill of seedlings along the foot of the masthead band, 210 × 8 mm: some show only their // seed leaves, the rest their first true leaves too. function leaf(x, y, len, wid, deg, fill) { // a leaf from its stalk end, deg from the vertical const a = (deg * Math.PI) / 180; const [dx, dy] = [Math.sin(a), -Math.cos(a)]; const [mx, my, px, py] = [x + (dx * len) / 2, y + (dy * len) / 2, -dy * wid, dx * wid]; return `<path d="M${f(x)} ${f(y)}Q${f(mx + px)} ${f(my + py)} ${f(x + dx * len)} ` + `${f(y + dy * len)}Q${f(mx - px)} ${f(my - py)} ${f(x)} ${f(y)}Z" fill="${fill}"/>`; } function drillSvg() { const rand = mulberry32(47); // the issue number const [W, H, RIDGE] = [210, 8, 1.3]; const SEED = mix(P.leaf, P.straw, 0.3); let out = rect(0, H - RIDGE, W, RIDGE, SOIL); for (let x = 2.6; x < W - 1; x += 4.6 + rand() * 1.2) { const ground = H - RIDGE; const grown = rand() < 0.62; const stem = grown ? 2.6 + rand() * 1.2 : 1.2 + rand() * 0.8; const lean = (rand() - 0.5) * 10; out += line(x, ground, x, ground - stem, P.leaf, 0.45); const top = ground - stem; if (grown) { out += leaf(x, ground - stem * 0.45, 1.6, 0.55, -68 + lean, SEED) + leaf(x, ground - stem * 0.45, 1.6, 0.55, 68 + lean, SEED) + leaf(x, top, 2.6 + rand() * 0.5, 1, -32 + lean, P.leaf) + leaf(x, top, 2.6 + rand() * 0.5, 1, 32 + lean, P.leaf); } else { out += leaf(x, top, 1.7, 0.6, -58 + lean, SEED) + leaf(x, top, 1.7, 0.6, 58 + lean, SEED); } } return svgOf(W, H, out); } // What the drawings say under them, in the sample's language: caption, credit note, alt text. const CAPTIONS = { plan: [t({ en: '**The top of the Wren Lane site, from the air.** The three new bays stand between ' + 'the hut and the top gate.', es: '**La parte alta del Soto, desde el aire.** Los tres cajones nuevos están entre la ' + 'caseta y la puerta de arriba.' }), t({ en: 'Drawing: The Gazette, from the society’s site plan', es: 'Dibujo: La Gaceta, a partir del plano de la asociación' }), t({ en: 'Plan of allotment plots seen from above, with three compost bays by the gate.', es: 'Plano de parcelas de huerto vistas desde arriba, con tres cajones de compost junto a la ' + 'puerta.' })], heat: [t({ en: '**Bay one in March.** Temperature at the centre of the heap, read at nine each ' + 'morning. In the shaded band, 55 to 65 °C, most weed seeds die.', es: '**El cajón uno en marzo.** Temperatura en el centro, a las nueve. En la franja, de ' + '55 a 65 °C, mueren casi todas las semillas de malas hierbas.' }), t({ en: 'Readings: the compost rota', es: 'Lecturas: el turno del compost' }), t({ en: 'Line chart: the heap rises from 12 °C to 64 °C in six days, cools to 47 °C, and ' + 'climbs back to 62 °C after it is turned on 18 March.', es: 'Gráfico de líneas: el montón sube de 12 °C a 64 °C en seis días, baja a 47 °C y vuelve ' + 'a 62 °C después del volteo del 18 de marzo.' })], bed: [t({ en: '**What goes where.** One cabbage to a square; four lettuces, chard or marigolds; ' + 'nine beetroot, beans or onions; sixteen radishes or carrots.', es: '**Cómo se planta el bancal.** Una col por cuadro; cuatro lechugas, acelgas o ' + 'caléndulas; nueve remolachas, judías o cebollas; dieciséis rábanos o zanahorias.' }), t({ en: 'Drawing: The Gazette', es: 'Dibujo: La Gaceta' }), t({ en: 'A square bed divided by strings into sixteen squares, each planted with one to ' + 'sixteen plants.', es: 'Un bancal cuadrado dividido con cuerdas en dieciséis cuadros, ' + 'cada uno con entre una y dieciséis plantas.' })], }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build (gotcha: fonts-first). const FONTS = { 'Work Sans': ['400', '700'], 'Titan One': ['400'], 'Courier Prime': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const face = await inlineFace(LABEL, 700); // the label face, for the drawings' own lettering await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)), loadSvg('bed.svg', bedSvg()), loadSvg('drill.svg', drillSvg())]); const doc = await buildWithFonts( () => buildDocument({ markdown, resources: resources() }, config()), markdown); showPages(doc, { title: t({ en: 'Community newsletter', es: 'Boletín vecinal' }) });العُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#ضع الأخبار القصيرة على اليسار
العمود العريض يُقرأ أولًا دائمًا، فيضع Markdown نفسه القصة الرئيسية على اليمين والأخبار القصيرة على اليسار.
layoutType: 'oneAndHalf',
+ sideColumnSide: 'left',#نضّد الأخبار القصيرة على عرض الصفحة
لا يجري النص الأساسي في أكثر من عمودين. وفي وصفة الصفحة الأولى من جريدة تمتد أربعة أخبار قصيرة على عرض أسفل الصفحة في صندوق فيه :::columns{count=4}.
أخطاء شائعة
خطأ شائع
تحتفظ الفقرة بعرض سطر العمود الذي تبدأ فيه
في postext 1.4.1 تُكسر الفقرة إلى أسطر مرة واحدة، بعرض العمود الذي تبدأ فيه. ففي صفحة بعمود ونصف عمود فيها نص في العمودين، تحتفظ الفقرة التي تمتد من العمود العريض إلى الضيق بأسطرها الطويلة، فتُقصّ عند حافة العمود الضيق؛ وتحتفظ الفقرة التي تمتد من العمود الضيق إلى العمود العريض في الصفحة التالية بأسطرها القصيرة. أنهِ كل عمود بفقرة كاملة: اضبط طول النص، وضع :::columnbreak بعد آخر فقرة لتفتتح الكتلة التالية العمود التالي. عمود ونصف →
خطأ شائع
لا يعمل :::columns إلا داخل إطار ولا ينقسم أبدًا
يُتجاهَل :::columns خارج الإطار، والإطار الذي ينقسم لا يقطع أبدًا داخل مجموعة أعمدة. السمة breaks تعدّ الكتل الأبناء، والإطار المتداخل يُعدّ كتلة واحدة. أعمدة داخل الإطار →
خطأ شائع
ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس
يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984". البيانات الوصفية للمستند →
خطأ شائع
النص غير المضبوط لا يُقسَّم بالواصلة أبدًا
لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر. تقسيم الكلمات ولغة المستند →
خطأ شائع
النص غير المضبوط لا يُفحص أبدًا بحثًا عن الكلمات المعزولة
تعمل optimalLineBreaking وavoidRunts وruntPenalty وruntMinCharacters على كاسر الأسطر Knuth–Plass، الذي لا يشغّله postext 1.4.1 إلا للنص المضبوط. الفقرة غير المضبوطة تُكسر سطرًا سطرًا وقد تنتهي بكلمة قصيرة واحدة أيًّا كانت تلك الإعدادات. اقرأ الأسطر الأخيرة من النص غير المضبوط وأعد صياغة الفقرة التي تنتهي بكلمة معزولة. الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة →
خطأ شائع
المسافة غير القابلة للكسر تكسر السطر مع ذلك
في postext 1.4.1 يعامل كاسر الأسطر U+00A0 معاملة المسافة العادية، فقد تنقسم 0.08 % أو 2.006 s أو Section 2 على سطرين. ألصق الجزأين (0.08%) أو أعد صياغة الجملة. الهروب والمحارف الحرفية →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا
في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب
يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
- في هذا الإخراج يبدأ الخط الفاصل بين العمودين في الصفحة 1 من أعلى منطقة النص، خلف ترويسة النشرة، مهما كان عدد أسطر الشبكة التي تحجزها. الشريط بلون القش مرسوم فوقه؛ أما ترويسة من غير شريط ملوّن فسيظهر فيها الخط مارًّا عبر الاسم.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- الخطوط
- Work Sans (SIL OFL 1.1) · Titan One (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


