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

دليل الوصفات · الفصل 7 · الأشكال والصور

مقالة مصوَّرة بلوحات تنزف حتى الحواف

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

في هذه الصفحة
المُخرَج
Canvas
المستوى
متوسط
Postext
اختُبرت مع Postext 1.8.3
تتطلب ≥ 1.8.0
الترخيص
حُدّثت في 28 سبتمبر 2026
الكود MIT · النص CC BY 4.0
  • نموذج باللغة الإسبانية: لا توجد طبعة عربية بعد
  • مقاس القص 280 × 210 مم
  • عمود واحد
  • Andada Pro 10.5/15
  • Syne
  • Syne Mono
  • 9 صفحات
  • المستوى
  • Postext 1.8.3
  • أُخرجت في 11 ms
  • أسطر الكود: 180

باختصار

كتاب صور أفقي يروي يومًا في الجبال بست صور. يبيّن كيف تملأ صفحات كاملة بالصور حتى الحافة، وكيف تمدّ صورة واحدة على صفحتين متقابلتين.

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

Sierra، كتاب صور أفقي على صفحة قياسها 280 × 210 mm: يوم واحد في سلسلة من الغرانيت في ست لوحات، من أول الضوء إلى الصباح الذي يلي أول ثلج. تملأ كل لوحة صفحتها حتى حدّ القص، ولا تحمل إلا رقمًا صغيرًا والساعة في زاويتها السفلى اليسرى؛ وتحمل الأولى أيضًا العنوان، SIERRA بخط Syne بحجم 72 pt، معكوسًا بالأبيض على سماء الفجر. تعبر العاصفة الفاصل الداخلي بين الصفحتين 4 و5 صورةً واحدة. ثلاثة نصوص قصيرة، منضَّدة في عمود عرضه 100 mm قرب الكعب، تقابل اللوحات التي تليها. وينتهي نص العاصفة بلوحة غسق أصغر تطفو تحته، رقمها في تعليقها. تُظهر اللوحة الأخيرة حلبة اللوحة الثانية من الصخرة نفسها، تحت الثلج. اللوحات صور فوتوغرافية مولَّدة بنماذج الانتشار؛ وصُنعت لوحة الثلج بتعديل لوحة الظهيرة، فيتطابق المنظران.

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

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

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

script.js · الأسطر 35–59في الكود الكامل
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
  { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
  { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});

المكونات

الخطوط
Andada Pro, Syne, Syne Mono (SIL OFL 1.1)
الأصول
  • alba-1600.jpg
  • atardecer-2000.jpg
  • mediodia-1600.jpg
  • nieve-1600.jpg
  • noche-1600.jpg
  • tormenta-2400.jpg
  • Plate I, dawn: ridges fading into a peach haze under a dark blue sky (Generated With Diffusion Models, أصلي)
  • Plate II, noon: a granite cirque and its lake (Generated With Diffusion Models, أصلي)
  • Plate III, storm: rain, lightning and a break of sun across a spread (Generated With Diffusion Models, أصلي)
  • Plate IV, dusk: the crest lit orange under a violet sky (Generated With Diffusion Models, أصلي)
  • Plate V, night: stars over the ridges and a lit refuge (Generated With Diffusion Models, أصلي)
  • Plate VI, snow: the cirque of plate II the morning after (Generated With Diffusion Models, أصلي)

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

#1 · نمط عنوان لكل لوحة، مبني من قائمة

الكود هو الجواب المختصر أعلاه. تبني plate() نمط عنوان من كل صف في PLATES، ويحدد Markdown ترتيب المقالة، سطرًا لكل لوحة: # Night {style="lamina" art="noche" n="V" hora="22:10"}. قيمة resourceId في عنصر الصورة هي '{attr.art}'، وهي عنصر نائب مثل عناصر النص، فتتشارك كل اللوحات البسيطة النمط lamina وتسمّي كل منها صورتها في سطر عنوانها؛ ولا يحتفظ بنمط خاص إلا الغلاف واللوحة الأخيرة، لأنهما يرسمان عناصر إضافية (عناصر الصور). يبدأ span: 'page' كل لوحة في صفحة خاصة بها. الهوامش الصفرية تجعل عمود اللوحة بطول الصفحة، ويملأ minHeight ذلك العمود، فيبدأ كل ما يلي اللوحة في الصفحة التالية. تحتاج إلى الإعدادين معًا. من دون minHeight لا يحتفظ النصف الأيمن من العاصفة، الذي لا يرسم أي نص، إلا بسطر عنوانه البالغ 7.6 mm، فيبدأ النص التالي في تلك الصفحة فوق الصورة. ومع هوامش صفحات النص يكون عمق العمود 158 mm، أقصر من minHeight البالغ 210 mm، فيُسقط postext 1.4.1 الحجز كليًا وتُطبع النصوص الثلاثة فوق اللوحات التي قبلها.

يرسم نمطا الصفحتين المتقابلتين، pliego وpliego-recto، الصورة نفسها بعرض 560 mm، وهي هنا art="tormenta": نصف الصفحة الزوجية عند الحافة اليسرى للصفحة، ونصف الصفحة الفردية مزاحًا 280 mm نحو اليسار، فتُظهر كل صفحة نصفها ويقصّ Canvas الباقي عند حدّ القص. يُبقي parity: 'even' النصفين متقابلين. من دونه، لو أُخرجت لوحة الظهيرة من Markdown لوقع النصف الأيسر في الصفحة 3، وهي صفحة فردية، والنصف الأيمن على ظهرها في الصفحة 4؛ ومعه تبقى الصفحة 3 فارغة وتملأ العاصفة الصفحتين 4 و5 كما هي.

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

script.js · الأسطر 63–74في الكود الكامل
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];

الرقم والساعة سمتان للعنوان، فيبقى المحتوى في Markdown ويخدم تصميم واحد كل اللوحات. تتعلّق الساعة right-of الرقم، فتتحاذى قمّتاهما. يقع خط الأساس لنص التصميم على بُعد 0.8 من صندوق سطره تحت أعلى العنصر، وصندوق السطر 1.2 مرة من حجم الحرف افتراضيًا، لذلك تُنزل dropTo(13, 8) الساعة بمقدار 0.8 × 1.2 × (13 − 8) pt وتضع الساعة بحجم 8 pt على خط أساس الرقم بحجم 13 pt، على بُعد 193.4 mm من أعلى الصفحة. تبدأ الساعة بنقطة وسطى، كما يفعل تعليق اللوحة IV، لأن حرف I في Syne عمود بسيط، ووحده بجانب الساعة يُقرأ خطًا. لا يرسم نصف العاصفة في الصفحة الفردية تعليقًا، وتنضّد لوحة الثلج تعليقها بلون الحبر، لأن مقدمتها بيضاء.

#3 · عنوان وبيانات طبع على اللوحات نفسها

script.js · الأسطر 78–92في الكود الكامل
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));

اللوحة الأولى هي الغلاف. يقرأ {title} و{subtitle} البيانات التمهيدية (frontmatter)، ويضيف extra في PLATES العنصرين إلى تصميم تلك اللوحة، بلون تعليقها. وتوضع بيانات الطبع على اللوحة الأخيرة بالطريقة نفسها، بلون الحبر على الثلج: عنصران بحجم 7.5 pt، واحد لكل سطر، من السمتين colofon وtipos، محاذيان إلى اليمين، والثاني على خط أساس الرقم. العنصر الواحد الملتف كان سينكسر حيثما نفد عرضه؛ أما بعنصر لكل سطر فيقع الكسر بعد الترخيص.

#4 · النصوص تلي لوحاتها بلا فاصل

script.js · الأسطر 96–116في الكود الكامل
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };

اللوحة التي تسبق النص تملأ صفحتها، فيبدأ النص الصفحة التالية من غير فاصل خاص به. الصفحة التي تكون أول كتلة فيها عنوانًا بفاصل هي صفحة افتتاح، ويستبعدها pages: 'body' في رقم الصفحة. ومن دون فاصل تحتفظ صفحات النص برقم صفحتها؛ أما اللوحات، التي تفتتحها عناوين ممتدة، فلا تأخذ رقمًا. يُسقط postext 1.4.1 أصلًا فاصل المستوى الأول متى وُجد كائن headings، فحذف breakBefore: { enabled: false } لا يغيّر شيئًا اليوم. يصبح هذا السطر مهمًا حين يعيد إصدار لاحق الفاصل الافتراضي إلى صفحة فردية دائمًا: فهو يمنع النصوص من الانتقال، فتبقى صفحاتها صفحات متن برقم صفحة. يضع FOOT رقم الصفحة على خط أساس أرقام اللوحات، على بُعد 193.4 mm من أعلى الصفحة، فيقع رقم صفحة النص ورقم اللوحة المقابلة له على سطر واحد.

#5 · صرّح بكل لوحة، واستشهد فقط بالتي تطفو

script.js · الأسطر 197–223في الكود الكامل
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
  shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
// JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi).
const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap',
  bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
  createdAt: 0, updatedAt: 0, altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
  picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.',
    es: 'Ocho crestas que se pierden en la bruma del alba.' }),
  picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.',
    es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
  picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning '
    + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
    + 'izquierda; un rayo y un claro de sol a la derecha.' }),
  picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.',
    es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
  picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.',
    es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
  // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure.
  picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.',
    es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
  { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];

عنصر الصورة في التصميم يرسم موردًا مصرَّحًا به، لذلك توجد الصور الست كلها في resources: ملفات JPEG في مجلد assets/ الخاص بالوصفة، كل منها مقصوص على شكل الصفحة أو الصفحتين المتقابلتين اللتين يملؤهما ومصرَّح به بحجمه بالبكسل، وهو كل ما يقرؤه الإخراج. تسجّل loadImage() كل ملف قبل البناء. اللوحة IV هي الوحيدة المستشهد بها عبر :ref، ولذلك هي الوحيدة التي تطفو. تأخذ أول خانة شاغرة بعد الاستشهاد، في صفحة نصها، على بُعد سطر تحت الفقرة الأخيرة. لو كان نوعها مرقَّمًا لجاء تعليقها Plate 1، لأنها المورد الوحيد من نوعها الذي يطفو. بدلًا من ذلك يحمل lamina قيمة numberingTemplate فارغة، ويبدأ التعليق نفسه بالرقم IV. وفي الجملة يسمّي text في :ref اللوحة، لأن :ref المجرَّد لا يطبع إلا كلمة plate.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════
// https://postext.dev/en/cookbook/photo-essay-full-bleed
// Code: MIT · Text: original (CC BY 4.0) · Plates: diffusion models
// Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.8.0
// Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of
// its own and one across the gutter of a spread, with three short texts between them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'photo-essay-full-bleed';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper between the plates, the ink and one rust for the labels
const palette = {
  ink: '#1c1f27', // text: the blue-black of the night plate
  paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates
  accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt
  muted: '#5f646d', // the running foot and the caption of the small plate
  white: '#fbfaf7', // type set on the plates
};
// Design elements read the hex, not the palette, in 1.4.1 (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries(palette)
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm
const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure
const LEAD = 15; // body leading in pt
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region answer: the plates' heading styles, generated from a list
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
  { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
  { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});
// #endregion

// #region caption: the plate's numeral and hour, lower left, from the heading's attributes
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
// #endregion

// #region cover: the book's title reversed out of the dawn sky of the first plate
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
// #endregion

// #region texts: a text between plates opens on the next page with its hours above it
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // The English sample is British English, set with the US patterns: 1.4.1 ships no en-gb.
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes: [lamina],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    backgroundColor: col('paper'), // the ground of every page; the plates cover it
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue
    referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text
    firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
    minWordSpacing: 0.7, maxWordSpacing: 1.6, // a narrower range than the defaults, 0.6 to 2
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  // A heading's own line is measured even where its design paints the title: set it in a face
  // the page loads, or the kit fetches Open Sans for it.
  headings: { fontFamily: 'Syne', levels: [
    // A text follows its plate with no forced break (gotcha: headings-drop-h1-break): the
    // plate fills its page, so the text still starts a page, and that page stays a body page,
    // with its running foot.
    { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener },
  ] },
  headingStyles: PLATES.map(plate),
  captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 43 · content.es.mdtitle: "Sierra" subtitle: "Ensayo en seis luces" --- # Alba {style="alba" n="I" hora="08:12"} # La subida {hora="06:40–14:06 · 1770–2180 m"} Salimos del aparcamiento a las seis y cuarenta, con frontales, por la calzada de losas que sube entre piornos. A esa hora el granito guarda todavía el frío de la noche y las botas suenan más de lo que deberían. Desde la primera curva se ven abajo las luces del pueblo, once, contadas dos veces; desde la segunda ya no. A las ocho y doce el sol toca la cresta más alta. Es un filo rosado que dura lo que se tarda en sacar la cámara; luego la luz baja por las canales, despacio, y tarda casi una hora en llegar al sendero. El cervunal está blanco de escarcha y cruje bajo las botas. Una cabra montés nos mira desde un bolo sin apartarse, y somos nosotros los que damos un rodeo. En el collado paramos a comer. Hay una fuente que no sale en el mapa, un caño de hierro clavado en la piedra, y el agua está tan fría que duelen los dientes. Andrés apunta en una libreta la hora y la altitud de cada foto; a la una, el termómetro que lleva colgado de la mochila marca once grados al sol. El mediodía solar cae aquí pasadas las dos, por el horario de verano. A esa hora las sombras se esconden debajo de las piedras y la laguna, vista desde el collado, parece una chapa que alguien ha dejado olvidada entre las paredes. # Mediodía {style="lamina" art="mediodia" n="II" hora="14:06"} # Tormenta {style="pliego" art="tormenta" n="III" hora="16:40"} # Tormenta {style="pliego-recto" art="tormenta"} # Once segundos {hora="15:00–19:50 · 2180–1950 m"} A las tres el aire se vuelve pesado. Por el oeste sube una nube que al principio parece otra montaña. Recogemos deprisa. El primer trueno llega once segundos después del relámpago: casi cuatro kilómetros, calcula Andrés, que siempre cuenta. El siguiente llega a los cuatro. Nos metemos bajo el voladizo de un bloque del tamaño de una casa. Primero cae granizo menudo y luego agua, y la pared de enfrente se borra detrás de la cortina y vuelve a salir más oscura, lavada. Nadie habla en una hora larga. Hacia las siete y media la tormenta se va valle abajo y deja el cielo roto en franjas. El sol asoma un momento por debajo de las nubes, ya pegado al horizonte, y enciende la cresta de un color que no dura ni tres minutos (:ref{id="atardecer" text="lámina IV"}). Bajamos al refugio empapados, junto al arroyo, que ahora baja lleno. # Noche {style="lamina" art="noche" n="V" hora="22:10"} # Primera nieve {hora="22:10–09:00 · 1950 m"} A las diez el cielo está limpio y tiene más estrellas de las que caben en la ventana del refugio. A medianoche alguien abre la puerta y dice que nieva. Salimos en calcetines. La nieve cae recta, sin viento, en copos grandes que tardan en deshacerse en las mangas, y la linterna solo alumbra un cono de puntos blancos que vienen hacia nosotros. Por la mañana el termómetro de la ventana marca dos bajo cero. Hay un palmo de nieve en la puerta y la laguna tiene una orilla de hielo fino. Las crestas que ayer eran grises son blancas con rayas negras, las aristas donde la nieve no agarra, y el cielo tiene el mismo color que el suelo. La última foto la hace Andrés a las ocho y treinta y uno, desde el mismo bolo que ayer a mediodía. Tarda un rato en encontrarlo: las piedras que le servían de referencia son ahora montones blancos iguales. Al final da con él comparando la cresta con la foto de ayer en la pantalla de la cámara. Es la primera nevada del otoño, dos semanas antes de lo habitual, según el guarda. Salimos a las nueve. La calzada de losas no se ve, y bajamos buscando los hitos de piedra. # Nieve {style="nieve" n="VI" hora="08:31" colofon="Sierra. Ensayo en seis luces · Láminas generadas con modelos de difusión · Textos: CC BY 4.0" tipos="Compuesto en Andada Pro, Syne y Syne Mono (SIL OFL)"}
`; // content.<lang>.md, inlined by the Cookbook // #region plates: every picture is a resource; only plate IV is cited, so only it floats // Plate IV is the one plate that floats, so a counter of its type would number it 1. The type // prints no number (no caption prefix, an empty template) and the caption carries the numeral. // The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else. const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; // JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi). const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap', bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0, altText: t(alt), ...extra }); const resources = [ // the five the heading styles draw, never cited, and plate IV picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.', es: 'Ocho crestas que se pierden en la bruma del alba.' }), picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.', es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }), picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning ' + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la ' + 'izquierda; un rayo y un claro de sol a la derecha.' }), picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.', es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }), picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.', es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }), // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure. picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.', es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' }, { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const files = resources.map((r) => r.bitmap.fileId); // a resource names a file: load each await Promise.all(files.map((f) => loadImage(f, asset(f)))); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape', es: 'Sierra: un ensayo fotográfico apaisado' }) });
العُدّة · 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 ↗ (يفتح في تبويب جديد)

تنويعات

#اجعل اللوحات تطفو مقابل مداخلها

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

#افتتح مقالة بصورة تنزف حتى الحواف

لصورة تنزف عبر رأس مقالة، وفوقها العنوان والمقدمة، انظر تحقيق المجلة.

أخطاء شائعة

خطأ شائع

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

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

خطأ شائع

الافتتاحية الأطول من عمودها تفقد حجزها كله

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

خطأ شائع

الصفحة 1 فردية: خطّط الصفحات بأرقامها الفعلية

الصفحة 1 صفحة يمنى والصفحة 2 أول صفحة زوجية، فخطّط الصفحتين المتقابلتين بأرقام الصفحات الفعلية: الافتتاحية في صفحة زوجية تقابل الصفحة الفردية التي تليها. فواصل الصفحات والأعمدة →

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

لا <marker> ولا مرشِّحات في رسوم SVG (وإلا البديل النقطي)

لا يبقى شكل SVG متّجهيًا في PDF إلا إذا خلا من <marker> والمرشِّحات والأقنعة؛ وإلا عاد إلى صورة نقطية، والمرشِّحات المتداخلة بعمق قد تجعله فارغًا في Chrome. ارسم رؤوس الأسهم مسارات. الأشكال والجداول بوصفها موارد →

خطأ شائع

إصلاح الكلمة المعزولة قد يضيّق تتبّعًا لا يُرسَم أبدًا

في postext 1.4.1، حين تنتهي فقرة بكلمة معزولة في آخرها، يجعلها الإخراج أقصر بسطر: أولًا بتضييق المسافات بين الكلمات، ثم بتتبّع سالب يصل إلى maxRuntTracking من الألف من em. لكن مُخرِجَي Canvas وPDF لا يرسمان التتبّع إلا إذا كان فوق الصفر، فتُطبع الفقرة المتتبَّعة بلا تتبّع: تفقد أسطرها المضبوطة الفرق من مسافات كلماتها فتبدو مضغوطة، وقد يتجاوز سطرها الأخير عرض السطر فيُقصّ عند حافة العمود. اضبط bodyText.maxRuntTracking: 0، فيبقى إصلاح المسافات بين الكلمات، وأعد صياغة أي كلمة معزولة تعود. الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة →

خطأ شائع

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

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

خطأ شائع

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

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

  • لا تستشهد أبدًا بلوحة يرسمها تصميم. إن استشهدت بها طفت أيضًا شكلًا: :ref إلى لوحة الظهيرة في النص الأول يضع نسخة ثانية منها في الصفحة 3، وتنتقل العاصفة صفحتين إلى الأمام، بعد صفحة فارغة.
  • تقع لوحة الغسق على بُعد سطر تحت نصها، لا في ذيل الصفحة. في الصفحة الأخيرة قبل لوحة، يرفع postext 1.4.1 العنصر العائم الواقع تحت النص إليه، ولا يغيّر position: 'bottom' شيئًا هناك.
  • كل نص مضبوط ليملأ صفحة واحدة في الطبعتين. حين يفيض النص الأول إلى الصفحة 3، تنتقل لوحة الظهيرة إلى الصفحة 4 والعاصفة إلى الصفحتين 6 و7، بعد صفحة 5 فارغة.
  • النصف الأيمن من العاصفة عنوان # Storm ثانٍ. لا تطبع الصفحة له عنوانًا، لكن :::toc المبني من العناوين يسرد العاصفة مرتين، في الصفحتين 4 و5.
  • تتوقف اللوحات عند حدّ القص، وهو حافة Canvas. مع تفعيل page.cutLines يكبر Canvas حول حدّ القص، ويُظهر النزف حول كل لوحة لون الورق. للطباعة، ثبّت اللوحات على 'bleed' وكبّر كل واحدة بمقدار النزف من كل جانب.

الحقوق

الوصفة
Ignacio Ferro
النص
  • The three texts, the captions and the colophon, in Spanish and English, and the six plates, drawn in code · Postext Cookbook · CC BY 4.0
الصور
  • Plate I, dawn: ridges fading into a peach haze under a dark blue sky · Generated With Diffusion Models · أصلي
  • Plate II, noon: a granite cirque and its lake · Generated With Diffusion Models · أصلي
  • Plate III, storm: rain, lightning and a break of sun across a spread · Generated With Diffusion Models · أصلي
  • Plate IV, dusk: the crest lit orange under a violet sky · Generated With Diffusion Models · أصلي
  • Plate V, night: stars over the ridges and a lit refuge · Generated With Diffusion Models · أصلي
  • Plate VI, snow: the cirque of plate II the morning after · Generated With Diffusion Models · أصلي
الخطوط
Andada Pro (SIL OFL 1.1) · Syne (SIL OFL 1.1) · Syne Mono (SIL OFL 1.1)
Sandbox