باختصار
مختارات قصيرة من رواية صينية كلاسيكية مع فهرس لأسماء شخصياتها. العلامات نفسها تعطي طبعتين: واحدة مرتبة بحسب النطق، وأخرى بحسب عدد ضربات القلم.
ما الذي ستنضده
مختارات نحيلة من 紅樓夢 (حلم الغرفة الحمراء) تعيد طبع المقاطع التي تظهر فيها الشخصيات أول مرة، من الفصل 1 إلى الفصل 3، وتنتهي بفهرس لأسمائها السبعة والأربعين، مع أربع إحالات من أسماء أخرى. وتأتي في طبعتين من العلامات نفسها. طبعة البر الصيني، بالحروف المبسطة، ترتّب الأسماء تحت الحرف الأول من نطقها بالبينيين: F وH وJ…؛ وطبعة تايوان، بالحروف التقليدية، تحت عدد ضربات الحرف الأول: 三畫، 四畫… والطبعتان كتابان ورقيا الغلاف بقطع 大32开، أي 140 × 203 مم، بستة وعشرين حرفًا في السطر بخط Noto Serif، وعناوين الفصول بخط Kai، والعناوين التمهيدية وأرقام الصفحات ورؤوس الفهرس بخط Noto Sans. وتكبّر البطاقة المجموعات الأولى من الفهرس المبسط، من F إلى L. وتبني وصفة فهرس للأعلام وفهرس للموضوعات فهرسًا من النوع نفسه لدراسة إنجليزية.
تجيب هذه الوصفة عن
- كيف أجمّع فهرس أسماء صينية حسب البينيين أو حسب عدد الضربات؟
- كيف أُدخل الحروف الصينية في ملف PDF من دون مربعات فارغة؟
الجواب المختصر
// Marks in the Markdown, one per passage; main is the page where the character enters:
// 字:index[士隐]{term="甄士隐" main} :index{term="贾化" see="贾雨村"}
// Printed at the end of each edition: # 人名索引 {style="index"}, then :::index
const index = (e) => ({
// 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant.
groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14),
separator: '\u3000', locatorSeparator: ',', // 贾宝玉 4,6: an ideographic space, then ,
see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic)
groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
marginTop: pt(7) },
});
const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' },
layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart
المكونات
- الميزات
- الفهرس الأبجدي في آخر الكتابالخطوط الصينية واليابانية والكوريةأنماط العناوينفصول بلا أرقامصفحات افتتاح مصمَّمةسمات العنوانالأرقام الصينيةشبكة المحارفتقسيم الأسطر في النص الصينيالترويسات وأرقام الصفحاتهوامش متناظرةأنماط الفقراتلوحة ألوان دلاليةالتصدير إلى PDF
- تستخدم أيضًا
- لون الورقالترويسات بحسب دور الصفحةالخطوط المضمَّنة في PDFهندسة القسمفواصل الأسطر في العناوين
- الخطوط
- Noto Serif SC, Noto Sans SC, Ma Shan Zheng, Noto Serif TC, Noto Sans TC, LXGW WenKai TC (SIL OFL 1.1)
- الأصول
- لا شيء: كل صورة مرسومة بالكود
طريقة التحضير
#1 · علّم كل اسم حيث يلقاه القارئ
الشيفرة هي الجواب المختصر أعلاه؛ والباقي في Markdown. تُدرج العلامة الاسم الكامل أيًّا كان ما يطبعه النص: 字:index[士隐]{term="甄士隐" main} تطبع 士隐 وتسجّل الصفحة تحت 甄士隐. وتجعل main رقم الصفحة عريضًا، وتشرح الملاحظة تحت عنوان الفهرس ما يعنيه العريض هنا: الصفحة التي تدخل فيها الشخصية بنفسها. يولد جيا باويو في الفصل 2 من غير أن يُسمّى (又生了一位公子)، فتوضع العلامة بلا شيء تطبعه، :index{term="贾宝玉"}، ويصير مدخله 4، 6. وتحصل الأسماء الأخرى والألقاب على إحالة خاصة بها، :index[凤辣子]{term="凤辣子" see="王熙凤"}، يطبعها Postext على الطريقة الصينية، بعد نقطة ومن غير مسافة: 凤辣子。见王熙凤 (علامات الفهرس).
#2 · دع اللغة تختار الترتيب
// The mainland edition in Simplified characters and the Taiwan edition in Traditional ones.
// Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no
// Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland
// titles take Ma Shan Zheng, a brush Kai.
const EDITIONS = {
hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' },
hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' },
};
تتشارك الطبعتان الإعدادات، وlocale هو المفتاح الوحيد الذي يحتاجه الفهرس: يجمّع groupBy: 'auto' فهرس zh-Hans بحسب البينيين وفهرس zh-Hant بحسب الضربات، ويكتب الجواب المختصر القيمتين صراحة لتراهما. تُرتَّب المداخل بالترتيب الذي تأتي منه الرؤوس، فتتبادل الأسماء نفسها أماكنها: يسبق 甄费 الاسمَ 甄士隐 تحت Z (fèi قبل shì)، ويليه تحت 十四畫 (士 ثلاث ضربات، و費 اثنتا عشرة)؛ ويفترق 宁国公 و荣国公 بين N وR، بينما يبقى 寧國公 و榮國公 معًا بين الأسماء ذات الأربع عشرة ضربة (الفهرس الأبجدي في آخر الكتاب). القراءات وأعداد الضربات مصدرها ترتيب المتصفح الصيني؛ والاسم العائلي الذي يقرؤه خطأً (曾، 单، 解) يأخذ مفتاح sort بحروف لها القراءة الصحيحة.
#3 · حمّل كل خط بالحروف التي ينضّدها
const DIGITS = '0123456789';
const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads
const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n');
for (const [key, md] of [['hans', markdown], ['hant', traditional]]) {
e = EDITIONS[key];
await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads
await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers
await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS);
await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone
}
يقطّع Fontsource كل وزن صيني إلى نحو مئة ملف، وتجلب loadCjkFonts الملفات التي يمسّها نصها. يأخذ خط Song الكتاب كله، فلا تكلّفه الترويسات شيئًا؛ ويأخذ وزنه العريض أرقام الصفحات الرئيسية؛ ويأخذ خط Hei نص سمات kicker ورؤوس الفهرس وأرقام الصفحات؛ ويأخذ خط Kai الأبيات المزدوجة الثلاثة و人名索引. ويسلك PDF الطريق نفسه: تضمّن cjkPdfProvider تلك الملفات، كلًّا منها مقتطعًا إلى الحروف التي تستعملها صفحاته، فلا يخرج أي حرف مربعًا فارغًا. ويتغير خط Kai بتغير الطبعة. فخط LXGW WenKai TC، وهو خط Kai النصي الوحيد على Fontsource، يرسم الأشكال الموروثة، 冷 بالقدم القديمة 卩 من 令، ويضع الفواصل والنقاط في وسط الخانة، فتُنضَّد العناوين المبسطة بخط Ma Shan Zheng، وهو خط Kai فرشاتي بأشكال البر الصيني (خطوط الصينية واليابانية والكورية).
#4 · ضع الملاحظة في صفحة افتتاح الفهرس
const opener = (e, sink, kicker, note = []) => ({
enabled: true,
minHeight: pt(LEAD * sink), // the text starts on the same line after every opener
slot: { elements: [
{ kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9),
fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center',
placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16),
lineHeight: 1.45, color: col('ink'), align: 'center',
overflow: 'wrap', // design text ends in an ellipsis by default
placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'),
placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } },
...note,
] },
});
// The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen.
const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}',
fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center',
overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4),
size: { width: 'fill', height: 'auto' } } }];
الملاحظة سمة من سمات العنوان، # 人名索引 {style="index" kicker="按汉语拼音排列" note="…"}، تُنضَّد على عرض السطر كله فوق العمودين. وحين كانت فقرة داخل العمودين صارت أداة لموازنة الأعمدة: لتسوية الصفحة الأخيرة في الطبعة التقليدية، أرخى الإخراج أسطرها إلى اثني عشر حرفًا في عمود يتسع لأربعة عشر. نص التصميم لا يعرف قواعد كسر الأسطر الصينية، وينضّد كل علامة ترقيم بعرض كامل، بينما ينضّد النص المبسط ,、; بنصف عرض، لذلك جاءت الملاحظة ثلاث جمل قصيرة، جملة في كل سطر (\n في السمة)، ليس في كل منها من علامات الترقيم إلا النقطة. وتستعمل صفحات افتتاح الفصول التصميم نفسه، مع 第{numberHan}回 عنوانًا تمهيديًا، فيُطبع 第一回 بخط المستند.
#5 · اعرض صفحتي الفهرس جنبًا إلى جنب
const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index')
?.pageIndex ?? doc.pages.length - 1];
const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }),
hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) };
document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style="
display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da">
${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)">
<canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff;
box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px;
font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a">
${labels[key]}</figcaption></figure>`).join('')}</section>`);
for (const [key, doc] of Object.entries(docs)) {
const canvas = document.getElementById(`index-${key}`);
const page = indexPage(doc);
canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`);
renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width });
}
يبني المثال الطبعتين ويعرض المبسطة على طاولة العرض. يبدّل الزرّان 简体版 و繁體版 ما على الطاولة، ويقدّم كل تبديل ملف PDF للطبعة المعروضة، chinese-name-index-hans.pdf أو chinese-name-index-hant.pdf. ويسجّل الالتقاط البناءين كليهما (capture.doc: ['first', 'last']): الصفحات الثماني الأولى أعلاه من الطبعة المبسطة، والأربع التي تليها من الطبعة التقليدية، صفحتها الأولى وفهرس الضربات. ويحمل الشريط فوق الطاولة أول صفحة فهرس من كل طبعة. وملف PDF المقدَّم مع الوصفة هو ملف الطبعة المبسطة.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 079 · An index of Chinese names, by pinyin and by strokes ═══ // https://postext.dev/en/cookbook/chinese-name-index // Code: MIT · Text: 紅樓夢 (1792), zh.wikisource revision 9685985 (CC BY-SA 4.0) · Pictures: none // Fonts: Noto Serif/Sans SC/TC, Ma Shan Zheng, LXGW WenKai TC (SIL OFL 1.1) · Needs postext ≥ 1.9.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 = 'chinese-name-index'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: ink on a warm paper, one cinnabar for the heads and the index letters const palette = { ink: '#1f1b18', // text cinnabar: '#a3301f', // the one accent: 回 numbers, index heads muted: '#6b635a', // running heads, folios, colophon rule: '#d6cdbf', // the short rule under each title paper: '#fbf8f1', }; // A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to main-color: point it at the accent, never the default blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.cinnabar, model: 'hex' } }, ]; // #endregion // #region editions: one book, two scripts; the locale decides the index order // The mainland edition in Simplified characters and the Taiwan edition in Traditional ones. // Each takes the Song (serif), Hei (sans) and Kai faces cut for its script. Fontsource has no // Kai text face with mainland forms: LXGW WenKai TC draws 冷 the Taiwan way, so the mainland // titles take Ma Shan Zheng, a brush Kai. const EDITIONS = { hans: { locale: 'zh-Hans', serif: 'Noto Serif SC', sans: 'Noto Sans SC', kai: 'Ma Shan Zheng' }, hant: { locale: 'zh-Hant', serif: 'Noto Serif TC', sans: 'Noto Sans TC', kai: 'LXGW WenKai TC' }, }; // #endregion const LEAD = 18; // body leading in pt: 1.7 × the 10.5 pt text (五号) const MEASURE = (26 * 10.5 * 25.4) / 72; // the grid's 26 characters of 10.5 pt, in mm const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region answer: the same marks, grouped by pinyin in zh-Hans and by strokes in zh-Hant // Marks in the Markdown, one per passage; main is the page where the character enters: // 字:index[士隐]{term="甄士隐" main} :index{term="贾化" see="贾雨村"} // Printed at the end of each edition: # 人名索引 {style="index"}, then :::index const index = (e) => ({ // 'auto' reads the same from the locale: pinyin for zh-Hans, strokes for zh-Hant. groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫… fontFamily: e.serif, fontSize: pt(9), lineHeight: pt(14), separator: '\u3000', locatorSeparator: ',', // 贾宝玉 4,6: an ideographic space, then , see: { italic: false }, // 见 / 見 upright: Chinese has no italic (gotcha: cjk-no-italic) groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'), marginTop: pt(7) }, }); const indexHeading = { id: 'index', numbered: false, breakBefore: { enabled: true, parity: 'any' }, layout: { layoutType: 'double', gutterWidth: pt(21) } }; // two columns, 2 characters apart // #endregion // #region opener: 第一回 in Hei over the couplet in Kai; the index adds its note const opener = (e, sink, kicker, note = []) => ({ enabled: true, minHeight: pt(LEAD * sink), // the text starts on the same line after every opener slot: { elements: [ { kind: 'text', id: 'kicker', content: kicker, fontFamily: e.sans, fontSize: pt(9), fontWeight: 700, letterSpacing: pt(2.7), color: col('cinnabar'), align: 'center', placement: { ...at('container', 'top', 0, 8), size: { width: 'fill', height: 'auto' } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: e.kai, fontSize: pt(16), lineHeight: 1.45, color: col('ink'), align: 'center', overflow: 'wrap', // design text ends in an ellipsis by default placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), placement: { ...at('#title', 'below', (MEASURE - 12) / 2, 4), size: { width: mm(12) } } }, ...note, ] }, }); // The note (note="…" on the heading) sits above the columns: no paragraph for balancing to loosen. const indexNote = (e) => [{ kind: 'text', id: 'note', content: '{attr.note}', fontFamily: e.serif, fontSize: pt(9), lineHeight: 1.6, color: col('muted'), align: 'center', overflow: 'wrap', placement: { ...at('#rule', 'below', -(MEASURE - 12) / 2, 4), size: { width: 'fill', height: 'auto' } } }]; // #endregion // #region running-heads: book title on the verso, the chapter on the recto, folios outside // The heads in the Song face, which has every character of the book loaded; Hei for the folios. const head = (e, id, content, parity, edge, y, extra = {}) => ({ kind: 'text', id, content, parity, fontFamily: e.sans, fontSize: pt(7.5), fontWeight: 700, letterSpacing: pt(0.8), color: col('muted'), align: edge.endsWith('left') ? 'left' : 'right', placement: at('container', edge, 0, y), ...extra, }); const song = (e) => ({ pages: 'body', fontFamily: e.serif, fontWeight: 400, fontSize: pt(8) }); const header = (e) => ({ elements: [ head(e, 'verso-title', '{title}', 'even', 'top-left', 14, song(e)), head(e, 'recto-title', '{chapterTitle}', 'odd', 'top-right', 14, song(e)), ] }); const footer = (e) => ({ elements: [ // folios at the outer foot, openers included head(e, 'verso-folio', '{pageNumber}', 'even', 'bottom-left', -12), head(e, 'recto-folio', '{pageNumber}', 'odd', 'bottom-right', -12), ] }); // #endregion let e = EDITIONS.hans; // the edition config() lays out: the build loop below sets it const config = () => ({ // a factory: the engine caches resolved configs per object locale: e.locale, // zh-Hans or zh-Hant, never LANG (gotcha: cjk-locale-tag) colorPalette, page: { sizePreset: 'custom', width: mm(140), height: mm(203), dpi: 150, // 大32开 backgroundColor: col('paper'), // Minimums: the character grid centres its type area in what they leave, 天头 over 地脚. margins: { top: mm(24), bottom: mm(20), left: mm(16), right: mm(20), mirror: true } }, layout: { layoutType: 'single' }, cjk: { grid: { enabled: true, charsPerLine: 26, linesPerPage: 24 } }, bodyText: { fontFamily: e.serif, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, // 2 characters avoidWidows: true, avoidOrphans: true }, headings: { fontFamily: e.kai, fontWeight: 400, color: col('ink'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontSize: pt(16), marginBottom: pt(0), advancedDesign: opener(e, 6, '第{numberHan}回'), // 第一回, 第二回… in the edition's script breakBefore: { enabled: true, parity: 'any' } }, // each 回 on a new page ] }, headingStyles: [{ ...indexHeading, span: 'page', marginBottom: pt(0), advancedDesign: opener(e, 7, '{attr.kicker}', indexNote(e)) }], index: index(e), paragraphStyles: [{ id: 'colophon', fontFamily: e.serif, fontSize: pt(7), lineHeight: pt(10.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header: header(e), footer: footer(e), }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 63 · content.en.md
title: "红楼梦人物初见" subtitle: "程乙本前三回选读" --- # 甄士隐梦幻识通灵 \\ 贾雨村风尘怀闺秀 却说那:index[女娲氏]{term="女娲"}炼石补天之时,于大荒山无稽崖炼成高十二丈、见方二十四丈大的顽石三万六千五百零一块。那娲皇只用了三万六千五百块,单单剩下一块未用,弃在青埂峰下。谁知此石自经锻炼之后,灵性已通,自去自来,可大可小。因见众石俱得补天,独自己无才,不得入选,遂自怨自愧,日夜悲哀。 又不知过了几世几劫,因有个:index[空空道人]{term="空空道人" main}访道求仙,从这大荒山无稽崖青埂峰下经过,忽见一块大石,上面字迹分明,编述历历。空空道人乃从头一看,原来是无才补天,幻形入世,被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}携入红尘,引登彼岸的一块顽石。 按那石头上书云:当日地陷东南,这东南有个姑苏城,城中阊门,最是红尘中一二等富贵风流之地。这阊门外有个十里街,街内有个仁清巷,巷内有个古庙,因地方狭窄,人皆呼作“葫芦庙”。庙旁住着一家乡宦,姓甄,名费:index{term="甄费" see="甄士隐"},字:index[士隐]{term="甄士隐" main},嫡妻:index[封氏]{term="封氏" main}。性情贤淑,深明礼义。家中虽不甚富贵,然本地也推他为望族了。因这甄士隐禀性恬淡,不以功名为念,每日只以观花种竹、酌酒吟诗为乐,倒是神仙一流人物。只是一件不足:年过半百,膝下无儿,只有一女,乳名:index[英莲]{term="英莲" main},年方三岁。 那僧道:“此事说来好笑。只因当年这个石头,娲皇未用,自己却也落得逍遥自在,各处去游玩。……一日,来到:index[警幻仙子]{term="警幻仙子" main}处,那仙子知他有些来历,因留他在赤霞宫中,名他为赤霞宫:index[神瑛侍者]{term="神瑛侍者" main}。他却常在西方灵河岸上行走,看见那灵河岸上三生石畔有棵:index[绛珠仙草]{term="绛珠仙草" main},十分娇娜可爱,遂日以甘露灌溉,‘这绛珠草’始得久延岁月。……” 这士隐正在痴想,忽见隔壁葫芦庙内寄居的一个穷儒——姓贾名化:index{term="贾化" see="贾雨村"},表字时飞,别号:index[雨村]{term="贾雨村" main}的——走来。这贾雨村原系湖州人氏,也是诗书仕宦之族。因他生于末世,父母祖宗根基已尽,人口衰丧,只剩得他一身一口,在家乡无益,因进京求取功名,再整基业。自前岁来此,又淹蹇住了,暂寄庙中安身,每日卖文作字为生,故士隐常与他交接。 真是闲处光阴易过,倏忽又是元宵佳节。士隐令家人:index[霍启]{term="霍启" main}抱了英莲去看社火花灯。半夜中,霍启因要小解,便将英莲放在一家门坎上坐着。待他小解完了来抱时,那有英莲的踪影?急的霍启直寻了半夜,至天明不见,那霍启也不敢回来见主人,便逃往他乡去了。 他岳丈名唤:index[封肃]{term="封肃" main},本贯大如州人氏,虽是务农,家中却还殷实。 # 贾夫人仙逝扬州城 \\ 冷子兴演说荣国府 却说:index[娇杏]{term="娇杏"}那丫头便是当年回顾:index[雨村]{term="贾雨村"}的。因偶然一看,便弄出这段奇缘,也是意想不到之事。谁知他命运两济:不承望自到雨村身边,只一年,便生一子;又半载,雨村嫡配忽染疾下世,雨村便将他扶作正室夫人。正是:“偶因一回顾,便为人上人。” 那日偶又游至维扬地方,闻得今年盐政点的是:index[林如海]{term="林如海" main}。 这林如海,姓林,名海,表字如海,乃是前科的探花,今已升兰台寺大夫。本贯姑苏人氏,今钦点为巡盐御史,到任未久。……只嫡妻:index[贾氏]{term="贾敏"}生得一女,乳名:index[黛玉]{term="林黛玉"},年方五岁,夫妻爱之如掌上明珠。 刚入肆门,只见座上吃酒之客,有一人起身大笑,接了出来,口内说:“奇遇,奇遇!”雨村忙看时,此人是都中古董行中贸易,姓:index[冷号子兴]{term="冷子兴" main}的,旧日在都相识。 子兴叹道:“正说的是这两门呢!待我告诉你:当日:index[宁国公]{term="宁国公"}与:index[荣国公]{term="荣国公"}是一母同胞弟兄两个。宁公居长,生了两个儿子。宁公死后,长子:index[贾代化]{term="贾代化"}袭了官,也养了两个儿子。长子名贾敷,八九岁上死了。只剩了一个次子:index[贾敬]{term="贾敬"},袭了官,如今一味好道,只爱烧丹炼汞,别事一概不管。幸而早年留下一个儿子,名唤:index[贾珍]{term="贾珍"},因他父亲一心想作神仙,把官倒让他袭了。他父亲又不肯住在家里,只在都中城外和那些道士们胡羼。这位珍爷也生了一个儿子,今年才十六岁,名叫:index[贾蓉]{term="贾蓉"}。如今敬老爷不管事了。这珍爷那里干正事?只一味高乐不了,把那宁国府竟翻过来了,也没有敢来管他的人。……自荣公死后,长子:index[贾代善]{term="贾代善"}袭了官,娶的是金陵世勋史侯家的小姐:index{term="贾母"}为妻,生了两个儿子:长名:index[贾赦]{term="贾赦"},次名:index[贾政]{term="贾政"}。如今代善早已去世,太夫人尚在。……这政老爷的夫人:index[王氏]{term="王夫人"},头胎生的公子名叫:index[贾珠]{term="贾珠"},十四岁进学,后来娶了妻,生了子,不到二十岁,一病就死了。第二胎生了一位小姐,生在大年初一,就奇了。不想隔了十几年又生了一位公子:index{term="贾宝玉"},说来更奇:一落胞胎,嘴里便衔下一块五彩晶莹的玉来,还有许多字迹。你道是新闻不是?” 子兴道:“便是贾府中现在三个也不错。政老爷的长女名:index[元春]{term="贾元春"},因贤孝才德选入宫作女史去了。二小姐乃是赦老爷姨娘所出,名:index[迎春]{term="贾迎春"};三小姐,政老爷庶出,名:index[探春]{term="贾探春"};四小姐乃宁府珍爷的胞妹,名:index[惜春]{term="贾惜春"}。因史老夫人极爱孙女,都跟在祖母这边一处读书,听得个个不错。” 若问那赦老爷,也有一子,名叫:index[贾琏]{term="贾琏"},今已二十多岁了,亲上做亲,娶的是政老爷夫人王氏内侄女:index{term="王熙凤"},今已娶了四五年。 # 托内兄如海荐西宾 \\ 接外孙贾母惜孤女 却说雨村忙回头看时,不是别人,乃是当日同僚一案参革的:index[张如圭]{term="张如圭" main}。 :index[黛玉]{term="林黛玉" main}方进房,只见两个人扶着一位鬓发如银的老母迎上来。黛玉知是:index[外祖母]{term="贾母" main},正欲下拜,早被外祖母抱住,搂入怀中,“心肝儿肉”叫着大哭起来。当下侍立之人无不落泪,黛玉也哭个不休。待众人慢慢劝解住了,那黛玉方拜见了外祖母,贾母方一一指与黛玉道:“这是你:index[大舅母]{term="邢夫人" main}。这是:index[二舅母]{term="王夫人" main}。这是你先前珠大哥的媳妇:index[珠大嫂子]{term="李纨" main}。”黛玉一一拜见了。 一语未完,只听后院中有笑语声,说:“我来迟了,没得迎接远客!”黛玉思忖道:“这些人个个皆敛声屏气如此,这来者是谁,这样放诞无礼?……”心下想时,只见一群媳妇丫鬟拥着一个丽人:index{term="王熙凤" main}从后房门进来。……一双丹凤三角眼,两弯柳叶吊梢眉。身量苗条,体格风骚。粉面含春威不露,丹唇未启笑先闻。 黛玉连忙起身接见。贾母笑道:“你不认得他。他是我们这里有名的一个‘泼辣货’,南京所谓‘辣子’你只叫他:index[凤辣子]{term="凤辣子" see="王熙凤"}就是了。”黛玉正不知以何称呼,众姊妹都忙告诉黛玉道:“这是琏二嫂子。”黛玉虽不曾识面,听见他母亲说过:“大舅贾赦之子贾琏娶的就是二舅母王氏的内侄女,自幼假充男儿教养,学名叫做:index[王熙凤]{term="王熙凤"}。”黛玉忙陪笑见礼,以“嫂”呼之。 一语未了,只听外面一阵脚步响,丫鬟进来报道宝玉来了:index{term="贾宝玉" main}。黛玉心想:“这个宝玉不知是怎样个惫懒人呢。……”及至进来一看,却是位青年公子。……面若中秋之月,色如春晓之花,鬓若刀裁,眉如墨画,鼻如悬胆,睛若秋波。虽怒时而似笑,即瞋视而有情。项上金螭缨络,又有一根五色丝绦,系着一块美玉。 黛玉只带了两个人来:一个是自己的奶娘:index[王嬷嬷]{term="王嬷嬷" main},一个是十岁的小丫头,名唤:index[雪雁]{term="雪雁" main}。贾母见雪雁甚小,一团孩气,王嬷嬷又极老,料黛玉皆不遂心,将自己身边一个二等小丫头,名唤:index[鹦哥]{term="鹦哥" main}的,与了黛玉。 当下王嬷嬷与鹦哥陪侍黛玉在碧纱橱内;宝玉乳母:index[李嬷嬷]{term="李嬷嬷" main}并大丫头名唤:index[袭人]{term="袭人" main}的陪侍在外面大床上。 原来这袭人亦是贾母之婢,本名蕊珠:index{term="蕊珠" see="袭人"}。贾母因溺爱宝玉,恐宝玉之婢不中使,素喜蕊珠心地纯良,遂与宝玉。宝玉因知他本姓花,又曾见旧人诗句有“花气袭人”之句,遂回明贾母,即把蕊珠更名袭人。 黛玉虽不知原委,探春等却晓得是议论金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"},倚财仗势打死人命,现在应天府案下审理。如今舅舅:index[王子腾]{term="王子腾"}得了信,遣人来告诉这边,意欲唤取进京之意。 # 人名索引 {style="index" kicker="按汉语拼音排列" note="本索引收录前三回选文中的人物。\n粗体页码为人物出场之页。\n别名与原名另立参见条目。"} :::index :::paragraphs{style="colophon"} Dream of the Red Chamber, chapters 1–3, Simplified edition: the passages where the characters first appear, from the Cheng–Gao text of 1792 (zh.wikisource, revision 9685985, CC BY-SA 4.0), converted to Simplified characters with OpenCC, with —— where the source has --. Cuts inside a paragraph are marked ……. Set in Noto Serif SC, Noto Sans SC and Ma Shan Zheng (SIL OFL). :::`; // the Simplified edition, content.<lang>.md const traditional = String.raw`---نموذج Markdown · أسطر: 63 · content.hant.en.md
title: "紅樓夢人物初見" subtitle: "程乙本前三回選讀" --- # 甄士隱夢幻識通靈 \\ 賈雨村風塵懷閨秀 卻說那:index[女媧氏]{term="女媧"}煉石補天之時,於大荒山無稽崖煉成高十二丈、見方二十四丈大的頑石三萬六千五百零一塊。那媧皇只用了三萬六千五百塊,單單剩下一塊未用,棄在青埂峰下。誰知此石自經鍛鍊之後,靈性已通,自去自來,可大可小。因見眾石俱得補天,獨自己無才,不得入選,遂自怨自愧,日夜悲哀。 又不知過了幾世幾劫,因有個:index[空空道人]{term="空空道人" main}訪道求仙,從這大荒山無稽崖青埂峰下經過,忽見一塊大石,上面字跡分明,編述歷歷。空空道人乃從頭一看,原來是無才補天,幻形入世,被那:index[茫茫大士]{term="茫茫大士" main}:index[渺渺真人]{term="渺渺真人" main}攜入紅塵,引登彼岸的一塊頑石。 按那石頭上書云:當日地陷東南,這東南有個姑蘇城,城中閶門,最是紅塵中一二等富貴風流之地。這閶門外有個十里街,街內有個仁清巷,巷內有個古廟,因地方狹窄,人皆呼作「葫蘆廟」。廟旁住著一家鄉宦,姓甄,名費:index{term="甄費" see="甄士隱"},字:index[士隱]{term="甄士隱" main},嫡妻:index[封氏]{term="封氏" main}。性情賢淑,深明禮義。家中雖不甚富貴,然本地也推他為望族了。因這甄士隱稟性恬淡,不以功名為念,每日只以觀花種竹、酌酒吟詩為樂,倒是神仙一流人物。只是一件不足:年過半百,膝下無兒,只有一女,乳名:index[英蓮]{term="英蓮" main},年方三歲。 那僧道:「此事說來好笑。只因當年這個石頭,媧皇未用,自己卻也落得逍遙自在,各處去遊玩。……一日,來到:index[警幻仙子]{term="警幻仙子" main}處,那仙子知他有些來歷,因留他在赤霞宮中,名他為赤霞宮:index[神瑛侍者]{term="神瑛侍者" main}。他卻常在西方靈河岸上行走,看見那靈河岸上三生石畔有棵:index[絳珠仙草]{term="絳珠仙草" main},十分嬌娜可愛,遂日以甘露灌溉,『這絳珠草』始得久延歲月。……」 這士隱正在痴想,忽見隔壁葫蘆廟內寄居的一個窮儒——姓賈名化:index{term="賈化" see="賈雨村"},表字時飛,別號:index[雨村]{term="賈雨村" main}的——走來。這賈雨村原係湖州人氏,也是詩書仕宦之族。因他生於末世,父母祖宗根基已盡,人口衰喪,只剩得他一身一口,在家鄉無益,因進京求取功名,再整基業。自前歲來此,又淹蹇住了,暫寄廟中安身,每日賣文作字為生,故士隱常與他交接。 真是閒處光陰易過,倏忽又是元宵佳節。士隱令家人:index[霍啟]{term="霍啟" main}抱了英蓮去看社火花燈。半夜中,霍啟因要小解,便將英蓮放在一家門坎上坐著。待他小解完了來抱時,那有英蓮的蹤影?急的霍啟直尋了半夜,至天明不見,那霍啟也不敢回來見主人,便逃往他鄉去了。 他岳丈名喚:index[封肅]{term="封肅" main},本貫大如州人氏,雖是務農,家中卻還殷實。 # 賈夫人仙逝揚州城 \\ 冷子興演說榮國府 卻說:index[嬌杏]{term="嬌杏"}那丫頭便是當年回顧:index[雨村]{term="賈雨村"}的。因偶然一看,便弄出這段奇緣,也是意想不到之事。誰知他命運兩濟:不承望自到雨村身邊,只一年,便生一子;又半載,雨村嫡配忽染疾下世,雨村便將他扶作正室夫人。正是:「偶因一回顧,便為人上人。」 那日偶又遊至維揚地方,聞得今年鹽政點的是:index[林如海]{term="林如海" main}。 這林如海,姓林,名海,表字如海,乃是前科的探花,今已升蘭臺寺大夫。本貫姑蘇人氏,今欽點為巡鹽御史,到任未久。……只嫡妻:index[賈氏]{term="賈敏"}生得一女,乳名:index[黛玉]{term="林黛玉"},年方五歲,夫妻愛之如掌上明珠。 剛入肆門,只見座上吃酒之客,有一人起身大笑,接了出來,口內說:「奇遇,奇遇!」雨村忙看時,此人是都中古董行中貿易,姓:index[冷號子興]{term="冷子興" main}的,舊日在都相識。 子興嘆道:「正說的是這兩門呢!待我告訴你:當日:index[寧國公]{term="寧國公"}與:index[榮國公]{term="榮國公"}是一母同胞弟兄兩個。寧公居長,生了兩個兒子。寧公死後,長子:index[賈代化]{term="賈代化"}襲了官,也養了兩個兒子。長子名賈敷,八九歲上死了。只剩了一個次子:index[賈敬]{term="賈敬"},襲了官,如今一味好道,只愛燒丹鍊汞,別事一概不管。幸而早年留下一個兒子,名喚:index[賈珍]{term="賈珍"},因他父親一心想作神仙,把官倒讓他襲了。他父親又不肯住在家裡,只在都中城外和那些道士們胡羼。這位珍爺也生了一個兒子,今年才十六歲,名叫:index[賈蓉]{term="賈蓉"}。如今敬老爺不管事了。這珍爺那裡幹正事?只一味高樂不了,把那寧國府竟翻過來了,也沒有敢來管他的人。……自榮公死後,長子:index[賈代善]{term="賈代善"}襲了官,娶的是金陵世勳史侯家的小姐:index{term="賈母"}為妻,生了兩個兒子:長名:index[賈赦]{term="賈赦"},次名:index[賈政]{term="賈政"}。如今代善早已去世,太夫人尚在。……這政老爺的夫人:index[王氏]{term="王夫人"},頭胎生的公子名叫:index[賈珠]{term="賈珠"},十四歲進學,後來娶了妻,生了子,不到二十歲,一病就死了。第二胎生了一位小姐,生在大年初一,就奇了。不想隔了十幾年又生了一位公子:index{term="賈寶玉"},說來更奇:一落胞胎,嘴裡便銜下一塊五彩晶瑩的玉來,還有許多字跡。你道是新聞不是?」 子興道:「便是賈府中現在三個也不錯。政老爺的長女名:index[元春]{term="賈元春"},因賢孝才德選入宮作女史去了。二小姐乃是赦老爺姨娘所出,名:index[迎春]{term="賈迎春"};三小姐,政老爺庶出,名:index[探春]{term="賈探春"};四小姐乃寧府珍爺的胞妹,名:index[惜春]{term="賈惜春"}。因史老夫人極愛孫女,都跟在祖母這邊一處讀書,聽得個個不錯。」 若問那赦老爺,也有一子,名叫:index[賈璉]{term="賈璉"},今已二十多歲了,親上做親,娶的是政老爺夫人王氏內侄女:index{term="王熙鳳"},今已娶了四五年。 # 託內兄如海薦西賓 \\ 接外孫賈母惜孤女 卻說雨村忙回頭看時,不是別人,乃是當日同僚一案參革的:index[張如圭]{term="張如圭" main}。 :index[黛玉]{term="林黛玉" main}方進房,只見兩個人扶著一位鬢髮如銀的老母迎上來。黛玉知是:index[外祖母]{term="賈母" main},正欲下拜,早被外祖母抱住,摟入懷中,「心肝兒肉」叫著大哭起來。當下侍立之人無不落淚,黛玉也哭個不休。待眾人慢慢勸解住了,那黛玉方拜見了外祖母,賈母方一一指與黛玉道:「這是你:index[大舅母]{term="邢夫人" main}。這是:index[二舅母]{term="王夫人" main}。這是你先前珠大哥的媳婦:index[珠大嫂子]{term="李紈" main}。」黛玉一一拜見了。 一語未完,只聽後院中有笑語聲,說:「我來遲了,沒得迎接遠客!」黛玉思忖道:「這些人個個皆斂聲屏氣如此,這來者是誰,這樣放誕無禮?……」心下想時,只見一群媳婦丫鬟擁著一個麗人:index{term="王熙鳳" main}從後房門進來。……一雙丹鳳三角眼,兩彎柳葉吊梢眉。身量苗條,體格風騷。粉面含春威不露,丹脣未啟笑先聞。 黛玉連忙起身接見。賈母笑道:「你不認得他。他是我們這裡有名的一個『潑辣貨』,南京所謂『辣子』你只叫他:index[鳳辣子]{term="鳳辣子" see="王熙鳳"}就是了。」黛玉正不知以何稱呼,眾姊妹都忙告訴黛玉道:「這是璉二嫂子。」黛玉雖不曾識面,聽見他母親說過:「大舅賈赦之子賈璉娶的就是二舅母王氏的內侄女,自幼假充男兒教養,學名叫做:index[王熙鳳]{term="王熙鳳"}。」黛玉忙陪笑見禮,以「嫂」呼之。 一語未了,只聽外面一陣腳步響,丫鬟進來報道寶玉來了:index{term="賈寶玉" main}。黛玉心想:「這個寶玉不知是怎樣個憊懶人呢。……」及至進來一看,卻是位青年公子。……面若中秋之月,色如春曉之花,鬢若刀裁,眉如墨畫,鼻如懸膽,睛若秋波。雖怒時而似笑,即瞋視而有情。項上金螭纓絡,又有一根五色絲絛,繫著一塊美玉。 黛玉只帶了兩個人來:一個是自己的奶孃:index[王嬤嬤]{term="王嬤嬤" main},一個是十歲的小丫頭,名喚:index[雪雁]{term="雪雁" main}。賈母見雪雁甚小,一團孩氣,王嬤嬤又極老,料黛玉皆不遂心,將自己身邊一個二等小丫頭,名喚:index[鸚哥]{term="鸚哥" main}的,與了黛玉。 當下王嬤嬤與鸚哥陪侍黛玉在碧紗櫥內;寶玉乳母:index[李嬤嬤]{term="李嬤嬤" main}並大丫頭名喚:index[襲人]{term="襲人" main}的陪侍在外面大床上。 原來這襲人亦是賈母之婢,本名蕊珠:index{term="蕊珠" see="襲人"}。賈母因溺愛寶玉,恐寶玉之婢不中使,素喜蕊珠心地純良,遂與寶玉。寶玉因知他本姓花,又曾見舊人詩句有「花氣襲人」之句,遂回明賈母,即把蕊珠更名襲人。 黛玉雖不知原委,探春等卻曉得是議論金陵城中居住的薛家姨母之子——表兄:index[薛蟠]{term="薛蟠"},倚財仗勢打死人命,現在應天府案下審理。如今舅舅:index[王子騰]{term="王子騰"}得了信,遣人來告訴這邊,意欲喚取進京之意。 # 人名索引 {style="index" kicker="依筆畫排列" note="本索引收錄前三回選文中的人物。\n粗體頁碼為人物出場之頁。\n別名與原名另立參見條目。"} :::index :::paragraphs{style="colophon"} Dream of the Red Chamber, chapters 1–3, Traditional edition: the passages where the characters first appear, from the Cheng–Gao text of 1792 (zh.wikisource, revision 9685985, CC BY-SA 4.0), with 原係 where the source has 原系 and —— where it has --. Cuts inside a paragraph are marked ……. Set in Noto Serif TC, Noto Sans TC and LXGW WenKai TC (SIL OFL). :::`; // the Traditional edition, content.hant.<lang>.md // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). The CJK families load by slices. const FONTS = { 'Noto Serif SC': ['400', '700'], 'Noto Sans SC': ['700'], 'Ma Shan Zheng': ['400'], 'Noto Serif TC': ['400', '700'], 'Noto Sans TC': ['700'], 'LXGW WenKai TC': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + traditional); // #region voices: each face loads the files of the characters it sets (gotcha: cjk-fonts-slices) const DIGITS = '0123456789'; const HEADS = '第回一二三四五六七八九十画畫ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // 第一回, the index heads const all = (md, re) => [...md.matchAll(re)].map((m) => m[1]).join('\n'); for (const [key, md] of [['hans', markdown], ['hant', traditional]]) { e = EDITIONS[key]; await loadCjkFonts({ [e.serif]: ['400'] }, md); // the text, the index, the running heads await loadCjkFonts({ [e.serif]: ['700'] }, DIGITS); // the bold page numbers await loadCjkFonts({ [e.sans]: ['700'] }, all(md, /kicker="([^"]*)"/g) + HEADS + DIGITS); await loadCjkFonts({ [e.kai]: ['400'] }, all(md, /^# ([^{\n]*)/gm)); // the titles alone } // #endregion const docs = {}; for (const [key, md] of [['hans', markdown], ['hant', traditional]]) { e = EDITIONS[key]; docs[key] = await buildWithFonts(() => buildDocument({ markdown: md }, config()), md); } const title = t({ en: 'A Chinese name index', es: 'Un índice de nombres chinos' }); // The desk shows either edition, and the PDF is built from the one on it. const switches = ['hans', 'hant'].map((key) => Object.assign(document.createElement('button'), { type: 'button', value: key, textContent: key === 'hans' ? '简体版' : '繁體版' })); const show = (key) => { showPages(docs[key], { title }); for (const b of switches) b.ariaPressed = String(b.value === key); // the edition on the desk document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove()); offerPdf(() => renderToPdf(docs[key], { fontProvider: cjkPdfProvider }), `${RECIPE}-${key}.pdf`); }; for (const b of switches) b.addEventListener('click', () => show(b.value)); show('hans'); document.getElementById('pt-actions').prepend(...switches); document.head.insertAdjacentHTML('beforeend', '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>'); // #region side-by-side: the first index page of each edition, above the desk const indexPage = (doc) => doc.pages[doc.blocks.find((b) => b.headingStyleId === 'index') ?.pageIndex ?? doc.pages.length - 1]; const labels = { hans: t({ en: 'Simplified · by pinyin', es: 'Simplificado · por pinyin' }), hant: t({ en: 'Traditional · by strokes', es: 'Tradicional · por trazos' }) }; document.getElementById('pages').insertAdjacentHTML('beforebegin', `<section id="indexes" style=" display:flex;flex-wrap:wrap;justify-content:center;gap:24px;padding:28px 16px;background:#e9e4da"> ${Object.keys(docs).map((key) => `<figure style="margin:0;width:min(340px,44vw)"> <canvas id="index-${key}" role="img" style="display:block;width:100%;background:#fff; box-shadow:0 18px 36px -18px rgb(0 0 0 / .45)"></canvas><figcaption style="margin-top:10px; font:600 11px/1 system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:#5b534a"> ${labels[key]}</figcaption></figure>`).join('')}</section>`); for (const [key, doc] of Object.entries(docs)) { const canvas = document.getElementById(`index-${key}`); const page = indexPage(doc); canvas.setAttribute('aria-label', `${labels[key]}, ${page.pageLabel}`); renderPageToCanvas(page, doc, canvas, { scale: (2 * canvas.clientWidth) / page.width }); } // #endregionالعُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#رتّب فهرسًا تقليديًا بالبينيين
يمكن لطبعة تقليدية موجهة إلى قراء يبحثون عن الأسماء بنطقها أن ترتّبها بالبينيين. اضبط groupBy: 'pinyin' في الطبعتين: تصير الرؤوس من A إلى Z وتحتفظ المداخل بأشكالها التقليدية. والعنوان التمهيدي يقول كيف رُتّب الفهرس، فغيّره في Markdown التقليدي أيضًا إلى kicker="依漢語拼音排列".
- groupBy: e.locale === 'zh-Hans' ? 'pinyin' : 'stroke', // A B C… or 三畫 四畫…
+ groupBy: 'pinyin', // A B C… in both scripts#اطبع الأسماء بلا رؤوس
قائمة قصيرة من الأسماء تُقرأ جيدًا بسطر فارغ بين المجموعات ومن غير رأس فوقها. أبقِ التجميع وأطفئ الرؤوس. لن يؤدي groupBy: 'none' الغرض: فهو لا يفصل إلا الرموز والأرقام والكلمات، وكل اسم هنا يبدأ بحرف هان، فتجري الأسماء السبعة والأربعون كتلة واحدة.
- groups: { fontFamily: e.sans, fontSize: pt(9.5), fontWeight: 700, color: col('cinnabar'),
- marginTop: pt(7) },
+ groups: { enabled: false, marginTop: pt(14) }, // no heads; a blank line between groupsأخطاء شائعة
خطأ شائع
الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk
يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص. الخطوط الصينية واليابانية والكورية →
خطأ شائع
لا مائل في الصينية
لا توفّر عائلات CJK وجهًا مائلًا، والطباعة الصينية تميّز التوكيد بنقاط بجانب الحروف، لا بالإمالة. في مستند موسوم بالصينية، يضع *…* نقاط التوكيد على الحروف الصينية التي يحتويها ويُبقي المائل للكلمات اللاتينية (cjk.emphasis: 'dots'، القيمة الافتراضية للصينية)؛ ويضعها :dots[…] في أي مكان. أما مع cjk.emphasis: 'italic'، أو في مستند موسوم بـ en أو es، فيجعل *…* لوحة Canvas تُميل الحروف القائمة، وهو مائل مصطنع لا تستخدمه الطباعة الصينية أبدًا (ويعود PDF إلى الوجه القائم): أبقِ الوسم الصيني، أو ضع التوكيد بالعريض أو بخط Kai (LXGW WenKai) عبر نمط فقرة أو نمط شارة. الخطوط الصينية واليابانية والكورية →
خطأ شائع
اوسم المستند بـ zh-Hans أو zh-Hant، لا بـ LANG
نسختا الوصفة هما en وes، لكن المثال الصيني صيني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، فيقسّم كلماته اللاتينية بالواصلة، ويسمّي أشكاله Figure أو Figura، ويعطي PDF لغة خاطئة. اكتب الوسم بنفسك: 'zh-Hans' (أعراف البر الرئيسي: كسر الأسطر وفق GB، وترقيم Kaiming) أو 'zh-Hant' (تايوان: ترقيم بعرض كامل متمركز)؛ و'zh-HK' لهونغ كونغ. أما 'zh' المجرّد فيُقرأ صينية مبسطة على أعراف البر الرئيسي. تقسيم الأسطر في النص الصيني →
خطأ شائع
ضع ترقيم كل منطقة بخط من تلك المنطقة
تنقل عروض علامات الترقيم الفراغ الذي بجانب كل علامة إلى الجهة التي تضعه فيها منطقة المستند، لا الجهة التي يرسمه فيها الخط: مع zh-Hans تحتفظ فاصلة Kaiming بالنصف الأيسر من إطارها، حيث يرسم الخط المبسط الحرف، وتتخلى عن النصف الأيمن. أما الخط التقليدي فيضع ,。 في وسط الإطار، فيحمل النصف المحذوف جزءًا من الحرف وتزاحم الفاصلةُ الحرفَ التالي. وهذا ما يفعله LXGW WenKai TC، وهو خط Kai النصي الوحيد في Fontsource، في النص المبسط. ضع نص البر الرئيسي وحواشيه واقتباساته بـ Noto Serif SC أو Noto Sans SC، واحتفظ بخطوط TC للنص zh-Hant، ولا تستخدم خط فرشاة مبسطًا (Ma Shan Zheng) إلا لأسطر العرض، حيث لا يطبّق نص التصميم عروض الترقيم. كما يرسم ذلك الخط أشكالًا موروثة لا يطبعها معيار البر الرئيسي ولا معيار تايوان، 為 برأس 爫 الذي في 爲، و令 (في 冷 و領) بقاعدة 卩: افحص الحروف التي تضعها الصفحة به. عروض علامات الترقيم الصينية →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
- إعداد
indexيخص المستند، فلا يطبع المستند الواحد إلا ترتيبًا واحدًا. والكتاب الذي يريد فهرسًا بالبينيين وقائمة بحث بالضربات معًا يُبنى مرتين، كما يفعل هذا المثال.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- Dream of the Red Chamber (紅樓夢), chapters 1–3, passages where the characters first appear, in the Cheng–Gao edition of 1792 (程乙本); Wikisource transcription, revision 9685985. The Traditional edition reads the source's 原系 as 原係; both set its -- as ——; the Simplified edition converted with OpenCC · Cao Xueqin, Cheng Weiyuan and Gao E; transcription by Wikisource editors · CC BY-SA 4.0
- الخطوط
- Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · Ma Shan Zheng (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


