انتقل إلى المحتوى الرئيسي

الفصل 3.19 · الجزء II · الحرفة

الاستخدام البرمجي: حزم .postext

فتح ملفات .postext وإخراجها ورسمها وإنشاؤها من الشيفرة، والحزم الثنائية اللغة، وملفات الإصدارات القديمة، والواجهة البرمجية منخفضة المستوى

آخر تحديث 2026-10-1627 دقيقةضمن الفصل 3 الإعداداتenescadefritptzhjaar

باختصار

هذه الصفحة لمن يكتبون الشيفرة. يحمل ملف .postext كتابًا كاملًا في ملف واحد: نصه وإعداداته وخطوطه وصوره. وتبيّن الصفحة كيف تفتح هذا الملف، وتُخرج صفحاته، وترسمها. وتبيّن كيف تصنع ملفًا جديدًا من نصك، وكيف تحفظ لغتين من الكتاب في ملف واحد. وتشرح أيضًا كيف تُقرأ الملفات التي صنعتها إصدارات أقدم من Postext.

#الحزم (ملفات .postext)

ملف .postext كتابٌ كامل في ملف واحد: أرشيف zip يضم ملف بيان (manifest) اسمه preset.json، وملف Markdown لكل فصل، وملفات الموارد (صور نقطية، وملفات SVG، ونسخ PDF الأصلية للطباعة (print masters))، وملفات الخطوط التي تسمّيها الإعدادات. يصدّره Sandbox ويستورده، وتسلّمه مهارة الوكيل (agent skill). وتستطيع مكتبة postext أيضًا إنشاءه وفتحه، فينتقل الكتاب بين هذه الأدوات وبرنامجك دون أن يفقد شيئًا.

my-book.postext
├── preset.json            manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2

يصف ملحق صيغة حزمة الإعداد المسبق في توثيق Sandbox البيانَ حقلًا حقلًا. وقد يحمل الملف أيضًا layouts.json، أي أعداد الصفحات التي حسبها Sandbox، فيُفتح الكتاب هناك مقسَّمًا إلى صفحات من البداية، أو ملفًا لكل طبعة من كتاب متعدد اللغات (layouts.zh-Hant.json، ويُقرأ أولًا). ويتجاهلها openBundle.

تُصدَّر الواجهة البرمجية من postext نفسها ومن المسار الفرعي postext/bundle، الذي يضيف الأدوات المساعدة منخفضة المستوى. استورد من postext إذا كنت سترسم أيضًا. بهذا تتشارك محوّلات الحزمة (adapters) والمُخرِجات نسخة واحدة من الوحدة، وهذا مهم على شبكة توزيع محتوى (CDN) مثل esm.sh، حيث تُبنى كل نقطة دخول على حدة.

#فتح حزمة

تأخذ openBundle بايتات الملف (Uint8Array أو ArrayBuffer أو Blob / File من <input type="file">) وتعيد كل ما يحتاجه المحرّك ومُخرِجاته (backends):

import { openBundle } from 'postext';
 
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
 
bundle.chapters;   // [{ title, file, markdown }, …] in book order
bundle.config;     // PostextConfig, ready for buildDocument
bundle.resources;  // Resource[]
bundle.files;      // Map<path, Uint8Array>: every file of the bundle
الحقلما يحتويه
manifestملف preset.json بعد التحقق من صحته.
id، name، descriptionمن البيان.
locale، localesاللغة التي قُرئ بها المحتوى، وكل لغة تحملها الحزمة الثنائية اللغة. يختار options.locale إحداها: الوسم المطابق تمامًا أولًا، ثم اللغة دون متغيرها الإقليمي، ثم لغة الحزمة نفسها.
chapters{ title, file, markdown } لكل فصل. الفصل الذي لا عنوان له في البيان يأخذ نص أول عنوان # فيه.
configلوحة الألوان الافتراضية وأنواع الموارد بلغة الحزمة، ثم config الموجود في البيان، ثم تجاوزات اللغة. لغة الحزمة هي locale أعلاه، فالحزمة المكتوبة بلغة واحدة تنال تسمياتها الخاصة أيًّا كان ما يطلبه options.locale. والبيان الذي لا يسمّي لغة يأخذ اللغة التي يضبطها config الخاص به (locale، ثم لغة تقسيم الكلمات بالواصلة)، وإلا فـoptions.locale. ويسرد customFonts عائلات الخطوط التي في الحزمة. هذه هي الإعدادات نفسها التي يفتح بها Sandbox الحزمة.
resourcesالموارد، مع تعليقات اللغة المختارة. المقاس الناقص من البيان يُقرأ من الملف.
fontsمُدخل واحد لكل وجه خط: { family, weight, style, format, file, bytes }.
filesكل ملف في الأرشيف، ومفتاحه مساره.
thumbnail، canvasScopeمسار صورة الغلاف، وطريقة العرض التي تطلبها الحزمة: الحقل view في البيان، وفوقه localized[…].view الخاص باللغة المقدَّمة.
startموضع بداية الكتاب، في حزمة تضمّ جزءًا من كتاب أطول: start في البيان، أو localized[…].start للغة المقروءة. لا يوجد في كتاب يبدأ من أوله.
warningsمشكلات لا توقف القراءة: ملف خط غير مدعوم، أو نسخة طباعة أصلية مفقودة.

معرّف fileId لأي ملف هو مساره داخل الحزمة. يمكن البحث عن resource.svg.fileId وresource.bitmap.fileId وfileId الخاص بكل متغير في customFonts مباشرةً في bundle.files. ترمي openBundle خطأً إذا لم تكن البايتات أرشيف zip، أو لم يوجد preset.json صالح (في الجذر أو تحت مجلد واحد في المستوى الأعلى)، أو كان ملف يسمّيه البيان مفقودًا.

الحزم التي كتبها postext 1.4 أو ما قبله

كل بيان يكتبه createBundle أو Sandbox يحمل configVersion: 14: قواعد الإعدادات التي كُتب لها config الخاص به. والبيان الذي يخلو منه كتبه postext 1.4 أو ما قبله، وكان يضبط ثمانية عشر أمرًا على نحو مختلف:

  • فواصل العناوين (القواعد 3): حتى 1.4، كان كائن headings الذي لا فاصل فيه لـH1 بلا أي فاصل (انظر التجاوزات حسب المستوى).
  • حجم الصيغ الرياضية (القواعد 4): حتى 1.4، كانت الصيغ تخرج أكبر بـ1.131 مرة مما يقوله fontSizeScale (انظر حجم الصيغ).
  • الفراغ تحت المورد المضمَّن (القواعد 5): حتى 1.4، كان النص الذي يلي شكلًا أو جدولًا موضعه placement.position: 'here' يُستأنف عند خط الشبكة التالي، دون فراغ العناصر العائمة تحته (انظر layout.inlineResourceGap في تخطيط الصفحة).
  • الفراغ حول المورد المضمَّن داخل إطار (القواعد 6): حتى 1.4، كان مثل هذا المورد يلتصق بنص الإطار المحيط به (انظر layout.inlineResourceGapInBoxes في تخطيط الصفحة).
  • العلامات المضمَّنة في العناوين (القواعد 6): حتى 1.4، كان العنوان يطبع كلمات *italic* و**bold** وسائر علاماته بنمطه العادي الخاص (انظر headings.inlineMarks في العناوين).
  • حجم الحرف الاستهلالي (القواعد 6): حتى 1.4، كان dropCap في نص تصميمي بلا fontSize بطول جميع صناديق الأسطر التي يمتد عليها، وأعلاه فوق السطر الأول (انظر dropCap في عناصر النص).
  • المساحة تحت سطر النقطتين (القواعد 6): حتى 1.4، كان keepColonWithList يعدّ سطرًا واحدًا من المساحة تحت السطر المنتهي بنقطتين كافيًا للقائمة، فينتقل البند الأول ذو السطرين، الذي تُبقيه قواعد اليتيمة والأرملة كاملًا، إلى العمود التالي من دونه (انظر bodyText.colonListRoom).
  • الأسطر التي يتركها قطع الإطار (القواعد 6): حتى 1.4، كان الإطار الذي ينقسم داخل فقرة أو بند قائمة قد يترك سطرًا واحدًا منها على أحد الجانبين، ما دام كل جانب من الإطار يضم أسطره splitMinLines إجمالًا (انظر layout.boxChildSplitMinLines في تخطيط الصفحة).
  • فواصل الأسطر عند الشرطة (القواعد 7): حتى 1.4، لم يكن Knuth-Plass ينهي سطرًا أبدًا بعد شرطة طويلة أو متوسطة ملتصقة بين كلمتين (say—that’s)، ولم يكن مقسِّم الأسطر سطرًا بسطر للنص المنسَّق ينهيه إلا بين حرفين (انظر bodyText.breakAfterDashes في النص الأساسي).
  • النص غير المضبوط (القواعد 7): حتى 1.4، كان النص الجاري غير المضبوط يُنضَّد سطرًا بسطر، فيُملأ كل سطر قبل الذي يليه، أيًّا كانت قيمة optimalLineBreaking (انظر bodyText.optimalRagged في النص الأساسي).
  • التقسيم تحت العنوان (القواعد 8): حتى 1.4، كانت الفقرة التي تلي عنوانًا في أسفل عمود تُبقي هناك كل ما يتسع من أسطرها، مهما قلّ ما ينتقل منها إلى العمود التالي (انظر headings.keepWithNextSplit في العناوين).
  • الفراغ تحت حاوية :::paragraphs (القواعد 8): حتى 1.4، كان فراغ النمط يوضع تحت الفقرة الأخيرة قبل المحاذاة إلى الشبكة، ويُضاف تحته فراغ الكتلة التالية من أعلاها (marginTop الخاص بالعنوان)، ويُهمَل تباعد الفقرات في النص (انظر bodyText.paragraphContainerSpacing في النص الأساسي).
  • فواصل الأسطر عند واصلة الكلمة المركبة (القواعد 8): حتى 1.4، لم يكن Knuth-Plass ينهي سطرًا مضبوطًا أبدًا بعد واصلة بين حرفين (well-known) في فقرة بلا تنسيق مضمَّن، بينما كان يفعل ذلك في فقرة فيها تنسيق (انظر bodyText.breakAfterHyphens في النص الأساسي).
  • القصائد بلا فاصل (القواعد 9): حتى 1.22 كانت قصيدة :::verse التي لا تحمل أسطرها || تُصَفّ أشطرًا مفردة، كل سطر في الوسط (انظر bodyText.verse.layout في الشعر).
  • إزاحة السطر الأول بجانب إزاحة معلّقة (القواعد 9): حتى 1.22 كانت hangingIndent في نمط الفقرة تحلّ محل firstLineIndent، فيبدأ السطر الأول عند indent (انظر أنماط الفقرات).
  • الشرطة المائلة العكسية في آخر السطر (القواعد 9): حتى 1.22 كانت الشرطة المائلة العكسية في آخر سطر من فقرة أو اقتباس أو عنصر قائمة، و\\ المتبوعة بمسافة، تُطبع كما هي، وتُوصل الأسطر بمسافة (انظر bodyText.hardLineBreaks تحت نص المتن).
  • أسوار الشيفرة (القواعد 9): حتى 1.22 كان سور ``` أو ~~~ والأسطر داخله تُقرأ Markdown: تندمج الأسطر في فقرات، ويصير السطر الذي يبدأ بـ# عنوانًا، وتُطبع الأسوار (انظر codeStyle.blocks تحت قوائم الشيفرة).
  • تتمّات أسطر الشعر (القواعد 10): في 1.23 كان سطر القصيدة المصفوفة سطرًا سطرًا الأعرض من عرض السطر يُكمَل في سطر تالٍ بتباعده الطبيعي بين الكلمات، مهما قلّ ما زاد منه (انظر bodyText.verse.tighten تحت الشعر).

يقرأ openBundle وreadBundle الحقل config في بيان كهذا، وإعدادات كل لغة في localized، عبر migrateConfig، التي تكتب صراحةً فواصل العناوين كما أخرجها الإصدار 1.4، وتضرب مقياس الصيغ في 1.131 (وتقسم عليه هوامش الصيغ المعروضة المعبَّر عنها بوحدة em). أما البيان الموسوم بقيمة من 3 إلى 7، الذي كتبه إصدار تجريبي من 1.5، فلا يتلقى إلا تثبيتات (pins) القواعد اللاحقة لوسمه. مع 3 يعني ذلك حجم الصيغ، والفراغ تحت المورد المضمَّن، وتثبيتات القواعد 6 الخمسة، وتثبيتَي القواعد 7، وتثبيتات القواعد 8 الثلاثة؛ ومع 4، الفراغ تحت المورد المضمَّن وتثبيتات القواعد 6 و7 و8؛ ومع 5، تثبيتات القواعد 6 و7 و8؛ ومع 6، تثبيتات القواعد 7 و8؛ ومع 7، تثبيتات القواعد 8 وحدها. يُكتب التقسيم تحت العنوان (pinLegacyHeadingSplit) على شكل headings.keepWithNextSplit: 'fill' في كائن headings الذي تُبقيه الطبقات ساريًا، إذا كان في أحد الفصول المقروءة عنوان، ولم تحدد الإعدادات قيمة خاصة بها، وأبقت headings.keepWithNext مفعَّلًا، ولم تعطّل bodyText.avoidOrphans. وتُكتب فواصل الكلمات المركبة (pinLegacyHyphenBreaks) على شكل bodyText.breakAfterHyphens: false في bodyText الساري، إذا وضع أحد الفصول المقروءة واصلة بين حرفين، ولم تكن الإعدادات تضبطه أصلًا ولا تعطّل optimalLineBreaking. ويُكتب الفراغ تحت الحاويات (pinLegacyParagraphContainerSpacing) على شكل bodyText.paragraphContainerSpacing: 'add' في bodyText الساري، إذا عرّفت الإعدادات نمط فقرة (في paragraphStyles أو في تجاوزات عارض HTML)، وفتح أحد الفصول المقروءة حاوية :::paragraphs في سطر مستقل، ولم تكن الإعدادات تضبطه أصلًا. وتُكتب فواصل الشرطات (pinLegacyDashBreaks) على شكل bodyText.breakAfterDashes: false في bodyText الساري، إذا وضع أحد الفصول المقروءة شرطة طويلة أو متوسطة ملتصقة بين كلمتين (قبلها حرف أو رقم أو علامة ترقيم إغلاق، وبعدها حرف أو رقم أو قوس فتح أو علامة اقتباس فتح؛ وتُحتسب علامة الاقتباس التي قبلها إذا سبقها حرف أو رقم أو علامة ترقيم إغلاق أو مسافة غير قابلة للكسر، كما في "no"—and، لا في said "—Hola؛ وتُحتسب علامة التنسيق المضمَّنة الملاصقة للشرطة أو لعلامة الاقتباس، مثل ** في **riddles.**—I، من أي الجانبين)، ولم تكن الإعدادات تضبطه أصلًا. ويُكتب تقسيم الأسطر غير المضبوطة (pinLegacyRaggedBreaking) على شكل bodyText.optimalRagged: false في bodyText الساري، إذا جعلت الإعدادات بعض النص الجاري غير مضبوط (قيمة textAlign غير 'justify' على النص الأساسي، أو على نمط فقرة، أو متن إطار، أو متن جزء، أو متن نمط قسم (headingStyles[].bodyStyle)، أو في تجاوزات عارض HTML)، ولم تكن تضبطه أصلًا، ولا تعطّل optimalLineBreaking. ويُكتب الفراغ في الأطر (pinLegacyBoxResourceGap) على شكل layout.inlineResourceGapInBoxes: false في layout الساري، إذا ضُمِّن مورد في سطر مستقل داخل :::callout في الفصول المقروءة، ولم تكن الإعدادات تضبطه أصلًا. ويُكتب قطع الإطار (pinLegacyBoxChildCut) على شكل layout.boxChildSplitMinLines: 1 في layout الساري، إذا فتح أحد الفصول المقروءة :::callout في سطر مستقل، ولم تكن الإعدادات تضبطه أصلًا. وتُكتب علامات العناوين (pinLegacyHeadingMarks) على شكل headings.inlineMarks: false في headings الذي تُبقيه الطبقات ساريًا، إذا حمل عنوانٌ في الفصول المقروءة علامةً (* أو _ أو ^ أو ~ أو :smallcaps[ أو رابطًا في نصه) ولم تحدد الإعدادات قيمة خاصة بها. أما الحروف الاستهلالية (pinLegacyDropCapSize) فيُكتب حجمها في 1.4 صراحةً على شكل dropCap.fontSize، أينما كانت: بوحدة تباعد أسطر العنصر إن كان طولًا، وإلا فبوحدة حجم خطه. وتُكتب المساحة تحت سطر النقطتين (pinLegacyColonListRoom) على شكل bodyText.colonListRoom: 'line' في bodyText الساري، إذا جاءت قائمة في الفصول المقروءة بعد سطر ينتهي بنقطتين (ويُسمح بأسطر فارغة بينهما)، ولم تحدد الإعدادات مساحة ولا عطّلت keepColonWithList. ويُكتب الفراغ تحت المورد المضمَّن (pinLegacyInlineGap) على شكل layout.inlineResourceGap: 'above' في layout الذي تُبقيه الطبقات ساريًا، إذا ضمَّن سطرٌ من الفصول المقروءة موردًا (::resource{id="…"} وحده في سطره، كما يقرؤه المحلِّل: ذكره في النص الجاري أو في مقطع شيفرة لا يُحتسب) ولم تحدد الإعدادات فراغًا خاصًا بها. ويُثبَّت الحجم على math الذي تُبقيه الطبقات ساريًا (إذ يحلّ math الخاص بلغةٍ ما محلّ المشترك)، ولا يُثبَّت إلا إذا كان في الفصول المقروءة الرمز $: فالحزمة التي لا صيغ فيها تحتفظ بـconfig كما كُتب. وإذا لم يحدد البيان ولا اللغة math، فالساري هو baseConfig الخاص بـreadBundle (إعدادات القارئ نفسه)، ويُثبَّت هو أيضًا، لأن 1.4 كان ينضّد صيغ الحزمة بذلك الحجم: فالقيمة الأساسية fontSizeScale: 1.5 تُقرأ 1.5 × 1.1312. أما فواصل العناوين في الإعدادات الأساسية فتؤخذ كما هي. وهكذا تحتفظ الحزمة القديمة بما أخرجته هذه القواعد، ويُظهر bundle.config ما تُخرَج به من فواصل، وحجم صيغ، وفراغات، وعلامات عناوين، وأحجام حروف استهلالية، ومساحة تحت سطر النقطتين، وقطع إطار، وفواصل شرطات، وفواصل كلمات مركبة، وتقسيم للأسطر غير المضبوطة، وتقسيم تحت العنوان، وفراغ تحت الحاويات. أما إصلاحات الإخراج في 1.5 فلا تثبيت لها، وتسري عليها كما تسري على أي كتاب، لذا قد تتحرك صفحة تمسّها (انظر حجم الصيغ للاطلاع على القائمة). ملف preset.json المكتوب يدويًا لقواعد اليوم يضبط "configVersion": 14؛ ووسم بيان حزمة قديمة بهذه القيمة هو أيضًا الطريقة ذات السطر الواحد لقراءتها بقواعد اليوم (وعندها تفقد الحزمة التي لا إصدار لها تثبيت فواصل العناوين أيضًا). والبيان الموسوم 8، الذي كتبه postext من 1.5 إلى 1.22، يتلقى التثبيتات الأربعة التي في القواعد 9 وحدها، كما يتلقاها كل بيان أقدم: يُكتب تخطيط الشعر (pinLegacyVerseLayout) بصفته bodyText.verse.layout: 'bayt' على bodyText النافذ، حين يصفّ فصل مقروء قصيدة :::verse لا يسمّي سطر فتحها تخطيطًا ولا تحمل أسطرها فاصل شطرين، ولا تحدّده الإعدادات بعد؛ وتحذف الإزاحات المزدوجة (pinLegacyPairedIndents) قيمة firstLineIndent من كل نمط فقرة (في paragraphStyles أو في تجاوزات عارض HTML) يحدّد أيضًا hangingIndent غير صفرية؛ وتُكتب فواصل الأسطر الإجبارية (pinLegacyHardBreaks) بصفتها bodyText.hardLineBreaks: false على bodyText النافذ، حين يختم فصل مقروء سطرًا من فقرة أو اقتباس أو عنصر قائمة بشرطة مائلة عكسية والكتلة مستمرة تحته، أو يضع \\ متبوعة بمسافة ونص (ما عدا الشيفرة والصيغ المضمّنة، والصيغ المعروضة، والعناوين، وقصائد :::verse)، ولا تحدّده الإعدادات بعد؛ وتُكتب أسوار الشيفرة (pinLegacyCodeBlocks) بصفتها codeStyle.blocks: false، حين يفتح فصل مقروء سور ``` أو ~~~ (من ثلاث علامات أو أكثر، بإزاحة لا تزيد على ثلاث مسافات) ولا تحدّده الإعدادات بعد. والبيان الموسوم 9، الذي كتبه postext 1.23، لا يتلقى إلا تثبيت القواعد 10، كما يتلقاه كل بيان أقدم أيضًا: تُكتب تتمّات أسطر الشعر (pinLegacyVerseTightening) بصفتها bodyText.verse.tighten: false في bodyText الساري، حين يصفّ فصل مقروء قصيدة سطرًا سطرًا (سطر فتح :::verse يسمّي layout=lines، أو لا يسمّي تخطيطًا وأسطره بلا فاصل بين الشطرين ما دامت الإعدادات لا تصفّ هذه القصائد أبياتًا) ولا تحدّده الإعدادات بعد. والبيان الموسوم 10، الذي كتبه postext 1.24، لا يتلقى إلا تثبيت القواعد 11، كما يتلقاه كل بيان أقدم أيضًا: تُكتب موازنة شبكة المحارف (pinLegacyGridBalancing) بصفتها headings.balancing.enabled: true في headings الساري، حين تضبط الإعدادات المدمجة cjk.grid.enabled في النص الأفقي ولا تضبط enabled نفسها، لأن 1.24 كان يوازن مثل هذه الصفحة افتراضيًا. وتمنع قواعد 11 أيضًا كسر عنوان الكتاب بعد محرف واحد، وتصفّ الأرقام المحاطة بدائرة كحروف صينية، ونص التصميم CJK بقواعد المتن (#637): البيان المختوم بـ 10 أو أقدم يتلقى cjk.titleMinChars: 1 (pinLegacyTitleBreaks) حين يكون في فصل مقروء عنوان (《 أو 〈 أو :book[)، وcjk.circledNumbers: 'western' (pinLegacyCircledNumbers) حين يكون فيه رقم محاط بدائرة (U+2460–U+24FF، U+2776–U+2793)، وcjk.composeDesignText: false (pinLegacyDesignText) حين يحوي فصل مقروء أو الإعداد نفسه نصًا CJK؛ كل منها على cjk السارية، وفقط إن لم يحدده الإعداد من قبل. وهي تقطع أيضًا الجداول المدرجة وتُخرج :::columns في النص الجاري (#634): البيان المختوم بـ 10 أو أقدم يتلقى tableStyle.splitInline: false (pinLegacyInlineTableSplit) على tableStyle السارية حين يضمّن فصل مقروء موردًا، وlayout.flowColumns: false (pinLegacyFlowColumns) على layout السارية حين يفتح فصل مقروء سياج :::columns في سطر مستقل، كل منهما فقط إن لم يحدده الإعداد من قبل. وهي تتيح كذلك لعنصر عائم رأس عمود الافتتاح الممتد بعرض الصفحة (#639): البيان المختوم بـ 10 أو أقدم يتلقى layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) على layout السارية حين يحدّد الإعداد المدمج مستوى عنوان أو نمط عنوان فيه span: 'page' ويكون في فصل مقروء عنوان، فقط إن لم يحدده الإعداد من قبل. وقواعد 12 تجعل الصفحة على شبكة المحارف تحمل أسطرها (#717): البيان المختوم بـ 11 أو أقدم، إذا فعّل إعداده المدمج cjk.grid.enabled، يتلقى bodyText.avoidWidows: true، وفي المنطقة japan يتلقى bodyText.avoidOrphans: true أيضًا (pinLegacyGridBreakRules)، على bodyText السارية، كلٌّ منهما فقط إن لم يحدده الإعداد من قبل. وهي تُبقي كذلك حرفين من الحاشية ذات الصفّين على كل جانب من كسر السطر (#720): مثل هذا البيان يتلقى cjk.warichu.minChars: 1 (pinLegacyNoteBreaks) على cjk السارية حين يكون في فصل مقروء حاشية :warichu[ أو يحدّد الإعداد المدمج citations.notes: 'warichu'، فقط إن لم يحدده الإعداد من قبل. وفي منطقة اليابان تأخذ الكلمة الغربية المسافة بين الهان واللاتيني بجوار أقواسها وعلاماتها (#721): البيان المختوم بـ 11 أو أقدم، إذا آل إعداده المدمج إلى منطقة اليابان، يتلقى cjk.latinSpacingEdge: 'letters' (pinLegacyLatinSpacingEdge) على cjk السارية حين يكون في فصل مقروء كانا أو كانجي، فقط إن لم يحدده الإعداد من قبل. والمورد الموضوع في النص يأخذ placement.captionSide كما يأخذها العنصر العائم بعرض العمود (#729): البيان المختوم بـ 11 أو أقدم، إذا ضبط إعداده المدمج layout.layoutType: 'oneAndHalf'، يتلقى layout.inlineCaptionSide: false (pinLegacyInlineCaptionSide) حين يضمّن فصل مقروء موردًا، فقط إن لم يحدده الإعداد من قبل.

وقواعد 12 تغيّر كذلك طريقة موازنة العمود القصير، والبيان المختوم بـ 11 أو أقدم يحتفظ بما صُفّ به. ترتيب الأدوات (#707): يتلقى headings.balancing.maxLinesBeforeLoose بقيمة maxLinesPerHeading الخاصة به، أو 4 إن لم يحدّد قيمة (pinLegacyLooseOrder)، على headings السارية، حين يكون في فصل مقروء عنوان ويكون الإعداد المدمج يوازن أعمدته ويسمح للعنوان بأكثر من سطرين ولا يحدّد المفتاح من قبل. السطر تحت شكل يمتد على عدة أعمدة (#708): يتلقى headings.balancing.stretchAfterWideFloats: 'always' (pinLegacyWideFloatStretch) على headings السارية، حين يُدرج فصل مقروء موردًا أو يشير إليه أو يجعل إطارًا عائمًا، ويكون الإعداد المدمج يوازن أعمدته ولا يوقف stretchAfterFloats ولا يحدّد المفتاح من قبل. الفواصل داخل مجموعات الفهرس (#712): يتلقى index.groups.splitMinEntries: 1 (pinLegacyIndexGroupSplit) على index السارية، حين يكون في فصل مقروء سياج :::index على سطر مستقل ولا يحدّد الإعداد المفتاح من قبل. وحدة القطع المتساوي (#711): يتلقى headings.balancing.trailingStep: 'grid' (pinLegacyTrailingStep) على headings السارية، حين يمكن أن يضع فصل مقروء نصًا خارج تباعد أسطر النص الأساسي (سياج :::، سياج شيفرة، اقتباس، قائمة، معادلة معروضة أو استشهاد)، ويكون الإعداد المدمج يوازن أعمدته ويُبقي trailing أو beforeSpan مفعّلًا ولا يحدّد المفتاح من قبل.

وقواعد 12 تكسر تسمية الإحالة عند مسافاتها حين تكون التسمية كلمات (#702). والبيان المختوم بـ 11 أو أقدم يتلقى crossRefs.breakLabels: false (pinLegacyRefLabelBreaks) على crossRefs السارية، حين يكون في فصل مقروء إحالة (:ref{…} أو \ref{…} أو \eqref{…} أو @id من pandoc-crossref) ولا يسمّي الإعداد هذا الخيار: فتبقى كل تسمية الكلمة الواحدة التي صُفّت بها.

وقواعد 12 تشكّل الكلمة الخالية من الحروف (46. و1367)) بكتابة النص المحيط بها (#725). والبيان الموسوم بـ11 أو أقدم يأخذ bodyText.scriptContext: false (pinLegacyScriptContext) على bodyText النافذ، أيًّا كان ما في فصوله، إذا لم يسمِّ الإعدادُ هذا الخيار: فتُشكَّل هذه الكلمة وحدها كما خُطِّطت. تعمل القاعدة في الخط الذي يرسم علامات ترقيمه بحسب الكتابة، ولا يُعرف عند قراءة البيان أيَّ الخطوط يحمّل الكتاب.

وقواعد 13 تجعل اللغة التي يُشكَّل بها النص خيارًا (#764)، وقواعد 14 تجعل لغة المستند نفسه قيمته الافتراضية (#770). والبيان الموسوم 13 أو أقدم يتلقى bodyText.shapingLanguage: 'host' (pinLegacyShapingLanguage) على bodyText النافذ، أيًّا كان ما في فصوله، إذا لم يسمِّ الإعدادُ هذا الخيار؛ والبيان الذي يسمّي قيمة يحتفظ بها. فيُقاس نصه بلغة الصفحة التي تستضيف التخطيط، وبلا لغة في العامل، كما خُطِّط. والبيان الموسوم 13 كتبه postext 1.29: فالإصداران 1.29.1 و1.29.2 خطّطا الكتاب الذي لا يسمّي قيمة بلغة المضيف، وهذا ما يفعله التثبيت؛ أما 1.29.0، وكانت قيمته الافتراضية 'document'، فخطّطه بلغة المستند ولم تتبع فيه Canvas ولا PDF القياس، وهو يُقرأ بـ 'host' كذلك. والبيان الموسوم 14 الذي لا يسمّي قيمة يُخطَّط بلغة المستند.

وقواعد 13 ترسم كذلك مربع بند المهمة (- [ ]، - [x]): مربع يرسمه المحرّك، وفيه علامة صح إذا أُنجزت المهمة، ويأخذ حيّزًا خاصًّا به قبل نص البند (#765). والبيان الموسوم 12 أو أقدم يتلقى unorderedLists.taskBox: 'character' (pinLegacyTaskBox) على unorderedLists النافذ، إذا احتوى أحد فصوله بند مهمة ولم يسمِّ الإعدادُ هذا الخيار. فيبقى مربعه الحرف ☐ أو ☑ مصفوفًا بخط القائمة، ويبدأ نص البند بعد عرض ذلك الحرف، كما خُطِّط. وذلك العرض كان يعطيه الخط الذي يحتوي الحرف، وهو في أغلب الكتب خط من خطوط النظام، ولا يُعرف عند قراءة البيان.

import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
 
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
//      layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
//      headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // today's rules: `config` itself

content هو نص Markdown الذي تُخرجه الإعدادات (سلسلة نصية أو قائمة فصول). من دونه يُثبَّت حجم الصيغ كلما كانت الرياضيات مفعَّلة، والفراغ تحت الحاويات كلما عرّفت الإعدادات نمط فقرة، أما الفراغان وعلامات العناوين والمساحة تحت سطر النقطتين وقطع الإطار وفواصل الشرطات والتقسيم تحت العنوان وفواصل الكلمات المركبة فتُثبَّت دائمًا، إذ لا يستطيع المحرّك أن يعرف هل في الكتاب صيغة، أو حاوية :::paragraphs، أو شكل مضمَّن، أو عنوان فيه علامة، أو قائمة تمهّد لها نقطتان، أو إطار، أو شرطة ملتصقة، أو عنوان، أو كلمة مركبة. أما تقسيم الأسطر غير المضبوطة فيُثبَّت بناءً على الإعدادات وحدها، سواء وُجد المحتوى أم لا. رحِّل الإعدادات المخزنة مرة واحدة وخزّنها من جديد تحت CONFIG_VERSION: فتثبيت الصيغ يضرب المقياس، والإعدادات التي تُرحَّل مرتين تكبر مرتين. ومن دونه يُثبَّت تخطيط الشعر وأسوار الشيفرة أيضًا، لأن الكتاب قد يحمل قصيدة :::verse بلا فاصل أو سورًا، أما الإزاحات المزدوجة فتُثبَّت بحسب الإعدادات وحدها. وتُثبَّت تتمّات أسطر الشعر في 1.23 أيضًا، لأن الكتاب قد يصفّ قصيدة سطرًا سطرًا.

#إخراج حزمة ورسمها

أربع أدوات مساعدة تصل الحزمة المفتوحة بالمحرّك وبالمُخرِجات:

  • loadBundleFonts(bundle) يسجّل أوجه خطوط الحزمة في document.fonts، وبايتاتها في سجلّ الخطوط في المحرّك لصور SVG (registerFontBytes). انتظر اكتماله (await) قبل الإخراج، لأن الإخراج يقيس النص بالخطوط المتاحة في المتصفح. أما العائلات التي تسمّيها الحزمة ولا تحملها (Google Fonts) فعليك تحميلها بنفسك، كما في أي مستند آخر.
  • registerBundleImages(bundle) يفكّ ترميز الصور لمُخرِج Canvas (renderPage وrenderToCanvas)، ومنها أغلفة مقاطع الفيديو. وbundleImageUrl(bundle) هو المحلِّل resourceImageUrl الذي يحتاجه renderToHtml، وbundleVideoUrl(bundle) محلِّله resourceVideoUrl لملفات الفيديو التي تحملها الحزمة. وكلاهما يعيد تلوين أشكال SVG حين يكون diagramStyle.singleInk مفعَّلًا، مرة واحدة: يعيدان تلوين الشيفرة ويعلّمان الصور كي لا يلوّنها أي مُخرِج مرة أخرى (انظر الحبر الواحد على Canvas وفي HTML). ويضمّن كلاهما أيضًا في كل SVG أوجه الخطوط التي يسمّيها نصه، من خطوط الحزمة نفسها أولًا، ثم من الأوجه المسجّلة في المحرّك (انظر الخطوط في نصوص SVG)؛ ويُبلّغ registerBundleImages(bundle, { onWarning }) وbundleImageUrl(bundle, { onWarning }) عن العائلة التي لا وجه لها.
  • buildBundle(bundle) يُخرج الفصول بالترتيب ويعيد VDTDocument لكل فصل. كل فصل يتابع ما قبله: عدّادات العناوين والموارد، والجزء المفتوح، وزوجية الصفحة، وترقيم الصفحات. والفصل الذي يطبع المحتويات (:::toc) أو الفهرس الأبجدي (:::index) يتلقى مخطط الكتاب كله. ويقبل الخيارات نفسها التي يقبلها buildDocument، إضافةً إلى config لتجاوز إعدادات الحزمة، وcache لمشاركة ذاكرة تخزين مؤقت للقياسات، وmetadata (انظر أدناه).
  • bundleResourceBytes(bundle) وbundleFontProvider(bundle, { decodeWoff2, fallback }) هما الخياران resourceBytes وfontProvider للدالة renderToPdf في postext-pdf. يختار مزوّد الخطوط من الحزمة أقرب وزن للنمط المطلوب. ويحتاج لوجه .woff2 إلى decompressWoff2، وللعائلة التي لا تحملها الحزمة يستدعي fallback بمعاملات المُخرِج، بما فيها request، ويمرّر ما تعيده كما هو. والبديل الذي لا يجلب إلا ملف latin للعائلة يطبع العائلة الصينية التي لا تضمّنها الحزمة مربعاتٍ فارغة؛ أما البديل الذي يجيب بشرائح، مثل sliceFontProvider في خطوط الصينية واليابانية والكورية، فيطبعها كاملة.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
  bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
 
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
 
const docs = buildBundle(bundle);                        // one VDTDocument per chapter
const firstPage = renderPage(docs[0].pages[0], docs[0]); // a <canvas>
 
const pdf = await renderToPdf(docs, {                    // the whole book
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
  resourceBytes: bundleResourceBytes(bundle),
});

لإخراج فصل واحد بنفسك، مرّر bundle.chapters[i].markdown وbundle.resources وbundle.config إلى buildDocument، كما تفعل مع أي مستند.

البيانات الوصفية للكتاب. كما في Sandbox، البيانات التمهيدية (front matter) للفصل الأول هي بيانات الكتاب: يسلّم buildBundle قيم title وauthor وسائرها إلى كل فصل، فتصحّ الترويسات {title} و{author} على كل صفحة، ويحملها doc.metadata في كل فصل. وتُتجاهَل كتلة البيانات التمهيدية في أعلى فصل لاحق: يُتعرَّف عليها بسطرَي --- وتُمحى دون تحليلها، فلا يضرّ YAML قد يرفضه المحلِّل. ويوفّر options.metadata القيم التي لا تضبطها البيانات التمهيدية (والغلبة للبيانات التمهيدية). ويصل عدد صفحات الكتاب إلى كل فصل أيضًا: يطبعه {bookTotalPages}، بينما يعدّ {totalPages} صفحات الفصل (انظر عدد صفحات الكتاب).

const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title;   // the first chapter's `title:`

موضع بداية الكتاب. قد تضمّ الحزمة جزءًا من مطبوعة أطول: الصفحات من 58 إلى 61 من عدد، أو الفصل الرابع من كتاب مدرسي. يحدّد start ما يسبقها، بحقول continuation في buildDocument: عدد الصفحات قبل الصفحة الأولى (pageIndexOffset، وهو يحدّد الجهة التي تقع فيها الصفحة 1، ومعها الهوامش المتناظرة وترويسات الصفحات الفردية والزوجية)، وترقيم الصفحات الساري (pageNumbering)، وعدّادات العناوين (headings)، والجزء المفتوح (part)، وعدّادات الموارد والعبارات المرقّمة والحواشي السفلية والأسطر. تكتبه createBundle في preset.json باسم start، وتعيده openBundle في bundle.start، وتُخرج buildBundle به الفصل الأول كما تفعل buildDocument({ markdown, continuation: start }, config)، وتصل به الفصول التالية؛ ويحسب {bookTotalPages} الصفحات السابقة للكتاب أيضًا. البيان الذي لا يحوي start يُقرأ كما كان، والقارئ الذي لا يعرف الحقل يتجاهله. وbookPageCount ليس منه: القارئ هو من يعدّ الصفحات. وفي حزمة بعدة لغات يمنح localized[…].start إحدى الطبعات بداية خاصة بها.

const { bytes } = await createBundle({
  name: 'Field notes, chapter 4',
  markdown,
  config,
  // Page 58, an even page, in chapter 4.
  start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start;                 // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel;       // '58'

#مثال حي: فتح حزمة

يحمّل هذا المثال على CodePen كتابًا نموذجيًا من فصلين (lantern.postext، بخطه الخاص وشكل SVG وجدول) من المستودع. ويسجّل خطوط الحزمة وصورها، ويُخرج الكتاب بـbuildBundle ويرسم كل صفحة. ويُنتج زر Make the PDF المستندات نفسها بـpostext-pdf، مضمِّنًا خطوط الحزمة. اختر ملف .postext خاصًا بك، مصدَّرًا من Sandbox مثلًا، لتراه بالطريقة نفسها.

Postext · فتح حزمة .postext
import {
  openBundle,
  loadBundleFonts,
  registerBundleImages,
  buildBundle,
  bundleResourceBytes,
  bundleFontProvider,
  renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
 
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
 
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
 
async function show(data) {
  // Chapters, config (fonts wired to the bundle's own files), resources and
  // every file, keyed by its path inside the bundle.
  const bundle = await openBundle(data);
 
  // Layout measures text with the fonts the browser has: register the
  // bundle's faces, and load the Google Fonts it names but does not carry
  // (the default running heads use Open Sans; see the pen's CSS).
  await loadBundleFonts(bundle);
  await document.fonts.load('600 16px "Open Sans"');
  await registerBundleImages(bundle);
 
  // One VDTDocument per chapter, each continuing the one before it.
  const docs = buildBundle(bundle);
  const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
  document.getElementById('pages').replaceChildren(...pages);
  status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
    + (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
  current = { bundle, docs };
  pdfButton.disabled = false;
  document.getElementById('links').hidden = true;
}
 
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
  if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
 
pdfButton.addEventListener('click', async () => {
  pdfButton.disabled = true;
  status.textContent = 'Rendering the PDF…';
  const { bundle, docs } = current;
  const bytes = await renderToPdf(docs, {
    fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
    resourceBytes: bundleResourceBytes(bundle),
  });
  const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
  document.getElementById('open').href = url;
  document.getElementById('download').href = url;
  document.getElementById('links').hidden = false;
  status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
  pdfButton.disabled = false;
});
 
document.getElementById('file').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  status.textContent = `Opening ${file.name}…`;
  await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
 
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());
index.html
<p>
  <label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
  <button id="pdf" disabled>Make the PDF</button>
  <span id="links" hidden>
    <a id="open" target="_blank" rel="noopener">open it</a> ·
    <a id="download" download="book.pdf">download it</a>
  </span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#pages {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: flex-start;
}
#pages canvas {
  display: block;
  width: 240px;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

يحمّل محررًا تفاعليًا من codepen.io. يستورد المثال أحدث إصدار من postext عبر شبكة توصيل المحتوى (CDN).

#إنشاء حزمة

يكتب createBundle ملف .postext من مستند: فصوله وإعداداته وموارده والملفات التي تشير إليها.

import { createBundle } from 'postext';
 
const { bytes, manifest, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [
    { markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
    { title: 'Night', markdown: '# Night\n\n…' },
  ],
  config,
  resources: [{
    id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
    svg: { fileId: 'lantern.svg', width: 240, height: 150 },
    createdAt: 0, updatedAt: 0,
  }],
  files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});
المُدخلالمعنى
name، id، description، localeالبيانات الوصفية للبيان. القيمة الافتراضية لـid صيغة مختصرة (slug) من name.
chapters أو markdownالكتاب، { title?, markdown } لكل فصل، أو مستند واحد.
configإعدادات PostextConfig. القيم المساوية للقيم الافتراضية لا تُكتب في البيان.
resourcesالموارد. تسمّي الصورة ملفها بـbitmap.fileId / svg.fileId (وsvg.pdfFileId لنسخة الطباعة الأصلية).
filesالملفات حسب fileId (كائن أو Map): الصور التي تشير إليها الموارد، وملفات الخطوط التي تشير إليها متغيرات config.customFonts. يمكن أن تكون القيم Uint8Array أو ArrayBuffer أو Blob أو سلسلة نصية (شيفرة SVG).
thumbnail{ data, mime }: صورة غلاف (PNG أو JPEG أو WebP أو GIF أو SVG).
canvasScopeالقيمة 'book' تطلب من العارضين إخراج الكتاب كله لوحةً واحدة.
startموضع بداية الكتاب، في حزمة تضمّ جزءًا من كتاب أطول: هو continuation الذي يُبنى به مستند مفرد. يُكتب في البيان باسم start.
mtimeتاريخ التعديل المكتوب على كل ملف في الأرشيف (Date أو طابع زمني أو سلسلة تاريخ). إذا أُغفل فهو وقت الاستدعاء، فيعطي استدعاءان بالمُدخل نفسه بايتات مختلفة. مرّر تاريخًا ثابتًا يعطِ المُدخل نفسه البايتات نفسها، فتستطيع حساب بصمتها (hash) أو مقارنتها. يحفظ ملف zip تاريخًا ووقتًا بلا منطقة زمنية، بخطوات من ثانيتين، من 1980 إلى 2099، ويُكتب التاريخ بالتوقيت المحلي للجهاز. وللحصول على بايتات متطابقة على كل جهاز، ابنِ التاريخ من حقول محلية، مثل new Date(1980, 0, 1): فالطابع الزمني أو السلسلة المنتهية بـZ يسمّيان لحظة بعينها، تقع في وقت محلي مختلف في كل منطقة زمنية ('1980-01-01T00:00:00Z' ما زال في 1979 غربَ UTC). والتاريخ الواقع خارج تلك السنوات، بالتوقيت المحلي، يرمي خطأً.
localizedلغات أخرى للكتاب نفسه، حسب وسم اللغة: { es: { chapters?, config?, resources? } }. تصبح المُدخلات أعلاه حينئذٍ محتوى locale، وهو مطلوب. انظر الحزم الثنائية اللغة.

تعيد الدالة بايتات الأرشيف bytes، وmanifest المكتوب على شكل preset.json، وكل ملف في files (المسار ← البايتات)، وقائمة warnings. تُسمّى الملفات باسم معرّف المورد (resources/lantern.svg) أو اسم ملف الخط (fonts/…)، والفصول بترتيبها وعنوانها (chapters/01-dusk.md). تُعرَّف الخطوط في fonts في البيان، ولا تُعرَّف أبدًا داخل config.customFonts. وتُستبعد بعض الأشياء، مع تحذير لكل منها:

  • مورد أو وجه خط ليس ملفه في files
  • وجه .woff (لا يستطيع مُخرِج PDF تضمينه)
  • عائلة معلَّمة بـredistributable: false

في المتصفح، سلّم bytes إلى رابط تنزيل: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). وفي Node، اكتبها بـfs.writeFile. لا يحتاج createBundle ولا openBundle إلى DOM. تستخدم ملفات التوزيع (dists) مسارات وحدات بلا امتداد، لذا تحتاج تحت Node المجرّد، دون أداة تجميع (bundler)، إلى خطاف حلّ (resolve hook). ويعرض الملف docs/examples/open-bundle/build-sample.mjs في المستودع خطافًا كهذا في بضعة أسطر.

#الحزم الثنائية اللغة

يمكن لملف .postext أن يحمل كتابًا بعدة لغات، ويقرؤه openBundle(bytes, { locale }) بأيٍّ منها. ويكتب createBundle ملفًا كهذا من localized: مُدخل واحد لكل لغة إضافية، فيه ما يختلف عن المحتوى الأساسي (مُدخل locale):

const { bytes, manifest } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
  config,
  resources: [lanternFigure, hoursTable],
  files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
  localized: {
    es: {
      chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
      config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
      resources: [
        { id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
        { id: 'hours', caption: 'Horas de luz.' },
      ],
    },
  },
});
 
const es = await openBundle(bytes, { locale: 'es' });   // Spanish chapters, config and captions
  • chapters: الكتاب بتلك اللغة. تذهب ملفات الفصول إلى مجلد لكل لغة (chapters/en/01-dusk.md، chapters/es/01-anochecer.md) ويصبح chapters في البيان خريطة من اللغة إلى الفصول. اللغة التي لا chapters لها تقرأ الفصول الأساسية؛ وإذا لم يكن لأي لغة فصول خاصة بها، تبقى قائمة واحدة.
  • config: الإعدادات لتلك اللغة. يحلّ كل مفتاح من المستوى الأعلى محل المفتاح المشترك برمّته حين تُقرأ الحزمة بتلك اللغة، فـheadings أعلاه يحلّ محل كائن headings كله. والمفاتيح المُغفلة، أو المساوية للمشتركة، مشتركة ولا تُكتب، فتمرير إعدادات اللغة كاملة يصلح كما يصلح تمرير المفاتيح القليلة التي تتغير. والمفتاح المضبوط على قيمه الافتراضية بينما المشترك ليس كذلك (layout: {}) يُكتب كما هو، فيعيد ضبط القيمة المشتركة. الخطوط مشتركة: تنضم عائلات customFonts الخاصة بلغة ما إلى fonts في الحزمة.
  • resources: صياغة الموارد المشتركة، مطابَقةً بـid: caption وnote وaltText وtable في الجدول. والصورة التي فيها كلمات يمكن أن يكون لها عملها الفني الخاص: يسمّي bitmap.fileId أو svg.fileId (وsvg.pdfFileId) ملفًا آخر في files، يُكتب على شكل resources/es/lantern.svg. أما الحقول الأخرى، كالنوع أو الموضع، فمشتركة. والمعرّف غير الموجود بين resources يُستبعد مع تحذير، والصورة المفقودة للغةٍ ما تُبقي تلك اللغة على الصورة المشتركة، مع تحذير أيضًا.

يسرد البيان كل لغة في locales (['en', 'es'])، ويحتفظ باللغة الأساسية في locale ويخزّن البقية تحت localized. ويقرأ openBundle دون لغةٍ اللغةَ الأساسية.

أي لغة يحصل عليها القارئ. يقدّم openBundle(bytes, { locale }) اللغة المطابقة تمامًا، وإلا فاللغة دون متغيرها الإقليمي (es-MX يقرأ es)، وإلا فاللغة الأساسية، ويبيّن bundle.locale أيّها قدّم. تأتي الفصول والصياغة دائمًا من اللغة نفسها. وتحتفظ اللغة الأساسية بالصياغة المشتركة حتى إن حمل localized متغيرًا إقليميًا منها: فالحزمة بـpt-PT التي فيها مُدخل pt-BR تقرأ التعليقات البرازيلية لـpt-BR وحدها، والتعليقات المشتركة لـpt-PT وpt.

#مثال حي: إنشاء حزمة

يبني هذا المثال على CodePen كتابًا من فصلين فيه شكل SVG، ويسرد الملفات التي كتبها createBundle مع البيان. ويعرض الأرشيف للتنزيل، ثم يفتحه من جديد بـopenBundle ويرسم صفحته الأولى: الدورة الكاملة ذهابًا وإيابًا في بضعة أسطر. استورد الملف المنزَّل في Sandbox لتواصل العمل عليه هناك.

Postext · إنشاء حزمة .postext
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
 
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
  <rect width="240" height="150" fill="#f3efe6"/>
  <path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
  <rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
  <circle cx="120" cy="79" r="11" fill="#fff4c2"/>
  <path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
 
const resources = [{
  id: 'lantern',
  typeId: 'figure',
  kind: 'svg',
  caption: 'The lantern by the door.',
  svg: { fileId: 'lantern.svg', width: 240, height: 150 },
  createdAt: 0,
  updatedAt: 0,
}];
 
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
 
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
  { markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
  { markdown: `# Night\n\n${text}\n\n${text}` },
];
 
const config = {
  layout: { layoutType: 'double' },
  // Two short chapters that run on, with no blank verso between them (an
  // H1 otherwise opens on a fresh recto), as in the open-bundle sample.
  headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
 
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters,
  config,
  resources,
  files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
 
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
  const li = document.createElement('li');
  li.textContent = `${path} (${data.length} B)`;
  return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
 
// Round trip: open the file just written, the way any program would.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
  `${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;
index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
  <a id="download" download="lantern.postext">Download lantern.postext</a> ·
  <a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
  <section>
    <h3>Files in the bundle</h3>
    <ul id="files"></ul>
    <h3>preset.json</h3>
    <pre id="manifest"></pre>
  </section>
  <section>
    <h3>Opened again: page 1</h3>
    <div id="page"></div>
  </section>
</div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#output {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
}
#output section {
  flex: 1 1 280px;
  min-width: 0;
}
h3 {
  margin: 8px 0;
  font-size: 14px;
}
ul {
  margin: 0;
  padding-left: 20px;
  font-family: ui-monospace, monospace;
  font-size: 13px;
}
pre {
  max-height: 320px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 12px;
}
#page canvas {
  display: block;
  max-width: 100%;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

يحمّل محررًا تفاعليًا من codepen.io. يستورد المثال أحدث إصدار من postext عبر شبكة توصيل المحتوى (CDN).

#العمل بالحزم

لأن Sandbox ومهارة الوكيل ومكتبة postext تقرأ كلها الملف نفسه وتكتبه، فملف .postext وسيلة عملية لتسليم كتاب من أداة إلى أخرى:

  • ابدأ من حزمة. انقل منشورًا قائمًا بـمهارة الوكيل، أو صمّم كتابًا في Sandbox وصدّره (تنزيل (.postext) في قائمة ⋯ في صفّه ضمن لوحة الكتب). حمّل الملف من برنامجك بـopenBundle لترسمه على Canvas أو في HTML أو PDF. واحتفظ بالملف مصدرًا للكتاب: عدّل الفصول أو الإعدادات أو الموارد في الشيفرة واكتبه من جديد بـcreateBundle، أو أعد تحميله ببساطة كلما تغيّر.
  • صحّح الأخطاء واضبط التفاصيل في Sandbox. حين يحتاج شيء في مخرجات برنامجك إلى عمل (شكل يقع في الصفحة الخطأ، أو نمط عنوان، أو توازن الأعمدة)، صدّر ما يُخرجه برنامجك بـcreateBundle. استورد ذلك الملف في Sandbox (الكتب › جديد › افتح ملف .postext…)، وأصلح النص أو التصميم أو الأشكال بالمعاينة الحية ولوحة الفحوص وعرض PDF، ثم صدّره من جديد. بعدها يحمّل برنامجك الملف المصحَّح بـopenBundle. أو انسخ ما تغيّر إلى شيفرتك: لا يحتوي config في البيان إلا على القيم التي تختلف عن الافتراضية، فيُقرأ كفرقٍ (diff) قصير.

#الواجهة البرمجية منخفضة المستوى

يصدّر postext/bundle أيضًا اللبنات التي يقوم عليها openBundle وcreateBundle، للمضيفين الذين يخزّنون الحزم أو يقدّمونها بطريقتهم (مجلد غير مضغوط عبر HTTP، أو سجلات في قاعدة بيانات):

  • openBundleZip(bytes) / zipBundle(files, { mtime }): طبقة الأرشيف. يتسامح الفتح مع مجلد في المستوى الأعلى، ويتجاهل مُدخلات __MACOSX والملفات التي يبدأ اسمها بنقطة (dotfiles). وتُرفض المسارات التي تخرج من الحزمة. ويؤرّخ mtime الملفات كما يفعل مُدخل createBundle.
  • readBundle(manifest, readFile, options) يقرأ بيانًا مع دالة الاستدعاء readFile(path) ويحوّلهما إلى فصول وإعدادات وموارد وصور وخطوط. يضبط options اللغة، وطريقة تسمية معرّفات الملفات (ids)، والإعدادات الأساسية (baseConfig، تحت إعدادات البيان؛ وهي افتراضيًا لوحة الألوان وأنواع الموارد في bundleBaseConfig بلغة الحزمة، التي يعيدها resolveBundleConfigLocale(manifest, locale)، وعلى المضيف الذي يمرّر baseConfig خاصًا به أن يكيّفه مع تلك اللغة؛ وتحت بيان أقدم من configVersion: 4 يُثبَّت math الخاص بها مع ما في الحزمة، وتحت بيان أقدم من 5 الفراغ تحت المورد المضمَّن في layout الخاص بها، وتحت بيان أقدم من 6 الفراغ في الأطر في layout، والمساحة تحت سطر النقطتين في bodyText، والعلامات المضمَّنة في headings، وأحجام الحروف الاستهلالية، وتحت بيان أقدم من 7 فواصل الشرطات وتقسيم الأسطر غير المضبوطة في bodyText، وتحت بيان أقدم من 8 التقسيم تحت العنوان في headings، وفواصل الكلمات المركبة والفراغ تحت حاويات :::paragraphs في bodyText، انظر الحزم التي كتبها postext 1.4 أو ما قبله)، وطريقة قياس المقاسات الأصلية. ويقرأ readResolution دقة كل صورة نقطية من ملفها إلى bitmap.fileResolution؛ وقيمته الافتراضية true حين تضبط الحزمة layout.bitmapResolution: 'file'.
  • planBundle(meta, content) / resolveBundleFiles(plan, sources): جانب الكتابة، مقسومًا إلى خطة خالصة (أسماء الملفات والبيان) وجلب البايتات عبر دالتَي الاستدعاء readBlob / readFont.
  • isBundleManifest(value)، ومنتقيات اللغة (pickChapterSpecs، pickLocaleOverrides، pickBundleView، resolveBundleLocale، resolveBundleConfigLocale)، وsvgSize / bitmapSize / bitmapInfo (بكسلات الصورة النقطية والدقة التي يذكرها ملفها)، وأنواع الصيغة (BundleManifest، BundleResourceSpec، BundleFontFamilySpec، …).
  • CONFIG_VERSION، migrateConfig(config, configVersion, { content })، pinLegacyHeadingBreaks(config)، pinLegacyMathSize(config)، pinLegacyInlineGap(config)، pinLegacyBoxResourceGap(config)، pinLegacyHeadingMarks(config)، pinLegacyDropCapSize(config)، pinLegacyColonListRoom(config)، pinLegacyBoxChildCut(config)، pinLegacyDashBreaks(config)، pinLegacyRaggedBreaking(config)، pinLegacyHeadingSplit(config)، pinLegacyParagraphContainerSpacing(config)، pinLegacyHyphenBreaks(config) وLEGACY_MATH_SIZE (0.5 ÷ 0.442): تحوّل إعدادات مخزنة إلى مصطلحات اليوم (انظر الحزم التي كتبها postext 1.4 أو ما قبله). يطبّقها readBundle؛ ويستطيع ذلك أيضًا المضيف الذي يخزّن الإعدادات بطريقته، مرة واحدة لكل نسخة مخزنة.

يقوم Sandbox على هذه الأدوات، ويضيف إليها معرّفات التخزين الخاصة به وسجلات الصفحات في layouts.json.