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

دليل الوصفات · الفصل 2 · الحروف والنص

إسبانية مضبوطة الأسطر في رواية جيب

بداية الفصل الأول من Marianela في طبعة جيب: تقسيم إسباني بالواصلة، ومسافات كلمات دون 1.7×، بلا كلمات معزولة، وحرف استهلالي مرفوع، وFigura 1 على الخريطة.

في هذه الصفحة

ص 8–9 · 1–2 من 5

  • نموذج باللغة الإسبانية: لا توجد طبعة عربية بعد
  • مقاس القص 115 × 180 مم
  • عمود واحد
  • Gentium Book Plus 10/13.8
  • Libre Bodoni
  • Marcellus SC
  • 5 صفحات
  • المستوى
  • Postext 1.12.2
  • أُخرجت في 18 ms
  • أسطر الكود: 180

باختصار

الصفحات الأولى من رواية إسبانية من عام 1878 في كتاب جيب. تُريك كيف تبقى المسافات بين الكلمات متساوية، وتنقسم الكلمات عند المقاطع الإسبانية الصحيحة.

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

بداية رواية Marianela (1878) لـ Galdós في طبعة جيب مقاسها 11.5 × 18 سم، مع خريطة تخمينية لمناجم Socartes تقابل الفصل الأول. فوق عنوان الفصل المائل بحرف Libre Bodoni تُكتب عبارة capítulo primero كلماتٍ بحروف كبيرة مصغّرة؛ ويبدأ النص، بحرف Gentium Book Plus، على ربع ارتفاع الصفحة بحرف استهلالي مرفوع بلون دم الثور. تنقسم الكلمات عند المقاطع الإسبانية (hie-rro، esta-blecimiento) وتبقى مسافات الكلمات دون 1.7 ضعف عرضها العادي؛ ولا تنتهي أي فقرة بذيل كلمة مقسومة بالواصلة. يُنضَّد الحوار بشرطات rayas، وهي الشرطات الطويلة (em) التي تفتح أيضًا incisos الراوي (استطراداته) وتغلقها. وشعار المسافر بين «comillas latinas»، وبين علامتَي تنصيص إنجليزيتين مزدوجتين حيث تقتبسه ملاحظة الطبعة داخل اقتباس. والخريطة هي Figura 1، ويُستشهد بها في النص بصورة [fig. 1]. وتسمّي الترويسات، بحرف Marcellus SC، المؤلف في الصفحات الزوجية والعنوان في الصفحات الفردية.

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

  • كيف أحصل على ضبط أسطر وتقسيم كلمات جيدين لنص إسباني أو فرنسي أو ألماني؟
  • كيف أتجنب الأسطر الأرامل والأسطر اليتيمة والأسطر الأخيرة ذات الكلمة الواحدة، وأبقي العنوان مع نصه؟
  • كيف أكتب شرطات الحوار، والسنوات في بداية الفقرة، والأسعار، والرموز الحرفية من دون أن يسيء Markdown قراءتها؟
  • كيف أحصل على تسميتي «شكل» و«جدول» بلغة مستندي؟
  • كيف أضيف إلى صفحة الافتتاح سطر المؤلف أو مقدمة تمهيدية أو فقرة أولى بحرف استهلالي؟

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

script.js · الأسطر 45–60في الكود الكامل
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));

المكونات

الميزات
تقسيم الكلمات ولغة المستندالتقسيم الأمثل للأسطر (Knuth–Plass)الحروف الاستهلالية في صفحات الافتتاحصفحات افتتاح مصمَّمةسمات العنوانالنصوص والخطوط والمربعات في تصاميم الصفحةالأسطر الأرامل والأسطر اليتيمة والكلمات المعزولةهوامش متناظرةفصول تبدأ في صفحة فرديةالترويسات بحسب دور الصفحةالترويسات وأرقام الصفحاتاستشهادات تضع الأشكالتعليقات مرقّمة«شكل» و«جدول» بلغتكأشكال في هذا الموضع بالضبطأنماط العناوينمسافة عمودية صريحةأنماط الفقراتالهروب والمحارف الحرفيةلون الورق
الخطوط
Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1)
الأصول
  • The conjectural map of the Socartes mines, drawn in code in the page’s palette (Ignacio Ferro, MIT)

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

#1 · مقاطع إسبانية، ومسافات مضبوطة الحدود

الشيفرة هي الجواب المختصر أعلاه. يختار locale: 'es' أنماط التقسيم الإسبانية، ولا يختارها إلا هذا الرمز بالضبط: مع 'es-ES' تكسر هذه الصفحات cabal-los وhi-erro وestablec-imiento بالقواعد الأمريكية، وتنفتح مسافات أحد الأسطر إلى 2.13× (اللغات المدعومة). ضبط الأسطر وKnuth–Plass وقاعدتا الأرملة واليتيمة مفعّلة افتراضيًا. ويحتاج عرض السطر البالغ 86 مم أيضًا إلى حصر مسافاته: مع القيمة الافتراضية maxWordSpacing: 2 تنفتح ثمانية أسطر فوق 1.6× ويبلغ أوسعها 1.89×؛ ومع 1.7 يكون أوسعها 1.65×، لأن خوارزمية الكسر تقسم hie-rro وca-lles بدلًا من ذلك (حدود مسافة الكلمات). وبعد الحد تصير كلفة السطر أعلى بكثير لكنه يبقى مقبولًا، ففي عرض أضيق لا كسر أفضل فيه قد يتجاوزه مع ذلك. ويلتقط runtMinCharacters: 26 بقايا الكلمات المقسومة التي يمررها الحد الافتراضي البالغ 20 عرض مسافة: siem- / pre adelante. يصير Adelante, / siempre adelante.، وSo- / cartes [fig. 1]. يصير de / Socartes [fig. 1]. (الكلمات المعزولة).

#2 · صفحة جيب من أسطر كاملة

script.js · الأسطر 33–41في الكود الكامل
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};

يُحسب الهامش السفلي من حدّ القص والهامش العلوي وLINES، فتتسع كتلة النص لثلاثين سطرًا بالضبط بتباعد 13.8 pt، وتنتهي كل صفحة ممتلئة على خط الأساس نفسه، كما تفعل الصفحتان 10 و11. ومع mirror: true يبقى الهامش الداخلي البالغ 15 مم عند الكعب والخارجي البالغ 14 مم عند الحافة الأمامية في صفحتي كل صفحتين متقابلتين (الهوامش المتناظرة)، ويتسع عرض السطر البالغ 86 مم لنحو 57 حرفًا من Gentium Book Plus بحجم 10 pt.

#3 · الفصل كلماتٍ وحرف استهلالي مرفوع

script.js · الأسطر 64–97في الكود الكامل
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };

قوالب الترقيم لا تطبع إلا أرقامًا (1، 01، I، i، A، a)، لا كلمات أبدًا، فتأتي capítulo primero من السمة ordinal الخاصة بالعنوان نفسه (سمات العنوان). لا يرسم Postext حرفًا استهلاليًا إلا في نص التصميم، وهو غير مضبوط، ولا يرسمه إلا حين يحمل ذلك النص overflow: 'wrap'. ولإخفاء الحافة الحرّة يأخذ الحرف الاستهلالي سطرًا واحدًا: يرفعه lines: 1 بجوار السطر الوحيد المحفوظ في السمة lead، وعُدِّل INITIAL_GAP حتى ينتهي ذلك السطر مستويًا، أقصر من عرض السطر بمقدار 0.1 مم. وتتابع الفقرة في Markdown بلا مسافة بادئة (العناصر النصية). ويحل marginBottom: 0 في مستوى الفصل محل الهامش الافتراضي، الذي كان سيضيف سطرًا فارغًا، فيتابع النص في السطر الذي يلي سطر الافتتاح مباشرة.

الصفحة 9: تسمية الفصل وعنوانه، والحرف الاستهلالي المرفوع بجوار سطر أول ينتهي مستويًا مع النص المضبوط تحته.

#4 · الخريطة تقابل الفصل بوصفها Figura 1

script.js · الأسطر 122–143في الكود الكامل
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };

يضع position: 'here' الخريطة حيث يقف ::resource{id="plano"}، تحت عنوان خاص بها. ذلك العنوان يجعل الصفحة 8 من الكتاب صفحة افتتاح، والترويسات تتخطى صفحات الافتتاح. ويُفرغ نمطه التذييل ويضبط انتقاله الخاص، فيُبقي اللوحة مقابل الفصل الأول، بلا ترويسة ولا رقم صفحة، حتى حين تُفتتح الفصول في صفحات فردية (أنماط العناوين). تطبع الأنواع الإسبانية في الجواب المختصر Figura 1. تحت الخريطة (ومن دون resourceTypes تُقرأ Figure 1.1 أيًّا كانت اللغة) وFig. 1 في النص، فيحوّلها case="lower" في :ref داخل Markdown إلى fig. 1. وتقع الإحالة بين قوسين معقوفين، وهما ما تستعمله الطبعات الإسبانية لتمييز ما يضيفه المحرر إلى نص المؤلف.

#5 · المؤلف في الصفحة الزوجية، والعنوان في الفردية

script.js · الأسطر 101–118في الكود الكامل
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };

يضع parity: 'even' المؤلف في الصفحات الزوجية و'odd' العنوان في الفردية، ويُبقي pages: 'body' كليهما بعيدًا عن صفحة الافتتاح، حيث لا شيء سوى رقم الصفحة السفلي المضبوط بـ pages: 'opener'. تُثبَّت كل ترويسة بالصفحة ويحرّكها SHIFT إلى منتصف كتلة النص، التي تبتعد عن منتصف الصفحة نصف مليمتر لاختلاف الهامشين الداخلي والخارجي. وللسبب نفسه يأتي رقم الصفحة السفلي بصيغتين بحسب الزوجية. وتُنضَّد أرقام الصفحات بحرف Gentium، لأن Marcellus SC يرسم 1 و0 مثل I وO.

#6 · علامات الترقيم الإسبانية في Markdown

script.js · الأسطر 147–155في الكود الكامل
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];

تنتهي الصفحة 12، وهي الأخيرة، بثلاثة أنماط فقرات. النجوم، بحرف Bodoni بلون دم الثور، متوسطة بعد :::space{lines=2}، لأن الأسطر الفارغة لا تضيف مسافة. ملاحظة الطبعة بحجم 8.3 pt على تباعد أسطر 11 pt، وبيانات الطبع بحجم 7.5 pt برمادي الترويسات. وتبقى الملاحظة بالحبر: نمط الفقرة لا لون مائلًا له، فكانت Marianela المائلة في ملاحظة رمادية ستُطبع أدكن من الكلمات حولها. وفي Markdown يُفتح الحوار بشرطة raya (—)، لا بشرطة قصيرة أبدًا، لأن الشرطة القصيرة ومسافة في أول السطر تفتحان قائمة. و1878. في أول الملاحظة كانت ستفتح قائمة مرقّمة، فيوضع رابط الكلمات (U+2060) قبل السنة. والنجوم مُهرَّبة (\*)؛ ومن دون تهريب كانت الأولى ستفتح قائمة نقطية والأخريان مقطعًا مائلًا، ويختفي السطر.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════
// https://postext.dev/en/cookbook/spanish-pocket-novel
// Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code
// Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.12.2
import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
  registerResourceImage } from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'spanish-pocket-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1e1b18', // text: a warm near-black
  oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route
  muted: '#6d645a', // running heads, the map's paths, the caption's credit
  paper: '#f7f2e8', // an ivory book paper
  slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river
};
// Each colour names its palette entry and carries its hex (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' } })),
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const TEXT = 'Gentium Book Plus'; // the text face
const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial
const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names
const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch
const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels
  color: col('oxblood'), align: 'center' };

// #region page: a pocket paperback; the text block holds 30 whole lines
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
// #endregion

// #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura
const LOCALE = 'es'; // config().locale; 'es-ES' gets US breaks (gotcha: hyphenation-locales)
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // the lead's paragraph goes on flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes, as the locale leaves captions in English (gotcha:
// resource-types-locale): 'Figura' and 'Fig.', numbered through the book, Figura 1.
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
// #endregion

// #region opener: the chapter spelled out, its title, and the lead under a raised initial
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the lead: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
// Design text is set ragged (gotcha: design-text-ragged): the lead is the one line beside
// the initial, fitted flush by the gap; the paragraph goes on in the Markdown.
const INITIAL_GAP = 0.55; // mm: the line ends 0.1 mm short of the measure
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // Numbering templates print numerals only (1, 01, I, i, A, a); a number in words comes
    // from the heading's own attribute: # Perdido {ordinal="primero" lead="Se puso el sol. …"}
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // longer titles wrap, not '…' (gotcha: overflow-ellipsis-default)
      placement: centred(TITLE_Y) },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: TEXT, fontSize: pt(BODY),
      lineHeight: LEAD / BODY, // a multiple, never a pt (gotcha: design-lineheight-multiple)
      color: col('ink'), align: 'left', overflow: 'wrap', // a dropCap needs wrapping text
      dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
        color: col('oxblood'), gap: mm(INITIAL_GAP) }, // lines: 1, a raised initial
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { y: pt(SINK * LEAD) }, size: { width: mm(MEASURE) } } },
  ] },
};
// The break restated (gotcha: headings-drop-h1-break): the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own: the text goes on
// on the line under the lead.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto; a drop folio on the opener
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
// #endregion

// #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1]
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
// #endregion

// #region styles: the asterisks that close the excerpt, the edition's note, the colophon
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink, not muted: a style has no italic colour (gotcha: style-italic-colour).
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: LOCALE,
  resourceTypes,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded
    levels: [chapter],
  },
  headingStyles: [frontispiece],
  paragraphStyles,
  captionStyle,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 45 · content.es.mdtitle: "Marianela" author: "Benito Pérez Galdós" --- # Las minas de Socartes {style="lamina"} ::resource{id="plano"} # Perdido {ordinal="primero" lead="Se puso el sol. Tras el breve crepúsculo vino tranquila"} y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.) Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa. Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino. —No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}]. Después de andar largo trecho, añadió: —Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento. Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar. —¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante. Dio un paso y hundiose en la frágil tierra movediza. —¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna. El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera. :::space{lines=2} :::paragraphs{style="asterismo"} \*  \*  \* ::: :::paragraphs{style="nota"} ⁠1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor. ::: :::paragraphs{style="colofon"} Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL) :::
`; // content.es.md, inlined by the Cookbook // #region art: the map of Socartes, in millimetres at its printed size, in the palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); // A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed. function smooth(list, close = false) { const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)]; let d = `M${n2(p[1][0])} ${n2(p[1][1])}`; for (let i = 1; i < p.length - 2; i++) { const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]]; d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} ` + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`; } return close ? `${d}Z` : d; } async function labelFace() { // Marcellus SC inside the SVG (gotcha: svg-no-webfonts) const url = 'https://cdn.jsdelivr.net/npm/@fontsource/marcellus-sc@5/files/' + 'marcellus-sc-latin-400-normal.woff2'; const res = await fetch(url); if (!res.ok) throw new Error(`Label face not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; } function drawMap(face, W, H) { // drawn for W 86 × H 112: the mines above, the town below const rnd = mulberry32(1878); const C = { ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45), wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35), earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag, deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45), edge: mix(palette.slag, palette.ink, 0.5), water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood, stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground }; const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`]; const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`); const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" ` + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" ` + `stroke-linejoin="round"${extra}/>`); // The names, set first so that no tree grows over them. const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'], [34.4, 19, 'talleres', 'end'], [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'], [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream], [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']]; const box = ([x, y, text, anchor = 'start']) => { const w = text.length * 1.45; const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x; return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8]; }; const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d); // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater". const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50], [36, 40], [35, 20]]; [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => { const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k, cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]); out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" ` + 'stroke-width="0.25"/>'); }); fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles. out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85], [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`); for (let i = 0; i < 100; i++) { const a = rnd() * Math.PI * 2; const r = Math.sqrt(rnd()) * 16; const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r]; const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood]; if (clear(x, y)) { out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`); } } [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]], [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]] .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"')); // The river, the footbridge, and the town the traveller left behind. stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]), C.water, 2.4); stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7); for (let i = 0; i < 18; i++) { const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5]; fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink); } fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge. stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72], [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75, ' stroke-dasharray="0.05 1.3"'); out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" ` + 'stroke-width="0.6"/>'); // North, up: a half-inked arrowhead over an N. fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink); out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`); labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" ` + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`)); out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" ` + `stroke="${C.ink}" stroke-width="0.4"/>`); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}"><style>${face}text{font-family:L;font-size:2.35px}</style>` + `${out.join('')}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note 'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial 'Marcellus SC': ['400'], // chapter label, plate name, running heads }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('plano.svg', drawMap(await labelFace(), MEASURE, MAP_H)); // The map is book page 8, a verso, facing chapter I: folios and parity follow the book. const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Español justificado en una novela de bolsillo' });
العُدّة · 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 ↗ (يفتح في تبويب جديد)

تنويعات

#افتتح كل فصل في صفحة فردية

مع 'odd' يأخذ الفصل الذي كان سيبدأ في صفحة زوجية صفحة بيضاء قبله، كما في طبعة أرحب. والفصل الأول يُفتتح أصلًا في صفحة فردية، وتحتفظ اللوحة بصفحتها لأن نمطها يضبط انتقاله الخاص.

-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },

#رقّم الأشكال بحسب الفصل

إن تُركت الأنواع الإسبانية المدمجة كما هي، فإنها ترقّم الأشكال داخل كل عنوان من المستوى الأول: تصير الخريطة Figura 1.1 والإحالة fig. 1.1.

-const resourceTypes = defaultResourceTypes(LOCALE)
-  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);

أخطاء شائعة

خطأ شائع

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

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

خطأ شائع

ترجم Figure/Table بـ defaultResourceTypes(locale)

يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes. «شكل» و«جدول» بلغتك →

خطأ شائع

نص التصميم لا يُضبط أبدًا، فتكون الأسطر بجانب الحرف الاستهلالي غير مضبوطة

في postext 1.4.1 يحاذي عنصر نص التصميم إلى اليسار أو الوسط أو اليمين وينكسر كلمةً كلمة: لا توجد محاذاة مضبوطة، وhyphenate: true لا يقسم إلا كلمة أطول من سطر كامل. لذلك تنتهي أسطر المقدّمة الموضوعة بجانب dropCap بحافة حرّة بجانب نص متن مضبوط. اقصر المقدّمة على الأسطر التي بجانب الحرف الاستهلالي واضبطها يدويًا: مع lines: 1 (حرف استهلالي مرفوع) تكون المقدّمة سطرًا واحدًا، يستطيع فراغ dropCap أن يجعله مضبوطًا؛ وتكمل الفقرة في Markdown. الحروف الاستهلالية في صفحات الافتتاح →

خطأ شائع

'1998. ' أو '- ' في أول الفقرة يفتح قائمة

الفقرة التي تبدأ برقم ثم نقطة ثم مسافة، أو بواصلة ثم مسافة، تصير بندًا في قائمة. ضع رابط الكلمات (U+2060) قبل الرقم، واكتب الحوار بالشرطة الطويلة. الهروب والمحارف الحرفية →

خطأ شائع

قيم السمات: لا { ولا }؛ ضع القيمة التي فيها " بين علامتي اقتباس مفردتين

تنتهي قيمة السمة عند القوس المعقوف الختامي، فلا يمكن أن تحتوي { أو }. القيمة التي فيها علامة اقتباس مزدوجة توضع بين علامتي اقتباس مفردتين؛ أما علامة الدولار فلا مشكلة فيها. سمات العنوان →

خطأ شائع

أي كائن 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 قبل البناء. لوحة ألوان دلالية →

خطأ شائع

نمط الفقرة ليس له لون للمائل

في postext 1.4.1 يضبط نمط الفقرة color وboldColor لكن لا يضبط italicColor: تأخذ مقاطعه المائلة bodyText.italicColor. فالنمط الخافت (الحرف الصغير، سطر المصدر) يطبع عناوينه المائلة أدكن من الكلمات المحيطة بها. أبقِ هذه الأنماط بحبر المتن، أو تجنّب المائل فيها. أنماط الفقرات →

خطأ شائع

يرث نمط العنوان فاصل الصفحة من مستواه

يأخذ مُدخل headingStyles كل حقل يتركه من مستوى عنوانه، بما فيه breakBefore. فصفحة المحتويات أو صفحة بيانات النشر المنسّقة على H1 بعد :::pagebreak ترث الزوجية 'odd' وتقع بعد صفحة فارغة. أعطِ هذا النمط breakBefore: { enabled: false }. أنماط العناوين →

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

  • بياض في رأس صفحة الفصل من تسعة أسطر بدلًا من ثمانية ترك الصفحة 11 ناقصة سطرًا، لأن avoidWidows لم يكن ليبدأ فقرة على آخر سطر في الصفحة. بعد أي تغيير في الإعدادات أو النص، افحص أسفل كل صفحة.
  • تتوقف السمة lead في منتصف الجملة (…vino tranquila) حيث تستأنف فقرة Markdown (y oscura la noche)، لأنها تحمل بالضبط الكلمات التي تتسع بجوار الحرف الاستهلالي. وأي حرف أو حجم أو عرض سطر أو INITIAL_GAP آخر يُفسد الانتهاء المستوي: انقل الكلمات بين الاثنتين حتى ينتهي السطر مستويًا من جديد.
  • لا يحوي ملف latin في Fontsource لأي من هذه الخطوط رمز النجوم الثلاث ⁂ (U+2042)، فكان المتصفح سيأخذه من خط النظام. والكتب الإسبانية تعلّم الفاصل على أي حال بثلاث نجوم متباعدة.

الحقوق

الوصفة
Ignacio Ferro
النص
  • Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation · Benito Pérez Galdós · ملك عام
  • The edition’s note and the map’s caption · Postext Cookbook · أصلي
الصور
  • The conjectural map of the Socartes mines, drawn in code in the page’s palette · Ignacio Ferro · MIT
الخطوط
Gentium Book Plus (SIL OFL 1.1) · Libre Bodoni (SIL OFL 1.1) · Marcellus SC (SIL OFL 1.1)
Sandbox