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

دليل الوصفات · الفصل 4 · الترويسات وأرقام الصفحات

معجم بفهرس إبهام متحرّك

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

في هذه الصفحة
المُخرَج
Canvas
المستوى
متقدم
Postext
اختُبرت مع Postext 1.4.1
تتطلب ≥ 1.4.1
الترخيص
حُدّثت في 29 سبتمبر 2026
الكود MIT · النص CC BY 4.0
  • نموذج باللغة الإنجليزية: لا توجد طبعة عربية بعد
  • مقاس القص 150 × 200 مم
  • عمودان, فاصل 4 مم بين الأعمدة
  • Alegreya 8/10.4
  • Alegreya SC
  • Alegreya Sans SC
  • 4 صفحات
  • المستوى
  • Postext 1.4.1
  • أُخرجت في 96 ms
  • أسطر الكود: 185

باختصار

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

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

أربع صفحات من طبعة جديدة لـ Sailor’s Word-Book (1867) للأدميرال سميث، الحروف من A إلى C، على صفحة 150 × 200 مم. تجري المداخل في عمودين مضبوطين بخط Alegreya 8 pt يفصل بينهما خط شعري، وكل كلمة مدخل بالأسود العريض بالكحلي، وكل سطر تالٍ معلّق بمقدار 1 em. يُفتتح الحرف داخل عموده، حرفًا استهلاليًا كحليًا بعمق أربعة أسطر فوق خط نحاسي، وتستمر المداخل بلا فاصل صفحة. وعلى الحافة الأمامية تطبع كل صفحة فهرس الإبهام كله، 22 لسانًا بلون باهت. لسان الحرف الحالي كحلي، أعرض من الألسنة الأخرى بـ 3.2 مم، ويهبط درجة مع كل حرف. وتحتفظ الطبعة الإسبانية بمداخل سميث الإنجليزية وتترجم الشروح.

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

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

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

script.js · الأسطر 36–66في الكود الكامل
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));

المكونات

الميزات
أشرطة النزف وألسنة الإبهامترويسات لكل قسمالغامق والمائل وألوانهماأنماط العناوينصفحات افتتاح مصمَّمةتثبيت عناصر التصميمالصور في تصاميم الصفحةسمات العنوانأنماط الفقراتقوائم المراجع ومسارد المصطلحاتالترويسات بحسب دور الصفحةالترويسات وأرقام الصفحاتالخط الفاصل بين الأعمدةموازنة الأعمدةالأسطر الأرامل والأسطر اليتيمة والكلمات المعزولةتقسيم الكلمات ولغة المستندلوحة ألوان دلاليةلون الورقهوامش متناظرةالأشكال والجداول بوصفها موارد
الخطوط
Alegreya, Alegreya SC, Alegreya Sans SC (SIL OFL 1.1)
الأصول
لا شيء: كل صورة مرسومة بالكود

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

#1 · أعطِ كل حرف نمط عنوان بلسانه الخاص

الشيفرة هي الجواب المختصر أعلاه. تسرد TABS اثنين وعشرين لسانًا، والحروف النادرة تتشارك لسانًا واحدًا (QR وUV وXYZ). عمق كل لسان سطرا متن (7.34 مم)، فتملأ الألسنة معًا أسطر كتلة النص الأربعة والأربعين. وتعطي الحلقة كل حرف نمط عنوان يضيف header فيه لسان ذلك الحرف الكحلي، أدنى بدرجة من سابقه، إلى الترويسات والفهرس الباهت (أنماط العناوين). تُكتب كل خانة مرتين، مرة لكل parity، وتُثبَّت إلى الحافة الخارجية للصفحة لا إلى حاوية الترويسة نفسها. فتلك الحاوية هي الهامش العلوي، بعرض كتلة النص، وتمتد من حدّ القص نزولًا إلى النص، بينما على الألسنة أن تبلغ الحافة الأمامية وتنزل على الأسطر الأربعة والأربعين كلها. وتأخذ الصفحة ترويسة آخر قسم يبدأ فيها، لذا تبدأ الصفحة 2 في A وتُظهر لسان B.

#2 · أبعد الترويسات عن الصفحة الأولى

script.js · الأسطر 70–86في الكود الكامل
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };

الترويسات عناصر نصية مثبتة إلى الصفحة ومُرشَّحة بـ parity وpages: 'body' (العناصر النصية). يمتد العنوان في الصفحة 1 عبر العمودين، وهذا يجعل الصفحة 1 صفحة افتتاح. فتتخطاها الترويسات، ويأتي رقم صفحتها من التذييل، وهو عنصر مضبوط على pages: 'opener' يُطبع أسفل كتلة النص بـ 6 مم. وفي الصفحة الفردية يطبع {chapterTitle} حرف آخر عنوان من المستوى الأول في الصفحة أو قبلها، Letter C. ومنذ postext 1.5.0 يطبع {firstMark.<key>} و{lastMark.<key>} أول مدخل في الصفحة وآخره بدلًا من ذلك، وهما كلمتا الدليل اللتان تحملهما معظم المعاجم؛ أما هذه الوصفة فنُضّدت بـ 1.4.1 وتحتفظ بالحرف.

#3 · لوّن كلمات المداخل ولا شيء غيرها

script.js · الأسطر 90–101في الكود الكامل
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];

كل مدخل فقرة واحدة من كتلة :::paragraphs{style="entry"}، وأسطره التالية معلّقة بمقدار 1 em (أنماط الفقرات). يبقي boldColor في المتن Note. في الصفحة 1 بلون الحبر، ويطبع boldColor في نمط المدخل كلمات المداخل بالكحلي. ولا italicColor لنمط الفقرة، فتحتفظ المداخل الفرعية المائلة بحبر المتن.

#4 · أنزل حرفًا استهلاليًا من أربعة أسطر في العمود

script.js · الأسطر 105–118في الكود الكامل
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };

LETTER، أي 55.8 pt، هو الحجم الذي يقف فيه حرف كبير من Alegreya SC 900 (ارتفاعه 0.652 em) على خط الأساس الرابع ويبلغ خط الحروف الكبيرة في سطر المتن الأول. ينتهي الحرف والخط النحاسي داخل السطر الخامس، وتنجذب كتلة العنوان إلى خط الشبكة التالي، فيكون عمق كل عنوان خمسة أسطر وتبقى المداخل تحته على الشبكة. وفي الصفحة 3 يقف C في رأس العمود الثاني على مستوى الأسطر الأربعة الأولى من العمود الأول. يضبط المستوى الأول breakBefore: { enabled: false }، وترثه أنماط الحروف. وفي 1.4.1 يُسقط أي كائن headings فاصل الصفحة في المستوى الأول أصلًا؛ ومع كتابة القيمة صراحة تستمر الحروف متصلة لو أعاد إصدار لاحق ذلك الفاصل.

#5 · مدّ العنوان عبر العمودين

script.js · الأسطر 122–148في الكود الكامل
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };

يمتد نمط العنوان عبر الصفحة بشريط يحمل المرساة، والاسم، وعنوانًا فرعيًا واسم المؤلف يُقرآن من سمات العنوان، وخطًّا مزدوجًا (الامتداد والتصميم المتقدم). ينتهي الخط الرفيع أسفل أعلى كتلة النص بـ 45.1 مم، فينجذب الشريط إلى 13 خطًّا من الشبكة، أي 47.7 مم. وتحت العنوان الممتد بعرض الصفحة يبدأ الخط الفاصل بين العمودين من أعلى كتلة النص رغم ذلك، فيقطع العنوان. لذا يغطي صندوق بلون الورق الشريط حتى الخط الرفيع، ولا يظهر الخط الفاصل إلا تحته. يصل {titleText} الأسطر الملتفة للعنوان المخفي بمسافة، و1.4.1 يلفّ العنوان الممتد بعرض الصفحة على عرض العمود. وبحجم H1 الافتراضي طبع الشريط Word- Book، لذا يضبط نمط العنوان العنوانَ المخفي بحجم المتن، فيتسع الاسم لسطر واحد من العمود ذي 60 مم.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 046 · Dictionary with a moving thumb index ═══════════════
// https://postext.dev/en/cookbook/dictionary-thumb-index
// Code: MIT · Text: W. H. Smyth, 1867 (PD); Spanish translation CC BY 4.0 · Pictures: code
// Fonts: Alegreya, Alegreya SC, Alegreya Sans SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink and navy on a warm paper, brass for the rules and the rope
const palette = {
  ink: '#1b1f24', // text
  navy: '#1f3a5f', // headwords, letters and tabs
  brass: '#a4834f', // hairlines and the rope of the vignette
  rule: '#cfccc3', // the column rule
  tint: '#e4e9f0', // the pale cells of the index
  slate: '#56657b', // their letters
  muted: '#62666b', // running heads
  paper: '#fbfaf6', // the page, and the letter reversed out of each tab
};
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 TEXT = 'Alegreya', DISPLAY = 'Alegreya SC', LABEL = 'Alegreya Sans SC';
const PT = 25.4 / 72; // mm in a point
const LEAD = 10.4; // pt: the body's leading, the grid every column is set on
const LINES = 44; // lines in a full column
const TOP = 18, INNER = 12, OUTER = 14; // mm; the foot margin makes the block whole lines
const BOTTOM = 200 - TOP - LINES * LEAD * PT;

// #region answer: one heading style per letter, each with its tab one step down the fore-edge
// 22 tabs share the 44 lines of the text block, two lines each; letters with few words
// share a tab, as in most thumb-indexed dictionaries.
const TABS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P',
  'QR', 'S', 'T', 'UV', 'W', 'XYZ'];
const STEP = 2 * LEAD * PT; // mm: 7.34
const EDGE = { odd: 'top-right', even: 'top-left' }; // the fore-edge: right on a recto
// A cell of the index, `width` mm inside the trim and 3 mm past it, so that only its
// inner corners show their rounding; its label is centred on the part inside the trim.
const cell = (id, label, i, parity, width, fill, ink, size) => {
  const y = mm(TOP + i * STEP + 0.3); // a 0.6 mm gap between neighbours
  const at = (x, w) => ({ anchor: { to: 'page', edge: EDGE[parity] }, offset: { x: mm(x), y },
    size: { width: mm(w), height: mm(STEP - 0.6) } });
  return [
    { kind: 'box', id, parity, style: { backgroundColor: col(fill), borderRadius: mm(1.2) },
      placement: at(parity === 'odd' ? 3 : -3, width + 3) },
    { kind: 'text', id: `${id}-label`, parity, content: label, fontFamily: LABEL, fontWeight: 700,
      fontSize: pt(size), lineHeight: 1, color: col(ink), align: 'center',
      verticalAlign: 'middle', placement: at(0, width) },
  ];
};
const sides = (make) => ['odd', 'even'].flatMap(make);
// Every page prints the whole index, pale; the section's own letter stands out of it.
const ladder = sides((p) => TABS.flatMap((label, i) =>
  cell(`index-${i}-${p}`, label, i, p, 5.8, 'tint', 'slate', 7.5)));
const letterStyles = () => TABS.flatMap((label, i) => [...label].map((letter) => ({
  id: letter,
  // '# B {style="B"}' opens the section of B: its pages take this header, and a page
  // where A ends and B begins takes B's (gotcha: section-last-wins).
  header: { elements: [...runningHeads, ...ladder,
    ...sides((p) => cell(`tab-${p}`, label, i, p, 9, 'navy', 'paper', 8.5))] },
})));
// #endregion

// #region running-heads: the book on the verso, the letter on the recto, folios outside
const HEAD = 11; // mm from the trim's top to the heads' top
const head = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity,
  pages: 'body', fontFamily: LABEL, fontSize: pt(8), fontWeight: 500, letterSpacing: pt(1),
  color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD) } }, ...extra });
const folio = { fontWeight: 700, color: col('ink'), letterSpacing: pt(0) };
const runningHeads = [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
  head('recto-letter', t({ en: 'Letter {chapterTitle}', es: 'Letra {chapterTitle}' }), 'odd',
    'top-right', -(OUTER + 8)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
];
// The first page drops its folio to the foot, under the text block.
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, {
  ...folio, pages: 'opener',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(6) } } })] };
// #endregion

// #region entries: one paragraph per entry, the headword bold in navy, the turnovers hanging
// Bold and italic default to the engine's blue: the body sets them to ink, and only the
// entry style prints its bold, the headwords, in navy.
const BODY = 8; // pt: a reference size, about 50 characters to the 60 mm column
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: mm(0),
  minWordSpacing: 0.7, maxWordSpacing: 1.7,
  maxRuntTracking: 0 }; // gotcha: runt-tracking-unpainted
const paragraphStyles = [
  { id: 'entry', hangingIndent: em(1), boldColor: col('navy') }, // :::paragraphs{style="entry"}
  { id: 'colophon', fontSize: pt(7), color: col('muted'), textAlign: 'center',
    marginTop: pt(LEAD) },
];
// #endregion

// #region letter-heads: a four-line initial over a brass hairline, in the column, running on
// Its cap line meets the first body line's and it stands on the fourth baseline. Cap
// heights measured on the glyph H: 0.652 em in Alegreya SC 900, 0.646 em in Alegreya.
const DROP = 4; // body lines the letter spans
const LETTER = ((DROP - 1) * LEAD + 0.646 * BODY) / 0.652; // pt: 55.8
// A design line sets its baseline 0.8 of its height below its top; a body line does too.
const letterHead = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'letter', content: '{titleText}', fontFamily: DISPLAY, fontWeight: 900,
    fontSize: pt(LETTER), lineHeight: 1, color: col('navy'), align: 'left',
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP - 0.2) * LEAD - 0.8 * LETTER) } } },
  { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6), color: col('brass'),
    placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: pt((DROP + 0.55) * LEAD) }, size: { width: 'fill' } } },
] } };
// #endregion

// #region title: the book's name across both columns, under a fouled anchor
const centred = (id, content, family, size, lineHeight, y, width, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('navy'), align: 'center',
  overflow: 'wrap', ...extra, placement: { anchor: { to: 'container', edge: 'top' },
    offset: { y: mm(y) }, size: { width: width ? mm(width) : 'fill' } } });
const rule = (id, y, thickness) => ({ kind: 'rule', id, direction: 'horizontal',
  thickness: pt(thickness), color: col('navy'), placement: { anchor: { to: 'container',
    edge: 'top-left' }, offset: { y: mm(y) }, size: { width: 'fill' } } });
const titleBand = { enabled: true, slot: { elements: [
  // The column rule starts at the top of the text block, under the band too: a field of
  // paper down to the thin rule covers it, so it hangs from the double rule.
  { kind: 'box', id: 'field', style: { backgroundColor: col('paper') }, placement: {
    anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill', height: mm(45) } } },
  { kind: 'image', id: 'anchor', resourceId: 'anchor', placement: {
    anchor: { to: 'container', edge: 'top' }, size: { width: 'auto', height: mm(18) } } },
  centred('name', '{titleText}', DISPLAY, 22, 1.1, 20, 0, { fontWeight: 900 }),
  centred('subtitle', '{attr.subtitle}', TEXT, 9, 1.25, 29.5, 92, { italic: true,
    color: col('ink') }),
  centred('byline', '{attr.byline}', LABEL, 7, 1.2, 38.8, 0, { fontWeight: 500,
    letterSpacing: pt(1.2), color: col('muted') }),
  rule('thick', 43.6, 1.2),
  rule('thin', 45, 0.4),
] } };
// '# The Sailor’s Word-Book {style="title" subtitle="…" byline="…"}' opens page 1. The band's
// {titleText} joins the hidden heading's wrapped lines with a space, and a page-span heading
// wraps at the column width: at the default H1 size the band printed 'Word- Book'. At body
// size the hidden title fits one line of the 60 mm column.
const titleStyle = { id: 'title', span: 'page', advancedDesign: titleBand, fontSize: pt(BODY) };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, footer,
  header: { elements: [] }, // every page takes the header of its letter's style
  page: { sizePreset: 'custom', width: mm(150), height: mm(200), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { gutterWidth: mm(4), // two columns is the default layout
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } },
  bodyText, paragraphStyles,
  // The designs paint every heading; the hidden ones are still measured, and in the face
  // FONTS loads, or the kit would fetch Open Sans 700 for text nobody sees.
  headings: { fontFamily: DISPLAY, fontWeight: 900, levels: [
    // Letters run on in the column. Any headings object already drops the H1 break
    // (gotcha: headings-drop-h1-break); stating it keeps them running on if that default
    // returns, and the letter styles inherit it (gotcha: style-inherits-break).
    { level: 1, breakBefore: { enabled: false }, marginTop: pt(LEAD), marginBottom: pt(0),
      advancedDesign: letterHead },
  ] },
  headingStyles: [
    titleStyle,
    ...letterStyles(),
  ],
});

// #region art: a fouled anchor, the Admiralty's badge, for the title band
// Drawn in the page's navy and brass; the rope's lay is a dashed navy stroke over it.
const anchorSvg = () => {
  const { navy, brass } = palette;
  const rope = 'M66.5 19 C88 26 86 44 60 52 C34 60 32 76 60 82 C88 88 86 104 60 110 '
    + 'C44 114 38 122 42 136';
  const fluke = (s) => `<path transform="translate(60 0) scale(${s} 1)" fill="${navy}"
    d="M-44 92 L-50 78 L-36 70 L-34 88 Z"/>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 150" width="120" height="150">
  <circle cx="60" cy="13" r="8.5" fill="none" stroke="${navy}" stroke-width="4"/>
  <path d="M56.5 21 L63.5 21 L64.5 124 L55.5 124 Z" fill="${navy}"/>
  <rect x="20" y="27" width="80" height="6.5" rx="3.2" fill="${navy}"/>
  <circle cx="19" cy="30.2" r="5" fill="${navy}"/>
  <circle cx="101" cy="30.2" r="5" fill="${navy}"/>
  <path d="M16 84 Q20 132 60 134 Q100 132 104 84" fill="none" stroke="${navy}"
    stroke-width="7" stroke-linecap="round"/>
  ${fluke(1)}${fluke(-1)}
  <path d="M60 126 L66 134 L60 142 L54 134 Z" fill="${navy}"/>
  <path d="${rope}" fill="none" stroke="${brass}" stroke-width="4.2" stroke-linecap="round"/>
  <path d="${rope}" fill="none" stroke="${navy}" stroke-opacity="0.5" stroke-width="4.2"
    stroke-dasharray="1 1.8"/>
</svg>`;
};
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 118 · content.en.mdtitle: "The Sailor’s Word-Book" author: "W. H. Smyth" --- # The Sailor’s Word-Book {style="title" subtitle="An alphabetical digest of nautical terms, including some more especially military and scientific, but useful to seamen" byline="Admiral W. H. Smyth · London, 1867"} **Note.** Admiral William Henry Smyth died on 8 September 1865 and left this word-book in manuscript. Sir Edward Belcher, a vice-admiral, revised it for the press, and Blackie and Son published it in London in 1867. This selection prints entries from the letters A, B and C in Smyth’s wording, some of them shortened, among them words now in everyday English: A1, *taken aback*, *aloof*, the *bitter end*, a wide *berth*, *clear the decks*, *cut and run*. Cross-references follow *see*; the entries they name are not in this selection. # A {style="A"} :::paragraphs{style="entry"} **A.** The highest class of the excellence of merchant ships on Lloyd’s books, subdivided into A1 and A2, after which they descend by the vowels: A1 being the very best of the first class. Formerly a river-built (Thames) ship took the first rate for 12 years, a Bristol one for 11, and those of the northern ports 10. Some of the out-port built ships keep their rating 6 to 8 years, and inferior ones only 4. But improvements in ship-building, and the large introduction of iron, are now claiming longer life. **Aback.** The situation of a ship’s sails when the wind bears against their front surfaces. They are *laid aback*, when this is purposely effected to deaden her way by rounding in the weather-braces; and *taken aback*, when brought to by an unexpected change of wind, or by inattention in the helmsman. – *All aback forward*, the notice given from the forecastle, when the head-sails are pressed aback by a sudden change in the wind. (*See* Work aback.) – *Taken aback*, a colloquialism for being suddenly surprised or found out. **Abeam.** In a line at right angles to the vessel’s length; opposite the centre of a ship’s side. **About.** Circularly; the situation of a ship after she has gone round, and trimmed sails on the opposite tack. – *Ready about!* and *About-ship!* are orders to the ship’s company to prepare for tacking by being at their stations. **Adrift.** Floating at random; the state of a boat or vessel broken from her moorings, and driven to and fro without control by the winds and waves. Cast loose; cut adrift. **Afloat.** Borne up and supported by the water; buoyed clear of the ground; also used for being on board ship. **Ahead.** A term especially referable to any object farther onward, or immediately before the ship, or in the course steered, and therefore opposed to *astern*. – *Ahead of the reckoning*, is sailing beyond the estimated position of the ship. – *Ahead* is also used for progress; as, *cannot get ahead*, and is generally applied to forward, in advance. **A-hull.** A ship under bare poles and her helm a-lee, driving from wind and sea, stern foremost. Also a ship deserted, and exposed to the tempestuous winds. **A-lee.** The contrary of *a-weather*: the position of the helm when its tiller is borne over to the lee-side of the ship, in order to go about or put her head to windward. – *Hard a-lee!* or *luff a-lee!* is said to the steersman to put the helm down. – *Helm’s a-lee!* the word of command given on putting the helm down, and causing the head-sails to shake in the wind. **Aloof.** The old word for “keep your luff,” in the act of sailing to the wind. (*See* Luff.) – *Keep aloof*, at a distance. **Anchor.** A large and heavy instrument in use from the earliest times for holding and retaining ships, which it executes with admirable force. With few exceptions it consists of a long iron shank, having at one end a ring, to which the cable is attached, and the other branching out into two arms, with flukes or palms at their bill or extremity. A stock of timber or iron is fixed at right angles to the arms, and serves to guide the flukes perpendicularly to the surface of the ground. According to their various form and size, anchors obtain the epithets of the *sheet*, *best bower*, *small bower*, *spare*, *stream*, *kedge*, and *grapling* (which see under their respective heads). **Apple-pie order.** A strange but not uncommon term for a ship in excellent condition and well looked to. Neat and orderly. Absurdly said to be a corruption of *du pol au pied*. **Ashore.** Aground, on land. – To *go ashore*, to disembark from a boat. Opposed to *aboard*. **Astern.** Any distance behind a vessel; in the after-part of the ship; in the direction of the stern, and therefore the opposite of *ahead*. – *To drop astern*, is to be left behind – when abaft a right angle to the keel at the main-mast, she drops astern. **Athwart.** The transverse direction; anything extending or across the line of a ship’s course. – *Athwart hawse*, a vessel, boat, or floating lumber accidentally drifted across the stem of a ship, the transverse position of the drift being understood. – *Athwart the fore-foot*, just before the stem; ships fire a shot in this direction to arrest a stranger, and make her bring-to. – *Athwart ships*, in the direction of the beam; from side to side: in opposition to *fore-and-aft*. **Avast.** The order to stop, hold, cease, or stay, in any operation: its derivation from the Italian *basta* is more plausible than *have fast*. **Awning.** A cover or canvas canopy suspended by a crow-foot and spread over a ship, boat, or other vessel, to protect the decks and crew from the sun and weather. (*See* Euphroe.) Also that part of the poop-deck which is continued forward beyond the bulk-head of the cabin. **Azure.** The deep blue colour of the sky, when perfectly cloudless. ::: # B {style="B"} :::paragraphs{style="entry"} **Ballast.** As a verb, signifies to steady; as a substantive, a comprehensive mind. A man is said to “lose his ballast” when his judgment fails him, or he becomes top-heavy from conceit. **Beam-ends.** A ship is said to be on her beam-ends when she has heeled over so much on one side that her beams approach to a vertical position; hence also a person lying down is metaphorically said to be on his beam-ends. **Berth.** The station in which a ship rides at anchor, either alone, or in a fleet; as, she lies in a good berth, *i.e.* in good anchoring ground, well sheltered from the wind and sea, and at a proper distance from the shore and other vessels. – *Snug berth*, a place, situation, or establishment. A sleeping berth. – *To berth a vessel*, is to fix upon, and put her into the place she is to occupy. – *To berth a ship’s company*, to allot to each man the space in which his hammock is to be hung, giving the customary 14 inches in width. – *To give a berth*, to keep clear of, as to give a point of land a wide berth, is to keep at a due distance from it. **Bitter-end.** That part of the cable which is abaft the bitts, and therefore within board when the ship rides at anchor. They say, “Bend to the bitter-end” when they would have that end bent to the anchor, and when a chain or rope is paid out to the bitter-end, no more remains to be let go. The bitter-end is the clinching end – sometimes that end is bent to the anchor, because it has never been used, and is more trustworthy. The first 40 fathoms of a cable of 115 fathoms is generally worn out when the inner end is comparatively new. **Booby.** A well-known tropical sea-bird, *Sula fusca*, of the family *Pelecanidae*. It is fond of resting out of the water at night, even preferring an unstable perch on the yard of a ship. The name is derived from the way in which it allows itself to be caught immediately after settling. The direction in which they fly as evening comes on often shows where land may be found. **Brace of shakes.** A moment: taken from the flapping of a sail. I will be with you before it shakes thrice. **Bring up with a round turn.** Suddenly arresting a running rope by taking a round turn round a bollard, bitt-head, or cleat. Said of doing a thing effectually though abruptly. It is used to bring one up to his senses by a severe rating. **Broach-to, to.** To fly up into the wind. It generally happens when a ship is carrying a press of canvas with the wind on the quarter, and a good deal of after-sail set. The masts are endangered by the course being so altered, as to bring it more in opposition to, and thereby increasing the pressure of the wind. In extreme cases the sails are caught flat aback, when the masts would be likely to give way, or the ship might go down stern foremost. **Broadside.** The whole array, or the simultaneous discharge of the artillery on one side of a ship of war above and below. It also implies the whole of that side of a ship above the water which is situate between the bow and quarter, and is in a position nearly perpendicular to the horizon. Also, a name given to the old folio sheets whereon ballads and proclamations were printed of old (broad-sheet). **By the board.** Over the ship’s side. When a mast is carried away near the deck it is said to go by the board. ::: # C {style="C"} :::paragraphs{style="entry"} **Cabin.** A room or compartment partitioned off in a ship, where the officers and passengers reside. In a man-of-war, the principal cabin, in which the captain or admiral lives, is the upper after-part of the vessel. **Cabin-boy.** A boy whose duty is to attend and serve the officers and passengers in the cabin. **Cable’s length.** A measure of about 100 fathoms, by which the distances of ships in a fleet are frequently estimated. This term is frequently misunderstood. In all marine charts a cable is deemed 607.56 feet, or one-tenth of a sea mile. In rope-making the cable varies from 100 to 115 fathoms; cablet, 120 fathoms; hawser-laid, 130 fathoms, as determined by the admiralty in 1830. **Caboose.** The cook-room or kitchen of merchantmen on deck; a diminutive substitute for the galley of a man-of-war. It is generally furnished with cast-iron apparatus for cooking. **Careen, to.** A ship is said to careen when she inclines to one side, or lies over when sailing on a wind; off her keel or carina. **Cat o’ nine tails.** An instrument of punishment used on board ships in the navy; it is commonly of nine pieces of line or cord, about half a yard long, fixed upon a piece of thick rope for a handle, and having three knots on each, at small intervals, nearest one end; with this the seamen who transgress are flogged upon the bare back. **Caulk, to.** (*See* Caulking.) To lie down on deck and sleep, with clothes on. **Chart.** A hydrographical map, or a projection of some part of the earth’s superficies *in plano*, for the use of navigators, further distinguished as plane-charts, Mercator’s charts, globular charts, and the bottle or current chart, to aid in the investigation of surface currents (all which see). A selenographic chart represents the moon, especially as seen by the aid of photography and Mr. De la Rue’s arrangement. **Cheer, to.** To salute a ship *en passant*, by the people all coming on deck and huzzahing three times; it also implies to encourage or animate. (*See also* Hearty and Man ship!) **Chock-a-block, or chock and block.** Is the same with *block-a-block* and *two-blocks* (which see). When the lower block of a tackle is run close up to the upper one, so that you can hoist no higher, the blocks being together. **Clear, to.** Has several significations, particularly to escape from, to unload, to empty, to prepare, &c., as: – *To clear for action.* To prepare for action. – *To clear away* for this or that, is to get obstructions out of the way. – *To clear the decks.* To remove lumber, put things in their places, and coil down the ropes. Also, to take the things off a table after a meal. – *To clear goods.* To pay the custom-house dues and duties. – *To clear the land.* To escape from the land. – *To clear a lighter, or the hold.* To empty either. **Coil.** A certain quantity of rope laid up in ring fashion. The manner in which all ropes are disposed of on board ship for convenience of stowage. They are laid up round, one fake over another, or by concentric turns, termed *Flemish coil*, forming but one tier, and lying flat on the deck, the end being in the middle of it, as a snake or worm coils itself. **Convoy.** A fleet of merchant ships similarly bound, protected by an armed force. Also, the ship or ships appointed to conduct and defend them on their passage. Also, a guard of troops to escort a supply of stores to a detached force. **Cross-trees.** Certain timbers supported by the cheeks and trestle-trees at the upper ends of the lower and top masts, athwart which they are laid to sustain the frame of the tops on the one, and to extend the top-gallant shrouds on the other. **Cuddy.** A sort of cabin or cook-room, generally in the fore-part, but sometimes near the stern of lighters and barges of burden. In the oceanic traders it is a cabin abaft, under the round-house or poop-deck, for the commander and his passengers. Also, the little cabin of a boat. **Cut and run, to.** To cut the cable for an escape. Also, to move off quickly; to quit occupation; to be gone. ::: :::paragraphs{style="colophon"} Type: Alegreya, Alegreya SC and Alegreya Sans SC (SIL OFL) Text: W. H. Smyth, 1867 (Project Gutenberg eBook 26000) Note and anchor: Postext Cookbook (CC BY 4.0) :::
`; // content.<lang>.md, inlined by the Cookbook // No paragraph cites the anchor, so it is never placed as a figure: only the title band's // image element draws it. const resources = [{ id: 'anchor', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, altText: t({ en: 'An anchor with a rope wound round its shank.', es: 'Un ancla con un cabo enrollado en la caña.' }), svg: { fileId: 'anchor.svg', width: 120, height: 150 } }]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Alegreya: ['400', '400i', '700'], 'Alegreya SC': ['900'], 'Alegreya Sans SC': ['500', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('anchor.svg', anchorSvg()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'The Sailor’s Word-Book', es: 'Vocabulario del marinero' }) });
العُدّة · 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 ↗ (يفتح في تبويب جديد)

تنويعات

#اطبع اللسان الحالي وحده

فهارس الإبهام المطبوعة لا تُظهر في العادة إلا لسان الحرف الحالي؛ أخرج ladder من الترويسة ويبقى اللسان الكحلي يهبط من A إلى C.

-  header: { elements: [...runningHeads, ...ladder,
+  header: { elements: [...runningHeads,

أخطاء شائعة

خطأ شائع

تأخذ الصفحة ترويسة آخر قسم يبدأ فيها

إذا بدأ نمطا عنوان في صفحة واحدة، أخذت الصفحة ترويسة الأخير منهما وتذييله ولوحة ألوانه. والصفحة الفارغة الفاصلة تتبع الفصل الذي قبلها، وصفحة الحشو لضبط الزوجية والفردية تتبع الفصل الذي بعدها. ترويسات لكل قسم →

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

عناصر الترويسة والتذييل تُرسَم فوق النص

تُرسَم عناصر الترويسة والتذييل فوق الصفحة، ولا تُفسح منطقة النص لها مكانًا. أبقِها داخل الهوامش، فالهوامش هي التي تحجز مساحتها. الترويسات وأرقام الصفحات →

خطأ شائع

الإزاحات السالبة نسبةً إلى الحاوية لا ترسم شيئًا

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

خطأ شائع

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

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

خطأ شائع

المسافة غير القابلة للكسر تكسر السطر مع ذلك

في postext 1.4.1 يعامل كاسر الأسطر U+00A0 معاملة المسافة العادية، فقد تنقسم 0.08 % أو 2.006 s أو Section 2 على سطرين. ألصق الجزأين (0.08%) أو أعد صياغة الجملة. الهروب والمحارف الحرفية →

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

  • في postext 1.4.1 يبدأ الخط الفاصل بين العمودين تحت العنوان الممتد بعرض الصفحة من أعلى كتلة النص، حتى حين يكون عمق الشريط عددًا صحيحًا من خطوط الشبكة، كما هنا. يغطيه صندوق بلون الصفحة يُرسم تحت عناصر التصميم الأخرى.
  • يتسع العمود ذو 60 مم لنحو 50 حرفًا، ولا يمكن إعادة صياغة إنجليزية سميث. في الالتقاط يمدّ 18 سطرًا من أصل 218 سطرًا إنجليزيًا مضبوطًا (8 %) مسافاتها إلى أكثر من 1.7 ضعف العرض المعتاد، مقابل أقل من 2 % في الإسبانية، التي فُصّلت ترجمتها على العمود. وكانت النسبة في الإنجليزية 17 % قبل استبدال المداخل الخمسة الأسوأ تنضيدًا (Aground وAloft وBelay وCockpit وCrow’s nest) بغيرها. اختر مداخل تُنضَّد جيدًا، أو وسّع العمود.

الحقوق

الوصفة
Ignacio Ferro
النص
الخطوط
Alegreya (SIL OFL 1.1) · Alegreya SC (SIL OFL 1.1) · Alegreya Sans SC (SIL OFL 1.1)
Sandbox