انتقل إلى المحتوى الرئيسي
الوصفة رقم 12

دليل الوصفات · الفصل 10 · الإخراج والدمج

من نص Markdown إلى صفحة مصمّمة

نص Markdown ببيانات تمهيدية يُنضَّد صفحات مجلة بعمودين ويُرسم على Canvas، بستة ألوان مسمّاة بدل الأزرق الافتراضي.

في هذه الصفحة
المُخرَج
Canvas
المستوى
متوسط
Postext
اختُبرت مع Postext 1.9.2
تتطلب ≥ 1.4.1
الترخيص
حُدّثت في 26 سبتمبر 2026
الكود MIT · النص CC BY 4.0
  • نموذج باللغة الإنجليزية: لا توجد طبعة عربية بعد
  • مقاس القص 180 × 240 مم
  • عمودان, فاصل 6 مم بين الأعمدة
  • Newsreader 9.5/13.5
  • Inter Tight
  • Young Serif
  • صفحتان
  • المستوى
  • Postext 1.9.2
  • أُخرجت في 89 ms
  • أسطر الكود: 173

باختصار

أول صفحتين من مقالة رحلات في مجلة صغيرة، بشريط ملوّن وصورة. تبيّن كيف يصير نص عادي فيه بضعة عناوين صفحة مصمّمة بألوانك أنت.

ما الذي ستنضده

الصفحات الافتتاحية لتحقيق عن رحلة في Field Notes، وهي مجلة صغيرة. الصفحة 1 صفحة افتتاح فردية: صورة لملّاحات وكثيب عند الغسق تغطي 58% من الصفحة من أعلاها، والعنوان بخط Young Serif بحجم 54 نقطة، وتحته عمودان مضبوطان يبدأ بهما النص بمدخل عريض. وتستمر الصفحة 2، على ظهر الورقة، تحت ترويسة بحروف كبيرة متباعدة، وتنتهي بعلامة نهاية وبيانات طبع. يحدد التصميمَ مصنعُ إعدادات واحد ولوحة من ستة ألوان. ويأتي العنوان من عنوان المستوى الأول، وتأتي المقدمة والكاتب والتاريخ من البيانات التمهيدية لنص Markdown، فينضّد المثال نفسه أي مقالة يتسع عنوانها لسطرين؛ ولا يخص هذه المقالة إلا الصورة.

تجيب هذه الوصفة عن

  • كيف أمنع العناوين والكلمات العريضة والنقاط من الظهور باللون الأزرق؟
  • كيف أضبط صفحة كتاب: مقاس القطع، وهوامش داخلية وخارجية متناظرة، وعمودين وفاصلًا بينهما؟
  • كيف أحوّل نص Markdown إلى صفحات منضَّدة وأرسم إحداها على لوحة Canvas؟

الجواب المختصر

script.js · الأسطر 126–163في الكود الكامل
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. The restated break
      // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});

المكونات

الخطوط
Newsreader, Young Serif, Inter Tight (SIL OFL 1.1)
الأصول
  • salt-road-1440.jpg
  • The salt pans and the dune at dusk on the opener, a photograph (Generated With Diffusion Models, أصلي)

طريقة التحضير

#1 · كل الوجوه، قبل أول بناء

script.js · الأسطر 223–227في الكود الكامل
const FONTS = {
  Newsreader: ['400', '400i', '700'], // text
  'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400
  'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon
};

يقيس Postext النص بالخطوط التي حمّلها المتصفح من قبل ويخزّن العروض مؤقتًا، فالبناء الذي يجري قبل وصول الخطوط يحتفظ بفواصل أسطره الخاطئة. تحمّل العُدّة هذه الملفات من Fontsource، وهي الملفات نفسها التي سيضمّنها ملف PDF. ثم يستعرض buildWithFonts الوجوه التي تستعملها الصفحات. فإن نُضّدت كتلة أو عنصر تصميم بوجه غير محمَّل، طبع تحذيرًا في وحدة التحكم وحمّل الوجه وأعاد البناء؛ وإن لم يوفّر Fontsource ذلك الوجه، توقّف المثال بخطأ (انظر «أخطاء شائعة»). أما المتغيّرات العريضة والمائلة لوجه النص فتُحمَّل دون تحذير، وفقط إن كانت العائلة تملكها.

#2 · سمِّ الألوان ثم أشِر إلى الأسماء

script.js · الأسطر 23–43في الكود الكامل
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the folios and the drop folio too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
  // point it at the accent. Header, footer and opener defaults do not follow it, so they
  // are restated below.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];

كل لون في الإعدادات هو col('id')، أي معرّف من اللوحة وبجانبه قيمته السداسية عشرية. تسمّي bodyText.boldColor وitalicColor وreferenceColor وunorderedLists.color اللون ink، وتسمّي headings.color لون التمييز. وقيم المحرّك الافتراضية للعناوين والعريض والمائل والنقاط مرتبطة بـmain-color، وهذه اللوحة توجّه ذلك المدخل إلى لون التمييز، فأي منها تتركه يخرج نيليًّا بدل الأزرق. ويرسم Postext 1.4.1 عناصر الترويسة والتذييل وصفحة الافتتاح، وreferenceColor، من القيمة السداسية عشرية ويتجاهل المعرّف، ولذلك كائنات التصميم مصانع: يستدعيها config() في كل بناء، وينسخ col() القيمة الحالية من palette في كل مرة. وعلامة النهاية عيّنة لون مضمّنة في السطر تسمّي band، فيصلها تغيير اللون هي أيضًا. أما الترويسة الافتراضية فلا تقرأ اللوحة وتبقى زرقاء حتى تستبدلها (الخطوة 5).

#3 · صورة مقصوصة على إطارها

script.js · الأسطر 47–53في الكود الكامل
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };

الملّاحات والكثيب وركام الحجارة صورة مولّدة بنماذج الانتشار (diffusion models)، ملتقطة عند الغسق ليُقرأ العنوان الفاتح على سماء داكنة. وملف JPEG مقصوص على إطار الشريط، PAGE.width × BAND مليمترًا، ويعلن المورد أبعاده بالبكسل، 1440 × 1120، فتملأ الصورة بعرضها الكامل الشريطَ تمامًا. والشريط الأعمق يحتاج إلى صورة مقصوصة على الإطار الجديد. وتعرضها صفحة الافتتاح عبر عنصر صورة يسمّي المورد بمعرّفه.

#4 · البيانات التمهيدية تكتب صفحة الافتتاح

script.js · الأسطر 57–92في الكود الكامل
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
      // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        overflow: 'wrap', placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});

يمتد عنوان المستوى الأول على العمودين، وصفحة افتتاحه خانة من العناصر: الصورة حتى منطقة النزف، وأربعة نصوص متسلسلة واحدًا تحت الآخر. فالعنوان الأطول يدفع المقدمة واسم الكاتب إلى الأسفل بدل أن يتراكب عليهما، لكن إذا تجاوز سطرين وقع اسم الكاتب على الملّاحات الفاتحة. {titleText} هو نص العنوان؛ و{subtitle} و{author} و{publishDate} حقول من البيانات التمهيدية. وتكسر \\ في العنوان السطر حيث تضعها (فواصل الأسطر في العناوين). ويُحسب minHeight من الهامش العلوي، فينتهي الارتفاع المحجوز على بعد 5 مم تحت الشريط.

#5 · ترويسات بدل الأزرق الافتراضي

script.js · الأسطر 96–115في الكود الكامل
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});

إذا تركت header عاد الافتراضي الخاص بالمحرّك: Open Sans فوق خط بعرض الصفحة، في كل صفحة. وهنا كل رقم صفحة مثبّت إلى الصفحة الفعلية ومُرشَّح بحسب الزوجية، فيقع دائمًا عند الحافة الخارجية. وتسميته مثبّتة إلى رقم الصفحة، على بعد GAP مليمترًا مع right-of أو left-of، فرقم الصفحة ذو الثلاثة أو الأربعة أرقام يدفع التسمية أمامه بدل أن يصطدم بها. ويُبعد pages: 'body' العناصر الأربعة عن صفحة الافتتاح. ويحمل التذييل رقم صفحة واحدًا في أسفل صفحة الافتتاح، ولأن footer معيَّن، تفقد صفحات المتن رقم الصفحة الموسَّط الافتراضي.

#6 · ابنِ بإعدادات جديدة ثم ارسم

script.js · الأسطر 232–240في الكود الكامل
await loadFonts(FONTS, markdown);
await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId));
const doc = await buildWithFonts(
  () => buildDocument({ markdown, resources }, config()), markdown);
// showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }).
showPages(doc, {
  title: t({ en: 'From a Markdown string to a designed page',
    es: 'De una cadena Markdown a una página diseñada' }),
});

config() دالة لأن المحرّك يخزّن كل إعداد محلول بحسب هوية الكائن، فلا يسري الإعداد المعدَّل إلا إذا تلقّى كل بناء كائنًا جديدًا. ويرتّب showPages الصفحات كما تقع في كتاب مجلّد، الصفحة 1 وحدها على اليمين، ويرسم كلًّا منها بـrenderPageToCanvas.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 012 · From a Markdown string to a designed page ══════════
// https://postext.dev/en/cookbook/first-page-from-markdown
// Code: MIT · Text: original (CC BY 4.0) · Photo: diffusion models
// Fonts: Newsreader, Young Serif, Inter Tight (SIL OFL 1.1) · Needs postext ≥ 1.4.1
//
// This pen sets a Markdown string with a frontmatter block on two magazine pages and paints
// them on canvases. The whole design is in config(); swap config() for {} in the build
// below to see the engine's defaults.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'first-page-from-markdown';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every setting that shapes the pages is in this part, from PAGE down to config().
const PAGE = { width: 180, height: 240 }; // mm: a 3:4 magazine page
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 14 }; // mm; inner is the spine side
const LEAD = 13.5; // pt: the body leading, the grid every vertical space steps on

// #region palette: six named colours; every colour in the config links to one of them
const palette = {
  ink: '#1b1e23', // text: a cool near-black, never #000
  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
  sand: '#e9dcc0', // the kicker, the title and the byline
  salt: '#f7f4ee', // the standfirst
  rule: '#d6d3cc', // the hairline over the colophon
  muted: '#66686e', // running heads and the colophon
};
// Each colour carries its palette id and its hex: 1.4.1 paints the elements of headers,
// footers and openers from the hex (gotcha: palette-skips-designs). The design objects
// below are factories that config() calls, so col() copies the hex out of `palette` on
// every build, and a retint reaches the folios and the drop folio too.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = () => [
  ...Object.entries(palette)
    .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The defaults of the text styles (headings, bold, italic, bullets) link to 'main-color':
  // point it at the accent. Header, footer and opener defaults do not follow it, so they
  // are restated below.
  { id: 'main-color', name: 'defaults', value: { hex: palette.band, model: 'hex' } },
];
// #endregion

// #region art: a photograph of the salt pans and the dunes at Sorra, cut for a page × band frame
// A JPEG in assets/, cut to PAGE.width × BAND (180 × 140 mm) and declared at its pixels, so at
// full width it fills the band exactly. A deeper band needs a picture cut to the new frame.
const landscape = { id: 'landscape', typeId: 'figure', kind: 'bitmap', createdAt: 0,
  updatedAt: 0, bitmap: { fileId: 'salt-road-1440.jpg', format: 'jpeg', width: 1440,
    height: 1120 },
  altText: 'Salt pans at dusk, a dune with a cairn of white stones on its crest, and a line of '
    + 'footprints crossing the pans towards it.' };
// #endregion

// #region opener: the H1 as a bleed band; kicker, title, standfirst and byline sit on it
const BAND = 140; // mm from the top edge: the band holds the top 58% of the page
const TITLE_W = 130; // mm: room for two lines of the title; a third would push the byline
// down onto the pale salt pans: keep titles short
const DECK_W = 104; // mm: the standfirst stops short of the dune and its cairn
const MAGAZINE = t({ en: 'Field notes', es: 'Cuaderno de campo' });
const label = { fontFamily: 'Inter Tight', fontSize: pt(7.5), fontWeight: 600,
  letterSpacing: pt(1.4), textTransform: 'uppercase' };
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
  offset: { y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = () => ({
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 5), // from the top margin to the band's foot, plus 5 mm
  slot: {
    elements: [
      // The photograph is PAGE.width × BAND (see art): at full width it fills the band.
      { kind: 'image', id: 'art', resourceId: 'landscape',
        placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
      { kind: 'text', id: 'kicker', content: MAGAZINE, ...label, color: col('sand'),
        placement: { anchor: { to: 'page', edge: 'top-left' }, // recto: inner on the left
          offset: { x: mm(MARGIN.inner), y: mm(20) } } },
      // {titleText} is the H1; {subtitle}, {author} and {publishDate} are frontmatter. A design
      // text's lineHeight multiplies its size, never pt() (gotcha: design-lineheight-multiple).
      { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Young Serif',
        fontSize: pt(54), lineHeight: 1, color: col('sand'), align: 'left',
        overflow: 'wrap', // not an ellipsis (gotcha: overflow-ellipsis-default)
        placement: below('kicker', 3, TITLE_W) },
      { kind: 'text', id: 'deck', content: '{subtitle}', fontFamily: 'Newsreader',
        fontSize: pt(12), lineHeight: 1.3, italic: true, color: col('salt'), align: 'left',
        overflow: 'wrap', placement: below('title', 5, DECK_W) },
      { kind: 'text', id: 'byline', ...label, color: col('sand'),
        content: t({ en: 'By {author} · {publishDate}',
          es: 'Por {author} · {publishDate}' }),
        placement: below('deck', 4.5) },
    ],
  },
});
// #endregion

// #region running-heads: folio and title on body pages, a drop folio under the opener
const HEAD = 12; // mm: the running heads from the top edge, the drop folio from the foot
const GAP = 3; // mm between a folio and its label, however many digits the folio has
const head = (id, content, parity, placement, color = col('muted')) => ({
  kind: 'text', id, content, parity, pages: 'body', ...label, color, placement,
});
const outer = (edge, x) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } });
const beside = (id, edge, x) => ({ anchor: { to: `#${id}`, edge }, offset: { x: mm(x) } });
const header = () => ({
  elements: [ // folios on the outer margin's edge; each label hangs off its folio
    head('verso-folio', '{pageNumber}', 'even', outer('top-left', MARGIN.outer), col('band')),
    head('verso-title', '{title}', 'even', beside('verso-folio', 'right-of', GAP)),
    head('recto-folio', '{pageNumber}', 'odd', outer('top-right', -MARGIN.outer), col('band')),
    head('recto-title', MAGAZINE, 'odd', beside('recto-folio', 'left-of', -GAP)),
  ],
});
const footer = () => ({
  elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
    ...label, color: col('band'),
    placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-HEAD) } } }],
});
// #endregion

// The colophon: small sans under a 0.5 pt hairline, with no box around it.
const colophon = () => ({ id: 'colophon', backgroundEnabled: false, marginTop: pt(LEAD),
  stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
  padding: { top: mm(1.6), right: pt(0), bottom: pt(0), left: pt(0) },
  body: { fontFamily: 'Inter Tight', fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } });

// #region answer: one config factory in place of the default skin: page, type, colour, slots
const config = () => ({ // a new object per build (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette: colorPalette(),
  page: { // mirror: left is the inner margin and right the outer one; versos swap them
    width: mm(PAGE.width), height: mm(PAGE.height),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  // 'double' is the default, stated so that the whole page setup reads in one place
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { // justified, hyphenated and broken by paragraph: all on by default
    fontFamily: 'Newsreader', // one family name (gotcha: font-family-one-name)
    fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    // Optional, for 70 mm columns: word spaces from 0.8 to 1.8 × the normal one, and runts
    // tightened by at most 4 thousandths of an em, so the grey of the text stays even.
    minWordSpacing: 0.8, maxWordSpacing: 1.8, maxRuntTracking: 4,
  },
  headings: {
    fontFamily: 'Young Serif', fontWeight: 400, color: col('band'), // it has one weight
    levels: [
      // span: 'page' opens the H1 on a new page, across both columns. The restated break
      // (gotcha: headings-drop-h1-break) puts the next article pasted in on a recto.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        advancedDesign: opener() },
      // A line of margin and a line and a half of head: 2.5 lines, which snapToGrid (on by
      // default) rounds up to 3, so the text below lands back on the grid.
      { level: 2, fontSize: pt(13), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) },
    ],
  },
  unorderedLists: { color: col('ink'), fontWeight: 400, bulletChar: '–',
    marginTop: pt(0), marginBottom: pt(0) },
  calloutStyles: [colophon()],
  header: header(),
  footer: footer(),
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// content.<lang>.md, inlined by the Cookbook: every frontmatter value is quoted, since an ISO
// date or a number would print empty (gotcha: quote-frontmatter). The end mark is an inline
// swatch that names `band`, so it follows the palette too.
const markdown = String.raw`---
نموذج Markdown · أسطر: 46 · content.en.mdtitle: "The Salt Road" subtitle: "Four days on foot along the carriers’ trail, from the salt pans of Arvela to the market at Castrel." author: "Lena Varga" publishDate: "May 2026" --- # The Salt \\ Road **The carriers set out from the dyke at Arvela.** At low water the flats below it run out for three kilometres, a grey plain scored by tidal channels, and from the dyke you see the salt pans before the sea: forty shallow squares of brine laid out like a chessboard, each one a shade whiter than the one before it. For six centuries the salt of Arvela left the coast on people’s backs. They loaded up at dawn, thirty kilos to a wicker basket, and walked inland on a trail that nobody ever paved: across the flats, over the dunes of Sorra, through the pine country and up to the old market town of Castrel, four days on foot. The railway ended the trade in 1911, but walkers have kept the trail open since. I walked it this April with Tomás Reis, whose grandmother carried salt when she was a girl, and who has spent twenty summers marking the route with cairns of white stones. “Watch the ground for *salt*,” he told me that first morning. “Where it was spilled, nothing grows.” On the second day, in the dunes, the path disappears for hours, and then a pale seam shows in the sand, a few centimetres wide, as if someone had drawn it in chalk. It is salt that fell from the baskets over six centuries, a crust that the wind uncovers every spring. Tomás walks beside it and never steps on it. We slept that night in a hollow of the dunes that the carriers called the Kitchen, because the wind never gets into it. Tomás lit no fire. He ate bread and dried fish and talked about his grandmother, who could taste a single grain and tell you whether it came from Arvela or from the pans of the south, and who never once, he says, lost the trail in fog. ## What came back The baskets went back to the coast full. A carrier’s ledger from 1887, kept in the parish archive at Sorra, lists the return loads of its first spring: - **Grain**, mostly rye and barley, from the upland farms above Castrel. - **Pine resin** and tar, to caulk the fishing boats at Arvela each winter. - **Wool and hides** from the May fair at Castrel. - **Nails and fish-hooks** from the smithy at Orsa. Until the railway came, a basket of salt bought a basket of grain. A good carrier made eleven round trips a year, eighty-eight days on the trail, and in between raked salt in the pans. The ledger is written in three hands. The first is a careful copperplate that records every load to the half kilo; the second, from 1893 on, writes nothing but numbers; the third, a child’s hand, turns up in the winters and spells the town Castrell. The last entry in the ledger, halfway down a page in the autumn of 1910, is a load of barley that was never marked as delivered, and nobody in Sorra knows why. Tomás thinks the carrier went to lay track for the railway, which paid in coin. ## Into the pines The third day is the longest. Beyond Orsa the trail climbs for eight hours through pine woods that smell of resin, past the ruined relay houses where the carriers slept on straw and paid for their beds in salt. Every house has a cairn by its door, topped with a white stone that Tomás put there. The best-kept house stands at the Nine Wells, an hour below the pass. Its lintel is cut with rows of short notches, one for each night a carrier slept under it, and in places they are so close that they have worn into one groove. Tomás tried to count them one winter and gave up at four thousand. On the fourth afternoon the trail comes out of the pines onto a bare ridge, and Castrel appears below: a grey town built around a square that is still called the Salt Market, although nobody has sold salt there for a hundred years. Like the carriers, Tomás leaves a handful of salt on the stone rim of the fountain before he drinks. Then he turns round and starts back towards the sea. The carriers never slept in Castrel, he says, because the inns there charged in coin and a carrier was paid in grain. :swatch{color="band"} :::callout{type="colophon"} A work of fiction: Arvela, Sorra, Orsa and Castrel are imaginary places. Set in Newsreader, Young Serif and Inter Tight (SIL Open Font License) · Text: original, CC BY 4.0 · Photo: diffusion models. :::
`; // The opener's image element points at the photograph by id. const resources = [landscape]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // #region fonts: every face the design uses, loaded first (gotcha: fonts-first) const FONTS = { Newsreader: ['400', '400i', '700'], // text 'Young Serif': ['400'], // display: it ships one weight, so the headings ask for 400 'Inter Tight': ['400', '600'], // labels: kicker, byline, running heads, colophon }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: fonts, the photograph, one buildDocument call with a fresh config, then paint await loadFonts(FONTS, markdown); await loadImage(landscape.bitmap.fileId, asset(landscape.bitmap.fileId)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); // showPages paints each page with renderPageToCanvas(page, doc, canvas, { scale }). showPages(doc, { title: t({ en: 'From a Markdown string to a designed page', es: 'De una cadena Markdown a una página diseñada' }), }); // #endregion
العُدّة · 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 نفسه على صفحة بمقاس 17 × 24 سم بخط EB Garamond بحجم 8 نقاط، بعناوين Open Sans زرقاء وترويسة زرقاء فوق خط؛ وتحمّل العُدّة هذه الوجوه بنفسها، مع تحذير في وحدة التحكم.

-  () => buildDocument({ markdown, resources }, config()), markdown);
+  () => buildDocument({ markdown, resources }, {}), markdown);

#أعِد تلوين الصفحة كلها

غيّر لون التمييز فيرسم التشغيل التالي أرقام الصفحات والعناوين الفرعية وعلامة النهاية باللون الجديد، لأن col() ينسخ القيمة الجديدة بجانب المعرّف في كل مرة يجري فيها config(). وتحتفظ الصورة بألوانها.

-  band: '#2b3a67', // the one accent (an indigo): the folios, the subheads, the end mark
+  band: '#3f5b3a', // the one accent (a pine green): the folios, the subheads, the end mark

#ارسم صفحة واحدة على Canvas خاص بك

يضبط renderPageToCanvas حجم عنصر Canvas على حجم الصفحة بالبكسل بدقة المستند (300 نقطة في البوصة افتراضيًا) مضروبة في scale، فيرسم scale: 0.5 بدقة 150 نقطة في البوصة تقريبًا، وتحدد CSS حجمه الظاهر على الشاشة.

-showPages(doc, {
-  title: t({ en: 'From a Markdown string to a designed page',
-    es: 'De una cadena Markdown a una página diseñada' }),
-});
+const canvas = document.body.appendChild(document.createElement('canvas'));
+renderPageToCanvas(doc.pages[0], doc, canvas, { scale: 0.5 });

أخطاء شائعة

خطأ شائع

لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة

يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →

خطأ شائع

أي كائن headings يُلغي فاصل الصفحة قبل H1

ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →

خطأ شائع

ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس

يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984". البيانات الوصفية للمستند →

خطأ شائع

حمّل كل أوجه الخط قبل الإخراج

يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →

خطأ شائع

fontFamily اسم عائلة واحدة، لا قائمة CSS أبدًا

قائمة مثل 'Lora, serif' تُقرأ اسمًا لعائلة واحدة غير موجودة، فيُقاس النص بخط بديل دون أي تنبيه وتختلف نتيجة Canvas وHTML وPDF. سمِّ عائلة واحدة. الخطوط قبل الإخراج →

خطأ شائع

يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا

يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →

خطأ شائع

8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا

يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →

خطأ شائع

فيض نص التصميم افتراضيًا 'ellipsis-end'

عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر. النصوص والخطوط والمربعات في تصاميم الصفحة →

خطأ شائع

lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا

في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →

  • لا يأتي Young Serif إلا بوزن واحد. والعناوين عريضة افتراضيًا، فلا بد هنا من headings.fontWeight: 400: بدونه تبحث العُدّة عن Young Serif عريض لا يملكه Fontsource، فيتوقف المثال بخطأ.

الحقوق

الوصفة
Ignacio Ferro
النص
نص أصلي, CC BY 4.0
الصور
  • The salt pans and the dune at dusk on the opener, a photograph · Generated With Diffusion Models · أصلي
الخطوط
Newsreader (SIL OFL 1.1) · Young Serif (SIL OFL 1.1) · Inter Tight (SIL OFL 1.1)
Sandbox