باختصار
كتاب صغير من القصائد الصينية من عهد أسرة تانغ، مع ترجمة إنجليزية قديمة في الصفحة المقابلة. كل صف من الشعر الصيني يقع على مستوى الأسطر الإنجليزية التي تترجمه.
ما الذي ستنضده
كتاب صغير ثنائي اللغة من قصائد عصر تانغ، منضَّد كما تنضّد طبعات النص المتوازي عملًا كلاسيكيًا: الصينية في الصفحة اليمنى، على ورقة ملوّنة كالورق القديم، ونظم Herbert A. Giles الإنجليزي المقفّى الصادر سنة 1901 في الصفحة اليسرى المقابلة لها. أربع قصائد لـLi Bai وDu Fu وWang Wei، تأخذ كل منها صفحتين متقابلتين، بعنوان في كل لغة وتوقيع باسم الشاعر. الصينية مكتوبة بخط LXGW WenKai، وهو خط من طراز Kai مرسوم على هيئة الفرشاة، بيتان في كل صف، فيقف كل صف على مستوى السطرين الإنجليزيين اللذين يترجمانه. وتقع الشروح بلغة القارئ أسفل الصفحة الصينية. تضع وصفة ترجمة متقابلة، مقطعًا بمقطع قصيدة إسبانية بجانب نسختها الإنجليزية في صفحة واحدة؛ أما هنا فلكل لغة صفحتها.
تجيب هذه الوصفة عن
- كيف أنضد قصيدة وترجمتها على صفحتين متقابلتين، والأصل دائمًا في الصفحة الفردية؟
- كيف أنضد الشعر: سطر لكل بيت، وفراغ بين المقاطع، ومسافة بادئة معلّقة للأسطر الملتفّة، وبلا تقسيم كلمات؟
- كيف أضيف الحواشي السفلية؟
- كيف أفرض فاصل صفحة أو عمود، وأبدأ كل فصل على صفحة يمنى؟
الجواب المختصر
const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line
const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
// A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm.
const titled = (size, title, poet, under = []) => ({
enabled: true, minHeight: pt(SINK * LEAD),
slot: { elements: [...under,
{ kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2,
align: 'center', overflow: 'wrap', color: col('ink'),
placement: at(23 - (size * 1.2 * 25.4) / 72), ...title },
{ kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'),
placement: at(24), ...poet },
] },
});
// Each style breaks to its own side of the spread (gotcha: style-inherits-break).
const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
advancedDesign: titled(19, { fontFamily: ROMAN, italic: true },
{ content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600,
letterSpacing: pt(1.5), textTransform: 'uppercase' }) };
// The original on a tinted leaf: span 'page' lets the tint reach past the text column.
const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) },
{ content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) },
[{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: 'fill', height: 'fill' } } }]) };
المكونات
- الميزات
- فصول تبدأ في صفحة فرديةأنماط العناوينالحواشي السفليةأنماط الفقراتشبكة المحارفالخطوط الصينية واليابانية والكوريةتقسيم الأسطر في النص الصينيصفحات افتتاح مصمَّمةسمات العنوانالنصوص والخطوط والمربعات في تصاميم الصفحةالأغلفة وصفحات العنوان وبيانات النشرالترويسات وأرقام الصفحاتترويسات لكل قسمالترويسات بحسب دور الصفحةهوامش متناظرةلون الورقلوحة ألوان دلاليةمسافة عمودية صريحةالتصدير إلى PDFالخطوط المضمَّنة في PDF
- الخطوط
- Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4, Source Sans 3 (SIL OFL 1.1)
- الأصول
- لا شيء: كل صورة مرسومة بالكود
طريقة التحضير
#1 · نمط عنوان لكل جهة من الصفحتين المتقابلتين
الشيفرة هي الجواب المختصر أعلاه. كل قصيدة عنوانان من المستوى الأول: # Night Thoughts {style="en" …} مع نظم Giles، ثم # 靜夜思 {style="zh" …} مع القصيدة. يقفز النمط en إلى الصفحة الزوجية التالية؛ والنمط zh إلى الصفحة الفردية التالية، المقابلة لها. وما دامت كل قصيدة تتسع في صفحتها لا تُضاف صفحة فارغة؛ أما نسخة إنجليزية تفيض عن صفحتها فتدفع أصلها إلى الصفحة الفردية التي تليها، خلف صفحة زوجية فارغة (Break before). يحجز التصميمان ستة أسطر من الشبكة، فيبدأ النصان الإنجليزي والصيني على السطر نفسه. يحمل النمط الصيني span: 'page' لأن أول عناصره، صندوق بحجم الصفحة، هو الورقة الملوّنة، والتصميم الباقي في العمود يتوقف عند أسفل العمود (الامتداد والتصميم المتقدم).

#2 · صف صيني مقابل سطرين إنجليزيين
// Each indent centres the longest line of the poems that use it on the 103.7 mm measure:
// 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's.
const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({
id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left',
firstLineIndent: pt(0), indent: mm(indent) }));
const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD),
textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD
// Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid.
// A fixed left edge, and the note numbers after each row's 。: every character keeps its column.
indent: pt((CHARS * BODY - 12 * SHI) / 2) };
// A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents
// his rhyming lines by become two em spaces behind a word joiner, which nothing trims.
const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003');
تباعد أسطر الصفوف الصينية ضعف تباعد الأسطر الإنجليزية، 32 pt مقابل 16، فيقابل صفٌّ من بيتين خماسيي الحروف البيتَ المزدوج الذي يصنعه Giles منه. في قصيدة ضوء القمر في الصفحتين 4 و5 يدخل :::space{lines=1} نفسه في النصين، فتبقى مقاطعها الثلاثة متساوية المستوى عبر الصفحتين. يبدأ الصف الصيني على بعد 51 pt، فتتوسط وحداته الاثنتا عشرة (em) الثماني والعشرين التي في الشبكة، وتقف كل علامة حاشية بعد 。 صفّها، خارج الاثنتي عشرة: لو كانت داخل الصف لدفعت كل حرف بعدها ستة أعشار em إلى اليمين، خارج محاذاة الصف الذي تحته. تأتي إزاحات النص الإنجليزي من قياس أطول بيت في كل قصيدة. يزيح Giles كل بيت ثانٍ؛ تحتفظ Markdown بمسافتيه، ويحوّلهما indentVerse إلى مسافتي em خلف رابط كلمات، لأن الفقرة تُسقط المسافات التي تبدأ بها.
#3 · الشروح أسفل الصفحة الصينية
const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
numberFormat: 'circled-decimal', markerSize: em(0.6), fontSize: pt(8), lineHeight: pt(11),
color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5),
spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } };
الشروح حواشٍ سفلية، واحدة لكل صف: 疑是地上霜。[^bed] يُنهي الصف الأول من القصيدة ويتبعه [^bed]: …، الذي يذكر بالغامق كل كلمة يشرحها، لأن الرقم لم يعد بجانبها. تُرقَّم الحواشي كما يرقّم الكتاب الصيني حواشيه (页下注): أرقام في دوائر على خط الأساس، يُعاد العد من ① في كل صفحة (numberFormat: 'circled-decimal'، numbering: 'page'). في الشعر تُضبط العلامة على ستة أعشار حجم القصيدة، markerSize: em(0.6)، قريبًا من حجم الحواشي؛ وفي أسفل الصفحة تبدأ كل حاشية بـ① بحجم نص الحاشية (الحواشي السفلية). تُنضَّد الحواشي بخط المتن، وخط المتن في هذا الكتاب Noto Serif TC لأن الشرح يمزج الحروف الصينية والبينيين؛ والحروف اللاتينية في Noto Serif TC مأخوذة من Source Serif، ولهذا تُنضَّد الصفحات الإنجليزية بـSource Serif 4. تُكتب الحواشي بلغة الطبعة: هي وترويسة الصفحة الزوجية والملاحظة على النص ما تغيّره الطبعة الإسبانية.
#4 · ترويسات بلغة كل صفحة
const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity,
pages: 'opener', // every poem page opens with its title; a blank page stays bare
fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'),
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style });
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }),
head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
{ letterSpacing: pt(1.2), textTransform: 'uppercase' }),
head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
{ fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }),
] };
كل صفحة في هذا الكتاب تبدأ بعنوان، فكل صفحة صفحة افتتاح، وتستعمل الترويسات pages: 'opener'. الصفحة الفارغة التي يضيفها قفز ستبقى عارية، وتُخلي صفحة العنوان الترويسات بترويسة فارغة خاصة بها. تأخذ الصفحة الزوجية عنوان الكتاب من subtitle في البيانات التمهيدية، التي تكتبها كل طبعة بلغتها؛ وتأخذ الصفحة الفردية اسم الشاعر من خاصية name في العنوان الصيني (الترويسات والتذييلات).
#5 · صفحة عنوان على قمر
const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) },
...(size ? { size: { width: mm(size), height: mm(size) } } : {}) });
const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title
const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon
const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center',
overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style });
const titlePage = {
id: 'title', span: 'page', runningChapter: false,
header: { elements: [] }, footer: { elements: [] },
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON),
style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } },
line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF,
fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }),
line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600,
letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }),
line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }),
line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11),
italic: true }),
// A seal reading 明月, the bright moon of three of the four poems, set down its square.
{ kind: 'box', id: 'seal', placement: onPage(170, 13),
style: { backgroundColor: col('seal'), borderRadius: mm(1) } },
line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl',
fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'),
verticalAlign: 'middle' }),
] } },
};
صفحة العنوان نمط عنوان هي الأخرى، مع runningChapter: false فلا تسمّي فصلًا. القمر صندوق تصميم نصف قطر زاويته نصف ضلعه، ويتوسطه 唐詩 بالحساب: منتصف القمر ناقص نصف ارتفاع العنوان. ويضع الختم 明月 بـwritingMode: 'vertical-rl'، حرفًا فوق حرف، كما تُحفر الأختام.
#6 · كل خط صيني يحمّل ما ينضّده
const grab = (re) => (markdown.match(re) ?? []).join('');
const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '①②③④⑤⑥⑦⑧⑨⑩'; // and note numbers
await loadFonts(FONTS, markdown);
await Promise.all([
loadCjkFonts({ [SERIF]: ['400'] }, markdown + poems), // the notes and their ①, the heading blocks
loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)),
loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)),
loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`),
loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)),
]);
تقطّع Fontsource كل خط صيني إلى نحو مئة ملف بحسب نطاق الحروف، ولا تجلب loadCjkFonts إلا الملفات التي يحتاجها نصها (الخطوط الصينية واليابانية والكورية). يأخذ خط Kai القصائد والختم، وخط Hei أسطر الشعراء وأسماءهم، وخط Song الغامق العناوين الأربعة ورؤوس كلمات الحواشي، ووزنه الأسود حرفي صفحة العنوان.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 083 · Tang poems facing their English verse ═══════════════ // https://postext.dev/en/cookbook/tang-poems-facing-english // Code: MIT · Text: Tang poems, zh.wikisource; H. A. Giles, 1901, Project Gutenberg (PD) // Fonts: Noto Serif TC, LXGW WenKai TC, Noto Sans TC, Source Serif 4 (OFL) · Needs postext ≥ 1.11.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 = 'tang-poems-facing-english'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Ink and seal red on a cream paper, and a pale moon for the title page. const palette = { ink: '#1f1b18', // text: a warm near-black seal: '#ad3526', // the accent: vermilion seal paste, for the poets, the rules and the seal moon: '#eadcb4', // the title page's moon leaf: '#f2e8cf', // the recto's tint, under the Chinese muted: '#6d655c', // running heads and the colophon paper: '#faf6ed', // a cream paper }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'seal (defaults)', value: { hex: palette.seal, model: 'hex' } }, ]; const SERIF = 'Noto Serif TC'; // 明: the Chinese titles and the notes const KAI = 'LXGW WenKai TC'; // 楷: the poems const HEI = 'Noto Sans TC'; // 黑: the poet lines and the recto's running head const ROMAN = 'Source Serif 4'; // the English: the Latin that Noto Serif TC is built on const LABEL = 'Source Sans 3'; // English labels and folios const [BODY, LEAD, SHI] = [10.5, 16, 16]; // pt: 五号 on a 16 pt pitch; the poems' size const [CHARS, LINES] = [28, 30]; // the character grid: 28 ems by 30 lines const TRIM = { width: 148, height: 210 }; // mm: 25開, a Taiwan trade trim const [INNER, OUTER] = [18, 21]; // mm: minimum sides; the grid centres its measure between them const MEASURE = (CHARS * BODY * 25.4) / 72; // 103.7 mm: 28 ems of 五号 const EDGE = OUTER + (TRIM.width - INNER - OUTER - MEASURE) / 2; // 23.6 mm: the outer text edge // #region answer: the English opens the next verso, the Chinese the recto that faces it const SINK = 6; // lines of LEAD under both titles, so the two poems start on the same line const at = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } }); // A title whose foot is 23 mm down at any size, and the poet's line under it at 24 mm. const titled = (size, title, poet, under = []) => ({ enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [...under, { kind: 'text', id: 'title', content: '{titleText}', fontSize: pt(size), lineHeight: 1.2, align: 'center', overflow: 'wrap', color: col('ink'), placement: at(23 - (size * 1.2 * 25.4) / 72), ...title }, { kind: 'text', id: 'poet', align: 'center', overflow: 'wrap', color: col('seal'), placement: at(24), ...poet }, ] }, }); // Each style breaks to its own side of the spread (gotcha: style-inherits-break). const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso advancedDesign: titled(19, { fontFamily: ROMAN, italic: true }, { content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.5), textTransform: 'uppercase' }) }; // The original on a tinted leaf: span 'page' lets the tint reach past the text column. const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: titled(28, { fontFamily: SERIF, fontWeight: 700, letterSpacing: pt(7) }, { content: '{attr.poet}', fontFamily: HEI, fontSize: pt(9), letterSpacing: pt(1.5) }, [{ kind: 'box', id: 'leaf', reserve: false, style: { backgroundColor: col('leaf') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: 'fill' } } }]) }; // #endregion // #region verse: a Chinese row faces two lines of English on the grid both pages share // Each indent centres the longest line of the poems that use it on the 103.7 mm measure: // 97.8 mm in the moonlight poem, 91–93 mm in two quatrains, 66 mm in Wang Wei's. const verses = [['verse-long', 3], ['verse', 6], ['verse-short', 19]].map(([id, indent]) => ({ id, fontFamily: ROMAN, fontSize: pt(BODY), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0), indent: mm(indent) })); const shi = { id: 'shi', fontFamily: KAI, fontSize: pt(SHI), lineHeight: pt(2 * LEAD), textAlign: 'left', firstLineIndent: pt(0), // two lines of verse to a row: 2 × LEAD // Five characters and a full-width mark, twice: 12 ems, centred in the 28 of the grid. // A fixed left edge, and the note numbers after each row's 。: every character keeps its column. indent: pt((CHARS * BODY - 12 * SHI) / 2) }; // A paragraph drops its leading spaces (gap: hard-line-breaks): the two spaces Giles indents // his rhyming lines by become two em spaces behind a word joiner, which nothing trims. const indentVerse = (md) => md.replace(/^ {2}(?=\S)/gm, '\u2060\u2003\u2003'); // #endregion // #region notes: glosses at the foot of the recto, in the edition's language const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page numberFormat: 'circled-decimal', markerSize: em(0.6), fontSize: pt(8), lineHeight: pt(11), color: col('ink'), textAlign: 'left', spaceAbove: pt(LEAD), spaceBelowRule: pt(5), spaceBetween: pt(3), separator: { width: 0.12, lineWidth: pt(0.6), color: col('seal') } }; // #endregion // #region title: 唐詩 on a pale moon, with a seal const onPage = (y, size) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) }, ...(size ? { size: { width: mm(size), height: mm(size) } } : {}) }); const [MOON_Y, MOON, HAN] = [36, 78, 60]; // mm: the moon's top and width; pt: the title const UNDER = MOON_Y + MOON; // mm: the English lines start under the moon const line = (id, content, y, style) => ({ kind: 'text', id, content, align: 'center', overflow: 'wrap', color: col('ink'), placement: onPage(y), ...style }); const titlePage = { id: 'title', span: 'page', runningChapter: false, header: { elements: [] }, footer: { elements: [] }, advancedDesign: { enabled: true, slot: { elements: [ { kind: 'box', id: 'moon', placement: onPage(MOON_Y, MOON), style: { backgroundColor: col('moon'), borderRadius: mm(MOON / 2) } }, line('han', '{titleText}', MOON_Y + MOON / 2 - (HAN * 25.4) / 144, { fontFamily: SERIF, fontWeight: 900, fontSize: pt(HAN), letterSpacing: pt(HAN / 4), lineHeight: 1 }), line('en', '{attr.en}', UNDER + 10, { fontFamily: LABEL, fontSize: pt(10), fontWeight: 600, letterSpacing: pt(3), textTransform: 'uppercase', color: col('seal') }), line('poets', '{attr.poets}', UNDER + 19, { fontFamily: ROMAN, fontSize: pt(12) }), line('giles', '{attr.english}', UNDER + 26, { fontFamily: ROMAN, fontSize: pt(11), italic: true }), // A seal reading 明月, the bright moon of three of the four poems, set down its square. { kind: 'box', id: 'seal', placement: onPage(170, 13), style: { backgroundColor: col('seal'), borderRadius: mm(1) } }, line('seal-text', '明月', 170, { placement: onPage(170, 13), writingMode: 'vertical-rl', fontFamily: KAI, fontSize: pt(14), lineHeight: 1, color: col('paper'), verticalAlign: 'middle' }), ] } }, }; // #endregion // #region heads: the book in English over the verso, the poet in Chinese over the recto const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'opener', // every poem page opens with its title; a blank page stays bare fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(12) } }, ...style }); const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', EDGE, { color: col('ink') }), head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8, { letterSpacing: pt(1.2), textTransform: 'uppercase' }), head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8), { fontFamily: HEI, fontWeight: 400, fontSize: pt(8), letterSpacing: pt(3) }), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -EDGE, { color: col('ink') }), ] }; // #endregion const paragraphStyles = [shi, ...verses, // Justified like the body, and divided by the patterns of the edition's language (bodyText). { id: 'prose', fontFamily: ROMAN, fontSize: pt(10), lineHeight: pt(LEAD), firstLineIndent: mm(4) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(10), color: col('muted'), italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ]; const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'zh-Hant', // the Chinese sets the rules; written out, never LANG (gotcha: cjk-locale-tag) colorPalette, page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), // 23 + 17.5 mm leave 169.5 mm for 30 lines of 16 pt (169.3 mm): a head deeper than the foot. margins: { top: mm(23), bottom: mm(17.5), left: mm(INNER), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'single' }, cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } }, bodyText: { fontFamily: SERIF, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: false, hyphenation: { enabled: true, locale: LANG } }, // no Chinese patterns: the Latin's language // The designs paint the titles; weight 400 keeps the heading blocks in a loaded face. // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). headings: { fontFamily: SERIF, fontWeight: 400, levels: [{ level: 1, marginTop: pt(0), marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' } }] }, headingStyles: [titlePage, english, chinese], paragraphStyles, footnotes, header, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 153 · content.en.md
title: "唐詩" subtitle: "Tang Poems" --- # 唐詩 {style="title" en="Tang Poems" poets="Li Bai · Du Fu · Wang Wei" english="with the English verse of Herbert A. Giles"} # Night Thoughts {style="en" kicker="Li Bai · 701–762"} :::paragraphs{style="verse"} I wake, and moonbeams play around my bed, Glittering like hoar-frost to my wondering eyes; Up towards the glorious moon I raise my head, Then lay me down,—and thoughts of home arise. ::: # 靜夜思 {style="zh" poet="〔唐〕李白" name="李白"} :::paragraphs{style="shi"} 牀前明月光,疑是地上霜。[^bed] 舉頭望明月,低頭思故鄉。[^moon] ::: [^bed]: **牀** chuáng, now written 床: a bed. Some commentators read it as the railing round a well, others as a folding seat set out in the courtyard; Giles keeps the bed. [^moon]: **明月** míngyuè, the bright moon. The collected works of Li Bai read 山月 here, the moon over the hills, and 看月光 in the first line; the 1705 edition of all Tang poetry reads 山月 but keeps 明月光. This page follows the anthology of 1763, the text Chinese children learn by heart. **鄉** xiāng rhymes with 光 guāng and 霜 shuāng at the ends of the first two lines; Giles rhymes his quatrain on alternate lines instead. # Drinking Alone under the Moon {style="en" kicker="Li Bai · 701–762"} :::paragraphs{style="verse-long"} An arbour of flowers and a kettle of wine: Alas! in the bowers no companion is mine. Then the moon sheds her rays on my goblet and me, And my shadow betrays we’re a party of three. :::space{lines=1} Though the moon cannot swallow her share of the grog, And my shadow must follow wherever I jog,— Yet their friendship I’ll borrow and gaily carouse, And laugh away sorrow while spring-time allows. :::space{lines=1} See the moon,—how she glances response to my song; See my shadow,—it dances so lightly along! While sober I feel you are both my good friends; When drunken I reel, our companionship ends. But we’ll soon have a greeting without a good-bye, At our next merry meeting away in the sky. ::: # 月下獨酌 {style="zh" poet="〔唐〕李白" name="李白"} :::paragraphs{style="shi"} 花間一壺酒,獨酌無相親。 舉杯邀明月,對影成三人。[^three] :::space{lines=1} 月既不解飲,影徒隨我身。 暫伴月將影,行樂須及春。[^jiang] :::space{lines=1} 我歌月徘徊,我舞影零亂。 醒時同交歡,醉後各分散。 永結無情遊,相期邈雲漢。[^wuqing] ::: [^three]: **三人** sān rén, three people: the poet, the moon and his shadow. [^jiang]: **將** jiāng: here it joins 月 to 影, the moon to the shadow. [^wuqing]: **無情遊** wúqíng yóu: a friendship with companions who feel nothing, the moon and the shadow; the poet binds himself to it for good. **雲漢** yúnhàn: the Milky Way, where the three will meet again, away in the sky. The rhyme changes at the ninth line, where the third stanza of Giles begins. # Stop-short {style="en" kicker="Du Fu · 712–770"} :::paragraphs{style="verse"} White gleam the gulls across the darkling tide, On the green hills the red flowers seem to burn; Alas! I see another spring has died…. When will it come—the day of my return? ::: # 絕句 {style="zh" poet="〔唐〕杜甫" name="杜甫"} :::paragraphs{style="shi"} 江碧鳥逾白,山青花欲燃。[^yu] 今春看又過,何日是歸年。[^guinian] ::: [^yu]: **逾** yú, all the more: against the green of the river the birds look whiter still. **欲燃** yù rán, about to catch fire: the red of the flowers, which in Giles seem to burn. [^guinian]: **歸年** guī nián, the year of going home. Du Fu wrote this quatrain, the second of a pair, in the spring of 764 at Chengdu, where the war in the north had driven him. # The Bamboo Lodge {style="en" kicker="Wang Wei · 701–761"} :::paragraphs{style="verse-short"} Beneath the bamboo grove, alone, I seize my lute and sit and croon; No ear to hear me, save mine own: No eye to see me—save the moon. ::: # 竹里館 {style="zh" poet="〔唐〕王維" name="王維"} :::paragraphs{style="shi"} 獨坐幽篁裏,彈琴復長嘯。[^huang] 深林人不知,明月來相照。 ::: [^huang]: **幽篁** yōuhuáng: a deep, dark grove of bamboo. **長嘯** cháng xiào: a long whistle through pursed lips, the art of recluses; in Giles the poet croons. The Bamboo Lodge was one of twenty places on the estate of Wang Wei at Wangchuan, south-east of the Tang capital, and he gave each of them a quatrain in 輞川集, the Wangchuan collection. # A note on the text {style="en" kicker="About this edition"} :::paragraphs{style="prose"} The Chinese poems follow *Three Hundred Tang Poems*, the anthology Sun Zhu compiled in 1763 under the name Hengtang Tuishi, except Du Fu’s quatrain, which is not in it and is printed as the *Complete Tang Poems* of 1705 gives it. All four are taken from Wikisource in traditional characters, with the old forms of some characters as found there. The English verse is Herbert A. Giles’s, as he printed it in *A History of Chinese Literature* (1901), where the poems have no titles; the English titles are this edition’s. Giles wrote Li Po and Tu Fu for Li Bai and Du Fu, and called the four-line *jueju* a “stop-short”, which gives Du Fu’s poem its English name. His rhymes, indents and punctuation are kept; the moonlight poem, which his book printed in half-lines, is set here in whole lines. Each Chinese poem is set two lines of verse to a row, so that every row faces the two lines of English that render it. The space between the stanzas of the moonlight poem is the editor’s, added on both pages. ::: :::paragraphs{style="colophon"} Set in Noto Serif TC, LXGW WenKai TC and Noto Sans TC for the Chinese, Source Serif 4 and Source Sans 3 for the English, all under the SIL Open Font License · Chinese text: Wikisource · English verse: H. A. Giles, *A History of Chinese Literature*, 1901, Project Gutenberg eBook 43711 :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Noto Serif TC': ['400', '700', '900'], // SERIF: the notes; the titles; 唐詩 'LXGW WenKai TC': ['400'], // KAI: the poems and the seal 'Noto Sans TC': ['400'], // HEI: the poet lines, the recto's head 'Source Serif 4': ['400', '400i'], // ROMAN: the verse, the titles, the note on the text 'Source Sans 3': ['400', '400i', '600'], // LABEL: kickers, the verso's head, folios, colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each Chinese face loads the files of the characters it sets const grab = (re) => (markdown.match(re) ?? []).join(''); const poems = grab(/:::paragraphs\{style="shi"\}[\s\S]*?\n:::/g) + '①②③④⑤⑥⑦⑧⑨⑩'; // and note numbers await loadFonts(FONTS, markdown); await Promise.all([ loadCjkFonts({ [SERIF]: ['400'] }, markdown + poems), // the notes and their ①, the heading blocks loadCjkFonts({ [SERIF]: ['700'] }, grab(/^# \S+(?= \{style="zh")|\*\*[^*]+\*\*/gm)), loadCjkFonts({ [SERIF]: ['900'] }, grab(/^# \S+(?= \{style="title")/gm)), loadCjkFonts({ [KAI]: ['400'] }, `${poems}明月`), loadCjkFonts({ [HEI]: ['400'] }, grab(/(?<=(?:poet|name)=")[^"]*/g)), ]); // #endregion const doc = await buildWithFonts( () => buildDocument({ markdown: indentVerse(markdown) }, config()), markdown); showPages(doc, { title: t({ en: 'Tang poems facing their English verse', es: 'Poemas Tang frente a su versión inglesa' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);العُدّة · core, fonts, viewer, pdf, cjk: نفسها في كل وصفة · أسطر: 422
// ─── 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 · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook ─ // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family goes to fontsourceProvider (the "pdf" block). */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return fontsourceProvider(family, weight, style); 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.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#ضع الأصل في الصفحة الزوجية
تضع طبعات Loeb وكثير من الكتب الصينية الإنجليزية الأصلَ على اليسار: بدّل الزوجية والفردية في نمطي العنوان وفي الترويستين اللتين تسمّيان الكتاب والشاعر، واكتب كل عنوان صيني وقصيدته قبل نسختهما الإنجليزية في Markdown.
-const english = { id: 'en', breakBefore: { enabled: true, parity: 'even' }, // a verso
+const english = { id: 'en', breakBefore: { enabled: true, parity: 'odd' }, // a recto
-const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'odd' },
+const chinese = { id: 'zh', span: 'page', breakBefore: { enabled: true, parity: 'even' },
- head('verso-book', '{subtitle}', 'even', 'top-left', EDGE + 8,
+ head('verso-book', '{subtitle}', 'odd', 'top-right', -(EDGE + 8),
- head('recto-poet', '{attr.name}', 'odd', 'top-right', -(EDGE + 8),
+ head('recto-poet', '{attr.name}', 'even', 'top-left', EDGE + 8,#ضع الشروح تحت القصيدة مباشرة
تطبع المختارات الصينية المشروحة (注釋本) الحواشي بعد كل قصيدة. وتفعل ذلك هنا حواشي آخر الفصل، لأن كل قصيدة فصل قائم بذاته.
-const footnotes = { placement: 'column', numbering: 'page', // the page foot; ① again on each page
+const footnotes = { placement: 'chapterEnd', chapterEndAlign: 'text',أخطاء شائعة
خطأ شائع
يرث نمط العنوان فاصل الصفحة من مستواه
يأخذ مُدخل headingStyles كل حقل يتركه من مستوى عنوانه، بما فيه breakBefore. فصفحة المحتويات أو صفحة بيانات النشر المنسّقة على H1 بعد :::pagebreak ترث الزوجية 'odd' وتقع بعد صفحة فارغة. أعطِ هذا النمط breakBefore: { enabled: false }. أنماط العناوين →
خطأ شائع
الصفحة 1 فردية: خطّط الصفحات بأرقامها الفعلية
الصفحة 1 صفحة يمنى والصفحة 2 أول صفحة زوجية، فخطّط الصفحتين المتقابلتين بأرقام الصفحات الفعلية: الافتتاحية في صفحة زوجية تقابل الصفحة الفردية التي تليها. فواصل الصفحات والأعمدة →
خطأ شائع
الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk
يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص. الخطوط الصينية واليابانية والكورية →
خطأ شائع
اوسم المستند بـ zh-Hans أو zh-Hant، لا بـ LANG
نسختا الوصفة هما en وes، لكن المثال الصيني صيني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، فيقسّم كلماته اللاتينية بالواصلة، ويسمّي أشكاله Figure أو Figura، ويعطي PDF لغة خاطئة. اكتب الوسم بنفسك: 'zh-Hans' (أعراف البر الرئيسي: كسر الأسطر وفق GB، وترقيم Kaiming) أو 'zh-Hant' (تايوان: ترقيم بعرض كامل متمركز)؛ و'zh-HK' لهونغ كونغ. أما 'zh' المجرّد فيُقرأ صينية مبسطة على أعراف البر الرئيسي. تقسيم الأسطر في النص الصيني →
خطأ شائع
الخط الصيني يضع علامات الاقتباس والفواصل العليا بعرض كامل، في النص اللاتيني أيضًا
يرسم Noto Serif TC وسائر الخطوط الصينية التقليدية ‘ ’ “ ” بعرض em كامل، كما يستخدمها النص الصيني، وهذا الحرف هو ما تحصل عليه الكلمة اللاتينية الموضوعة بذلك الخط: تُطبع Li Bai’s بفجوة واسعة بعد الفاصلة العليا، ويُفتح الاقتباس الإنجليزي ويُغلق على em كامل من الفراغ. ولا مائل في خط CJK أيضًا، والإمالة المصطنعة على الشاشة تُطبع قائمة في PDF. النص الذي يمزج الصينية بلغة لاتينية في خط واحد (الحواشي السفلية التي تأخذ خط المتن، أو الشروح مع pinyin) يُقرأ أفضل بلا فواصل عليا وعلامات اقتباس منحنية ومائل؛ أما « » وعلامات Latin-1 فتحتفظ بعرضها. وحيث يجري النص اللاتيني وحده، أعطه خطًا لاتينيًا عبر نمط فقرة. الخطوط الصينية واليابانية والكورية →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- Three Tang poems in traditional characters, Li Bai’s 靜夜思 and 月下獨酌 and Wang Wei’s 竹里館, as Three Hundred Tang Poems (1763) gives them · Li Bai, Wang Wei · ملك عام
- Du Fu’s 絕句, the second of his 絕句二首, as the Complete Tang Poems (1705, juan 228) prints it · Du Fu · ملك عام
- The English verse translations, from A History of Chinese Literature (1901), Project Gutenberg eBook 43711 · Herbert A. Giles · ملك عام
- The notes, the English titles, the note on the text and the colophon · Postext Cookbook · أصلي
- الخطوط
- Noto Serif TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · Source Serif 4 (SIL OFL 1.1) · Source Sans 3 (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


