باختصار
كتاب قراءة مدرسي من تايوان مطبوع في أعمدة من الأعلى إلى الأسفل. بجانب كل حرف صيني دليل نطق صغير، فيقرأ الأطفال كلمات لم يتعلموها بعد.
ما الذي ستنضده
الدرس 12 من الكتاب 9 من 國語، وهو كتاب قراءة متخيَّل للصف الخامس من تايوان: حكايتان من Han Feizi، فلاح سونغ الذي ينتظر بجانب جذع شجرة أرنبًا آخر، ورجل تشنغ الذي يثق بمقاسه أكثر من قدميه. تُنضَّد كتب القراءة في تايوان عموديًا، بخط Kai، وزهويين كل حرف في عمود على يمينه، فيقرأ الطفل الحرف قبل أن يتعلمه. يُفتتح الدرس على صفحتين متقابلتين مجلدتين على اليمين. في الأعلى طبقة تحمل الصور والحواشي والمؤلف والحروف الجديدة؛ وفي الأسفل يجري الدرس بخط Iansui بحجم 16 pt، ثلاثة وعشرون حرفًا في السطر. ونظيره بالحرف اللاتيني هو الكتاب المدرسي بعمود هامشي، الذي يصير عموده الجانبي المخصص للعناصر العائمة هنا الطبقةَ العليا، مُدارًا ربع دورة.
تجيب هذه الوصفة عن
- كيف أنضد التشويين (zhuyin) بجانب كل حرف من نص صيني عمودي؟
- كيف أنضد كتابًا صينيًّا عموديًّا، مجلَّدًا من اليمين؟
الجواب المختصر
const layout = {
writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right
layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above
sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text
sideColumnPercent: 34,
gutterWidth: pt(2 * SIZE),
};
const cjk = {
// The lower tier: 23 characters down, 13 lines across the page.
grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 },
// Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em,
// so three symbols fit beside one character, and beside each of two in a row.
ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
};
// The line pitch is twice the size: a gap of one em, which the zhuyin and its tone
// marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page.
const LINE = 2 * SIZE;
const bodyText = {
fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true,
};
المكونات
- الميزات
- قراءات ruby: pinyin وzhuyinالنص العموديكتب مجلّدة من اليمينالخطوط الصينية واليابانية والكوريةعروض علامات الترقيم الصينيةشبكة المحارفعمود ونصفعمود هامشي للعناصر العائمةملاحظات الهامشالأرقام الصينيةالقوائم المرقّمةأنواع موارد مخصّصةإطارات التنبيهأنماط الفقراتأنماط العناوينالنصوص والخطوط والمربعات في تصاميم الصفحةالترويسات وأرقام الصفحاتالتصدير إلى PDF
- تستخدم أيضًا
- تقسيم الأسطر في النص الصينيسمات العنوانأشكال في هذا الموضع بالضبطالترويسات بحسب دور الصفحةالخطوط المضمَّنة في PDFالأشكال والجداول بوصفها مواردأرقام قائمة في النص العمودي
- الخطوط
- Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1)
- الأصول
plate-1-1500.jpgplate-2-1500.jpg- The farmer waiting by the stump, a brush painting (Generated With Diffusion Models, أصلي)
- The man of Zheng hurrying home from the shoe stall, a brush painting (Generated With Diffusion Models, أصلي)
طريقة التحضير
#1 · عمود زهويين بجانب كل حرف
الشيفرة هي الجواب المختصر أعلاه. يعطي {宋人|ㄙㄨㄥˋ|ㄖㄣˊ} كل حرف قراءته الخاصة، واحدة لكل |؛ وتبقى علامات الترقيم خارج الأقواس، فلا يأخذ 。 ولا , شيئًا. في النص العمودي تقع القراءة بالبوبوموفو يمين العمود، ورموزها مكدسة، وعلامة النبرة يمين آخرها ونقطة النبرة المحايدة فوق أولها. وبنصف حجم النص يضبط cjk.ruby.fontSize الرموز على 0.3 em، فتتسع ثلاثة رموز بجانب حرف، وقراءتان كهاتين متتاليتين، كما في 就像، تبقيان على بعد ربع رمز على الأقل دون أن يتسع أي من الحرفين؛ أما بحجم 0.6 em فيصير ㄇㄧㄥˊ أطول من 明 ويباعد بين حرفي 明明. ويترك تباعد الأسطر البالغ 32 pt فراغًا قدره em واحد، يملأ الزهويين وعلامات نبراته نصفه. وتطلب clreq 1.5 em حين يقف الزهويين بين أسطر عمودية؛ وهذا تباعد 40 pt وعشرة أسطر في الصفحة، لذا تحتفظ هذه الصفحة بـ em واحد لثلاثة عشر سطرًا من ثلاثة وعشرين حرفًا، وتبقى كل قراءة على بعد نحو 8 pt من السطر المجاور.
#2 · الصور والحواشي في الطبقة العليا
const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}',
resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption
const box = (id, title, body) => ({ id, title, background: col('tint'),
padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) },
titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') },
body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'),
italicColor: col('ink'), ...body } });
// Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt.
const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text
box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }),
box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24),
textAlign: 'justify', firstLineIndent: em(2) }),
box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40),
textAlign: 'center' }),
];
في النص العمودي تصير أعمدة تخطيط oneAndHalf طبقات، ويضع sideColumnSide: 'left' الطبقة المخصصة للعناصر العائمة في الأعلى. يرسل ::resource{id="plate-1"} في النص الصورة الأولى إلى هناك؛ ونوع plate بلا بادئة تعليق والصور بلا تعليق، فلا يُطبع شيء تحتها. والإطار المسيَّج بـ span="side" يقع في الطبقة ابتداءً من السطر الذي سُيِّج فيه، ويجري نصه نزولًا في الطبقة. وحواشي الحكايتين إطار واحد، مرقمة على امتداد الدرس ومسيَّجة بعد النص الأصلي الأول، الذي ينتهي في الصفحة 86: فتملأ طبقة الصفحة 87، فتقع حواشي الحكايتين على الصفحتين المتقابلتين اللتين تحملان نصيهما الأصليين. والزهويين 0.3 em من النص الذي يقرؤه، وcjk.ruby إعداد واحد للكتاب كله، لذا تُنضَّد الحواشي وإطار المؤلف بحجم 13 pt لتبقى قراءاتها بحجم 3.9 pt.
#3 · أربعة خطوط، كل منها محمَّل بنصه
// {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face.
const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm;
const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1');
const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join('');
await loadFonts(FONTS, markdown);
await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true });
const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n');
await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true });
await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true });
await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true });
await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId))));
// Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread.
const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } };
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources, continuation }, config()), markdown);
showBook(doc, { title: t({ en: 'A vertical reader with zhuyin',
es: 'Un libro de lectura vertical con zhuyin' }) });
offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
الدرس بخط Iansui، وهو خط Kai مرسوم من Klee One على صيغ الحروف المعيارية في تايوان، وهي التي يتعلم الأطفال كتابتها. والقراءات بخط LXGW WenKai TC، الذي تبقى نقطة نبرته المحايدة مستديرة بحجم 4.8 pt، والحواشي بخط Noto Serif TC، والتسميات بخط Noto Sans TC. يحمّل كل خط الملفات التي تحتوي الحروف التي ينضّدها: تُفصل القراءات عن حروفها، وتذهب إطارات الحواشي إلى خط Ming وحده. ويضيف vertical: true الصيغ العمودية، فيقف 「」 و、 كما ينبغي نزولًا في العمود. ويجعل pageIndexOffset: 1 الصفحة 86 صفحة زوجية، فيُفتتح الدرس على صفحتين متقابلتين.
#4 · اللسان وأرقام الصفحات على الحافة الخارجية
// The verso lies on the right of the spread, so even pages carry both at the right edge.
const tab = (parity, edge) => [
{ kind: 'box', id: `tab-${parity}`, parity, pages: 'all',
placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) },
size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } },
{ kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all',
writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700,
color: col('paper'), align: 'center', verticalAlign: 'middle',
placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' },
size: { width: mm(9), height: mm(64) } } },
];
const foot = (id, parity, edge, x, content, extra = {}) => ({
kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8),
color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } },
...extra,
});
const folio = { fontSize: pt(9), fontWeight: 700 };
const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] };
const footer = {
elements: [
foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio),
foot('book', 'even', 'bottom-right', -26, '國語 第九冊'),
foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio),
foot('lesson', 'odd', 'bottom-left', 26, '{chapterTitle}'),
],
};
في كتاب مجلد على اليمين تقع الصفحة الزوجية على يمين الصفحتين المتقابلتين، فتحمل الصفحات الزوجية لسان الوحدة ورقم الصفحة على الحافة اليمنى، والصفحات الفردية على اليسرى. واللسان صندوق فوقه عنصر نصي؛ يضع writingMode: 'vertical-rl' في العنصر 第三單元 寓言故事 نزولًا على اللسان، بينما تبقى أرقام الصفحات وأسطر التذييل أفقية.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 080 · A vertical reader with zhuyin to the right ═════════ // https://postext.dev/en/cookbook/zhuyin-vertical-reader // Code: MIT · Text: Han Feizi, zh.wikisource (CC BY-SA 4.0) · Pictures: diffusion models // Fonts: Iansui, LXGW WenKai TC, Noto Serif TC, Noto Sans TC (SIL OFL 1.1) · Needs postext ≥ 1.9.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, loadVerticalAlternates, } 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 = 'zhuyin-vertical-reader'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, every one linked by id const palette = { ink: '#2b2520', // text and zhuyin accent: '#b83f28', // lesson title, labels, the unit's tab (4.9:1 on the tint) tint: '#f7efdf', // the boxes of the upper tier muted: '#72675b', // lead, folios, colophon paper: '#ffffff', // the lettering on the tab }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion const KAI = 'Iansui'; // 楷: the lesson, drawn to Taiwan's standard forms (為, not 爲) const ZHUYIN = 'LXGW WenKai TC'; // the readings: a round dot for the neutral tone const MING = 'Noto Serif TC'; // 明: notes and the author box const HEI = 'Noto Sans TC'; // 黑: labels, folios, the tab const SIZE = 16; // the text size (三號) // #region answer: vertical text with a zhuyin column right of every character const layout = { writingMode: 'vertical-rl', // lines run down the page, read from the right; bound on the right layoutType: 'oneAndHalf', // two tiers: the text below, pictures and notes above sideColumnRole: 'floats', sideColumnSide: 'left', // 'left' is the top tier in vertical text sideColumnPercent: 34, gutterWidth: pt(2 * SIZE), }; const cjk = { // The lower tier: 23 characters down, 13 lines across the page. grid: { enabled: true, charsPerLine: 23, linesPerPage: 13 }, // Readings at half the text size; zhuyin sets its symbols at 60 % of that, 0.3 em, // so three symbols fit beside one character, and beside each of two in a row. ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) }, }; // The line pitch is twice the size: a gap of one em, which the zhuyin and its tone // marks half fill. clreq asks for 1.5 em; one em keeps 13 lines of 23 on the page. const LINE = 2 * SIZE; const bodyText = { fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(2), indentAfterHeading: true, }; // #endregion // #region upper-tier: pictures without a caption line, boxes of notes set down the tier const resourceTypes = [{ id: 'plate', name: '插圖', shortLabel: '圖', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '' }]; // no prefix, no caption const box = (id, title, body) => ({ id, title, background: col('tint'), padding: { top: mm(3), right: mm(3), bottom: mm(3), left: mm(3) }, titleStyle: { fontFamily: HEI, fontSize: pt(11), fontWeight: 700, color: col('accent') }, body: { color: col('ink'), firstLineIndent: pt(0), textAlign: 'left', boldColor: col('ink'), italicColor: col('ink'), ...body } }); // Zhuyin is 0.3 em of the text it reads: at 13 pt the notes' readings are 3.9 pt. const calloutStyles = [ // fenced :::callout{type="notes" span="side"} in the text box('notes', '注釋', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24) }), box('author', '作者', { fontFamily: MING, fontSize: pt(13), lineHeight: pt(24), textAlign: 'justify', firstLineIndent: em(2) }), box('chars', '生字', { fontFamily: KAI, fontSize: pt(22), lineHeight: pt(40), textAlign: 'center' }), ]; // #endregion // #region furniture: the unit's tab and the folios, on the outer edge of a right-bound book // The verso lies on the right of the spread, so even pages carry both at the right edge. const tab = (parity, edge) => [ { kind: 'box', id: `tab-${parity}`, parity, pages: 'all', placement: { anchor: { to: 'page', edge }, offset: { y: mm(24) }, size: { width: mm(9), height: mm(64) } }, style: { backgroundColor: col('accent') } }, { kind: 'text', id: `unit-${parity}`, content: '第三單元 寓言故事', parity, pages: 'all', writingMode: 'vertical-rl', fontFamily: HEI, fontSize: pt(9), fontWeight: 700, color: col('paper'), align: 'center', verticalAlign: 'middle', placement: { anchor: { to: `#tab-${parity}`, edge: 'align-top' }, size: { width: mm(9), height: mm(64) } } }, ]; const foot = (id, parity, edge, x, content, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'all', fontFamily: HEI, fontSize: pt(8), color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(-9) } }, ...extra, }); const folio = { fontSize: pt(9), fontWeight: 700 }; const header = { elements: [...tab('even', 'top-right'), ...tab('odd', 'top-left')] }; const footer = { elements: [ foot('folio-even', 'even', 'bottom-right', -16, '{pageNumber}', folio), foot('book', 'even', 'bottom-right', -26, '國語 第九冊'), foot('folio-odd', 'odd', 'bottom-left', 16, '{pageNumber}', folio), foot('lesson', 'odd', 'bottom-left', 26, '{chapterTitle}'), ], }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'zh-Hant', // Taiwan: full-width punctuation, centred in its cell (gotcha: cjk-locale-tag) colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(184), height: mm(260), dpi: 150, // 16開 margins: { top: mm(24), bottom: mm(18), left: mm(18), right: mm(16), mirror: true }, }, layout, cjk, bodyText, headings: { fontFamily: KAI, color: col('ink'), fontWeight: 400, // Iansui has one weight levels: [ // The lesson and fable numbers are typed in the headings: a numberingTemplate would // join the number to the title's first reading (see the recipe's workarounds). { level: 1, fontSize: pt(30), lineHeight: pt(2 * LINE), color: col('accent'), breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, { level: 2, fontSize: pt(20), lineHeight: pt(2 * LINE), marginTop: pt(LINE), marginBottom: pt(0) }, { level: 3, fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) }, ], }, // 想一想 and 語文天地: exercise heads in the label face, one line tall. headingStyles: [{ id: 'drill', fontFamily: HEI, fontWeight: 700, fontSize: pt(13), lineHeight: pt(LINE), color: col('accent'), marginTop: pt(LINE / 2), marginBottom: pt(0) }], orderedLists: { numberFormat: 'trad-chinese-informal', separator: '、', color: col('accent'), fontFamily: HEI, fontWeight: 700, marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [ { id: 'lead', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('muted'), textAlign: 'justify', firstLineIndent: em(0) }, { id: 'plain', fontFamily: KAI, fontSize: pt(14), lineHeight: pt(LINE), color: col('ink'), textAlign: 'justify', firstLineIndent: em(2) }, { id: 'idiom', fontFamily: KAI, fontSize: pt(SIZE), lineHeight: pt(LINE), color: col('ink'), boldColor: col('accent'), boldFontWeight: 400, textAlign: 'justify', // bold in colour only firstLineIndent: em(0) }, { id: 'colophon', fontFamily: HEI, fontSize: pt(7), lineHeight: pt(11), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LINE) }, ], calloutStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# 第十二課 {寓言兩則|ㄩˋ|ㄧㄢˊ|ㄌㄧㄤˇ|ㄗㄜˊ}نموذج Markdown · أسطر: 91 · content.en.md
:::paragraphs{style="lead"} {寓言用一個短短的故事|ㄩˋ|ㄧㄢˊ|ㄩㄥˋ|ㄧ|˙ㄍㄜ|ㄉㄨㄢˇ|ㄉㄨㄢˇ|˙ㄉㄜ|ㄍㄨˋ|˙ㄕ},{說出一個道理|ㄕㄨㄛ|ㄔㄨ|ㄧ|˙ㄍㄜ|ㄉㄠˋ|ㄌㄧˇ}。{這一課的兩則寓言|ㄓㄜˋ|ㄧ|ㄎㄜˋ|˙ㄉㄜ|ㄌㄧㄤˇ|ㄗㄜˊ|ㄩˋ|ㄧㄢˊ},{都選自戰國時代韓非所寫的|ㄉㄡ|ㄒㄩㄢˇ|ㄗˋ|ㄓㄢˋ|ㄍㄨㄛˊ|ㄕˊ|ㄉㄞˋ|ㄏㄢˊ|ㄈㄟ|ㄙㄨㄛˇ|ㄒㄧㄝˇ|˙ㄉㄜ}《{韓非子|ㄏㄢˊ|ㄈㄟ|ㄗˇ}》。{讀的時候想一想|ㄉㄨˊ|˙ㄉㄜ|ㄕˊ|ㄏㄡˋ|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ}:{故事裡的人|ㄍㄨˋ|˙ㄕ|ㄌㄧˇ|˙ㄉㄜ|ㄖㄣˊ},{錯在哪裡|ㄘㄨㄛˋ|ㄗㄞˋ|ㄋㄚˇ|ㄌㄧˇ}? ::: ::resource{id="plate-1"} ## 一 {守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ} {宋人有耕田者|ㄙㄨㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄍㄥ|ㄊㄧㄢˊ|ㄓㄜˇ},{田中有株|ㄊㄧㄢˊ|ㄓㄨㄥ|ㄧㄡˇ|ㄓㄨ},{兔走觸株|ㄊㄨˋ|ㄗㄡˇ|ㄔㄨˋ|ㄓㄨ},{折頸而死|ㄓㄜˊ|ㄐㄧㄥˇ|ㄦˊ|ㄙˇ},{因釋其耒而守株|ㄧㄣ|ㄕˋ|ㄑㄧˊ|ㄌㄟˇ|ㄦˊ|ㄕㄡˇ|ㄓㄨ},{冀復得兔|ㄐㄧˋ|ㄈㄨˋ|ㄉㄜˊ|ㄊㄨˋ},{兔不可復得|ㄊㄨˋ|ㄅㄨˋ|ㄎㄜˇ|ㄈㄨˋ|ㄉㄜˊ},{而身為宋國笑|ㄦˊ|ㄕㄣ|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}。 :::callout{type="notes" span="side"} ①{株|ㄓㄨ}:露出地面的樹根。 ②{走|ㄗㄡˇ}:跑。 ③{觸|ㄔㄨˋ}:碰,撞。 ④{釋|ㄕˋ}:放下。 ⑤{耒|ㄌㄟˇ}:古代翻土用的農具。 ⑥{冀|ㄐㄧˋ}:希望。 ⑦{為宋國笑|ㄨㄟˊ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄒㄧㄠˋ}:被宋國人譏笑。 ⑧{且置履|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ}:將要買鞋。 ⑨{度|ㄉㄨㄛˋ}:量長短。 ⑩{坐|ㄗㄨㄛˋ}:同「座」,座位。 ⑪{操|ㄘㄠ}:拿,帶著。 ⑫{度|ㄉㄨˋ}:量好的尺碼。 ⑬{反|ㄈㄢˇ}:同「返」,回去。 ⑭{罷|ㄅㄚˋ}:結束,散了。 ⑮{寧|ㄋㄧㄥˊ}:寧可。 ::: ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {宋國有一個種田的人|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|ㄧ|˙ㄍㄜ|ㄓㄨㄥˋ|ㄊㄧㄢˊ|˙ㄉㄜ|ㄖㄣˊ},{田裡有一截樹樁|ㄊㄧㄢˊ|ㄌㄧˇ|ㄧㄡˇ|ㄧ|ㄐㄧㄝˊ|ㄕㄨˋ|ㄓㄨㄤ}。{有一天|ㄧㄡˇ|ㄧ|ㄊㄧㄢ},{一隻兔子跑過來|ㄧ|ㄓ|ㄊㄨˋ|˙ㄗ|ㄆㄠˇ|ㄍㄨㄛˋ|˙ㄌㄞ},{一頭撞在樹樁上|ㄧ|ㄊㄡˊ|ㄓㄨㄤˋ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄕㄤˋ},{折斷脖子死了|ㄓㄜˊ|ㄉㄨㄢˋ|ㄅㄛˊ|˙ㄗ|ㄙˇ|˙ㄌㄜ}。{這個人就放下手裡的農具|ㄓㄜˋ|˙ㄍㄜ|ㄖㄣˊ|ㄐㄧㄡˋ|ㄈㄤˋ|ㄒㄧㄚˋ|ㄕㄡˇ|ㄌㄧˇ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄐㄩˋ},{天天守在樹樁旁邊|ㄊㄧㄢ|ㄊㄧㄢ|ㄕㄡˇ|ㄗㄞˋ|ㄕㄨˋ|ㄓㄨㄤ|ㄆㄤˊ|ㄅㄧㄢ},{希望再撿到兔子|ㄒㄧ|ㄨㄤˋ|ㄗㄞˋ|ㄐㄧㄢˇ|ㄉㄠˋ|ㄊㄨˋ|˙ㄗ}。{兔子再也沒有來過|ㄊㄨˋ|˙ㄗ|ㄗㄞˋ|ㄧㄝˇ|ㄇㄟˊ|ㄧㄡˇ|ㄌㄞˊ|ㄍㄨㄛˋ},{他自己倒成了宋國人的笑話|ㄊㄚ|ㄗˋ|ㄐㄧˇ|ㄉㄠˋ|ㄔㄥˊ|˙ㄌㄜ|ㄙㄨㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}。 ::: ::resource{id="plate-2"} ## 二 {鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ} {鄭人有且置履者|ㄓㄥˋ|ㄖㄣˊ|ㄧㄡˇ|ㄑㄧㄝˇ|ㄓˋ|ㄌㄩˇ|ㄓㄜˇ},{先自度其足而置之其坐|ㄒㄧㄢ|ㄗˋ|ㄉㄨㄛˋ|ㄑㄧˊ|ㄗㄨˊ|ㄦˊ|ㄓˋ|ㄓ|ㄑㄧˊ|ㄗㄨㄛˋ},{至之市而忘操之|ㄓˋ|ㄓ|ㄕˋ|ㄦˊ|ㄨㄤˋ|ㄘㄠ|ㄓ}。{已得履|ㄧˇ|ㄉㄜˊ|ㄌㄩˇ},{乃曰|ㄋㄞˇ|ㄩㄝ}:「{吾忘持度|ㄨˊ|ㄨㄤˋ|ㄔˊ|ㄉㄨˋ}。」{反歸取之|ㄈㄢˇ|ㄍㄨㄟ|ㄑㄩˇ|ㄓ}。{及反|ㄐㄧˊ|ㄈㄢˇ},{市罷|ㄕˋ|ㄅㄚˋ},{遂不得履|ㄙㄨㄟˋ|ㄅㄨˋ|ㄉㄜˊ|ㄌㄩˇ}。{人曰|ㄖㄣˊ|ㄩㄝ}:「{何不試之以足|ㄏㄜˊ|ㄅㄨˋ|ㄕˋ|ㄓ|ㄧˇ|ㄗㄨˊ}?」{曰|ㄩㄝ}:「{寧信度|ㄋㄧㄥˊ|ㄒㄧㄣˋ|ㄉㄨˋ},{無自信也|ㄨˊ|ㄗˋ|ㄒㄧㄣˋ|ㄧㄝˇ}。」 ### {語譯|ㄩˇ|ㄧˋ} :::paragraphs{style="plain"} {鄭國有個人要買鞋子|ㄓㄥˋ|ㄍㄨㄛˊ|ㄧㄡˇ|˙ㄍㄜ|ㄖㄣˊ|ㄧㄠˋ|ㄇㄞˇ|ㄒㄧㄝˊ|˙ㄗ}。{他先在家裡量好自己腳的大小|ㄊㄚ|ㄒㄧㄢ|ㄗㄞˋ|ㄐㄧㄚ|˙ㄌㄧ|ㄌㄧㄤˊ|ㄏㄠˇ|ㄗˋ|ㄐㄧˇ|ㄐㄧㄠˇ|˙ㄉㄜ|ㄉㄚˋ|ㄒㄧㄠˇ},{把量好的尺碼放在座位上|ㄅㄚˇ|ㄌㄧㄤˊ|ㄏㄠˇ|˙ㄉㄜ|ㄔˇ|ㄇㄚˇ|ㄈㄤˋ|ㄗㄞˋ|ㄗㄨㄛˋ|ㄨㄟˋ|ㄕㄤˋ}。{到了市場|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{卻忘了把尺碼帶在身上|ㄑㄩㄝˋ|ㄨㄤˋ|˙ㄌㄜ|ㄅㄚˇ|ㄔˇ|ㄇㄚˇ|ㄉㄞˋ|ㄗㄞˋ|ㄕㄣ|ㄕㄤˋ}。{他已經挑好了鞋子|ㄊㄚ|ㄧˇ|ㄐㄧㄥ|ㄊㄧㄠ|ㄏㄠˇ|˙ㄌㄜ|ㄒㄧㄝˊ|˙ㄗ},{才說|ㄘㄞˊ|ㄕㄨㄛ}:「{我忘了帶尺碼|ㄨㄛˇ|ㄨㄤˋ|˙ㄌㄜ|ㄉㄞˋ|ㄔˇ|ㄇㄚˇ}。」{就轉身回家去拿|ㄐㄧㄡˋ|ㄓㄨㄢˇ|ㄕㄣ|ㄏㄨㄟˊ|ㄐㄧㄚ|ㄑㄩˋ|ㄋㄚˊ}。{等他再回到市場|ㄉㄥˇ|ㄊㄚ|ㄗㄞˋ|ㄏㄨㄟˊ|ㄉㄠˋ|ㄕˋ|ㄔㄤˊ},{市場已經散了|ㄕˋ|ㄔㄤˊ|ㄧˇ|ㄐㄧㄥ|ㄙㄢˋ|˙ㄌㄜ},{終於沒買到鞋子|ㄓㄨㄥ|ㄩˊ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}。{有人問他|ㄧㄡˇ|ㄖㄣˊ|ㄨㄣˋ|ㄊㄚ}:「{為什麼不用腳試穿呢|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄅㄨˋ|ㄩㄥˋ|ㄐㄧㄠˇ|ㄕˋ|ㄔㄨㄢ|˙ㄋㄜ}?」{他說|ㄊㄚ|ㄕㄨㄛ}:「{我寧可相信尺碼|ㄨㄛˇ|ㄋㄧㄥˊ|ㄎㄜˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄔˇ|ㄇㄚˇ},{也不相信自己的腳|ㄧㄝˇ|ㄅㄨˋ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄗˋ|ㄐㄧˇ|˙ㄉㄜ|ㄐㄧㄠˇ}。」 ::: ## {想一想|ㄒㄧㄤˇ|ㄧ|ㄒㄧㄤˇ} {style="drill"} 1. {宋國的農夫為什麼成了別人的笑話|ㄙㄨㄥˋ|ㄍㄨㄛˊ|˙ㄉㄜ|ㄋㄨㄥˊ|ㄈㄨ|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄔㄥˊ|˙ㄌㄜ|ㄅㄧㄝˊ|ㄖㄣˊ|˙ㄉㄜ|ㄒㄧㄠˋ|ㄏㄨㄚˋ}? 2. {鄭國人明明到了市場|ㄓㄥˋ|ㄍㄨㄛˊ|ㄖㄣˊ|ㄇㄧㄥˊ|ㄇㄧㄥˊ|ㄉㄠˋ|˙ㄌㄜ|ㄕˋ|ㄔㄤˊ},{為什麼沒買到鞋子|ㄨㄟˋ|ㄕㄣˊ|˙ㄇㄜ|ㄇㄟˊ|ㄇㄞˇ|ㄉㄠˋ|ㄒㄧㄝˊ|˙ㄗ}? 3. {這兩個人做錯的地方|ㄓㄜˋ|ㄌㄧㄤˇ|˙ㄍㄜ|ㄖㄣˊ|ㄗㄨㄛˋ|ㄘㄨㄛˋ|˙ㄉㄜ|ㄉㄧˋ|ㄈㄤ},{有什麼相同|ㄧㄡˇ|ㄕㄣˊ|˙ㄇㄜ|ㄒㄧㄤ|ㄊㄨㄥˊ}? :::callout{type="author" span="side"} {韓非|ㄏㄢˊ|ㄈㄟ},戰國時代韓國的貴族,大約生於西元前二八〇年,死於西元前二三三年。他和{李斯|ㄌㄧˇ|ㄙ}都是{荀子|ㄒㄩㄣˊ|ㄗˇ}的學生,說話口吃,卻很會寫文章。後人把他的文章編成《韓非子》,書中有許多有趣的寓言故事。 ::: ## {語文天地|ㄩˇ|ㄨㄣˊ|ㄊㄧㄢ|ㄉㄧˋ} {style="drill"} 「{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}」{與|ㄩˇ}「{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}」{後來都成了成語|ㄏㄡˋ|ㄌㄞˊ|ㄉㄡ|ㄔㄥˊ|˙ㄌㄜ|ㄔㄥˊ|ㄩˇ}。 :::paragraphs{style="idiom"} **{守株待兔|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}**:{比喻不肯努力|ㄅㄧˇ|ㄩˋ|ㄅㄨˋ|ㄎㄣˇ|ㄋㄨˇ|ㄌㄧˋ},{只想白白得到好處|ㄓˇ|ㄒㄧㄤˇ|ㄅㄞˊ|ㄅㄞˊ|ㄉㄜˊ|ㄉㄠˋ|ㄏㄠˇ|ㄔㄨˋ}。{例|ㄌㄧˋ}:{考試前不讀書|ㄎㄠˇ|ㄕˋ|ㄑㄧㄢˊ|ㄅㄨˋ|ㄉㄨˊ|ㄕㄨ},{只希望考的題目剛好都會|ㄓˇ|ㄒㄧ|ㄨㄤˋ|ㄎㄠˇ|˙ㄉㄜ|ㄊㄧˊ|ㄇㄨˋ|ㄍㄤ|ㄏㄠˇ|ㄉㄡ|ㄏㄨㄟˋ},{這就是守株待兔|ㄓㄜˋ|ㄐㄧㄡˋ|ㄕˋ|ㄕㄡˇ|ㄓㄨ|ㄉㄞˋ|ㄊㄨˋ}。 **{鄭人買履|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}**:{比喻只相信死板的規定|ㄅㄧˇ|ㄩˋ|ㄓˇ|ㄒㄧㄤ|ㄒㄧㄣˋ|ㄙˇ|ㄅㄢˇ|˙ㄉㄜ|ㄍㄨㄟ|ㄉㄧㄥˋ},{不看實際的情形|ㄅㄨˋ|ㄎㄢˋ|ㄕˊ|ㄐㄧˋ|˙ㄉㄜ|ㄑㄧㄥˊ|ㄒㄧㄥˊ}。{例|ㄌㄧˋ}:{買衣服不試穿|ㄇㄞˇ|ㄧ|˙ㄈㄨ|ㄅㄨˋ|ㄕˋ|ㄔㄨㄢ},{只看尺寸|ㄓˇ|ㄎㄢˋ|ㄔˊ|˙ㄘㄨㄣ},{就像鄭人買履|ㄐㄧㄡˋ|ㄒㄧㄤˋ|ㄓㄥˋ|ㄖㄣˊ|ㄇㄞˇ|ㄌㄩˇ}。 ::: :::callout{type="chars" span="side"} {株|ㄓㄨ} {耒|ㄌㄟˇ} {冀|ㄐㄧˋ} {履|ㄌㄩˇ} {罷|ㄅㄚˋ} {遂|ㄙㄨㄟˋ} ::: :::paragraphs{style="colophon"} Set in Iansui, LXGW WenKai TC, Noto Serif TC and Noto Sans TC (SIL OFL). Text: Han Feizi, chapters :sideways[49] and :sideways[32], Chinese Wikisource, revisions 2642850 and 2327662 (CC BY-SA 4.0). Zhuyin checked against the Revised Mandarin Chinese Dictionary (Ministry of Education, Taiwan); modern versions, notes and exercises written for this recipe (CC BY 4.0). :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Iansui: ['400'], 'LXGW WenKai TC': ['400'], 'Noto Serif TC': ['400'], 'Noto Sans TC': ['400', '700'], }; // What the label face sets: the tab, the foot of the page, box titles and exercise heads. const LABELS = '第三單元寓言故事國語第九冊十二課兩則注釋作者生字語譯想一文天地、0123456789'; // #region art: two brush paintings for the upper tier, JPEGs in assets/ cut to 15 : 7 const plates = { 'plate-1': 'plate-1-1500.jpg', 'plate-2': 'plate-2-1500.jpg' }; // #endregion const altText = { 'plate-1': '守株待兔:農夫坐在樹樁旁,農具丟在田裡,一隻兔子跑遠了。', 'plate-2': '鄭人買履:鄭國人從鞋攤走回家,量好的尺碼還放在家門口的凳子上。', }; const resources = Object.entries(plates).map(([id, fileId]) => ({ id, typeId: 'plate', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText: altText[id], placement: { span: 'side' }, bitmap: { fileId, format: 'jpeg', width: 1500, height: 700 } })); // declared at their pixels // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: each voice loads with the text it sets; vertical forms for the canvas // {株|ㄓㄨ}: the characters are the text, the readings go to the zhuyin face. const BOXES = /^:::callout\{type="(?:notes|author)"[^}]*\}\n([\s\S]*?)^:::$/gm; const bases = (md) => md.replace(/\{([^|{}]+)((?:\|[^|{}]+)+)\}/g, '$1'); const readings = [...markdown.matchAll(/\{[^|{}]+((?:\|[^|{}]+)+)\}/g)].map((m) => m[1]).join(''); await loadFonts(FONTS, markdown); await loadCjkFonts({ [KAI]: ['400'] }, bases(markdown.replace(BOXES, '')), { vertical: true }); const notesText = [...markdown.matchAll(BOXES)].map((m) => m[1]).join('\n'); await loadCjkFonts({ [MING]: ['400'] }, bases(notesText), { vertical: true }); await loadCjkFonts({ [ZHUYIN]: ['400'] }, readings.replaceAll('|', ''), { vertical: true }); await loadCjkFonts({ [HEI]: ['400', '700'] }, LABELS, { vertical: true }); await Promise.all(Object.values(plates).map((fileId) => loadImage(fileId, asset(fileId)))); // Lesson 12 of a reader: page 86 is a verso, so the lesson opens on a spread. const continuation = { pageIndexOffset: 1, pageNumbering: { startAt: 86 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'A vertical reader with zhuyin', es: 'Un libro de lectura vertical con zhuyin' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregionالعُدّة · core, fonts, viewer, pdf, images, cjk: نفسها في كل وصفة · أسطر: 457
// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#اطبع الزهويين بلون ثانٍ
بعض كتب القراءة تطبع القراءات أفتح من النص، فتبرز الحروف.
- ruby: { fontFamily: ZHUYIN, fontSize: em(0.5) },
+ ruby: { fontFamily: ZHUYIN, fontSize: em(0.5), color: col('muted') },#ضع القراءات فوق نص أفقي
ينضّد كتاب البينيين الأول مقطعًا صوتيًا واحدًا فوق كل حرف على عرض الصفحة، كما تفعل كتب القراءة الأولى في البر الرئيسي وهونغ كونغ.
أخطاء شائعة
خطأ شائع
القراءات تُطبع في النص، لا في التصاميم ولا في التعليقات ولا في الخلايا
تُرسَم قراءات ruby في الفقرات والعناوين وبنود القوائم والاقتباسات والإطارات. أما عنصر نص التصميم (افتتاحية، ترويسة، شارة)، والتعليق، والحاشية السفلية، وخلية الجدول، فتطبع الحروف الأساسية دون قراءاتها. العنوان الذي يحتاج إلى pinyin الخاص به عنوانٌ بلا تصميم خاص به: ارسم ما يحيط به (شريط، رقم الدرس) بتصميم العنوان الذي قبله، الذي تُرسَم عناصره ذات reserve: false تحت نص افتتاحية span: 'page'. قراءات ruby: pinyin وzhuyin →
خطأ شائع
في النص العمودي يصير العمود الجانبي طبقة، و'left' هي العليا
مع layout.writingMode 'vertical-rl' تكون الصفحة صفحة أفقية مُدارة ربع دورة، فيصير العمود الجانبي في تخطيط oneAndHalf طبقة عبر الصفحة: sideColumnSide 'left' يضعها في الرأس، موضع تعليقات الهامش العلوي (眉批)، و'right' في الأسفل. وتُقرأ 'outer' و'inner' على أنهما 'right' و'left' (فلا حافة خارجية للطبقة)، فالقيمة 'outer' لعمود هامش الكتاب المدرسي ترسل الحواشي إلى الأسفل. وsideColumnPercent نسبة من ارتفاع الصفحة. عمود هامشي للعناصر العائمة →
خطأ شائع
الخطوط الصينية تُحمَّل شرائح، عبر الكتلة cjk
يقدّم Fontsource العائلة الصينية أو اليابانية أو الكورية في نحو مئة ملف لكل وزن، يغطي كل منها نطاقًا من الحروف. لا يجلب loadFonts إلا ملف latin، فتأتي حروف الهان على الشاشة من خط النظام وتُقاس خطأً، ويسلّم fontsourceProvider ملف latin ذاك إلى PDF، فيطبعها مربعات فارغة. اذكر كتلة الأدوات cjk، واستدعِ loadCjkFonts(FONTS, markdown) بعد loadFonts (مرة لكل صوت، مع النص الذي يضعه، حين يستخدم الكتاب عدة خطوط CJK)، وأعطِ renderToPdf القيمة fontProvider: cjkPdfProvider: كلاهما يأخذ الملفات التي تحتوي حروف النص. الخطوط الصينية واليابانية والكورية →
خطأ شائع
الكتاب المجلَّد على اليمين يعرض صفحاته المتقابلة بـ showBook
في الكتاب المجلَّد على اليمين (نص عربي أو عبري أو فارسي، أو صيني عمودي، أو page.binding 'right') تبقى الصفحة 1 هي الصفحة الفردية، لكنها تقع على يسار الكعب، وتُقرأ الأزواج [3 | 2]. يعرض showPages كل كتاب مجلَّدًا على اليسار؛ أما showBook من الكتلة book (وتحمل الكتلة cjk الدالة نفسها) فيقرأ doc.binding ويعكس الأزواج. ولا يزال capture.hero يسمّي الصفحتين المتقابلتين بترتيب القراءة، [verso, recto]: [2, 3]. كتب مجلّدة من اليمين →
خطأ شائع
اوسم المستند بـ zh-Hans أو zh-Hant، لا بـ LANG
نسختا الوصفة هما en وes، لكن المثال الصيني صيني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، فيقسّم كلماته اللاتينية بالواصلة، ويسمّي أشكاله Figure أو Figura، ويعطي PDF لغة خاطئة. اكتب الوسم بنفسك: 'zh-Hans' (أعراف البر الرئيسي: كسر الأسطر وفق GB، وترقيم Kaiming) أو 'zh-Hant' (تايوان: ترقيم بعرض كامل متمركز)؛ و'zh-HK' لهونغ كونغ. أما 'zh' المجرّد فيُقرأ صينية مبسطة على أعراف البر الرئيسي. تقسيم الأسطر في النص الصيني →
-
خذ القراءات من قاموس تايوان. فالأدوات المبنية على قواميس البر الرئيسي، ومنها pypinyin، تعطي 度 duó و寧 nìng و時候 shíhou و市場 shìchǎng؛ أما Revised Mandarin Chinese Dictionary الصادر عن وزارة التعليم، الذي تتبعه كتب القراءة في تايوان، فيقرأ ㄉㄨㄛˋ وㄋㄧㄥˊ وㄕˊ ㄏㄡˋ وㄕˋ ㄔㄤˊ، ويضع 故事 و家裡 و過來 بمقطع ثانٍ محايد النبرة. ويقدّمه moedict.tw كلمة كلمة. وهو يُبقي 一 و不 بنبرتهما الأصلية، وكذلك يفعل هذا الدرس.
-
ارسم بعض الحروف بكل خط قبل أن تمزج الخطوط. يتبع LXGW WenKai TC الصيغ الموروثة ويرسم 為 (U+70BA) على هيئة 爲، فطبع درس منضد به 爲宋國笑 في النص و為宋國笑 في حاشية Ming على الصفحتين المتقابلتين نفسيهما. أما Iansui فيرسم 為 المعيارية.
-
أرقام الدرس والحكايات مكتوبة في العناوين (
# 第十二課 {寓言兩則|…}). فـnumberingTemplateيصل الرقم بالحرف الأول من العنوان، وحين يكون لذلك الحرف قراءة تتوسط القراءة فوق الرقم والحرف معًا. -
احسب ما يجب أن تحمله الطبقة العليا في كل صفحة، وسيّج كل إطار حيث تتسع له صفحته. يُسيَّج إطار المؤلف قبل 語文天地 ليفتتح طبقة الصفحة 89، وتليه الحروف الجديدة المسيَّجة بعد التعبيرات الاصطلاحية. والإطار أو الصورة الذي لا يجد مكانًا قبل نهاية النص يُنضَّد في صفحة لا شيء تحته فيها، ويُترك التالي بلا أي تحذير، لذا تحقّق من أن كلًّا منها موجود في الصفحات.
-
لخط Iansui وزن واحد. تبرز العناوين بالحجم والمداخل الاصطلاحية باللون: يضبط نمط
idiomالقيمةboldFontWeight: 400ويجعل اللونَ المميِّز قيمةَboldColor، فيغيّر**…**اللون لا الخط.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- Han Feizi, chapter 49, “Wu du” (The five vermin): the man of Song who waited by the stump · Han Fei; transcription and punctuation by Wikisource editors (爲 set as 為, the Taiwan standard form) · CC BY-SA 4.0
- Han Feizi, chapter 32, “Wai chu shuo zuo shang”: the man of Zheng who went to buy shoes · Han Fei; transcription and punctuation by Wikisource editors · CC BY-SA 4.0
- The zhuyin (checked against the Ministry of Education's Revised Mandarin Chinese Dictionary), the modern Chinese versions, the notes, the author box, the lead and the exercises, written for this recipe · Postext Cookbook · CC BY 4.0
- الصور
- The farmer waiting by the stump, a brush painting · Generated With Diffusion Models · أصلي
- The man of Zheng hurrying home from the shoe stall, a brush painting · Generated With Diffusion Models · أصلي
- الخطوط
- Iansui (SIL OFL 1.1) · LXGW WenKai TC (SIL OFL 1.1) · Noto Serif TC (SIL OFL 1.1) · Noto Sans TC (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


