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

دليل الوصفات · الفصل 6 · الإطارات والملاحظات

بطاقة وصفة: المقادير بجانب طريقة التحضير

صفحتان من كتاب طبخ ببطاقة وصفة بيضاء تحت لسان SERVES 4: المقادير والوسوم وقائمة تحقق على اليسار، والخطوات بأرقام حمراء بحجم 26 pt على اليمين.

في هذه الصفحة

ص 58–59 · 1–2 من 2

  • نموذج باللغة الإسبانية: لا توجد طبعة عربية بعد
  • مقاس القص 190 × 250 مم
  • عمود واحد
  • Figtree 9.4/12.8
  • Caveat
  • Young Serif
  • صفحتان
  • المستوى
  • Postext 1.9.2
  • أُخرجت في 13 ms
  • أسطر الكود: 188

باختصار

صفحتان من كتاب طبخ منزلي، وصفة في كل صفحة. تبيّن كيف تضع المقادير وخطوات الطهو جنبًا إلى جنب على بطاقة.

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

صفحتان متقابلتان من Salt & Olive Oil، كتاب طبخ منزلي إسباني متخيَّل، بحدّ قص 190 × 250 mm، وصفة في كل صفحة. صورة فوتوغرافية، مولَّدة بنماذج الانتشار ومحمَّلة ملفَّ JPEG، تنزف عبر أعلى 104 mm من الصفحة: عجّة اقتُطعت منها شريحة على طاولة بلون الزعفران، ووعاء من الغازباتشو على طاولة بلون المريمية. فوقها سطر تمهيدي، والعنوان بخط Young Serif بحجم 42 pt، وكبسولات للوقت والصعوبة والموسم، وملحوظة بخط اليد Caveat تشير إلى الطبق. تحت المقدمة المائلة بطاقة بيضاء على زاويتها العليا اليمنى لسان أحمر بلون الطماطم لعدد الحصص. تتتابع المقادير في عمودها الأيسر بشَرطات زيتونية وشارات وسوم؛ وتحت مقادير العجّة ترأس رسومٌ خطية لوعاء ومقلاة وصحن قائمةَ تحقق. وتملأ طريقة التحضير العمود الأيمن، تتقدّم كلَّ خطوة فيها أرقامٌ عتيقة الطراز بحجم 26 pt.

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

  • كيف أضع عمودين داخل إطار (عمود نص بجانب عمود شكل)؟
  • كيف أضيف لسانًا مرقّمًا («BOX 1-1»)، أو أيقونة في الزاوية، أو أيقونة في الهامش مع خط؟
  • كيف أخصّص القوائم: نقاط لكل مستوى، وترقيم (a)/(i)، ومربعات اختيار للمهام، وتباعد يبقى على الشبكة؟
  • كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
  • كيف أضيف إلى صفحة الافتتاح سطر المؤلف أو مقدمة تمهيدية أو فقرة أولى بحرف استهلالي؟

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

script.js · الأسطر 25–47في الكود الكامل
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].

المكونات

الخطوط
Young Serif, Figtree, Caveat (SIL OFL 1.1)
الأصول
  • gazpacho-1520.jpg
  • tortilla-1520.jpg
  • The tortilla on its plate, a photograph (Generated With Diffusion Models, أصلي)
  • The bowl of gazpacho, a photograph (Generated With Diffusion Models, أصلي)
  • The note’s arrow and the pictograms, drawn in code in the page’s palette (Ignacio Ferro, CC BY 4.0)

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

#1 · أعطِ كل عمود إطارًا خاصًا به

مع القوائم الحرّة تنقطع مجموعة الأعمدة داخل الإطار حيث تتساوى أعمدتها على أفضل وجه، بين الكتل أو بين أسطر كتلة واحدة، فتنقسم الخطوة الأولى من الغازباتشو عبر الفاصل بين العمودين: سطرها الأول يختم عمود المقادير والباقي يفتتح عمود طريقة التحضير. أما الإطار المتداخل، كما في الجواب المختصر أعلاه، فكتلة واحدة لا تقطعها المجموعة أبدًا. والصورة الموضوعة في إطار كهذا تحتاج إلى ::resource بموضع 'here'، لأن أي موضع آخر يجعلها عنصرًا عائمًا يغادر الإطار ويوضع كأي شكل مستشهد به. يحوّل label في نمط البطاقة السمة label="SERVES 4" في السياج إلى اللسان: إزاحة offset تساوي ارتفاعه height توقفه على الحافة العليا للبطاقة، ويمتد rule من الزاوية البعيدة حتى الشوكة والسكين (أنماط الإطارات).

#2 · وسّط أرقام الخطوات على سطرين

script.js · الأسطر 65–75في الكود الكامل
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };

يُرسم رقم القائمة موسَّطًا فوق خط الأساس الأول للبند بقليل، فلو استُعمل numberFontSize وحده لجاءت الأرقام بحجم 26 pt على مستوى السطر الأول؛ ينزلها numberVerticalOffset مسافة 2.5 mm، إلى منتصف الشريط الممتد من الأحرف الكبيرة في السطر الأول إلى خط أساس السطر الثاني. لخط Young Serif أرقام عتيقة الطراز: 3 و4 و5 تنزل تحت خط الأساس الذي يقف عليه 1 و2. حين يُضبط separatorColor يُرسم الفاصل مقطعًا مستقلًا، فتُطبع النقطة الافتراضية بالزيتوني بجانب رقم أحمر (القوائم المرقَّمة).

#3 · رأّس قائمة التحقق بشريط من الرسوم

script.js · الأسطر 51–61في الكود الكامل
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };

لا يحوي الإطار prep إلا عنوانه وأيقونته، فيعمل رأسًا: عرض أيقونة width يساوي 3.75 مرة حجمها size يمطّ مربع الأيقونة إلى شريط قياسه 18.75 × 5 mm، وتملؤه الرسوم الثلاثة يسار العنوان (أنماط الإطارات). تأخذ الأيقونة عمودًا خاصًا بها بجانب كل ما في إطارها، لذلك توضع المهام بعد الإطار، حيث تصطف مع شَرطات المقادير بدل أن تبدأ على بُعد 21 mm نحو الداخل. تطبع بنود - [ ] مربع المهمة الافتراضي، ويتيح unorderedLists.marginTop: 0 أن تلي الرأس على مسافة marginBottom الخاصة به، وهي الفجوة البالغة 2.4 mm التي تحافظ عليها عناوين الأعمدة (القوائم غير المرقَّمة).

#4 · اجعل الوسوم شارات

script.js · الأسطر 79–81في الكود الكامل
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];

يضع :chip[Vegetarian]{style="tag"} كلمة في صندوق لا ينكسر أبدًا بين سطرين. قيمة borderRadius الأكبر من نصف ارتفاع الصندوق تُقصَر على ذلك النصف، فيتحول طرفاه إلى نصفَي دائرة؛ ومع borderWidth: 0 تُرسم الكبسولة بالتعبئة الفاتحة وحدها. ارتفاع الصندوق 11.4 pt، أقل من تباعد أسطر البطاقة البالغ 12.8 pt، فلا يلامس سطر ثانٍ من الوسوم السطر الأول (أنماط الشارات).

#5 · تصميم واحد، وصورة فوتوغرافية لكل وصفة

script.js · الأسطر 85–119في الكود الكامل
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The photo reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } };

يختار # Tortilla \\ de patatas {style="receta" art="tortilla" kicker="…" lead="…"} نمط العنوان ويملأ خاناته من السمات، ومنها المقدمة المائلة (سمات العناوين). قيمة resourceId في عنصر الصورة هي '{attr.art}'، وهي عنصر نائب مثل عناصر النص، فتتشارك الوصفتان نمطًا واحدًا ويسمّي كل عنوان صورته (عناصر الصور). كل كبسولة مثبَّتة right-of التي قبلها، والعنوان above الكبسولة الأولى، والسطر التمهيدي فوق العنوان، فإذا طال العنوان دفع السطر التمهيدي إلى أعلى وبقيت الكبسولات على سطرها. لا تحجز الصورة الفوتوغرافية ارتفاعًا في 1.4.1، لكن المقدمة التي تحتها تحجزه، فتبدأ البطاقة تحت المقدمة من غير minHeight. الملحوظة مثبَّتة على الصفحة، مثل الصورة الفوتوغرافية والسهم، وهو عنصر صورة ثانٍ مرسوم بالكود، وتنتهي قبل السهم بمقدار 2 mm في الصفحتين، مع أن الهوامش المعكوسة تزيح كتلة النص 2 mm نحو اليمين في الصفحة الفردية.

#6 · سجّل الصور موارد

script.js · الأسطر 272–294في الكود الكامل
// The opener's image elements and both icons name a resource id; the resource names the file
// the canvas paints (loadImage or loadSvg registers it). No :ref cites them, so none is numbered.
const photo = (id, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
  altText, bitmap: { fileId: `${id}-1520.jpg`, format: 'jpeg', width: 1520, height: 832 } });
const ART = { // markup, width and height in mm, and the alt text
  arrow: [arrow, PAGE.w, BAND, t({ en: 'An arrow to the dish', es: 'Una flecha al plato' })],
  cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })],
  kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate',
    es: 'Un bol con dos huevos, una sartén y un plato' })] };
const resources = [
  photo('tortilla', t({ en: 'A potato omelette with a slice pulled out, on a blue-rimmed plate '
    + 'and a red-checked napkin', es: 'Una tortilla de patatas con una porción separada, en un '
    + 'plato de borde azul sobre una servilleta de cuadros rojos' })),
  photo('gazpacho', t({ en: 'A bowl of gazpacho with diced vegetables and a spoon, on a '
    + 'blue-checked napkin beside a tomato', es: 'Un cuenco de gazpacho con dados de verdura '
    + 'y una cuchara, sobre una servilleta de cuadros azules junto a un tomate' })),
  ...Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure',
    kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10,
      height: h * 10 } })), // 10 px a millimetre: the sizes only set the aspect ratio here
];
const photos = resources.filter((r) => r.kind === 'bitmap').map((r) => r.bitmap.fileId);
await Promise.all([...photos.map((file) => loadImage(file, asset(file))),
  ...Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))]);

عنصرا الصورة في صفحة الافتتاح، وأيقونة اللسان، وأيقونة رأس قائمة التحقق، كلٌّ منها يسمّي موردًا، ويرسم Canvas الملف الذي سجّلته له loadImage أو loadSvg. لا يستشهد أي :ref بهذه الموارد، فلا يصبح أيٌّ منها شكلًا مرقَّمًا. لا تستطيع الصورة الفوتوغرافية ولا SVG المرسوم صورةً استعمال خطوط الويب في الصفحة، لذلك لا تحمل الصور كلمات: الملحوظات المكتوبة بخط اليد نص تصميم منضَّد فوقها.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 033 · Recipe card: ingredients beside the method ═══════
// https://postext.dev/en/cookbook/recipe-card
// Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models · Pictograms: CC BY 4.0
// Fonts: Young Serif, Figtree, Caveat (SIL OFL 1.1) · Needs postext ≥ 1.8.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#2b2118', muted: '#76634e', // text; folios and the colophon
  paper: '#f7eddb', card: '#fffdf8', // the cream page; the white recipe card on it
  tomato: '#bf3d29', olive: '#6b7a3a', tint: '#f6e3c1' }; // numbers and tab; dashes; tags
// The hex rides along: design elements read it, not the palette (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the tomato, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.tomato })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, HAND] = ['Figtree', 'Young Serif', 'Caveat'];
const PAGE = { w: 190, h: 250, top: 22, inner: 18, outer: 16 }; // mm, mirrored margins
const [BODY, LEAD] = [9.4, 12.8]; // pt: the text of the cards and the notes under them

// #region answer: a white card with a servings tab, two columns inside it
// In the Markdown, :::callout{type="card" label="SERVES 4"} holds a :::columns{count=2} group
// of two nested boxes, :::callout{type="column" title="Ingredients"} and one for the method.
// The group levels its columns by cutting between blocks or lines, so loose lists would run
// the method on under the ingredients. A nested box is one block that never splits, so the
// only cut left is between the two (gotcha: callout-columns).
const TAB = 5.6; // mm: the tab's height, and how far it rises above the card
const card = { id: 'card', background: col('card'),
  padding: { top: mm(4.5), right: mm(6), bottom: mm(5), left: mm(6) },
  // No space of its own above: the tab starts on the first grid line under the opener.
  columnGap: mm(7), marginTop: mm(0),
  // label="…" on the fence prints here: a tab on the top-right corner, a cutlery pictogram
  // beside it and a rule from the far corner that makes the tab part of the card.
  label: { fontFamily: TEXT, fontSize: pt(8), color: col('card'), // bold by default
    background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
    icon: { resourceId: 'cutlery', width: mm(TAB * 6 / 8), gap: mm(1.6) }, // as tall as the tab
    rule: { enabled: true, color: col('tomato'), width: pt(1.2) } } };
// The two columns: frameless boxes whose only device is a tracked title.
const NONE = { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) };
const column = { id: 'column', backgroundEnabled: false, padding: NONE,
  lists: { gap: mm(2.2), itemSpacing: pt(3.5) }, // for the steps as well as the dashes
  titleStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('tomato'), // bold by default
    textTransform: 'uppercase', letterSpacing: pt(1.5), gap: mm(2.4) } };
// config() plugs them in: calloutStyles: [card, column, prep].
// #endregion

// #region prep: a checklist under a header with a strip of three pictograms
// :::callout{type="prep" title="Before you start"}, closed at once, is a header: a box that
// holds only its title and icon. The icon is one picture of three drawings; a width KIT times
// its size makes its box a strip, and the picture is fitted into width × size, left of the
// title. Tasks set inside the box would start after that column, 21 mm in, so the '- [ ]'
// items follow the box, where they print the default task box, '☐'.
const [STRIP, KIT] = [5, 30 / 8]; // mm: the strip's height; the drawing's width over height
const prep = { id: 'prep', backgroundEnabled: false, padding: NONE,
  marginTop: pt(LEAD), marginBottom: column.titleStyle.gap, // the tasks follow at this gap
  icon: { kind: 'resource', resourceId: 'kit', size: mm(STRIP), width: mm(STRIP * KIT),
    align: 'center' }, // the title centred on the strip
  titleStyle: { ...column.titleStyle, color: col('olive') } };
// #endregion

// #region steps: big step numbers in the display face, an olive full stop after each
// Young Serif has old-style figures: 1 and 2 stand 0.56 em, a little above its 0.50 em
// x-height, so 5.1 mm at STEP. A list number is centred 0.3 em (of the text) above the item's
// first baseline, with the canvas 'middle' baseline, which Chrome puts 0.24 em above Young
// Serif's own (gotcha: list-number-centred). DROP centres the figures on the step's first two
// lines, from the cap height of the first to the baseline of the second.
const [STEP, FIGURE, MIDDLE, CAP] = [26, 0.56, 0.24, 0.7]; // pt; em of each face
const DROP = (LEAD - CAP * BODY) / 2 + 0.3 * BODY + (FIGURE / 2 - MIDDLE) * STEP; // pt
const orderedLists = { fontFamily: DISPLAY, fontWeight: 400, // Young Serif ships 400 only
  numberFontSize: pt(STEP), color: col('tomato'),
  separatorColor: col('olive'), separatorGap: pt(0.6), // the default '.' as its own run
  numberVerticalOffset: pt(DROP) };
// #endregion

// #region chips: tags for diet and occasion, as pills in the text
const chipStyles = [{ id: 'tag', fontSize: em(0.86), bold: true, background: col('tint'),
  borderWidth: pt(0), borderRadius: em(1), // no outline; a radius past half the height: a pill
  paddingX: em(0.7), paddingY: em(0.18), gap: em(0.3) }];
// #endregion

// #region opener: the photograph bled across the head, the title and pills set on it
const BAND = 104; // mm: the photo's foot; the pills sit 13 mm above it, the lead 6 mm below
const NOTE = { x: 18, y: 24, w: 70 }; // mm on the page: the box ends 2 mm before the arrow
const at = (x, y, size) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: mm(x), y: mm(y - PAGE.top) }, size }); // y in mm from the top of the page
const text = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content,
  fontFamily: family, fontSize: pt(size), color: col('ink'), align: 'left',
  overflow: 'wrap', placement, ...extra }); // gotcha: overflow-ellipsis-default
const tracked = { fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(1.6) };
// Pills are text boxes chained right-of each other; each prints one heading attribute.
const pill = (id, after) => text(id, `{attr.${id}}`, TEXT, 8.4, after ? { anchor:
  { to: `#${after}`, edge: 'right-of' }, offset: { x: mm(1.8) } } : at(0, BAND - 13), {
  fontWeight: 600, box: { backgroundColor: col('card'), borderRadius: mm(3),
    padding: { top: mm(1.1), right: mm(2.8), bottom: mm(1.1), left: mm(2.8) } } });
// One heading style for every recipe; each heading names its photo: art="tortilla".
const opener = { id: 'receta', advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: '{attr.art}', // filled in per heading, like a text
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    { kind: 'image', id: 'arrow', resourceId: 'arrow', // the note's arrow, over the photo
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: mm(PAGE.w), height: mm(BAND) } } },
    text('note', '{attr.note}', HAND, 19, { anchor: { to: 'page', edge: 'top-left' },
      offset: { x: mm(NOTE.x), y: mm(NOTE.y) }, size: { width: mm(NOTE.w) } },
    { fontWeight: 600, align: 'right' }), // on the page, like the arrow and the photo
    pill('time'), pill('level', 'time'), pill('season', 'level'),
    text('title', '{titleText}', DISPLAY, 42, { anchor: { to: '#time', edge: 'above' },
      offset: { y: mm(-3.2) }, size: { width: mm(96) } }, // two lines: the \\ in the heading
    { lineHeight: 1 }), // a multiple, never pt() (gotcha: design-lineheight-multiple)
    text('kicker', '{attr.kicker}', TEXT, 8.2, { anchor: { to: '#title', edge: 'above' },
      offset: { y: mm(-2.4) }, size: { width: mm(96) } }, tracked),
    // The photo reserves no height (gotcha: opener-image-no-reserve); the lead under it does,
    // so the card starts below the lead without a minHeight.
    text('lead', '{attr.lead}', TEXT, 10.5, at(0, BAND + 6, { width: mm(122) }),
      { italic: true, lineHeight: 1.45 }),
  ] } } };
// #endregion

// Folios at the foot of the outer corner: the book on versos, the recipe on rectos.
const foot = (id, content, parity, edge, x, look) => text(id, content, TEXT, 7.6,
  { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-12) } },
  { color: col('muted'), parity, align: edge.endsWith('left') ? 'left' : 'right', ...look });
const folio = { fontFamily: DISPLAY, fontSize: pt(10), color: col('tomato') };
const footer = { elements: [
  foot('verso-folio', '{pageNumber}', 'even', 'bottom-left', PAGE.outer, folio),
  foot('verso-book', '{title}', 'even', 'bottom-left', PAGE.outer + 9, tracked),
  foot('recto-dish', '{chapterTitle}', 'odd', 'bottom-right', -(PAGE.outer + 9), tracked),
  foot('recto-folio', '{pageNumber}', 'odd', 'bottom-right', -PAGE.outer, folio),
] };

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, chipStyles, orderedLists, footer, header: { elements: [] },
  page: { width: mm(PAGE.w), height: mm(PAGE.h), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(PAGE.top), bottom: mm(20), left: mm(PAGE.inner),
      right: mm(PAGE.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: mm(0) }, // ragged and flush: the notes under the cards
  // A designed heading's own text is hidden but still measured: in Young Serif 400, the only
  // weight it ships, not in the default Open Sans 700 that FONTS does not load.
  headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    // 'any': each recipe opens the next page, whichever side it is on. span: 'page' paints the
    // opener outside the column's clip: kept in the column, the drawing is cut at the top margin.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
  ] },
  headingStyles: [opener],
  // Olive dashes for the whole document: an olive lists.color on the column style would turn
  // the step numbers olive too (gotcha: box-list-colour-numbers).
  unorderedLists: { bulletChar: '–', color: col('olive'),
    marginTop: mm(0), // under the checklist's header, the header's marginBottom alone
    marginBottom: pt(LEAD / 2) }, // half a line above the tags
  calloutStyles: [card, column, prep],
  paragraphStyles: [{ id: 'colophon', fontSize: pt(7.4), lineHeight: pt(10),
    color: col('muted'), marginTop: pt(LEAD) }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 66 · content.es.mdtitle: "Sal y aceite" --- # Tortilla \\ de patatas {style="receta" art="tortilla" kicker="Huevos y patatas" time="45 min" level="Dificultad media" season="Todo el año" note="¡con cebolla, siempre!" lead="La de casa: patata pochada despacio en mucho aceite de oliva, huevo apenas cuajado y diez minutos de reposo para que la patata se empape. Con cebolla, como la de la abuela; sin ella, si en tu casa se discute."} :::callout{type="card" label="PARA 4"} :::columns{count=2} :::callout{type="column" title="Ingredientes"} - **6** huevos camperos grandes - **800 g** de patatas para freír - **1** cebolla mediana - **300 ml** de aceite de oliva virgen extra - Sal fina :chip[Vegetariana]{style="tag"} :chip[Sin gluten]{style="tag"} :chip[De fiambrera]{style="tag"} :::callout{type="prep" title="Antes de empezar"} ::: - [ ] Los huevos, fuera de la nevera - [ ] Un bol para el huevo y la patata - [ ] Un plato más ancho que la sartén ::: :::callout{type="column" title="Elaboración"} 1. Pela las patatas y córtalas en láminas finas; la cebolla, en juliana. Sálalas. 2. Pocha las dos en el aceite, a fuego medio y en una sartén de 24 cm, unos 20 minutos: tiernas y sin dorar. 3. Escúrrelas y guarda el aceite. Bate los huevos con sal, añade la patata aún caliente y deja reposar 10 minutos. 4. Calienta a fuego fuerte una cucharada del aceite, vierte la mezcla y cuájala dos minutos, despegando los bordes. 5. Dale la vuelta con un plato, cuájala un minuto más y sírvela templada. ::: ::: ::: **Guarda el aceite.** Colado y en un tarro, sirve para freír la próxima tortilla. # Gazpacho \\ andaluz {style="receta" art="gazpacho" kicker="Sopas frías" time="25 min + 2 h de nevera" level="Fácil" season="De junio a septiembre" note="¡bien frío!" lead="En muchas casas andaluzas se bebe en vaso, recién sacado de la nevera. Pide tomates maduros de verdad y el aceite en hilo con la batidora en marcha, que lo deja cremoso y anaranjado."} :::callout{type="card" label="PARA 6"} :::columns{count=2} :::callout{type="column" title="Ingredientes"} - **1,5 kg** de tomates de pera maduros - **1** pimiento verde italiano - **1** pepino - **1** diente de ajo - **100 g** de pan del día anterior, remojado - **120 ml** de aceite de oliva virgen extra - **3 cucharadas** de vinagre de Jerez - Sal y agua fría :chip[Vegana]{style="tag"} :chip[Sin fuego]{style="tag"} :chip[De víspera]{style="tag"} ::: :::callout{type="column" title="Elaboración"} 1. Trocea los tomates, el pimiento sin semillas, el pepino pelado y el ajo. 2. Ponlo todo en la batidora con el pan remojado, el vinagre y una cucharadita de sal; deja macerar 15 minutos. 3. Tritura 2 minutos a máxima potencia y, sin parar el motor, añade el aceite en hilo fino hasta que emulsione. 4. Pásalo por un colador fino y aligéralo con agua fría a tu gusto. 5. Rectifica de sal y vinagre y enfríalo al menos 2 horas antes de servirlo. ::: ::: ::: **Hazlo de víspera.** Aguanta dos días en la nevera, en una jarra tapada; remuévelo antes de servirlo. :::paragraphs{style="colophon"} *Sal y aceite* · Compuesto en Young Serif, Figtree y Caveat (SIL OFL) · Recetas originales, CC BY 4.0 · Fotos generadas :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the note's arrow and the pictograms, drawn in the palette's colours // The dishes are photographs, JPEGs in assets/ cut to the band's 190 × 104 mm. The arrow from // the handwritten note to the dish and the line pictograms stay vector; the note itself is a // design element set in Caveat. const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width, extra = '') => path(d, 'none', ` stroke="${color}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}`); const group = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${body}</g>`; // A hand-drawn line and its head, as paths, never a <marker>, in a box as large as the photo: // it runs from the end of the note, over the plate's rim, to the edge of the dish. const arrow = svgDoc(PAGE.w, BAND, stroke('M90 29C97 26 102 28 106 33', palette.ink, 0.55) + group(106, 33, 62, stroke('M-2.4-1.3L0 0-2.4 1.5', palette.ink, 0.55))); const cutlery = svgDoc(6, 8, stroke('M1.6 .6V7.4M.6 .6V2.6C.6 3.4 2.6 3.4 2.6 2.6V.6', palette.tomato, 0.55) + stroke('M4.6 7.4V.6C5.8 1.4 5.8 3.6 4.6 4.4', palette.tomato, 0.55)); // The kit in line drawings: a bowl with two eggs, the frying pan and the plate that turns the // tortilla over, in a box KIT times as wide as it is tall. const egg = (x, turn) => `<ellipse cx="${x}" cy="2.5" rx="1.05" ry="1.35" ` + `transform="rotate(${turn} ${x} 2.5)" fill="none" stroke="${palette.olive}" ` + 'stroke-width=".6"/>'; const kit = svgDoc(8 * KIT, 8, egg(3, -12) + egg(5, 14) + stroke('M.6 3.9H7.4M1 3.9C1 6.4 2.4 7.4 4 7.4S7 6.4 7 3.9', palette.olive, 0.7) + stroke('M9.8 4.4H16.8M10.2 4.4L10.8 6.7C10.9 7.1 11.2 7.3 11.6 7.3H15C15.4 7.3 15.7 7.1 ' + '15.8 6.7L16.4 4.4', palette.olive, 0.7) + stroke('M16.8 5L20.2 4.1', palette.olive, 1.1) + circle(26.2, 4.2, 3.3, 'none', ` stroke="${palette.olive}" stroke-width=".7"`) + circle(26.2, 4.2, 2, 'none', ` stroke="${palette.olive}" stroke-width=".5"`)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses. Layout measures with the browser's fonts, so the // kit loads them from Fontsource before the first build (gotcha: fonts-first). const FONTS = { Figtree: ['400', '400i', '600', '700'], 'Young Serif': ['400'], Caveat: ['600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: the photos and the pictograms are resources, cited by id, never by :ref // The opener's image elements and both icons name a resource id; the resource names the file // the canvas paints (loadImage or loadSvg registers it). No :ref cites them, so none is numbered. const photo = (id, altText) => ({ id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText, bitmap: { fileId: `${id}-1520.jpg`, format: 'jpeg', width: 1520, height: 832 } }); const ART = { // markup, width and height in mm, and the alt text arrow: [arrow, PAGE.w, BAND, t({ en: 'An arrow to the dish', es: 'Una flecha al plato' })], cutlery: [cutlery, 6, 8, t({ en: 'Fork and knife', es: 'Tenedor y cuchillo' })], kit: [kit, 8 * KIT, 8, t({ en: 'A bowl with two eggs, a frying pan and a plate', es: 'Un bol con dos huevos, una sartén y un plato' })] }; const resources = [ photo('tortilla', t({ en: 'A potato omelette with a slice pulled out, on a blue-rimmed plate ' + 'and a red-checked napkin', es: 'Una tortilla de patatas con una porción separada, en un ' + 'plato de borde azul sobre una servilleta de cuadros rojos' })), photo('gazpacho', t({ en: 'A bowl of gazpacho with diced vegetables and a spoon, on a ' + 'blue-checked napkin beside a tomato', es: 'Un cuenco de gazpacho con dados de verdura ' + 'y una cuchara, sobre una servilleta de cuadros azules junto a un tomate' })), ...Object.entries(ART).map(([id, [, w, h, altText]]) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 } })), // 10 px a millimetre: the sizes only set the aspect ratio here ]; const photos = resources.filter((r) => r.kind === 'bitmap').map((r) => r.bitmap.fileId); await Promise.all([...photos.map((file) => loadImage(file, asset(file))), ...Object.entries(ART).map(([id, [svg]]) => loadSvg(`${id}.svg`, svg))]); // #endregion await loadFonts(FONTS, markdown); // The excerpt is pages 58 and 59 of the book: 57 pages come before it, so the tortilla opens // on a verso and the two recipes face each other. const doc = await buildWithFonts(() => buildDocument({ markdown, resources, continuation: { pageIndexOffset: 57, pageNumbering: { startAt: 58 } } }, config()), markdown); showPages(doc, { title: t({ en: 'Recipe card', es: 'Tarjeta de receta' }) });
العُدّة · 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 ↗ (يفتح في تبويب جديد)

تنويعات

#عُدّ الكتل بدل وضع الأعمدة في إطارات

من دون إطارات الأعمدة، يسمّي breaks الابنَ الذي يفتتح العمود الأيمن، ويُعدّ كل بند من القائمة وسطر الوسوم ورأس قائمة التحقق ابنًا واحدًا: يحتاج الغازباتشو إلى breaks="10" (تأتي أولًا ثمانية مقادير والوسوم)، والعجّة إلى breaks="11". وتختفي معها عناوين الأعمدة والمسافة بين الخطوات التي يضعها نمط العمود.

 :::callout{type="card" label="SERVES 6"}
-:::columns{count=2}
-:::callout{type="column" title="Ingredients"}
+:::columns{count=2 breaks="10"}
 - **1.5 kg** ripe plum tomatoes
@@ seven more ingredients and a blank line @@
 :chip[Vegan]{style="tag"} :chip[No-cook]{style="tag"} :chip[Make ahead]{style="tag"}
-:::
-:::callout{type="column" title="Method"}
+
 1. Chop the tomatoes, seeded pepper, peeled cucumber and garlic.
@@ steps 2 to 5 @@
 :::
-:::
 :::

#أوقف اللسان على الزاوية اليسرى

يعكس 'top-left' التسمية: ينتقل اللسان إلى الزاوية اليسرى، والشوكة والسكين إلى يمينه، ويمتد الخط من اليمين.

     background: col('tomato'), height: mm(TAB), offset: mm(TAB), paddingX: mm(2.6),
+    position: 'top-left', // 'top-right' by default

أخطاء شائعة

خطأ شائع

لا يعمل :::columns إلا داخل إطار ولا ينقسم أبدًا

يُتجاهَل :::columns خارج الإطار، والإطار الذي ينقسم لا يقطع أبدًا داخل مجموعة أعمدة. السمة breaks تعدّ الكتل الأبناء، والإطار المتداخل يُعدّ كتلة واحدة. أعمدة داخل الإطار →

خطأ شائع

رقم القائمة متمركز على سطره الأول، لا موضوع على خط الأساس

في postext 1.4.1 يُرسَم رقم القائمة المرقّمة بخط الأساس 'middle' في Canvas، على 0.3 em من النص فوق أول خط أساس للبند، فالرقم بخط آخر أو بقيمة numberFontSize أكبر يكبر صعودًا ونزولًا من هناك بدل أن يقف على السطر: يرتفع خط العرض، ويتدلى رقم الخطوة الكبير عبر السطر الأول. ضعه بـ orderedLists.numberVerticalOffset، وافحص النتيجة بالمقاس الكامل. القوائم المرقّمة →

خطأ شائع

lists.color في الإطار يعيد تلوين أرقام قوائمه أيضًا

في postext 1.4.1 يصل lists.color في نمط الإطار إلى أرقام قوائمه المرقّمة كلما اختلف عن unorderedLists.color، حتى إن ضبط orderedLists لونًا خاصًا به: أعطِ إطارًا نقاطًا زيتونية بهذه الطريقة فتصير أرقام خطواته زيتونية أيضًا. لتلوين نقاط الإطار وحدها، اضبط unorderedLists.color للمستند واترك lists.color خارج نمط الإطار. إطارات التنبيه →

خطأ شائع

الشارات الأطول من خطوة السطر تلامس السطر التالي

الشارة التي يزيد ارتفاع إطارها على خطوة السطر تلامس شارات السطر التالي (التحذير chipOverlap). قلّل حشوها العمودي أو حدّها أو مقاسها، أو زِد تباعد الأسطر. الشارات داخل السطر →

خطأ شائع

القوائم تقول 'arabic'، والموارد 'roman-upper'، والصفحات 'upper-roman'

كل إعداد ترقيم يكتب صيغه بطريقة مختلفة: القوائم تأخذ numberFormat 'arabic' (القيمة 'decimal' تطبع "undefined")، وأنواع الموارد تأخذ counterFormat 'roman-upper'، والصفحات و:::numbering تأخذ 'upper-roman'. القوائم المرقّمة →

خطأ شائع

صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه

في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب

يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →

خطأ شائع

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

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

خطأ شائع

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

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

  • لا تملك التسمية textTransform ولا letterSpacing في 1.4.1، لذلك كُتبت SERVES 4 وPARA 4 بأحرف كبيرة في السياجات.
  • مربع المهمة ☐ غير موجود في أي من الخطوط الثلاثة، فيرسمه المتصفح من خط النظام ويتغير شكله من نظام إلى آخر.
  • أبقِ الأيقونة العريضة في العمود الخاص بالإطار: عند position: 'corner' يزيحها 1.4.1 بمقدار نصف ارتفاعها لا نصف عرضها، فيتجاوز الشريط البالغ 18.75 mm الحافة اليمنى للإطار بمقدار 16 mm.

الحقوق

الوصفة
Ignacio Ferro
النص
نص أصلي, CC BY 4.0
الصور
  • The tortilla on its plate, a photograph · Generated With Diffusion Models · أصلي
  • The bowl of gazpacho, a photograph · Generated With Diffusion Models · أصلي
  • The note’s arrow and the pictograms, drawn in code in the page’s palette · Ignacio Ferro · CC BY 4.0
الخطوط
Young Serif (SIL OFL 1.1) · Figtree (SIL OFL 1.1) · Caveat (SIL OFL 1.1)
Sandbox