باختصار
كتاب صغير من الشعر الياباني: يوميات سفر باشو وقصائد الهايكو فيها على أسطر خاصة بها، وقصائد تاكوبوكو القصيرة في ثلاثة أسطر لكل منها، لا يقطعها قلب الصفحة أبدًا.
ما الذي ستنضده
ثماني صفحات من كتاب شعر صغير على صفحة 四六判 بمقاس 127 × 188 مم، مجلَّدة من اليمين. الجزء الأول سبع محطات من «الطريق الضيق إلى الشمال القصي» لباشو، نثره وقصائد الهايكو فيه؛ والثاني اثنتا عشرة تانكا من «حفنة رمل» لإيشيكاوا تاكوبوكو، كل منها في الأسطر الثلاثة التي كتبها فيها. يجري النثر من الأعلى إلى الأسفل، 36 حرفًا في السطر، بخط Shippori Mincho بحجم 10 pt وخطوة واسعة قدرها 1.9 em. تقف كل هايكو على سطر خاص بها على بعد ثلاثة حروف من الرأس، وكل تانكا على بعد حرفين، وتحمل هايكو سورا توقيعه 曾良 قرب ذيل السطر. لا تنقسم التانكا بين صفحتين أبدًا، وتفصل نجمة * بلون ثمرة الكاكي بين المجموعات. وكتاب الشعر الأوروبي هو رقم 015، وقصائد عصر تانغ بالصينية العمودية هي رقم 083.
تجيب هذه الوصفة عن
- كيف أنضد الشعر: سطر لكل بيت، وفراغ بين المقاطع، ومسافة بادئة معلّقة للأسطر الملتفّة، وبلا تقسيم كلمات؟
- ما الخطوط التي تنضّد اليابانية، بالكانجي في أشكاله اليابانية، على الشاشة وفي PDF؟
الجواب المختصر
// Each line of verse is a paragraph of its own, so a style sets where it starts. A haiku
// stands three characters down from the head of the line; Takuboku's tanka keep his three
// lines, two down; 曾良 signs his haiku raised five characters from the foot (地から五字上げ).
const paragraphStyles = [
{ id: 'haiku', indent: em(3), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
{ id: 'tanka', indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) },
{ id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) },
// The ornament between groups of poems, in the middle of its line.
{ id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon') },
];
// :::paragraphs{style="haiku"} :::paragraphs{style="by"}
// 夏草や兵どもが夢の跡 曾良
// ::: :::
المكونات
- الميزات
- أنماط الفقراتعناوين تشغل أسطرًا من المتن (gyōdori)النص العموديكتب مجلّدة من اليمينالمسافات البادئة والمحاذاة والتباعد بين الفقراتفوريغانا: قراءات الكانا فوق الكانجيكسر الأسطر الياباني (kinsoku)الأعداد اليابانية وعدّادات الكاناالكانا والكانجي والروماجيشبكة المحارفالخطوط الصينية واليابانية والكوريةاستيراد نصوص Aozora Bunkoأنماط العناوينصفحات افتتاح مصمَّمةالنصوص والخطوط والمربعات في تصاميم الصفحةالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةهوامش متناظرةلوحة ألوان دلاليةالتصدير إلى PDF
- تستخدم أيضًا
- إطارات تنقسم أو تبقى كاملةإطارات التنبيهتقسيم الأسطر في النص الصينيسمات العنوانلون الورقالخطوط المضمَّنة في PDFترقيم روماني للصفحات التمهيديةقراءات ruby: pinyin وzhuyinترويسات لكل قسم
- الخطوط
- Shippori Mincho, Noto Sans JP (SIL OFL 1.1)
- الأصول
- لا شيء: كل صورة مرسومة بالكود
طريقة التحضير
#1 · بيت الشعر فقرة، ونمطها يحدد أين يبدأ
الشيفرة هي الجواب المختصر أعلاه. لا يعرف Postext فاصل السطر القسري، لذا يكون كل سطر من القصيدة فقرة مستقلة داخل سياج :::paragraphs، ويضبط نمط السياج الإزاحة: يُنزل indent: em(3) الهايكو ثلاثة حروف تحت رأس السطر (يضعها نص Aozora على بعد حرفين، والثلاثة تمنح القصائد فسحة أوسع)، ويمنع firstLineIndent: em(0) إزاحة المتن البالغة حرفًا واحدًا من أن تُضاف إليها. حروف الهايكو السبعة عشر أو نحوها تتسع في أي سطر من 36 حرفًا، فلا تنكسر أبدًا. والتوقيع فقرة مستقلة: يضعه textAlign: 'end' ملاصقًا لذيل السطر (地付き, jitsuki)، ويرفعه endIndent: em(5) خمسة حروف عنه (地から五字上げ)، كما تفعل الطبعة التي أُخذ منها هذا النص (أنماط الفقرات).
#2 · أبقِ كل تانكا في صفحة واحدة
// Postext has no hard line break and never keeps one paragraph with the next, so each tanka
// sits in a box with nothing drawn round it: a box moves on whole (keepTogether, its
// default), and a tanka is never cut at the foot of a page. One blank line follows each.
// :::callout{type="tanka"} :::paragraphs{style="tanka"} … three lines … ::: :::
const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
marginTop: pt(0), marginBottom: pt(LEAD),
body: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
firstLineIndent: em(0) } }];
أسطر التانكا الثلاثة ثلاث فقرات، والمحرّك يُبقي أسطر الفقرة الواحدة معًا لكنه لا يربط فقرة بالتي تليها. أما الإطار فيُبقي محتواه معًا، لذا توضع كل تانكا في :::callout لا يرسم نمطه شيئًا: لا خلفية ولا حشو، وبعده هامش من سطر واحد، هو السطر الفارغ بين القصائد. في الصفحة 5 لا تتسع التانكا الثالثة تحت عنوان الجزء والقصيدتين الأوليين، فتفتتح الصفحة 6 كاملة بدل أن تترك سطرها الأول خلفها (أنماط الإطارات).
#3 · باعد أسماء الأماكن إلى عرض واحد
// The place names over the prose take two lines (2行取り), four characters down, and a
// two-character name is spaced out to three (字取り: 平泉 → 平 泉), so they all end level.
const place = {
level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11),
lineSpan: 2, indent: em(4), jidori: 3,
};
محطات طريق باشو عناوين من المستوى الثاني بارتفاع سطرين من أسطر المتن (2行取り)، فيبقى النثر والهايكو اللذان بعدها على الشبكة. يباعد jidori: 3 الاسم الأقصر إلى عرض ثلاثة من حروفه (字取り): يُطبع 平泉 هكذا 平 泉 وبين حرفيه حرف كامل، وينتهي كل اسم على الارتفاع الذي ينتهي عنده 立石寺 و最上川. ويُترك الاسم المؤلف من ثلاثة حروف أصلًا على حاله (تجاوزات كل مستوى).
#4 · افتتح كل جزء في صفحة خاصة به
// A design on a vertical page is laid out in the flow frame: x runs down the column from
// the head of the type area, y across the page leftwards from its right edge.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
offset: { x: pt(down), y: pt(across) } });
const TITLE = 18; // pt
const part = {
level: 1, breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break
advancedDesign: { enabled: true, minHeight: lines(5), slot: { elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700,
fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(4), color: col('ink'),
placement: at(3 * BODY, 1.5 * LEAD - TITLE / 2) },
{ kind: 'text', id: 'abridged', content: '(抄)', fontFamily: MINCHO, fontSize: pt(9),
color: col('ink'), placement: { anchor: { to: '#title', edge: 'right-of' },
offset: { x: pt(6), y: pt((TITLE - 9) / 2) } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
color: col('persimmon'), placement: { ...at(3 * BODY, 2.5 * LEAD),
size: { width: pt(4 * TITLE) } } },
{ kind: 'text', id: 'poet', content: '{attr.author}', fontFamily: MINCHO, fontSize: pt(11),
letterSpacing: pt(3), color: col('ink'),
placement: at(CHARS * BODY - 4 * 11 - 3 * 3 - 2 * BODY, 3.5 * LEAD - 11 / 2) },
] } },
};
كل جزء عنوان من المستوى الأول يضع تصميمه العنوانَ، وبعده مباشرة (抄) صغيرة، أي «مختارات»، مثبَّتة بـanchor.to: '#title'، وخطًا قصيرًا بلون ثمرة الكاكي بجانب العنوان، واسم الشاعر قرب ذيل السطر. يحجز التصميم خمسة أسطر، ويبدأ النص بعدها على الشبكة نفسها.
#5 · اختر خطًا يحوي العلامات القديمة
const title = markdown.match(/^title: "(.*)"$/m)[1];
const heads = markdown.match(/^#+ [^{\n]*/gm).join('');
await loadFonts(FONTS, markdown); // the Latin files: the colophon
await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true });
await loadCjkFonts({ [MINCHO]: ['700'] }, heads);
await loadCjkFonts({ [GOTHIC]: ['400'] },
`${title}${heads}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true });
يكتب نثر باشو الكلمات المكرّرة بعلامة التكرار القديمة 〻 (日〻، 人〻)، وتستعمل النصوص الأقدم أيضًا علامة くの字点 الطويلة 〳〵. لا يحوي Zen Old Mincho أيًّا منهما: كانت اللوحة (Canvas) ستستعيرهما من خط من خطوط النظام، وكان ملف PDF سيرسم مكانهما مربعات فارغة. أما Shippori Mincho وNoto Serif JP وShippori Mincho B1 فتحويهما، لذا نُضّدت القصائد بخط Shippori Mincho (خطوط الصينية واليابانية والكورية). ويحتفظ نص Aozora أيضًا بملاحظات المحرر على الإملاء القديم، とら{え|(へ)}て، فتُطبع الكانا المتوقعة بجانب الكانا التي كتبها باشو.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 119 · Haiku and tanka set vertically ═══════════════════ // https://postext.dev/en/cookbook/haiku-and-tanka // Code: MIT · Text: 芭蕉『おくのほそ道』, 啄木『一握の砂』, Aozora Bunko (PD) · Pictures: none // Fonts: Shippori Mincho, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the colophon; the poems are Japanese in both const RECIPE = 'haiku-and-tanka'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: ink, one persimmon, a soft poetry stock const palette = { ink: '#22201d', // the poems persimmon: '#b5512c', // 柿: the part titles' rule and the ornaments muted: '#6a645c', // the hashira, the folios, the colophon paper: '#fbf8f1', }; 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: 'defaults', value: { hex: palette.persimmon, model: 'hex' } }, ]; // #endregion // Shippori Mincho: it has the 〻 of 日〻 and 人〻, which Zen Old Mincho lacks. const [MINCHO, GOTHIC] = ['Shippori Mincho', 'Noto Sans JP']; const [BODY, LEAD, CHARS, LINES] = [10, 19, 36, 14]; // pt, pt: 36字 × 14行, a 1.9 em pitch const lines = (n) => pt(n * LEAD); // #region answer: a haiku on one line three characters down, a tanka in three, 地付き names // Each line of verse is a paragraph of its own, so a style sets where it starts. A haiku // stands three characters down from the head of the line; Takuboku's tanka keep his three // lines, two down; 曾良 signs his haiku raised five characters from the foot (地から五字上げ). const paragraphStyles = [ { id: 'haiku', indent: em(3), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) }, { id: 'tanka', indent: em(2), firstLineIndent: em(0), marginTop: pt(0), marginBottom: pt(0) }, { id: 'by', textAlign: 'end', endIndent: em(5), firstLineIndent: em(0) }, // The ornament between groups of poems, in the middle of its line. { id: 'ornament', textAlign: 'center', firstLineIndent: em(0), color: col('persimmon') }, ]; // :::paragraphs{style="haiku"} :::paragraphs{style="by"} // 夏草や兵どもが夢の跡 曾良 // ::: ::: // #endregion // #region tanka: a box with nothing drawn round it keeps each tanka on one page // Postext has no hard line break and never keeps one paragraph with the next, so each tanka // sits in a box with nothing drawn round it: a box moves on whole (keepTogether, its // default), and a tanka is never cut at the foot of a page. One blank line follows each. // :::callout{type="tanka"} :::paragraphs{style="tanka"} … three lines … ::: ::: const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginTop: pt(0), marginBottom: pt(LEAD), body: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), firstLineIndent: em(0) } }]; // #endregion // #region places: the place names take two lines, spaced to three characters // The place names over the prose take two lines (2行取り), four characters down, and a // two-character name is spaced out to three (字取り: 平泉 → 平 泉), so they all end level. const place = { level: 2, fontFamily: MINCHO, fontWeight: 700, fontSize: pt(11), lineSpan: 2, indent: em(4), jidori: 3, }; // #endregion // #region part: each part opens a page with its title, (抄) and the poet at the foot // A design on a vertical page is laid out in the flow frame: x runs down the column from // the head of the type area, y across the page leftwards from its right edge. const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' }, offset: { x: pt(down), y: pt(across) } }); const TITLE = 18; // pt const part = { level: 1, breakBefore: { enabled: true, parity: 'any' }, // gotcha: headings-drop-h1-break advancedDesign: { enabled: true, minHeight: lines(5), slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 700, fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(4), color: col('ink'), placement: at(3 * BODY, 1.5 * LEAD - TITLE / 2) }, { kind: 'text', id: 'abridged', content: '(抄)', fontFamily: MINCHO, fontSize: pt(9), color: col('ink'), placement: { anchor: { to: '#title', edge: 'right-of' }, offset: { x: pt(6), y: pt((TITLE - 9) / 2) } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6), color: col('persimmon'), placement: { ...at(3 * BODY, 2.5 * LEAD), size: { width: pt(4 * TITLE) } } }, { kind: 'text', id: 'poet', content: '{attr.author}', fontFamily: MINCHO, fontSize: pt(11), letterSpacing: pt(3), color: col('ink'), placement: at(CHARS * BODY - 4 * 11 - 3 * 3 - 2 * BODY, 3.5 * LEAD - 11 / 2) }, ] } }, }; // #endregion // #region furniture: the book's title down the fore-edge, the folio below it const foreEdge = (id, content, parity, edge, y, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', writingMode: 'vertical-rl', fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'), overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } }, ...extra, }); const header = { elements: [ foreEdge('book', '{title}', 'even', 'top', 3), foreEdge('part', '{chapterTitle}', 'odd', 'top', 3), foreEdge('folio', '{pageNumber}', 'all', 'bottom', -3, { pages: 'all' }), ] }; const none = { elements: [] }; // The first part's opening page carries the colophon across the foot. const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon', content: '{attr.colophon}', pages: 'opener', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45, color: col('muted'), overflow: 'wrap', align: 'center', placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-7) }, size: { width: mm(100) } } }] } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag) colorPalette, page: { sizePreset: 'custom', width: mm(127), height: mm(188), dpi: 150, // 四六判 backgroundColor: col('paper'), margins: { top: mm(32), bottom: mm(24), left: mm(15), right: mm(17), mirror: true }, pageNumbering: { format: 'japanese-informal' }, }, layout: { layoutType: 'single', writingMode: 'vertical-rl' }, cjk: { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } }, bodyText: { fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true, }, headings: { fontFamily: MINCHO, color: col('ink'), levels: [part, place] }, headingStyles: [first], paragraphStyles, calloutStyles, header, footer: none, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 183 · content.en.md
title: "芭蕉と啄木" subtitle: "俳句と短歌" --- # おくのほそ道 {style="first" author="松尾芭蕉" colophon="Matsuo Bashō, おくのほそ道 (The Narrow Road to the Deep North, 1702), selections, after Sugiura Shōichirō’s 1957 edition; Ishikawa Takuboku, 一握の砂 (A Handful of Sand, 1910), twelve tanka. Text: Aozora Bunko 61619 and 816, public domain. Set in Shippori Mincho and Noto Sans JP (SIL OFL)."} ## 冒頭 {月日|つきひ}は{百代|はくだい}の{過客|くわかく}にして、{行|ゆき}かふ{年|とし}も{又|また}{旅人|たびびと}{也|なり}。{舟|ふね}の{上|うへ}に生涯をうかべ馬の口とら{え|(へ)}て{老|おい}をむかふる{物|もの}は、{日〻|ひび}{旅|たび}にして{旅|たび}を{栖|すみか}とす。古人も多く旅に死せるあり。予もいづれの年よりか、{片雲|へんうん}の風にさそはれて、漂泊の思ひやまず海濱にさすらへ、{去年|こぞ}の秋{江上|かうじやう}の{破屋|はをく}に{蜘|くも}の古巣をはらひて、やゝ年も{暮|くれ}春{立|たて}る霞の空に白川の關こえんと、そゞろ{神|がみ}の物につきて心をくるはせ、{道祖神|だうそじん}のまねきにあひて{取|とる}もの手につかず、もゝ{引|ひき}の{破|やぶれ}をつゞり{笠|かさ}の{緒|を}{付|つけ}か{え|(へ)}て、三里に灸す{ゆ|(う)}るより松嶋の月{先|まづ}心にかゝりて、{住|すめ}る{方|かた}は人に讓り{杉風|さんぷう}が{別墅|べつしよ}に移るに、 :::paragraphs{style="haiku"} 草の戸も{住替|すみかは}る{代|よ}ぞひなの家 ::: {面八句|おもてはちく}を{庵|いほり}の柱に{懸置|かけおく}。 ## 旅立 :::paragraphs{style="haiku"} {行|ゆく}春や鳥{啼|なき}{魚|うを}の目は{泪|なみだ} ::: ## 平泉 三代の{榮耀|えいえう}{一睡|いつすゐ}の{中|うち}にして、{大門|だいもん}の跡は一里こなたに{有|あり}。{秀衡|ひでひら}が跡は田野に{成|なり}て、金鷄山のみ形を殘す。{先|まづ}{高舘|たかだち}にのぼれば、北上川南部より流るゝ{大河|たいが}也。衣川は和泉が{城|じやう}をめぐりて、高舘の下にて大河に{落入|おちいる}。{康衡|やすひら}等が旧跡は、衣が關を{隔|へだて}て南部口をさし堅め、{夷|えぞ}をふせぐとみえたり。{偖|さて}も義臣すぐつて{此城|このしろ}にこもり、功名一時の{叢|くさむら}となる。「國破れて山河あり、{城|じやう}春にして草青みたり」と笠{打敷|うちしき}て時のうつるまで泪を落し侍りぬ。 :::paragraphs{style="haiku"} {夏草|なつくさ}や{兵|つはもの}どもが夢の跡 {卯|う}の{花|はな}に{兼房|かねふさ}みゆる{白毛|しらが}かな ::: :::paragraphs{style="by"} 曾良 ::: ## 立石寺 山形領に{立石寺|りふしやくじ}と{云|いふ}山寺あり。{慈覺大師|じかくだいし}の開基にして、{殊|ことに}清閑の地也。一見すべきよし、人〻のすゝむるに{依|よつ}て、尾花澤よりとつて返し、{其|その}間七里ばかり也。日いまだ{暮|くれ}ず。{麓|ふもと}の坊に宿かり{置|おき}て、山上の堂にのぼる。岩に{巖|いはほ}を{重|かさね}て山とし、{松柏|しようはく}{年旧|としふり}、{土石|どせき}{老|おい}て苔{滑|なめらか}に、岩上の院〻{扉|とびら}を{閉|とぢ}て物の音きこえず。{岸|きし}をめぐり岩を{這|はひ}て{佛閣|ぶつかく}を拜し、{佳景|かけい}{寂莫|じやくまく}として心すみ{行|ゆく}のみおぼゆ。 :::paragraphs{style="haiku"} {閑|しづか}さや岩にしみ{入|いる}蝉の聲 ::: ## 最上川 :::paragraphs{style="haiku"} 五月雨をあつめて早し最上川 ::: ## 越後路 :::paragraphs{style="haiku"} 文月や六日も常の夜には似ず 荒海や佐渡によこたふ{天河|あまのがは} ::: ## 大垣 :::paragraphs{style="haiku"} 蛤のふたみにわかれ{行|ゆく}秋ぞ ::: # 一握の砂 {author="石川啄木"} :::callout{type="tanka"} :::paragraphs{style="tanka"} {東海|とうかい}の{小島|こじま}の{磯|いそ}の{白砂|しらすな}に われ{泣|な}きぬれて {蟹|かに}とたはむる ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} {頬|ほ}につたふ なみだのごはず {一握|いちあく}の砂を{示|しめ}しし人を忘れず ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} いのちなき砂のかなしさよ さらさらと {握|にぎ}れば指のあひだより落つ ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} たはむれに母を{背負|せお}ひて そのあまり{軽|かろ}きに泣きて 三歩あゆまず ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} はたらけど はたらけど{猶|なほ}わが{生活|くらし}楽にならざり ぢっと手を見る ::: ::: :::paragraphs{style="ornament"} * ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} {不来方|こずかた}のお城の草に寝ころびて 空に吸はれし {十五|じふご}の心 ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} ふるさとの{訛|なまり}なつかし {停車場|ていしやば}の人ごみの中に そを{聴|き}きにゆく ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} 石をもて追はるるごとく ふるさとを{出|い}でしかなしみ 消ゆる時なし ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} やはらかに柳あをめる {北上|きたかみ}の{岸辺|きしべ}目に見ゆ 泣けとごとくに ::: ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} ふるさとの山に向ひて 言ふことなし ふるさとの山はありがたきかな ::: ::: :::paragraphs{style="ornament"} * ::: :::callout{type="tanka"} :::paragraphs{style="tanka"} {函館|はこだて}の{青柳町|あをやぎちやう}こそかなしけれ 友の{恋歌|こひうた} 矢ぐるまの花 ::: :::`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Shippori Mincho': ['400', '700'], // the poems and the prose; the titles 'Noto Sans JP': ['400'], // the hashira, the folios and the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files that hold the characters it sets const title = markdown.match(/^title: "(.*)"$/m)[1]; const heads = markdown.match(/^#+ [^{\n]*/gm).join(''); await loadFonts(FONTS, markdown); // the Latin files: the colophon await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['700'] }, heads); await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}${heads}${markdown.match(/colophon="([^"]*)"/)[1]}一二三四五六七八九十`, { vertical: true }); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'Haiku and tanka set vertically', es: 'Haikus y tankas compuestos en vertical' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);العُدّة · core, fonts, viewer, pdf, cjk: نفسها في كل وصفة · أسطر: 453
// ─── 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 gets the latin file fontsourceProvider fetches (the "pdf" block) * and, when the face sets letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); 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())); })); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. A face Fontsource ships without latin-ext, or whose file does * not come, gets latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); 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 id = fontsourceId(family); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); return ext ? [latin, ext] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** 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 ↗ (يفتح في تبويب جديد)
تنويعات
#باعد بين مقاطع الهايكو الثلاثة
تضع المختارات المدرسية أحيانًا مسافة إيديوغرافية بين الأصوات الخمسة والسبعة والخمسة، للقرّاء الذين يتعلمون هذا الشكل؛ والمسافة حرف من حروف النص.
-{夏草|なつくさ}や{兵|つはもの}どもが夢の跡
+{夏草|なつくさ}や {兵|つはもの}どもが 夢の跡#اسمح للتانكا الطويلة بأن تنقسم
نمط إطار بـkeepTogether: false يسمح للتانكا بأن تبدأ عند ذيل صفحة وتكمل في ظهرها، كما تفعل مختارات مكتظة من ألف قصيدة.
-const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: true,
+const calloutStyles = [{ id: 'tanka', backgroundEnabled: false, keepTogether: false,أخطاء شائعة
خطأ شائع
اوسم النص الياباني بـ 'ja'، لا بـ zh-Hans ولا بـ LANG
نسختا الوصفة هما en وes، لكن المثال الياباني ياباني في كلتيهما: `locale: LANG` سيسمه بالإنجليزية أو الإسبانية، والوسم الصيني سينضّده بالقواعد الصينية (ترقيم Kaiming، وكانا صغيرة يجوز أن تبدأ بها الأسطر، و图 بدل 図، وأشكال صينية للحروف في PDF). اكتب 'ja': يختار منطقة اليابان (كسر الأسطر والترقيم وفق JLReq، ونقاط السمسم، وتوزيع الفوريغانا، وتسميتي 図 و表) ويوقف تقسيم الكلمات. ويُفشل الفحصُ نصًّا فيه كانا موسومًا بـ zh أو ko. كسر الأسطر الياباني (kinsoku) →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
الخطوط الصينية تُحمَّل شرائح، عبر الكتلة 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]. كتب مجلّدة من اليمين →
خطأ شائع
ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس
يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984". البيانات الوصفية للمستند →
خطأ شائع
قد يَعِد نطاق Fontsource بحروف لا يحويها ملفه
يعطي Fontsource كل ملف نطاق unicode-range، ويحمّل loadCjkFonts الملفات التي تغطي نطاقاتُها نص المثال، لكن النطاق قد يَعِد بأكثر مما في ملفه. شريحة Zen Old Mincho الخاصة بـ U+3028–303F لا تحوي 〻 ولا علامات التكرار الطويلة 〳〴〵، ولا يحوي الخط كذلك الصوائت بالمدّة ō وū وā؛ وملف latin-ext في Shippori Mincho B1 فيه حروف قليلة ليس بينها ō ولا ū ولا ā. لا يفشل شيء أثناء تشغيل المثال: تأتي هذه الحروف على الشاشة من خط النظام وتُقاس به، ويطبع PDF مربع .notdef في الخط، ولا يُبلغ عنه إلا فحص C25 بعد ذلك. ابحث في الخط عن العلامة النادرة أو كلمة الروماجي قبل اختياره (fontTools على ملف Fontsource)، وانضدها بخط يحويها: في Shippori Mincho B1 وNoto Serif JP العلامات 〻 و〳〴〵، وفي Noto Serif JP وNoto Sans JP الصوائت بالمدّة. الخطوط الصينية واليابانية والكورية →
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- 松尾芭蕉『おくのほそ道』 (The Narrow Road to the Deep North, 1702), seven sections, the editor’s notes left out; Aozora Bunko card 61619, from 「芭蕉 おくのほそ道」 (Iwanami, 1975) after 杉浦正一郎’s Iwanami Bunko edition (1957), input 砂場清隆, proofreading 木下聡, converted with aozora.py · Matsuo Bashō; Sugiura Shōichirō (editor, 1911–1957); Aozora Bunko volunteers · ملك عام
- 石川啄木『一握の砂』 (A Handful of Sand, 1910), twelve tanka; Aozora Bunko card 816, converted with aozora.py · Ishikawa Takuboku; Aozora Bunko volunteers · ملك عام
- الخطوط
- Shippori Mincho (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


