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

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

حواشٍ سفلية في أسفل العمود الذي يحيل إليها

علامات [^id] في Markdown تصير حواشي مرقّمة في أسفل العمود الذي يحيل إليها، مع مفاتيح لعمود واحد، وتعليقات في آخر الفصل، وترقيم متصل.

في هذه الصفحة
المُخرَج
Canvas · PDF · عناصر تحكم حية
المستوى
متوسط
Postext
اختُبرت مع Postext 1.6.0
تتطلب ≥ 1.6.0 · postext-pdf ≥ 1.6.0
الترخيص
حُدّثت في 28 سبتمبر 2026
الكود MIT · النص CC BY 4.0
  • نموذج باللغة الإنجليزية: لا توجد طبعة عربية بعد
  • مقاس القص 210 × 280 مم
  • عمودان, فاصل 7 مم بين الأعمدة
  • EB Garamond 10.5/13.5
  • Bodoni Moda
  • IBM Plex Sans Condensed
  • 3 صفحات
  • المستوى
  • Postext 1.6.0
  • أُخرجت في 117 ms
  • أسطر الكود: 164

باختصار

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

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

طبعة مدرسية من Lazarillo de Tormes، بمقاس 210 × 280 mm في عمودين بخط EB Garamond: المقدمة وبداية الفصل الأول (tratado)، حيث يولد Lázaro في طاحونة على نهر Tormes ويخرج إلى الدنيا مع رجل أعمى. تشرح الحواشي ما كان يعرفه قارئ سنة 1554 ولا يعرفه قارئ اليوم: قرية Tejares، والحملة على جزيرة Los Gelves، والثور الحجري على جسر Salamanca. كل حاشية تقع في أسفل العمود الذي يحيل إليها، بحروف أصغر تحت خط قصير بلون أحمر داكن، فتنزل العين من الرقم إلى الحاشية وتعود من غير أن تقلب صفحة. وتحمل الطبعة الإنجليزية حواشي Clements Markham نفسه من ترجمته الصادرة سنة 1908.

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

  • كيف أضيف الحواشي السفلية؟

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

script.js · الأسطر 40–52في الكود الكامل
// In the Markdown: `Tejares,[^9]` cites a note (the id may be a word: `[^tejares]`), and
// a paragraph that opens with `[^9]:` anywhere in the chapter defines it. The engine
// numbers the notes in order of citation and keeps each note in the column of the line
// that cites it: a line whose note does not fit under it moves on with the note.
const footnotes = () => ({
  placement: options.placement, // 'column' (the foot of the citing column) or 'chapterEnd'
  numbering: options.numbering, // 'chapter': 1, 2, 3… again under every chapter heading
  fontSize: pt(8.5), // off the baseline grid: the notes stack up from the column's foot
  lineHeight: pt(10.5),
  hangingIndent: em(0.9), // turnover lines align past the number
  spaceBetween: pt(1.5),
  separator: { width: 0.25, lineWidth: pt(0.5), color: col('oxblood') },
});

المكونات

الخطوط
EB Garamond, Bodoni Moda, IBM Plex Sans Condensed (SIL OFL 1.1)
الأصول
لا شيء: كل صورة مرسومة بالكود

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

#1 · أحِل إلى الحاشية في موضعها، وعرّفها في أي مكان

الإعدادات هي الجواب المختصر أعلاه؛ والباقي Markdown. يطبع natives of Tejares,[^9] رقمًا مرتفعًا ملتصقًا بالفاصلة، والفقرة التي تبدأ بـ [^9]: تحمل الحاشية. التعريفات تخرج من مجرى النص حيثما كتبتها، لذلك يجمعها هذا المثال كلها في آخر الفصل، كما كانت حواشي Markham في الملف المصدر. تتبع الأرقام ترتيب الإحالة، فيمكن أن تكون معرّفات Markdown كلمات ([^tejares] في الطبعة الإسبانية)، وإعادة ترقيم الفصل لا تعني أبدًا تعديل الحواشي.

#2 · اترك في العمود مكانًا لحواشيه

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

#3 · أنزل كل صفحة افتتاح إلى السطر نفسه

script.js · الأسطر 56–74في الكود الكامل
const opener = {
  enabled: true,
  // The sink holds a two-line title and its rule, with ~8 mm to the first line of text,
  // so every chapter starts on the same grid line whatever its title's length.
  minHeight: pt(LEAD * 10),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(8.5),
      letterSpacing: pt(1.8), color: col('oxblood'), align: 'center',
      placement: at('container', 'top', 0, 7) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Bodoni Moda', italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('container', 'top-left', 0, 13),
        size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('oxblood'),
      placement: { anchor: { to: '#title', edge: 'below' }, // a rule starts at the box's left
        offset: { x: mm((MEASURE - 14) / 2), y: mm(4.5) }, size: { width: mm(14) } } },
  ] },
};

تمتد صفحة الافتتاح على العمودين وترسم السطر التمهيدي للفصل من السمة kicker في العنوان. يحجز minHeight عشرة أسطر من الشبكة، وهي تكفي لعنوان من سطرين بخط Bodoni مع خطه، فيبدأ نص كل فصل على السطر نفسه ولا يقع الخط أبدًا على السطر الأول من النص.

#4 · ضع اسم الكتاب على الصفحة الزوجية واسم الفصل على الفردية

script.js · الأسطر 78–95في الكود الكامل
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers
  ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted'),
  placement: at('page', edge, x, 13), ...extra,
});
const folio = { color: col('oxblood') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer,
    { ...folio, align: 'left' }),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9, { align: 'left' }),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9),
    { align: 'right', overflow: 'ellipsis', size: { width: mm(120) } }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, align: 'center', placement: at('page', 'bottom', 0, -11) }), pages: 'opener' }] };

الترويسات مثبّتة على الصفحة حسب زوجيتها، ورقم الصفحة على الحافة الخارجية؛ ويُبعدها pages: 'body' عن صفحات الافتتاح، التي تأخذ رقم صفحة سفليًا في الوسط. وعنوان الفصل الطويل ينتهي بنقاط حذف عند 120 mm بدلًا من أن يصطدم برقم الصفحة.

#5 · جرّب المواضع الأخرى

script.js · الأسطر 204–230في الكود الكامل
const CHOICES = {
  columns: [[2, t({ en: 'Two columns', es: 'Dos columnas' })],
    [1, t({ en: 'One column', es: 'Una columna' })]],
  placement: [['column', t({ en: 'Column foot', es: 'Pie de columna' })],
    ['chapterEnd', t({ en: 'End of chapter', es: 'Final del capítulo' })]],
  numbering: [['chapter', t({ en: 'Per chapter', es: 'Por capítulo' })],
    ['document', t({ en: 'Continuous', es: 'Continua' })]],
};
const bar = document.createElement('form');
bar.id = 'options';
bar.ariaLabel = t({ en: 'Footnote options', es: 'Opciones de las notas' });
for (const [key, choices] of Object.entries(CHOICES)) {
  const group = bar.appendChild(document.createElement('fieldset'));
  for (const [value, text] of choices) {
    const input = Object.assign(document.createElement('input'), { type: 'radio', name: key,
      value: String(value), checked: options[key] === value });
    const item = group.appendChild(document.createElement('label'));
    item.append(input, text);
  }
}
bar.addEventListener('change', (event) => {
  const { name, value } = event.target;
  options[name] = name === 'columns' ? Number(value) : value;
  doc = buildDocument({ markdown }, config()); // the fonts are loaded by now
  showPages(doc, { title: TITLE });
});
document.getElementById('pages').before(bar);

كل مفتاح يبني الفصل من جديد مع تغيير خيار واحد. One column (عمود واحد) يضع الحواشي في أسفل الصفحة، وهي العمود في هذه الحالة. End of chapter (آخر الفصل) يضعها بعد آخر فقرة في الفصل وينزلها إلى أسفل العمود الذي تختمه، تحت الخط نفسه. Continuous (متصل) يواصل الأرقام من المقدمة إلى الفصل الأول بدلًا من أن يبدأ من جديد من 1.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 071 · Footnotes at the foot of the column ══════════════
// https://postext.dev/en/cookbook/novel-footnotes-column-foot
// Code: MIT · Text: Lazarillo de Tormes (1554) and Markham's 1908 translation (PD)
// Fonts: EB Garamond, Bodoni Moda, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.6.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'novel-footnotes-column-foot';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: oxblood for the kickers, the folios and the notes' rule; ink on warm paper
const palette = {
  ink: '#241e1a', // text: a warm near-black
  oxblood: '#7d2b1f', // the one accent
  muted: '#6f655b', // running heads, colophon
  rule: '#c9bdae', // hairlines
  paper: '#fbf8f2',
};
// A design element paints the hex written beside its paletteId (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' } })),
  // The engine's defaults link to main-color: point it at the accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
// #endregion
const TRIM = { width: 210, height: 280 }; // a school edition: two 82 mm columns
const MARGIN = { top: 23, bottom: 22, inner: 21, outer: 17 }; // mirrored
const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 172 mm
const LEAD = 13.5; // body leading in pt: the baseline grid
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const label = { fontFamily: 'IBM Plex Sans Condensed', fontWeight: 600,
  textTransform: 'uppercase' };

// The switches under the title change these and build again (#region live).
const options = { columns: 2, placement: 'column', numbering: 'chapter' };

// #region answer: the notes at the foot of the column that cites them
// In the Markdown: `Tejares,[^9]` cites a note (the id may be a word: `[^tejares]`), and
// a paragraph that opens with `[^9]:` anywhere in the chapter defines it. The engine
// numbers the notes in order of citation and keeps each note in the column of the line
// that cites it: a line whose note does not fit under it moves on with the note.
const footnotes = () => ({
  placement: options.placement, // 'column' (the foot of the citing column) or 'chapterEnd'
  numbering: options.numbering, // 'chapter': 1, 2, 3… again under every chapter heading
  fontSize: pt(8.5), // off the baseline grid: the notes stack up from the column's foot
  lineHeight: pt(10.5),
  hangingIndent: em(0.9), // turnover lines align past the number
  spaceBetween: pt(1.5),
  separator: { width: 0.25, lineWidth: pt(0.5), color: col('oxblood') },
});
// #endregion

// #region opener: kicker, a Bodoni title and a short rule, centred over both columns
const opener = {
  enabled: true,
  // The sink holds a two-line title and its rule, with ~8 mm to the first line of text,
  // so every chapter starts on the same grid line whatever its title's length.
  minHeight: pt(LEAD * 10),
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(8.5),
      letterSpacing: pt(1.8), color: col('oxblood'), align: 'center',
      placement: at('container', 'top', 0, 7) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Bodoni Moda', italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('container', 'top-left', 0, 13),
        size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('oxblood'),
      placement: { anchor: { to: '#title', edge: 'below' }, // a rule starts at the box's left
        offset: { x: mm((MEASURE - 14) / 2), y: mm(4.5) }, size: { width: mm(14) } } },
  ] },
};
// #endregion

// #region running-heads: book title on the verso, chapter on the recto, folios outside
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers
  ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted'),
  placement: at('page', edge, x, 13), ...extra,
});
const folio = { color: col('oxblood') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer,
    { ...folio, align: 'left' }),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9, { align: 'left' }),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9),
    { align: 'right', overflow: 'ellipsis', size: { width: mm(120) } }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, align: 'center', placement: at('page', 'bottom', 0, -11) }), pages: 'opener' }] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: options.columns === 2
    ? { layoutType: 'double', gutterWidth: mm(7) }
    : { layoutType: 'single' },
  bodyText: { fontFamily: 'EB Garamond', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: 'EB Garamond', color: col('ink'), levels: [
    // Restated on purpose: any headings object drops the H1 break
    // (gotcha: headings-drop-h1-break). 'any': the treatise opens on the next page.
    { level: 1, span: 'page', advancedDesign: opener, marginBottom: pt(0),
      breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), italic: true, fontWeight: 400,
      color: col('oxblood'), marginTop: pt(LEAD), marginBottom: pt(0) },
  ] },
  paragraphStyles: [
    { id: 'colophon', ...label, textTransform: 'none', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  footnotes: footnotes(),
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 54 · content.en.mdtitle: "Lazarillo de Tormes" --- # Prologue I hold it to be good that such remarkable things as have happened to me, perhaps never before seen or heard of, should not be buried in the tomb of oblivion. It may be that some one who reads may find something that pleases him. For those who do not go very deep into the matter there is a saying of Pliny “*that there is no book so bad that it does not contain something that is good*.”[^8] Moreover, all tastes are not the same, and what one does not eat another will. Thus we see things that are thought much of by some, depreciated by others. Hence no circumstance ought to be omitted, how insignificant soever it may be, but all should be made known, especially as some fruit might be plucked from such a tree. If this were not so, very few would write at all, for it cannot be done without hard work. Authors do not wish to be recompensed with money, but by seeing that their work is known and read, and, if it contains anything that is worthy, that it is praised. On this point Tully says: “Honour creates the arts.” Think you that the soldier who is first on the ladder cares less for his life than the others? Certainly not. It is the desire for fame that leads him to seek such danger. It is the same in the arts and in letters. We say: “The Doctor preaches very well and he is one who desires much the welfare of souls,” but ask him whether he is much offended when they say, “How wonderfully your reverence has done it!” So also in arms, men report how such an one has jousted wretchedly, and he has given his arms to a jester because he praised him for using his lances so well. What would he have given if he had been told the truth? Now that all things go in this manner, I confess that I am not more righteous than my neighbours. I write in this rough style, and all who may find any pleasure in it will be satisfied to know that there lives a man who has met with such fortunes, encountered such dangers, and suffered such adversities. I beseech your Honour that you will accept the poor service of one who would be richer if his power was equal to his desire. Well, your Honour! This author writes what he writes, and relates his story very fully. It seemed to him that he should not begin in the middle, but quite at the beginning, so that there might be a full notice of his personality, and also that those who inherit noble estates may consider how little fortune owes them, having been so very partial to them in its gifts; and how much more those have done who, not being so favoured, have, by force and management, arrived at a good estate. # Lazaro relates the way of his birth and tells whose son he is {kicker="Chapter I"} Well! your Honour must know, before anything else, that they call me Lazarillo de Tormes, and that I am the son of Thomé Gonçales and Antonia Perez, natives of Tejares,[^9] a village near Salamanca. My birth was in the river Tormes,[^10] for which reason I have the river for a surname, and it was in this manner. My father, whom God pardon, had charge of a flour mill which was on the banks of that river. He was the miller there for over fifteen years, and my mother, being one night taken with me in the mill, she gave birth to me there. So that I may say with truth that I was born in the river. When I was a child of eight years old, they accused my father of certain misdeeds done to the sacks of those who came to have their corn ground. He was taken into custody, and confessed and denied not, suffering persecution for justice’s sake. So I trust in God that he is in glory, for the Evangelist tells us that such are blessed. At that time there was a certain expedition against the Moors[^11] and among the adventurers was my father, who was banished for the affair already mentioned. He went in the position of attendant on a knight who also went, and, with his master, like a loyal servant, he ended his life. My widowed mother, finding herself without husband or home, determined to betake herself to the good things so as to be among them; so she went to live in the city. She hired a small house, and was employed to prepare victuals for certain students. She also washed the clothes of the stable-boys who had charge of the horses of the Comendador de la Magdalena.[^12] Thus she frequented the stables, she and a dark-coloured man, who was one of those who had the care of the horses. They came to know each other. Sometimes he came to our house late, and went away in the morning. At other times he came to the door in the day-time, with the excuse that he wanted to buy eggs, and walked into the house. At first I did not like him, for I was afraid of his colour and his ugly face. But when I saw that his coming was the sign of better living, I began to like him, for he always brought pieces of meat, bread, and in the winter, fuel to warm us. This intercourse went on until one day my mother gave me a pretty little brown brother, whom I played with and helped to keep warm. I remember once that when my stepfather was fondling the child, it noticed that my mother and I were white, and that he was not. It frightened the child, who ran to my mother, pointing with its finger and saying, “Mother, he is ugly!” To this he replied laughing; but I noticed the words of my little brother, and, though so young, I said to myself, “How many there are in the world who run from others because they do not see themselves in them.” It was our fate that the intimacy of the Zayde, for so they called my stepfather, came to the ears of the steward. On looking into the matter he found that half the corn he gave out for the horses was stolen, also that the fuel, aprons, pillions, horse-cloths, and blankets were missing, and that when nothing else was left, the horse-shoes were taken. With all this my mother was helped to bring up the child. We need not wonder at a priest or a friar, when one robs the poor, and the other his female devotees to help a friend such as himself, when the love of a poor stable-lad brings him to this. All I have related was proved, because they cross-questioned me with threats, and being a child I answered and let out all I knew from fear, down to certain horse-shoes which, by my mother’s order, I sold to a blacksmith. They flogged my unhappy stepfather, and put my mother on the accustomed penance as a punishment. An order was given that she was not to enter the stables of the Comendador, nor to receive the flogged Zayde in her house. The poor woman complied with the sentence that she might not lose all; and to avoid danger and silence evil tongues she went away into service. She was employed in the open gallery of an inn, and so she contrived to rear the little brother, though suffering from many difficulties. She raised him until he could walk, and me until I was a fine little boy, who went for wine and lights for the guests, and for anything else they wanted. ## How Lazaro took service with a blind man At that time a blind man came to lodge at the inn, who, seeing that I would do to lead him, asked for me from my mother. She gave me to him, saying that I was the son of a good father, and boasting that he had been killed at the Island of Gelves. She told the blind man that she trusted in God that I would not turn out a worse man than my father, and she begged him to treat me well and look after me, as I was an orphan. He answered that he would do so, and that he received me not as his servant but as his son. Thus it was that I began to serve and to lead my new master. We were in Salamanca for some days, but, as the earnings were not to my master’s liking, he determined to go somewhere else. When we were about to depart, I went to see my mother, and, both weeping, she gave me her blessing and said, “I shall see you no more. Strive to be good, and may God direct your ways. You have been brought up, and are now put with a good master. Farewell!” And so I went away to my master who was waiting for me. We went out of Salamanca and came to the bridge. There is, at the entrance of it, an animal of stone[^14] which almost has the shape of a bull. The blind man told me to go near this animal, and, being there, he said, “Lazaro, put your ear against this bull, and you will hear a great noise inside.” I did so, like a simpleton, believing it to be as he said. When he felt that my head was against the stone, he raised his hand and gave me a tremendous blow against the devil of a bull, so that I felt the pain for more than three days. Then he said to me, “This will teach you that a blind man’s boy ought to be one point more knowing than the devil himself”; and he laughed heartily at his joke. It seemed to me that, in an instant, I awoke from my simplicity in which I had reposed from childhood. I said to myself, “This man says truly that it behoves me to keep my eyes open, for I am alone and have to think for myself.” We set out on our road, and in a very few days I showed myself to be sprightly, which pleased the blind man, and he said, “I can give you neither gold nor silver, but I can teach you much in the ways of getting a livelihood.” It was so that, after a few days, he showed me many things, and being blind himself, he enlightened and guided me in the ways of life. I mention these trifles to your Honour to show how much knowledge men must have when they are down, and to keep from falling when they are exalted. :::paragraphs{style="colophon"} Set in EB Garamond, Bodoni Moda and IBM Plex Sans Condensed (SIL OFL) · Text: *The Life of Lazarillo de Tormes*, translated by Clements Markham (1908), with his notes; the prologue and the first chapter, abridged (public domain) ::: [^8]: Cervantes knew his *Lazarillo* well. He copies this quotation and puts it into the mouth of the curate when he was examining the books of Don Quixote. [^9]: Tejares is a small village on the left bank of the river Tormes, about two miles from Salamanca. It consists of a church dedicated to San Pedro, and about fifty houses on the skirts of a hill. [^10]: The river Tormes rises in the Sierra de Gredos, a range of hills dividing Estremadura from Old Castille, on the confines of the province of Avila. Its chief sources are a large sheet of water called the “Laguna de Gredos,” and a perennial stream called “Tornella.” Receiving several streams from the Gredos hills, the Tormes flows north, passing by Alba de Tormes, where there is a stone bridge; and then turns north-west, passing Salamanca, where there is another fine stone bridge, and Ledesma. Finally, it falls into the Douro, on the Portuguese frontier. The Tormes turns many flour mills. [^11]: This expedition against the Moors started from Malaga under the command of Don Garcia de Toledo in 1510, when Lazarillo was seven years old. The fleet first touched at Sicily and then made for the island of Los Gelves, off the African coast, between Tunis and Tripoli, now called Zerbi. With Toledo were Diego de Vera and Count Pedro Navarro. Zerbi was a low sandy island covered with palm-trees, ruled by a Sheikh of its own. The army landed on the 8th of August 1510. But the Spaniards fell into an ambuscade and were defeated, Toledo being among the slain. Four thousand were killed or taken prisoners. The rest escaped to the ships and returned to Sicily. Toledo was a grandson of the first Duke of Alva. [^12]: Comendadores were knights of the Orders of Santiago, Calatrava, and Alcantara. Each had a title affixed to their knighthood. The Comendador of La Magdalena was a knight of the Order of Alcantara. [^14]: There was a huge mass of granite rudely carved in the shape of an animal, which had been on the bridge from time immemorial; and which Lazarillo thought was like a bull. Its great weight was considered a danger, and it was removed about thirty years ago. It is now in the vestibule of the cloister of San Domingo at Salamanca, but without a head.
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { 'EB Garamond': ['400', '400i', '600', '600i', '700'], 'Bodoni Moda': ['400i'], 'IBM Plex Sans Condensed': ['600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const TITLE = t({ en: 'Footnotes at the foot of the column', es: 'Notas al pie de columna' }); let doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showPages(doc, { title: TITLE }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`); // #region live: three switches, each a fresh build of the chapter with the other option const CHOICES = { columns: [[2, t({ en: 'Two columns', es: 'Dos columnas' })], [1, t({ en: 'One column', es: 'Una columna' })]], placement: [['column', t({ en: 'Column foot', es: 'Pie de columna' })], ['chapterEnd', t({ en: 'End of chapter', es: 'Final del capítulo' })]], numbering: [['chapter', t({ en: 'Per chapter', es: 'Por capítulo' })], ['document', t({ en: 'Continuous', es: 'Continua' })]], }; const bar = document.createElement('form'); bar.id = 'options'; bar.ariaLabel = t({ en: 'Footnote options', es: 'Opciones de las notas' }); for (const [key, choices] of Object.entries(CHOICES)) { const group = bar.appendChild(document.createElement('fieldset')); for (const [value, text] of choices) { const input = Object.assign(document.createElement('input'), { type: 'radio', name: key, value: String(value), checked: options[key] === value }); const item = group.appendChild(document.createElement('label')); item.append(input, text); } } bar.addEventListener('change', (event) => { const { name, value } = event.target; options[name] = name === 'columns' ? Number(value) : value; doc = buildDocument({ markdown }, config()); // the fonts are loaded by now showPages(doc, { title: TITLE }); }); document.getElementById('pages').before(bar); // #endregion
العُدّة · core, fonts, viewer, pdf: نفسها في كل وصفة · أسطر: 275// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)

تنويعات

#أبقِ تعليقات آخر الفصل تحت النص

مع placement: 'chapterEnd' تقف الحواشي افتراضيًا في أسفل العمود الأخير؛ ويضعها chapterEndAlign: 'text' تحت الفقرة الأخيرة مباشرة.

   placement: options.placement,
+  chapterEndAlign: 'text',

#احذف الخط

من غير الخط تبقى الحواشي منفصلة عن النص بمقدار spaceAbove وspaceBelowRule.

-  separator: { width: 0.25, lineWidth: pt(0.5), color: col('oxblood') },
+  separator: { enabled: false },

أخطاء شائعة

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

تحجز الافتتاحية ارتفاعًا يصل إلى أدنى عناصرها المثبّتة على الصفحة

تحجز الافتتاحية ذات التصميم المتقدّم ارتفاع أدنى عناصرها، وتُحتسب كذلك العناصر المثبّتة على الصفحة أو على النزف تحت العنوان، فالزخرفة في أسفل الصفحة تدفع النص إلى الصفحة التالية. أبقِ هذه الزخرفة فوق العنوان، أو انقلها إلى خانة في الترويسة أو التذييل، أو حدّد الحجز بـ minHeight. صفحات افتتاح مصمَّمة →

تحذير الإخراج · undefinedFootnote

حاشية سفلية بلا تعريف

السبب. علامة [^id] تستشهد بحاشية لا تعرّفها أي فقرة [^id]:، فيُطبع الرقم فوق حاشية فارغة.

الحل. أضف فقرة [^id]: فيها نص الحاشية، أو صحّح معرّف العلامة. التوثيق →

تحذير الإخراج · unusedFootnote

حاشية سفلية لا يُستشهد بها

السبب. تعريف [^id]: ليس له علامة في النص، فلا تُنضَّد الحاشية.

الحل. استشهد بها بـ [^id] في موضعها، أو احذف التعريف. التوثيق →

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

الحقوق

الوصفة
Ignacio Ferro
النص
  • La vida de Lazarillo de Tormes, Burgos edition of 1554: the prologue and the beginning of the first treatise · Anonymous · ملك عام
  • The Life of Lazarillo de Tormes, translated by Clements Markham (1908), with his notes: the prologue and the first chapter, abridged · Clements Markham · ملك عام
  • The Spanish notes, adapted from Markham's and written for this edition · Ignacio Ferro · CC BY 4.0
الخطوط
EB Garamond (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
SandboxPDF