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

دليل الوصفات · الفصل 5 · بنية الكتاب

ملاحق الأطروحة: ملحق ومسرد وفهرس أبجدي

الصفحات الأخيرة من أطروحة بالأبيض والأسود: ملحق بحرف، ومسرد في عمودين، ومراجع بأسلوب APA، وفهرس أبجدي يُبنى من علامات في النص.

في هذه الصفحة

ص 174–175 · 4–5 من 7

  • نموذج باللغة الإنجليزية: لا توجد طبعة عربية بعد
  • مقاس القص 176 × 250 مم
  • عمود واحد
  • Libertinus Serif 11/14.6
  • Libertinus Sans
  • Libertinus Serif Display
  • 7 صفحات
  • المستوى
  • Postext 1.7.0
  • أُخرجت في 63 ms
  • أسطر الكود: 181

باختصار

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

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

الصفحات السبع الأخيرة من أطروحة دكتوراه عن القراءة من الشاشات والورق، منضَّدة على صفحة B5 بالأبيض والأسود. يفتح كلٌّ من الفصل 6 والملحق A والمسرد والمراجع والفهرس الأبجدي تحت الشريط الأسود نفسه، بعمق 62 مم، والعنوان مطبوع بالأبيض داخله. يُظهر الفصل رقمه في الشريط والملحق حرفه؛ وتحمل أقسام المادة الختامية العبارة التمهيدية BACK MATTER وملاحظة قصيرة بالمائل. يجري الفصل في عمود واحد مضبوط. ينتقل المسرد والفهرس إلى عمودين غير مضبوطين وتعود المراجع إلى عمود واحد، وتُزاح الأسطر المرحَّلة لكل مدخل. يأتي الفهرس من علامات في النص: يرتّب المحرّك المصطلحات تحت حروفها، ويجد صفحة كل علامة، ويضم الصفحات المتتالية في نطاقات مثل 171–73. وتشير الأرقام الغامقة إلى تعريفات المسرد.

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

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

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

script.js · الأسطر 27–50في الكود الكامل
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
  backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
    header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
  backMatter('glossary', { layout: twoColumns }),
  backMatter('references'),
  backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation). The index has its own settings.
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
  entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
  entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
];

المكونات

الخطوط
Libertinus Serif, Libertinus Serif Display, Libertinus Sans (SIL OFL 1.1)
الأصول
لا شيء: كل صورة مرسومة بالكود

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

#1 · أعطِ كل جزء من المادة الختامية نمط عنوان

الشيفرة هي الجواب المختصر أعلاه. يفتح # Glossary {style="glossary" note="…"} قسمًا يمتد حتى العنوان التالي من المستوى الأول، وتأخذ صفحاته تخطيط النمط وترويساته وصفحة افتتاحه (أنماط العناوين)، فينتقل المسرد والفهرس إلى عمودين بينما تعود المراجع، التي لا يضبط نمطها تخطيطًا، إلى عمود المستند الوحيد. يُبقي numbered: false هذه العناوين خارج عدّ الفصول (احذفه فيطبع شريط المسرد 8)، ويسمّي كل نمط قفزة صفحته، لأن النمط الذي لا يسمّي قفزة يرث 'odd' من الفصل ويترك صفحات زوجية فارغة. مداخل المسرد والمراجع فقرات في كتلة :::paragraphs{style="…"} بحجم 9.3 pt على 12.4 pt، مقابل 11 على 14.6 للنص، وأسطرها المرحَّلة تتدلّى بمقدار 1 em في المسرد و1.5 em في المراجع (أنماط الفقرات).

#2 · ارسم شريطًا واحدًا لكل صفحات الافتتاح

script.js · الأسطر 54–78في الكود الكامل
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
  color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
  verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
    size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
      anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
      { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
    text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
    text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
    text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
  ] } });

العلامة هي {number}، الفارغة في العنوان غير المرقّم، فيخدم تصميم واحد الفصلَ، والملحقَ الذي يمرّر {attr.letter} علامةً له، وأقسامَ المادة الختامية التي تطبع {attr.note} حيث كان الرقم سيقف. يحجز minHeight ثمانية أسطر ارتفاع كل منها 14.6 pt، أي 41.2 مم من أعلى كتلة النص، فيتجاوز الشريط بـ3.2 مم ويُبقي النص على شبكته. يضع نص التصميم خط أساسه عند 0.8 من السطر، فالصندوق المحاذى إلى الأسفل الذي تقع قاعدته below() تحت خط أساس يضع سطره الأخير على ذلك الخط. يقف العنوان والرقم بحجم 118 pt والسطر الأخير من الملاحظة كلها على خط أساس العنوان، على بعد 52.5 مم تحت حدّ القص. سطر الرقم 0.72 من حجمه، لأن السطر الأطول من صندوقه في الإصدار 1.4.1 يتجاهل verticalAlign: 'bottom'.

#3 · ضع حرف الملحق بيدك

script.js · الأسطر 108–112في الكود الكامل
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
  id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'

في postext 1.4.1 لا يستطيع نمط العنوان تغيير ترقيم مستواه، لذا يكون الملحق غير مرقّم ويحمل # Interview guide {style="appendix" letter="A"} الحرف. تغذّي الخاصية الشريطَ وترويسة الملحق، ويرقّم نوع مورد خاص به جداوله A.1 وA.2 وهكذا؛ ومع النوع الافتراضي يكون الجدول Table 6.2، لأن العنوان غير المرقّم يترك عدّاد الفصول عند 6. أما عناوين الفصل نفسه فتعدّ من قوالب المستويات: يطبع '{1}.{2}' الرقم 6.1، ويجعل continuation.headings.h1: 5 هذا الفصل السادس.

#4 · ضع الترويسات على الحافة الخارجية

script.js · الأسطر 82–104في الكود الكامل
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
    anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
  head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
  head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
  // The header's container spans the text block, so one rule serves both pages.
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };

عناصر النص الأربعة مثبَّتة إلى الصفحة ومصفّاة بـparity، الذي يُبقي رقم الصفحة على الحافة الخارجية للصفحتين؛ ويوضع كل منها من أعلاه، above() فوق خط أساس على بعد 17.5 مم تحت حدّ القص، فيقف رقم الصفحة بحجم 9.5 pt والحروف الكبيرة بحجم 7.5 pt على سطر واحد. تحمل الصفحة الزوجية عنوان الأطروحة ({title}، من البيانات التمهيدية) والصفحة الفردية عنوان القسم ({chapterTitle})، مثل INDEX في الصفحة 177. يُبعدها pages: 'body' عن صفحات الافتتاح، التي لا رقم صفحة لها إلا رقم التذييل، متوسَّطًا تحت كتلة النص. كانت الصفحة الفردية للفصل ستقرأ Chapter 6. Conclusion وصفحة الملحق Appendix A. Interview guide، لكن في هذه العيّنة لا يمتد إلى صفحة فردية إلا الفهرس.

#5 · علِّم المصطلحات حيث يناقشها النص

script.js · الأسطر 116–122في الكود الكامل
// Glossary definitions carry 'main' (bold numbers); runs of pages join as 171–72 (Chicago).
// The heads stand on the entries' 11.6 pt pitch, 7.5 pt of space above them: 19 pt from the
// last entry of a letter to the next letter's baseline, 11.6 pt from a letter to its first entry.
const index = { fontFamily: TEXT, fontSize: pt(9), lineHeight: pt(11.6), color: col('ink'),
  indent: em(1), turnoverIndent: em(2), rangeFormat: 'chicago',
  groups: { fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 400, color: col('ink'),
    marginTop: pt(7.5) } };

يطبع :index[tablet] كلمته ويُدرجها تحت الكلمة نفسها؛ ولا يطبع Rayner:index{term="Rayner, Keith"} شيئًا ويُدرج صفحة Rayner تحت الاسم الكامل؛ ويصنع term="interviews!timing of" مدخلًا فرعيًا. تحمل مصطلحات المسرد main، فتُطبع صفحتها بالغامق، كما تقول الملاحظة في شريط الفهرس. يطبع :::index تحت # Index {style="index"} المداخل في عمودي النمط، ويعيد buildDocument إخراج المستند حتى تكفّ أرقام الصفحات عن التحرك. المداخل بحجم 9 على 11.6 pt، وأسطرها المرحَّلة تتدلّى 2 em والمداخل الفرعية مزاحة 1 em؛ والحروف بحجم 13 pt بخط العرض. لا تعمل العلامات في خلايا الجداول، فلا تضيف صفوف الجدول 6.1 صفحات: يحيل مدخل look-backs إلى الفقرة التي تتناولها، لا إلى الجدول.

#6 · أبقِ كل القيم الافتراضية بالحبر

script.js · الأسطر 15–19في الكود الكامل
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

تتبع القيم الافتراضية للمحرّك في الغامق والمائل وعلامات القوائم مدخل لوحة الألوان main-color، فتوجيهه إلى الحبر يطبعها بالأسود بدل الأزرق (لوحة الألوان). لا تبلغ لوحة الألوان bodyText.referenceColor في الإصدار 1.4.1، لذا تعيد الإعدادات ذكره: من دون ذلك السطر تُطبع Table 6.1 في النص بالأزرق #295AA3. ويضع referenceBold: false الإحالة بالخط القائم، مثل استشهادات المؤلف–السنة المحيطة بها.

الوصفة كاملة

Sandbox
// ═══ Postext Cookbook · Nº 031 · Thesis back matter: appendix, glossary and index ═══
// https://postext.dev/en/cookbook/thesis-back-matter
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Libertinus Serif, Serif Display and Sans (SIL OFL 1.1) · Needs postext ≥ 1.7.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes,
} 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')
const RECIPE = 'thesis-back-matter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one ink; every colour is black or white, each under its own name
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Libertinus Serif', DISPLAY = 'Libertinus Serif Display', LABEL = 'Libertinus Sans';
const TOP = 24, INNER = 25, OUTER = 31; // mm: a 120 mm measure, about 70 characters at 11 pt
const LEAD = 14.6; // pt: the body's leading, the grid every page is set on
const BAND = 62; // mm from the trim's top: the black band at the head of every opener

// #region answer: back matter as unnumbered heading styles, entries in hanging indents
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
  backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
    header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
  backMatter('glossary', { layout: twoColumns }),
  backMatter('references'),
  backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation). The index has its own settings.
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
  lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
  entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
  entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
];
// #endregion

// #region opener: a black band across the head of the page, the title reversed out of it
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
  color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
  verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
    size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
      anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
    text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
      { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
    text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
    text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
    text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
  ] } });
// #endregion

// #region running-heads: the thesis on the verso, the section on the recto, a hairline under
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
  parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
    anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
  head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
  head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
  // The header's container spans the text block, so one rule serves both pages.
  { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
      offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };
// #endregion

// #region appendix: the letter comes from the heading, '# Interview guide {letter="A"}'
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
  id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'
// #endregion

// #region index: the pages of the :index marks, sorted under letters in the display face
// Glossary definitions carry 'main' (bold numbers); runs of pages join as 171–72 (Chicago).
// The heads stand on the entries' 11.6 pt pitch, 7.5 pt of space above them: 19 pt from the
// last entry of a letter to the next letter's baseline, 11.6 pt from a letter to its first entry.
const index = { fontFamily: TEXT, fontSize: pt(9), lineHeight: pt(11.6), color: col('ink'),
  indent: em(1), turnoverIndent: em(2), rangeFormat: 'chicago',
  groups: { fontFamily: DISPLAY, fontSize: pt(13), fontWeight: 400, color: col('ink'),
    marginTop: pt(7.5) } };
// #endregion

const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity)
  colorPalette, header: chapterHeads, footer, layout: { layoutType: 'single' },
  page: { sizePreset: 'custom', width: mm(176), height: mm(250), dpi: 150, // B5
    margins: { top: mm(TOP), bottom: mm(24), left: mm(INNER), right: mm(OUTER), mirror: true } },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
    // 'Table 6.1' in roman and in ink, outside the palette's reach (gotcha: palette-skips-designs)
    referenceColor: col('ink'), referenceBold: false,
    firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.8 },
  // Exact heading margins (snapToGrid: false), no lines added above them; the chapter's heads
  // measure whole grid lines.
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), snapToGrid: false,
    balancing: { maxLinesPerHeading: 0 }, levels: [
      // The H1 break restated (gotcha: headings-drop-h1-break). span: 'page' (the styles inherit
      // it) sets the band above the columns: inside a column, its top would be clipped.
      { level: 1, numberingTemplate: '{1}', span: 'page', marginBottom: pt(0),
        advancedDesign: opener('Chapter'), breakBefore: { enabled: true, parity: 'odd' } },
      { level: 2, numberingTemplate: '{1}.{2}', fontSize: pt(14), lineHeight: pt(LEAD),
        marginTop: pt(LEAD * 1.5), marginBottom: pt(LEAD / 2) }, // three lines in all
    ] },
  headingStyles: headingStyles(), paragraphStyles: paragraphStyles(), index,
  orderedLists: { marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) },
  unorderedLists: { bulletChar: '–' },
  resourceTypes: [...defaultResourceTypes(LANG), appendixTables], // tables 6.1… and A.1…
  // Captions in the text face, as APA sets a table's number and title.
  captionStyle: { fontSize: pt(9), position: 'above', gap: pt(4), note: { fontSize: pt(8) } },
  // Rules only and a bold header: filled header cells show seams between the columns.
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackgroundEnabled: false, headerFontSize: pt(9.5),
    bodyFontSize: pt(9.5), cellPadding: mm(1) },
  calloutStyles: [{ id: 'colophon', span: 'page', marginTop: pt(LEAD), backgroundEnabled: false,
    stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') },
    padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) },
    body: { fontSize: pt(8), lineHeight: pt(10.5), firstLineIndent: pt(0), textAlign: 'left' } }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
نموذج Markdown · أسطر: 126 · content.en.mdtitle: "Reading on Screens and Paper" subtitle: "A Mixed-Methods Study of Comprehension, Confidence and Navigation" author: "Ines Varley" --- # Conclusion This thesis set out to test whether it matters if a long text:index{term="texts, length of"} is read on paper or on a screen. Chapters 3 to 5 reported a within-subjects:index{term="within-subjects design"} experiment with forty-eight :index[undergraduates] and :index[interviews] with sixteen of them, combined in the :index[convergent design] described by Creswell:index{term="Creswell, John W."} and Plano Clark:index{term="Plano Clark, Vicki L."} (2018). This chapter brings the two strands together and sets out what they mean for :index[teaching] and for the :index[design of reading software]. ## What the study found :ref{id="findings" style="full"} summarises the results.:index{term="comprehension"} On :index[literal]{term="comprehension!literal"} questions, answerable from a single sentence, the medium made no difference. On :index[inferential]{term="comprehension!inferential"} questions, which required connecting ideas across paragraphs, paper readers scored higher. The difference points the same way as the :index[meta-analyses] of Delgado:index{term="Delgado, Pablo"} et al. (2018) and Clinton:index{term="Clinton, Virginia"} (2019), which found the :index[paper advantage] in :index[expository]{term="expository text"} rather than :index[narrative]{term="narrative text"} texts. Calibration:index{term="calibration"} showed the larger difference. Screen readers:index{term="calibration!on screen"} predicted:index{term="confidence!judgements of"} higher scores than paper readers and obtained lower ones, so the gap between :index[confidence] and :index[accuracy]:index{term="calibration!bias in"} was nearly three times as wide. The result repeats the :index[overconfidence] that Ackerman:index{term="Ackerman, Rakefet"} and Goldsmith:index{term="Goldsmith, Morris"} (2011) found in students who read on screen and set their own study time.:index{term="self-regulated study"} Such readers stop once they judge a text understood, so overconfidence cuts their study short. Paper readers also turned back:index{term="look-backs"} almost twice as often as screen readers scrolled back:index{term="scrolling"}, most often just before an inferential question.:index{term="comprehension!inferential"} In the interviews:index{term="interviews"}, eleven of the sixteen :index[participants] remembered:index{term="memory"} where on a page:index{term="spatial memory"} an idea had been (“top left, next to the diagram”), and three gave up looking for a passage on the :index[tablet] because “it could have been anywhere”. Liu:index{term="Liu, Ziming"} (2005) described a drift towards :index[browsing] and :index[keyword spotting] on screen; these readers went through the whole text but had fewer :index[landmarks] to return to. ## Implications for teaching and design For short texts and factual questions, screens serve as well as paper.:index{term="teaching"} For long expository:index{term="expository text"} texts that students must understand:index{term="comprehension"} rather than search, paper remains the safer choice.:index{term="paper advantage"} Where it is not available, students should test their understanding instead of trusting their sense of it: in the :index[pilot sessions], a short :index[self-test] after reading halved the overconfidence:index{term="overconfidence"} on screen.:index{term="calibration!on screen"} Readers also used the fixed position of text on a page as a map,:index{term="landmarks"} one of the uses of paper that Sellen:index{term="Sellen, Abigail J."} and Harper:index{term="Harper, Richard H. R."} (2002) observed in offices.:index{term="offices, paper in"} Reading applications:index{term="design of reading software"} that keep a stable page and show the reader’s place in the whole text may restore some of that map. ## Limitations and further work The participants:index{term="participants"} were students at one university:index{term="university, single"} who read English fluently,:index{term="limitations"} and the medium matters more for some readers, texts and tasks than it does for others (Singer:index{term="Singer, Lauren M."} & Alexander:index{term="Alexander, Patricia A."}, 2017). The texts were expository and about 1,800 words long,:index{term="texts, length of"} and the screen condition used a single tablet.:index{term="tablet"} A :index[replication] with a larger sample, several devices and the eye-movement:index{term="eye movements"} recording reviewed by Rayner:index{term="Rayner, Keith"} (1998) would show where on the page the two media part company. Huey:index{term="Huey, Edmund Burke"} (1908) thought that a complete analysis of what we do when we read would be almost the acme of a psychologist’s achievements. The experiments reported here add a small part to that analysis; the replication proposed above could measure how far readers rely on the position of a passage on the page when they look back. # Interview guide {style="appendix" letter="A"} The interviews:index{term="interviews"} took place within a week of each participant’s:index{term="participants"} second session. They were audio-recorded,:index{term="interviews!recording of"} transcribed:index{term="transcription"} in full and analysed thematically:index{term="thematic analysis"} following Braun:index{term="Braun, Virginia"} and Clarke:index{term="Clarke, Victoria"} (2006); :ref{id="session-plan" style="full"} gives their timing:index{term="interviews!timing of"}: a free recall:index{term="recall"} of the two study texts, the questions below and a short debriefing.:index{term="debriefing"} The questions were asked in this order,:index{term="interviews!questions asked"} and a prompt:index{term="interviews!prompts in"} only when the participant had not already covered its point. 1. Tell me about the last long text:index{term="texts, length of"} you read for a course.:index{term="courses, reading for"} - Where did you read it, and on paper or on a screen? 2. Which of the texts in this study do you remember:index{term="memory"} best, and why? 3. When you wanted to check an earlier passage, what did you do?:index{term="look-backs"} - How did you know where to look? 4. How sure were you of your answers?:index{term="confidence"} What made you more or less sure? 5. Did reading on the tablet:index{term="tablet"} feel different from reading on paper? 6. Some students say they read more carefully on paper. Do you? 7. What would the ideal way to read a long text for study be like?:index{term="design of reading software"} # Glossary {style="glossary" note="Words in italics are defined under entries of their own."} :::paragraphs{style="term"} **calibration**:index{term="calibration" main} The agreement between a reader’s confidence in having understood a text and the accuracy of that understanding, measured here as the difference between predicted and actual scores. **comprehension, inferential**:index{term="comprehension!inferential" main} Understanding that requires the reader to connect information from different parts of a text or to add knowledge the text does not state. **comprehension, literal**:index{term="comprehension!literal" main} Understanding of what a single sentence or passage states directly. **confidence judgement**:index{term="confidence!judgements of" main} A reader’s estimate, made after reading and before seeing the questions, of how many answers will be correct. **convergent design**:index{term="convergent design" main} A mixed-methods design in which quantitative and qualitative data are collected in the same period, analysed separately and then compared. **expository text**:index{term="expository text" main} A text written to explain or inform, such as a textbook chapter or a report, as opposed to a narrative text. **fixation**:index{term="fixation" main} A pause of the eyes, typically about a quarter of a second, during which the reader takes in text; fixations alternate with *saccades*. **look-back**:index{term="look-backs" main} Any return to an earlier part of a text during reading: turning back a page, scrolling up or following a link to a previous section. **metacomprehension**:index{term="metacomprehension" main} A reader’s knowledge and monitoring of their own understanding of a text; *calibration* is one of its measures. **navigation**:index{term="navigation" main} The movements a reader makes through a text as a whole, as distinct from the movements of the eyes along a line. **overconfidence**:index{term="overconfidence" main} Positive *calibration* bias: predicting a higher score than the one actually obtained. **saccade**:index{term="saccade" main} A rapid movement of the eyes from one *fixation* to the next, during which little or no text is taken in. **screen inferiority effect**:index{term="screen inferiority effect" main} The finding that comprehension of the same text is lower on screen than on paper, most consistently for *expository texts* read under time pressure. **self-regulated study**:index{term="self-regulated study" main} Reading in which the reader, not the experimenter, decides how long to spend on a text. **spatial memory for text**:index{term="spatial memory" main} Memory of where on a page or in a document a piece of information appeared, used as a cue for *look-backs*. **thematic analysis**:index{term="thematic analysis" main} A method for identifying, analysing and reporting patterns of meaning across qualitative data such as interview transcripts. **within-subjects design**:index{term="within-subjects design" main} An experimental design in which every participant takes part in every condition, here reading on both paper and screen. ::: # References {style="references" note="Every work cited in the thesis, set in APA style (7th edition)."} :::paragraphs{style="reference"} Ackerman, R., & Goldsmith, M. (2011). Metacognitive regulation of text learning: On screen versus on paper. *Journal of Experimental Psychology: Applied, 17*(1), 18–32. Baron, N. S. (2015). *Words onscreen: The fate of reading in a digital world.* Oxford University Press. Braun, V., & Clarke, V. (2006). Using thematic analysis in psychology. *Qualitative Research in Psychology, 3*(2), 77–101. Clinton, V. (2019). Reading from paper compared to screens: A systematic review and meta-analysis. *Journal of Research in Reading, 42*(2), 288–325. Creswell, J. W., & Plano Clark, V. L. (2018). *Designing and conducting mixed methods research* (3rd ed.). SAGE. Delgado, P., Vargas, C., Ackerman, R., & Salmerón, L. (2018). Don’t throw away your printed books: A meta-analysis on the effects of reading media on reading comprehension. *Educational Research Review, 25*, 23–38. Dillon, A. (1992). Reading from paper versus screens: A critical review of the empirical literature. *Ergonomics, 35*(10), 1297–1326. Huey, E. B. (1908). *The psychology and pedagogy of reading.* Macmillan. Liu, Z. (2005). Reading behavior in the digital environment: Changes in reading behavior over the past ten years. *Journal of Documentation, 61*(6), 700–712. Mangen, A., Walgermo, B. R., & Brønnick, K. (2013). Reading linear texts on paper versus computer screen: Effects on reading comprehension. *International Journal of Educational Research, 58*, 61–68. Noyes, J. M., & Garland, K. J. (2008). Computer- vs. paper-based tasks: Are they equivalent? *Ergonomics, 51*(9), 1352–1375. Paterson, D. G., & Tinker, M. A. (1940). *How to make type readable.* Harper & Brothers. Rayner, K. (1998). Eye movements in reading and information processing: 20 years of research. *Psychological Bulletin, 124*(3), 372–422. Sellen, A. J., & Harper, R. H. R. (2002). *The myth of the paperless office.* MIT Press. Singer, L. M., & Alexander, P. A. (2017). Reading on paper and digitally: What the past decades of empirical research reveal. *Review of Educational Research, 87*(6), 1007–1041. Tinker, M. A. (1963). *Legibility of print.* Iowa State University Press. Wolf, M. (2018). *Reader, come home: The reading brain in a digital world.* Harper. ::: # Index {style="index" note="Bold numbers refer to the definitions in the glossary."} :::index :::callout{type="colophon"} Set in Libertinus Serif, Libertinus Serif Display and Libertinus Sans (SIL Open Font License). Text: original, CC BY 4.0. The thesis, its author, its participants and its results are fictional; the works in the references are real. :::
`; // content.<lang>.md, inlined by the Cookbook // A table from rows of 'cell|cell|cell'; aligns has a letter a column, l or r. const table = (id, typeId, caption, note, widths, aligns, rows) => ({ id, typeId, kind: 'table', caption, note, createdAt: 0, updatedAt: 0, table: { model: { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => row.split('|').map((content, c) => ({ content, isHeader: r === 0, align: aligns[c] === 'r' ? 'right' : 'left' }))) } } }); const resources = [ table('findings', 'table', 'Main results by medium', 'Means for 48 participants. Bias is the predicted minus the actual score.', [5, 1.4, 1.4], 'lrr', ['Measure|Paper|Screen', 'Literal comprehension (of 10)|7.8|7.7', 'Inferential comprehension (of 10)|6.4|5.6', 'Predicted score (%)|75|78', 'Actual score (%)|71|66.5', 'Calibration bias (points)|+4.0|+11.5', 'Look-backs per text|5.8|3.1']), table('session-plan', 'table-a', 'Timing of an interview session', undefined, [1, 6, 1.4], 'llr', ['Part|Content|Minutes', '1|Welcome, consent and a check of the recorder|3', '2|Free recall of the two study texts|5', '3|Questions 1–4: reading habits, look-backs and confidence|12', '4|Questions 5–7: the two media and an ideal design|12', '5|Debrief|3']), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libertinus Serif': ['400', '400i', '700'], 'Libertinus Serif Display': ['400'], 'Libertinus Sans': ['700'] }; // every face the pages use, loaded first (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── // The thesis's sixth and last chapter opens on page 171, a recto. const continuation = { pageIndexOffset: 170, pageNumbering: { startAt: 171 }, headings: { h1: 5 } }; await loadFonts(FONTS, markdown); // The index is laid out again until its page numbers settle, inside this one call. const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Reading on Screens and Paper: the back matter' }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);
العُدّة · 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 ↗ (يفتح في تبويب جديد)

تنويعات

#ابدأ كل قسم في صفحة فردية

كثيرًا ما تبدأ الأطروحات المجلَّدة للمكتبات كل قسم في صفحة يمنى. عندها ينتقل المسرد والمراجع والفهرس إلى الصفحات 175 و177 و179، كل منها بعد صفحة زوجية فارغة، وتشير الأرقام الغامقة في الفهرس إلى الصفحة 175.

-const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
-  parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
+const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
+  parity: 'odd' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });

#نضّد مقدمة الأطروحة أيضًا

الصفحات التمهيدية، المرقّمة بالأرقام الرومانية قبل الصفحة 1، في وصفة صفحات تمهيدية بأرقام رومانية، ثم الصفحة 1.

أخطاء شائعة

خطأ شائع

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

يأخذ مُدخل 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 قبل البناء. لوحة ألوان دلالية →

خطأ شائع

النص غير المضبوط لا يُقسَّم بالواصلة أبدًا

لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر. تقسيم الكلمات ولغة المستند →

خطأ شائع

يطلب PDF كل وزن وكل نمط من كل عائلة

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

خطأ شائع

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

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

الحقوق

الوصفة
Ignacio Ferro
النص
نص أصلي, CC BY 4.0
الخطوط
Libertinus Serif (SIL OFL 1.1) · Libertinus Serif Display (SIL OFL 1.1) · Libertinus Sans (SIL OFL 1.1)
SandboxPDF