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

دليل الوصفات · الفصل 8 · الجداول

قائمة مطعم: أسعار مصفوفة بلا علامات جدولة

قائمة طعام لمطعم صغير مطبوعة على الوجهين، أسعارها في جداول بلا حدود، ولكل عنوان صنف خطّ نحاسي مثبّت على كل جانب منه.

في هذه الصفحة
  • نموذج باللغة الإنجليزية: لا توجد طبعة عربية بعد
  • مقاس القص 230 × 310 مم
  • عمود واحد
  • Noticia Text 10.5/14.5
  • Josefin Sans
  • Limelight
  • صفحتان
  • المستوى
  • Postext 1.4.1
  • أُخرجت في 4 ms
  • أسطر الكود: 153

باختصار

قائمة طعام بوجهين لمطعم باريسي متخيَّل. تبيّن كيف تصفّ أسماء الأطباق وأسعارها في صفوف مرتبة باستعمال جداول لا تظهر فيها أي خطوط.

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

قائمة الخريف لمطعم Les Tanneurs، وهو مطعم باريسي متخيَّل: ورقة واحدة مقاسها 230 × 310 mm مطبوعة على الوجهين. على الوجه تتدلى مظلة مخططة فوق الاسم المنضّد بخط Limelight. كل عنوان صنف يقف بين خطين نحاسيين يمتدان إلى الهامشين، وتحته أربعة أطباق. الطبق هو اسمه الفرنسي بالأسود العريض مع المرافقات، وتحته سطر إنجليزي بالمائل، والسعر بالأسود العريض ملاصقًا للحافة اليمنى. قرص أخضر داكن عليه V يدلّ على الأطباق النباتية، وكبسولة محددة الإطار عليها SG على الأطباق الخالية من الغلوتين. على الظهر تأتي قائمة النبيذ تحت رف من الزجاجات، وسعر الكأس وسعر الزجاجة في عمودين عنوانهما 12 cl و75 cl. ليس في Postext علامات جدولة (tab stops)، لذلك ينتمي كل عمود أسعار إلى جدول بلا خطوط ولا تعليق.

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

  • كيف أصنع جدولًا فيه صفوف رأس، وخلايا مدمجة، وعروض أعمدة، ومحاذاة لكل خلية؟
  • كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
  • كيف أنضد رسومًا غير مرقّمة: زخارف، وحليات، وشعارات؟
  • كيف أضيف صورًا وجداول من الشيفرة (موارد) بدل ![]() في Markdown؟

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

script.js · الأسطر 26–43في الكود الكامل
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}

المكونات

الخطوط
Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1)
الأصول
  • The awning and the shelf of bottles, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

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

#1 · جدول في موضع علامات الجدولة

الشيفرة في الجواب المختصر أعلاه. كل صنف جدول من عمودين. الطبق وفاصل سطر والترجمة بالمائل تتقاسم الخلية الأولى، والسعر ملاصق للحافة اليمنى في الخلية الثانية، على خط الأساس الأول للطبق، لأن الخليتين تبدآن من أعلى صفّهما. يأخذ columnWidths أوزانًا، فيعطي [6, 1] الأسعارَ 22.6 mm من عرض السطر البالغ 158 mm. ويزيل rules: 'none' وheaderBackgroundEnabled: false الشبكة وصفّ العناوين الرمادي اللذين يرسمهما نمط الجداول الافتراضي. ويجعل cellPadding: pt(LEAD / 4) كل طبق سطرين ونصفًا من تباعد الأسطر البالغ 14.5 pt، فينتهي صنف من أربعة أطباق على خطه العاشر في الشبكة. مع الحشوة الافتراضية، 0.375 em، ينتهي كل صنف تحت ذلك الخط بـ 0.9 mm، وينزل كل عنوان بعده سطرًا، وتمتد القائمة إلى أربع صفحات.

#2 · نوع مورد واحد، في مكانه ومن غير ترقيم

script.js · الأسطر 47–54في الكود الكامل
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });

نوعا الشكل والجدول المدمجان يطبعان سطر تعليق ويعوّمان كل مورد إلى أول موضع فارغ بعد أول إحالة إليه. أما النوع menu فليست له بادئة تعليق، وقطعه بلا تعليق، فلا يُطبع شيء تحت الصنف. أعطه captionPrefix: 'Menu' فيُطبع سطر مرقّم تحت كل قطعة، أوله «Menu 1.» تحت المظلة، وتمتد القائمة إلى أربع صفحات. يضع defaultPlacement كل جدول وكل رسم عند سطر ::resource الخاص به (التضمين الكتلي). من دونه تتلاصق عناوين الأصناف الثلاثة في أعلى الوجه وتطفو الجداول تحتها، والحلوى أولًا. المظلة والزجاجات مواردُ SVG من النوع نفسه، مرسومة بالشيفرة ومسجّلة بدالة loadSvg في الأدوات المساعدة.

#3 · ادمج صفوف المناطق

script.js · الأسطر 58–71في الكود الكامل
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}

في الإصدار 1.4.1 لا تعلّم parseTSV أي صف بوصفه صف عناوين، لذلك تضبط wineList القيمة headerRowCount: 1 يدويًا. عندئذ تُطبع Appellation · cépage و12 cl و75 cl فوق أعمدتها بخط العناوين، Josefin Sans. السطر الذي فيه حقل واحد يسمّي منطقة. تصبح خليته خلية عنوان في الوسط، وتمدّها mergeCells على الأعمدة الثلاثة وتعلّم الخليتين اللتين تغطيهما بـ hiddenBy (بناء نماذج الجداول). لو توسّطت BLANCS العمود الأول وحده لوقفت على بعد 22.6 mm يسار المحور الذي تتوسطه La Cave وLe Comptoir. والسطر الفارغ في ملف TSV يصبح صفًّا من الخلايا الفارغة بارتفاع سطر ونصف، وهذا الصف هو الفراغ فوق Blancs وRouges.

#4 · علّق خطًّا على كل جانب من عنوان الصنف

script.js · الأسطر 75–89في الكود الكامل
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };

عنوان الصنف تصميم داخل العمود. العنصر {titleText} ليس له عرض، فصندوقه بعرض حروفه الكبيرة، وتضعه المرساة 'top' في وسط العمود. كل خط يتعلّق بحافة من حافتي العنوان بـ 'left-of' أو 'right-of' ويمتد إلى حافة العمود (تموضع العناصر)، فيبلغ طول الخطين بجانب PLATS 66.7 mm وبجانب LE COMPTOIR 56.2 mm، وكل منهما على بعد 4 mm من الحروف. قيمة lineHeight للعنوان مضاعف، 0.96، فيصير صندوقه 14.4 pt ويبقى داخل سطر واحد من الشبكة البالغ 14.5 pt. مع القيمة 1 يصير الصندوق 15 pt، ويأخذ كل عنوان سطرين، وتمتد القائمة إلى أربع صفحات.

#5 · رموز الأطباق في شارات، والحروف الصغيرة في أنماط فقرات

script.js · الأسطر 93–111في الكود الكامل
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];

ترسم الشارة (chip) صندوقًا حول مقطع من النص داخل السطر (أنماط الشارات)، فإذا كتبت :chip[V]{style="veg"} بعد المرافقات ظهر القرص على السطر الفرنسي للطبق، في خلية الجدول كما في الفقرة. تضبط أنماط الشارات bold: true لأن الشارة تأخذ وزن النص المحيط بها، أي 400 بعد المرافقات، وFONTS لا يحمّل Josefin Sans إلا بالوزن 700. الملاحظات تحت الحلوى وبيانات الطبع على الظهر كتل :::paragraphs بقياسَي 9 و7.5 pt، ولكلا النمطين marginTop قدره سطر واحد (أنماط الفقرات)، لأن الجدول المضبوط على 'here' لا يترك فراغًا تحته. من دون هذا الهامش تبدأ الملاحظات على السطر التالي لسطر طبق الأجبان الإنجليزي، كأنها جزء من الطبق.

#6 · ضع القائمة كلها على محور واحد

script.js · الأسطر 115–126في الكود الكامل
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };

يضع headings.textAlign وbodyText.textAlign كل ما ليس جدولًا على خط وسط القائمة. ويعطي lineHeight: pt(3 * LEAD) الاسمَ المنضّد بقياس 48 pt ثلاثة أسطر من الشبكة. من دونه يأخذ الاسم تباعد الأسطر الافتراضي وأربعة أسطر، فينتقل السطر الثاني من الملاحظات إلى صفحة وحده وتمتد القائمة إلى أربع صفحات. في الإصدار 1.4.1 يُسقط أي كائن headings فاصل الصفحة قبل H1، ومع ذلك يصرّح المستوى 1 بـ breakBefore: { enabled: false }، لأن الاسم وLa Cave مكانهما في صفحة الرسم الذي فوقهما. أعد القيمة الافتراضية، { enabled: true, parity: 'always-odd' }، فيقف كل رسم وحده في صفحة، سبع صفحات في المجموع. لا يضبط الإعداد أي locale، لأن الإصدار 1.4.1 لا يقسّم بالواصلة إلا النص المضبوط، ولا شيء مضبوط في هذه القائمة.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 052 · Bistro menu: prices aligned without tab stops ═══════
// https://postext.dev/en/cookbook/bistro-menu
// Code: MIT · Text: original, in French (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Limelight, Noticia Text, Josefin Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// The autumn menu of an imaginary Paris bistro: two sides of one card, every price column a
// table with its rules switched off.
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  parseTSV, mergeCells } from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'bistro-menu';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#1f2a24', paper: '#f6efdf', // green-black text on cream card
  wine: '#6d1f2c', brass: '#a9823a', // the name, heads and labels; rules and the drawings' metal
  straw: '#e8d4a8', bottle: '#2f4235', sage: '#8a9a78', // awning stripes and labels; glass
  muted: '#6b6457' }; // the colophon
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color' (#295aa3, a blue); this palette makes it the wine red.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.wine })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Noticia Text', 'Limelight', 'Josefin Sans'];
const [BODY, LEAD] = [10.5, 14.5]; // pt: the text, and the leading every table row keeps

// #region answer: dish on the left, price flush right: a two-column table with no rules
// Postext has no tab stops, so each course is a table. The document's tableStyle turns off
// every rule and fill, so nothing on the page shows that a table is there. The wine list's head
// row and merged region rows are in #region wines.
const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
  bodyFontFamily: TEXT, bodyFontSize: pt(BODY), bodyColor: col('ink'),
  headerFontFamily: LABEL, headerFontSize: pt(8.5), headerColor: col('wine'),
  headerBackgroundEnabled: false }; // header cells: the wine list's labels
// The kitchen keeps the menu as TSV: course · dish · the dish in the reader's language · price.
function course(id, tsv) {
  const rows = parseTSV(tsv).rows.filter(([c]) => c.content === id)
    .map(([, dish, translation, price]) => [
      { content: `${dish.content}\n*${translation.content}*` }, // one cell, two lines
      // Both cells start at the top of the row, so the price shares the dish's first baseline.
      { content: `**${price.content}**`, align: 'right' },
    ]);
  // columnWidths are weights: the price column takes 1/7 of the 158 mm measure, 22.6 mm.
  return piece(id, 'table', { table: { model: { rows, columnWidths: [6, 1] } } });
}
// #endregion

// #region type: one resource type for the whole card: set where it stands, never numbered
// No caption prefix and no caption, so no 'Table 1' line prints under a course. Placement
// 'here' sets each piece at its ::resource line; such a table never splits, so a course that
// outgrows the page moves to the next one whole (gotcha: here-table-no-split).
const resourceTypes = [{ id: 'menu', name: 'Menu', shortLabel: 'Menu', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here' } }];
const piece = (id, kind, body) => ({ id, typeId: 'menu', kind, createdAt: 0, updatedAt: 0,
  ...body });
// #endregion

// #region wines: region rows merged across the three columns, prices under their labels
function wineList(tsv) {
  let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [5, 1, 1] };
  m.rows = m.rows.map((row, r) => row.map((cell, c) => (c === 0 ? cell : { align: 'right',
    content: r > 0 && cell.content ? `**${cell.content}**` : cell.content }))); // as the dishes'
  m.rows.forEach(([first, ...rest], r) => { // a line with one field names a region
    if (r === 0 || !first.content || rest.some((cell) => cell.content)) return;
    // One cell across the table, centred on the card like the course heads; a label centred
    // in the first column alone would sit 22.6 mm left of them. The label face is the header's.
    m.rows[r][0] = { ...first, isHeader: true, align: 'center' };
    // mergeCells marks the two cells it covers hiddenBy (gotcha: merged-cells-hiddenby).
    m = mergeCells(m, { start: { row: r, col: 0 }, end: { row: r, col: 2 } });
  });
  return piece('wines', 'table', { table: { model: m } });
}
// #endregion

// #region heads: a course head: its title centred, a brass rule anchored to each side of it
// The title has no width, so it shrink-wraps its text, and 'top' centres it on the column. Each
// rule hangs off one edge of the title ('left-of', 'right-of'), 4 mm away, and 'fill' runs it
// to the column's edge: 66.7 mm beside PLATS, 56.2 mm beside the longer LE COMPTOIR.
const rule = (edge, x) => ({ kind: 'rule', id: `rule-${edge}`, color: col('brass'),
  thickness: pt(0.75), // required in 1.4.1: a rule without it paints nothing
  placement: { anchor: { to: '#title', edge },
    size: { width: 'fill' }, // to the column's edge
    offset: { x: mm(x), y: pt(6) } } }); // 6 pt down: the middle of Limelight's capitals
const courseHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(15),
    lineHeight: 0.96, // a multiple (gotcha: design-lineheight-multiple): 14.4 pt, one line
    textTransform: 'uppercase', color: col('wine'),
    placement: { anchor: { to: 'container', edge: 'top' } } },
  rule('left-of', -4), rule('right-of', 4),
] } };
// #endregion

// #region badges: dietary badges as chips, explained in small type under the desserts
// A chip is a box around inline text: the V disc is filled, the SG pill only outlined. A chip
// takes the weight of the text around it, so without bold the V is set in 400, a weight not loaded.
const badge = { fontFamily: LABEL, fontSize: pt(7.5), bold: true, // Josefin Sans 700
  borderRadius: pt(8), paddingY: em(0.1), gap: em(0.6) }; // radius clamped to a half-height
const chipStyles = [
  // V: paddingX makes the box as wide as it is tall, a disc. borderWidth 0 removes the default
  // outline, 0.5 pt of main-color, which would ring the green in wine red.
  { id: 'veg', name: 'Vegetarian', ...badge, paddingX: em(0.3), borderWidth: pt(0),
    background: col('bottle'), color: col('paper') },
  { id: 'gf', name: 'Gluten-free', ...badge, paddingX: em(0.45), backgroundEnabled: false,
    borderColor: col('bottle'), borderWidth: pt(0.6), color: col('bottle') },
];
// The notes under the desserts and the colophon on the back: smaller, on the card's axis.
// marginTop gives the line of air that a table set 'here' does not leave below itself.
const paragraphStyles = [
  { id: 'notes', name: 'Notes', fontSize: pt(9), marginTop: pt(LEAD) },
  { id: 'colophon', name: 'Colophon', fontSize: pt(7.5), color: col('muted'),
    marginTop: pt(LEAD) },
];
// #endregion

// #region centred: one axis for the card: the name, the course heads and the notes centred
// bodyText (in config) centres the lines under the name and the notes; only the tables keep a
// left edge. A centred line is never hyphenated (gotcha: ragged-no-hyphenation), so the config
// sets no locale: French patterns would change nothing on this card.
const headings = { fontFamily: DISPLAY, fontWeight: 400, color: col('wine'), textAlign: 'center',
  levels: [ // Limelight ships one weight, 400, and no italic
    // Any headings object drops the H1 break (gotcha: headings-drop-h1-break). Stated off, since
    // a break before an H1 would part the name from the awning and La Cave from its bottles.
    { level: 1, fontSize: pt(48), lineHeight: pt(3 * LEAD), breakBefore: { enabled: false },
      marginTop: pt(LEAD), marginBottom: pt(0) }, // three grid lines, one of air above
    { level: 2, lineHeight: pt(LEAD), advancedDesign: courseHead, // one line; #region heads
      marginTop: pt(LEAD), marginBottom: pt(0) }, // the table below adds a line of its own
  ] };
// #endregion

// #region art: a striped awning over the name, a shelf of bottles over the wine list
function awning() { // 158 × 20 mm; the canopy narrows 6 % toward the wall
  const [W, N, ROD, DROP, HEM, INSET] = [1660, 19, 12, 104, 46, 50];
  const s = W / N, top = (i) => INSET + i * (W - 2 * INSET) / N, bottom = (i) => i * s;
  const shade = { [palette.wine]: mix(palette.wine, palette.ink, 0.25), // the valance: each
    [palette.straw]: mix(palette.straw, palette.brass, 0.4) }; // stripe a shade darker
  let shapes = '';
  for (let i = 0; i < N; i++) {
    const fill = i % 2 ? palette.straw : palette.wine, y = ROD + DROP;
    shapes += `<path d="M${top(i)} ${ROD}H${top(i + 1)}L${bottom(i + 1)} ${y}H${bottom(i)}Z" `
      + `fill="${fill}"/><path d="M${bottom(i)} ${y}h${s}v${HEM}a${s / 2} ${s / 2} 0 0 1 `
      + `${-s} 0Z" fill="${shade[fill]}"/>`;
  }
  shapes += `<rect x="${INSET - 16}" y="0" width="${W - 2 * INSET + 32}" height="${ROD}" rx="6" `
    + `fill="${palette.brass}"/><rect x="0" y="${ROD + DROP - 3}" width="${W}" height="6" `
    + `fill="${palette.brass}"/>`; // the rod on the wall, a brass bead along the front edge
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 212" width="${W}" `
    + `height="212">${shapes}</svg>`;
}
function mix(a, b, t) { // a blend of two palette colours, t of the way from a to b
  const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
  const [x, y] = [rgb(a), rgb(b)];
  return `#${x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, '0'))
    .join('')}`;
}
function bottles() { // 158 × 29 mm: glasses, bottles and a carafe on a brass shelf
  const [W, H, BASE] = [1660, 300, 286];
  const glass = (x) => // a tulip glass, a third full
    `<path d="M${x - 34} ${BASE - 170}C${x - 36} ${BASE - 110} ${x - 20} ${BASE - 88} ${x} `
      + `${BASE - 86}C${x + 20} ${BASE - 88} ${x + 36} ${BASE - 110} ${x + 34} ${BASE - 170}Z" `
      + `fill="none" stroke="${palette.bottle}" stroke-width="4"/>`
      + `<path d="M${x - 33} ${BASE - 132}C${x - 30} ${BASE - 104} ${x - 16} ${BASE - 91} ${x} `
      + `${BASE - 90}C${x + 16} ${BASE - 91} ${x + 30} ${BASE - 104} ${x + 33} ${BASE - 132}Z" `
      + `fill="${palette.wine}"/>`
      + `<rect x="${x - 2.5}" y="${BASE - 88}" width="5" height="82" fill="${palette.bottle}"/>`
      + `<ellipse cx="${x}" cy="${BASE - 5}" rx="30" ry="5" fill="${palette.bottle}"/>`;
  const bottle = (x, h, w, shoulder, body, foil) => { // straight or sloping shoulders
    const neck = 13, top = BASE - h, sh = BASE - h * 0.62;
    return `<path d="M${x - w} ${BASE}V${sh}C${x - w} ${sh - shoulder} `
      + `${x - neck} ${sh - shoulder} ${x - neck} ${sh - shoulder * 1.6}`
      + `V${top + 6}Q${x - neck} ${top} ${x - neck + 6} ${top}`
      + `H${x + neck - 6}Q${x + neck} ${top} ${x + neck} ${top + 6}V${sh - shoulder * 1.6}`
      + `C${x + neck} ${sh - shoulder} ${x + w} ${sh - shoulder} ${x + w} ${sh}V${BASE}Z" `
      + `fill="${body}"/>`
      + `<rect x="${x - neck - 1}" y="${top}" width="${2 * neck + 2}" height="${h * 0.16}" `
      + `rx="4" fill="${foil}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.44}" width="${2 * w - 16}" height="${h * 0.26}" `
      + `fill="${palette.straw}"/>`
      + `<rect x="${x - w + 8}" y="${BASE - h * 0.3}" width="${2 * w - 16}" height="6" `
      + `fill="${foil}"/>`;
  };
  const carafe = (x) => `<path d="M${x - 16} ${BASE - 200}H${x + 16}V${BASE - 150}`
    + `C${x + 70} ${BASE - 120} ${x + 76} ${BASE - 20} ${x + 44} ${BASE}H${x - 44}`
    + `C${x - 76} ${BASE - 20} ${x - 70} ${BASE - 120} ${x - 16} ${BASE - 150}Z" fill="none" `
    + `stroke="${palette.bottle}" stroke-width="4"/>`
    + `<path d="M${x - 64} ${BASE - 74}C${x - 70} ${BASE - 30} ${x - 58} ${BASE - 8} ${x - 42} `
    + `${BASE - 4}H${x + 42}C${x + 58} ${BASE - 8} ${x + 70} ${BASE - 30} ${x + 64} ${BASE - 74}Z" `
    + `fill="${palette.wine}"/>`;
  const C = W / 2;
  const art = glass(C - 330) + bottle(C - 225, 250, 38, 12, palette.bottle, palette.wine)
    + bottle(C - 125, 262, 42, 34, palette.sage, palette.brass) + carafe(C)
    + bottle(C + 125, 256, 44, 36, palette.bottle, palette.brass)
    + bottle(C + 225, 250, 38, 12, palette.bottle, palette.wine) + glass(C + 330)
    + `<rect x="${C - 420}" y="${BASE}" width="840" height="6" fill="${palette.brass}"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" `
    + `height="${H}">${art}</svg>`;
}
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, tableStyle, resourceTypes,
  page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150,
    backgroundColor: col('paper'), // one card, printed both sides: margins are not mirrored
    margins: { top: mm(22), bottom: mm(20), left: mm(36), right: mm(36) } },
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), // both default to main-color, the wine red
    textAlign: 'center', firstLineIndent: mm(0) }, // the lines under the name and the notes
  headings, // the card's axis (#region centred)
  chipStyles, paragraphStyles, // the badges and the small print (#region badges)
  header: { elements: [] }, footer: { elements: [] }, // a menu has no running heads or folios
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 46 · content.en.mdtitle: "Les Tanneurs" --- ::resource{id="awning"} # Les Tanneurs *Bistrot parisien depuis 1931* Carte d’automne 2026 · *autumn menu* ## Entrées ::resource{id="entrees"} ## Plats ::resource{id="plats"} ## Desserts ::resource{id="desserts"} :::paragraphs{style="notes"} :chip[V]{style="veg"} Plat végétarien · *vegetarian* · :chip[SG]{style="gf"} Sans gluten · *gluten-free* Bœuf d’origine France · *French beef* · Prix nets, service compris · *service included* ::: :::pagebreak ::resource{id="bottles"} # La Cave Vins au verre et à la bouteille · *wine by the glass and by the bottle* ::resource{id="wines"} ## Le Comptoir ::resource{id="counter"} :::paragraphs{style="colophon"} Set in Limelight, Noticia Text and Josefin Sans (SIL OFL) · Les Tanneurs is an imaginary bistro · Text and drawings CC BY 4.0 :::
`; // the two sides of the card, in French const dishes = String.raw`entrees **Œuf mayonnaise**, cornichons de la maison :chip[V]{style="veg"} :chip[SG]{style="gf"} Egg mayonnaise, house pickles 7
نموذج Markdown · أسطر: 15 · content.carte.en.mdentrees **Velouté de potimarron**, crème crue, noisettes torréfiées :chip[V]{style="veg"} :chip[SG]{style="gf"} Red kuri squash soup, raw cream, toasted hazelnuts 9 entrees **Terrine de campagne** au poivre vert, pain grillé Country terrine with green peppercorns, toast 11 entrees **Poireaux vinaigrette**, œuf mimosa :chip[V]{style="veg"} :chip[SG]{style="gf"} Leeks in vinaigrette, egg mimosa 10 plats **Blanquette de veau** à l’ancienne, riz pilaf Veal blanquette the old way, pilaf rice 24 plats **Paleron de bœuf** braisé au vin rouge, carottes fondantes Beef chuck braised in red wine, slow-cooked carrots 26 plats **Filet de lieu jaune**, beurre blanc, poireaux fondus :chip[SG]{style="gf"} Pollack fillet, beurre blanc, softened leeks 27 plats **Risotto aux cèpes**, mascarpone et sauge :chip[V]{style="veg"} :chip[SG]{style="gf"} Cep risotto, mascarpone and sage 22 desserts **Tarte fine aux pommes**, crème fraîche Thin apple tart, crème fraîche 10 desserts **Mousse au chocolat noir** Dark chocolate mousse 9 desserts **Île flottante**, pralines roses Floating island, pink pralines 9 desserts **Trois fromages affinés**, confiture de cerises noires Three matured cheeses, black cherry jam 12 counter **Kir** au vin blanc et cassis White wine with blackcurrant liqueur 6 counter **Pastis**, carafe d’eau fraîche Pastis with a jug of cold water 5 counter **Bière à la pression**, 25 cl Draught beer, 25 cl 5 counter **Café**, noisette ou allongé Espresso, macchiato or lungo 3
`; // TSV: course · dish · translation · price const wines = String.raw`Appellation · cépage 12 cl 75 cl
نموذج Markdown · أسطر: 16 · content.cave.en.mdBULLES **Crémant de Loire** brut · *chenin blanc* 9 44 BLANCS **Muscadet Sèvre-et-Maine** sur lie 2023 · *melon de Bourgogne* 7 32 **Mâcon-Villages** 2022 · *chardonnay* 9 40 **Sancerre** 2023 · *sauvignon blanc* 11 52 **Chablis** 2022 · *chardonnay* 12 58 ROUGES **Côtes-du-Rhône** 2022 · *grenache, syrah* 7 32 **Saumur-Champigny** 2022 · *cabernet franc* 9 42 **Morgon** 2022 · *gamay* 10 46 **Bordeaux supérieur** 2020 · *merlot, cabernet sauvignon* 8 38 **Saint-Joseph** 2021 · *syrah* 12 56 **Saint-Émilion grand cru** 2018 · *merlot, cabernet franc* — 78
`; // TSV: head row, regions, wines; one file for both editions const resources = [ piece('awning', 'svg', { svg: { fileId: 'awning.svg', width: 1660, height: 212 }, altText: 'A striped wine-red and straw awning on a brass rod.' }), piece('bottles', 'svg', { svg: { fileId: 'bottles.svg', width: 1660, height: 300 }, altText: 'Two glasses of red wine, four bottles and a carafe on a brass shelf.' }), course('entrees', dishes), course('plats', dishes), course('desserts', dishes), wineList(wines), course('counter', dishes), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Noticia Text': ['400', '400i', '700'], Limelight: ['400'], 'Josefin Sans': ['700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = markdown + dishes + wines; await loadFonts(FONTS, allText); await loadSvg('awning.svg', awning()); await loadSvg('bottles.svg', bottles()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: t({ en: 'Les Tanneurs: autumn menu', es: 'Les Tanneurs: carta de otoño' }) });
العُدّة · 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 ↗ (يفتح في تبويب جديد)

تنويعات

#أوقف الخطوط عند طول ثابت

عرض ثابت مكان 'fill' يبقي كل خط بطول 20 mm وعلى بعد 4 mm من العنوان، فيقترب الزوج من الوسط بجانب PLATS أكثر مما بجانب LE COMPTOIR.

-    size: { width: 'fill' }, // to the column's edge
+    size: { width: mm(20) }, // 20 mm, wherever the title ends

#سطّر الصفوف كدفتر الحسابات

يرسم rules: 'horizontal' خطًّا نحاسيًا رفيعًا بسماكة 0.5 pt فوق كل صف وتحته. الخطوط لا تأخذ مساحة، فيحتفظ الوجهان بإخراجهما، ويحصل كل من الصفين الفارغين فوق Blancs وRouges على زوج خطوط خاص به.

-const tableStyle = { rules: 'none', cellPadding: pt(LEAD / 4), // a dish: 2½ lines of LEAD
+const tableStyle = { rules: 'horizontal', borderColor: col('brass'),
+  borderWidth: pt(0.5), cellPadding: pt(LEAD / 4),

أخطاء شائعة

خطأ شائع

الخلايا المدمجة تحتاج إلى خلايا نائبة hiddenBy: استخدم mergeCells

تُوزَّع الخلايا بحسب موضعها في مصفوفة الصف، فالخلية المدمجة تحتاج إلى خلايا نائبة معلَّمة بـ hiddenBy حيث تمتد؛ وحذفها، كما يفعل HTML، يزيح كل الأعمدة التالية. ابنِ الدمج بـ mergeCells. جداول من البيانات →

خطأ شائع

الجدول 'here' لا ينقسم أبدًا

لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة. جداول عبر الصفحات →

خطأ شائع

الشكل داخل النص يحصل على فراغ فوقه لا تحته

في postext 1.4.1 يحصل الشكل الذي يضعه ::resource في الموضع 'here' على سطر شبكة واحد من الفراغ فوقه، أما تحته فلا يحصل إلا على ما يتبقى حين يلتصق السطر التالي بشبكة خطوط الأساس: من سطر كامل إلى لا شيء تقريبًا، فقد تبدأ الفقرة التالية تحت التعليق مباشرةً. أتبِع سطر ::resource بـ :::space{lines=1}؛ وهو، مثل أي :::space، يُحذف في أعلى العمود. أشكال في هذا الموضع بالضبط →

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

النص غير المضبوط لا يُقسَّم بالواصلة أبدًا

لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر. تقسيم الكلمات ولغة المستند →

خطأ شائع

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

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

خطأ شائع

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

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

أعطِ كل عنصر خط قيمة thickness. في الإصدار 1.4.1 لا يرسم الخط الذي ليست له هذه القيمة شيئًا، مع أن مرجع الإعدادات يذكر 0.5 pt قيمةً افتراضية. احذفها من rule() فيختفي الخطان من كل عنوان صنف.

الحقوق

الوصفة
Ignacio Ferro
النص
نص أصلي, CC BY 4.0
الصور
  • The awning and the shelf of bottles, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
الخطوط
Limelight (SIL OFL 1.1) · Noticia Text (SIL OFL 1.1) · Josefin Sans (SIL OFL 1.1)
Sandbox