الفصل 3.18 · الجزء II · الحرفة
الاستخدام البرمجي: HTML وFolio وEPUB
الكتاب المُخرَج داخل صفحة ويب، وكتابًا ثلاثي الأبعاد مع postext-folio، وبصيغة EPUB 3 ثابتة التخطيط أو متدفقة مع postext-epub
باختصار
هذه الصفحة لمن يكتبون الشيفرة ويريدون عرض كتاب على الشاشة. يضع القسم الأول الصفحات داخل صفحة ويب، ويشرح كيف يختلف هذا العرض عن Canvas وPDF. ويعرض القسم الثاني الكتاب بالأبعاد الثلاثة، مفتوحًا على مكتب، بصفحات يقلّبها القارئ. ويصنع القسم الأخير كتابًا إلكترونيًا بصيغة EPUB، بصفحات ثابتة أو بنص يتدفق ليناسب الشاشة، ثم يفحص الملف.
#دمج عارض HTML
عارض HTML هو مُخرِج Postext المصمَّم للشاشة أولًا. فبدلًا من تحويل الصفحات إلى صورة نقطية، يُنتج عُقد DOM ذات مواضع مطلقة تحدّد هندستَها سلسلةُ المعالجة نفسها التي تُنتج مخرجات الطباعة. وهذا يجعله الخيار المناسب حين تريد في المتصفح تنضيدًا مقروءًا وقابلًا للتحديد ويستجيب لتغيّر المقاس، في تطبيق قراءة أو معاينة داخل منتج أو واجهة توثيق مضمّنة، دون الحاجة إلى عارض PDF.
العناصر الأساسية من الواجهة البرمجية العامة:
buildDocument(content, config, cache?): يشغّل خطّ معالجة الإخراج كاملًا ويُعيدVDTDocument.renderToHtmlIndexed(doc, options): يحوّل VDT إلى سلسلة HTML واحدة مع تفصيل لكل صفحة ولكل كتلة. ويتيح هذا التفصيل ترقيع DOM بكلفة زهيدة حين لا تتغيّر بين عمليتَي عرض إلا كتل قليلة.resolveHtmlViewerConfig(partial): يملأ القيم الافتراضية لعارض HTML (maxCharsPerLine،columnGap،optimalLineBreaking).buildFontString+measureGlyphWidth+dimensionToPx: أدوات قياس أولية تُستخدم لاستنتاج عرض عمود فعلي بالبكسل من عدد حروف مستهدف.createMeasurementCache/clearMeasurementCache: ذاكرات مؤقتة قابلة للتوصيل تتيح لك إعادة استخدام القياسات عبر عمليات إعادة الإخراج.prepareFonts/buildDocumentWithFonts/watchFonts/onFontsChanged: تحمّل أوجه المستند قبل الإخراج وتعيد الإخراج حين تصل أوجه جديدة (انظر تحميل الخطوط قبل الإخراج).
#مثال حيّ: سلسلة HTML
الدورة الكاملة بلغة JavaScript المجرّدة، قبل التكامل مع React أدناه: ابنِ المستند، وسلّم VDTDocument إلى renderToHtml، وضع السلسلة في حاوية. يرصّ mode: 'single' الصفحات عموديًا؛ ويمنحها background لونًا، لأن الصفحات شفافة افتراضيًا. ويطبع المثال على CodePen أيضًا الترميز المولَّد، فترى الأسطر ذات المواضع المطلقة التي يُنتجها المُخرِج، والتي يرسمها المتصفح دون أن يعيد تدفّقها أبدًا.
import { buildDocument, renderToHtml } from 'https://esm.sh/postext';
const markdown = `# The Lantern
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.
## Two columns
Postext lays this text out in **two columns**, breaking each paragraph with the *Knuth–Plass* algorithm and hyphenating with TeX patterns. Widows and orphans are avoided, and the columns are balanced on the last page.
The light it gave was small, but it was enough to find the step. 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.
The light it gave was small, but it was enough to find the step. 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.`;
const config = {
// 96 dpi: page pixels are CSS pixels, so the HTML shows at its real size.
page: { sizePreset: '17x24', dpi: 96 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
// One HTML string for the whole document. Every line is an absolutely
// positioned element, so the browser never reflows the text.
const html = renderToHtml(doc, { mode: 'single', background: '#ffffff' });
document.getElementById('viewer').innerHTML = html;
document.getElementById('source').textContent = html;
document.getElementById('status').textContent =
`${doc.pages.length} page(s) · ${(html.length / 1024).toFixed(1)} KB of HTML`;index.html
<p id="status">Laying out…</p>
<div id="viewer"></div>
<details>
<summary>Generated HTML</summary>
<pre id="source"></pre>
</details>style.css
body {
margin: 16px;
font-family: system-ui, sans-serif;
background: #e8e8e8;
}
/* The page is wider than this pane: let it scroll instead of clipping it.
The renderer centres pages with an inline style, hence the !important. */
#viewer {
overflow: auto;
}
#viewer .pt-doc {
align-items: flex-start !important;
}
/* Each page is a .pt-page block; the renderer positions every line inside it. */
#viewer .pt-page {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
details {
margin-top: 16px;
}
#source {
max-height: 240px;
overflow: auto;
padding: 8px;
background: #fff;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
}يحمّل محررًا تفاعليًا من codepen.io. يستورد المثال أحدث إصدار من postext عبر شبكة توصيل المحتوى (CDN).
#التكامل الأدنى
المقتطف أدناه أقصر تكامل مفيد: ابنِ المستند بمقاس مساحة العرض الحالي، وارسمه في حاوية، وأعد التشغيل عند تغيّر المقاس.
import { useEffect, useRef } from 'react';
import {
buildDocument,
renderToHtmlIndexed,
resolveHtmlViewerConfig,
buildFontString,
measureGlyphWidth,
dimensionToPx,
createMeasurementCache,
watchFonts,
onFontsChanged,
} from 'postext';
import type { PostextConfig, MeasurementCache } from 'postext';
// Screen-friendly DPI: at 144 DPI, an 8pt body size resolves to 16 px.
const HTML_DPI = 144;
const PADDING_PX = 24;
// Prose sample used to measure the target column width. Proportional fonts
// make "N × average width" unreliable, so we measure a representative string.
const SAMPLE =
'The quick brown fox jumps over the lazy dog. Sphinx of black quartz, judge my vow.';
function sampleForChars(n: number): string {
let s = SAMPLE;
while (s.length < n) s += ' ' + SAMPLE;
return s.slice(0, n);
}
export function PostextHtmlViewer({
markdown,
config,
mode = 'multi',
}: {
markdown: string;
config: PostextConfig;
mode?: 'single' | 'multi';
}) {
const hostRef = useRef<HTMLDivElement | null>(null);
const cacheRef = useRef<MeasurementCache>(createMeasurementCache());
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const relayout = () => {
const rect = host.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const viewer = resolveHtmlViewerConfig(config.htmlViewer);
const fontFamily = config.bodyText?.fontFamily ?? 'EB Garamond';
const fontWeight = config.bodyText?.fontWeight ?? 400;
const fontSize = config.bodyText?.fontSize ?? { value: 8, unit: 'pt' as const };
const fontSizePx = dimensionToPx(fontSize, HTML_DPI);
// Measure the *actual* column width for N characters of body prose.
const targetColumnPx = measureGlyphWidth(
sampleForChars(viewer.maxCharsPerLine),
buildFontString(fontFamily, fontSizePx, String(fontWeight), 'normal'),
);
const inner = Math.max(rect.width - PADDING_PX * 2, 100);
let columnWidthPx: number;
if (mode === 'single') {
columnWidthPx = Math.min(targetColumnPx, inner);
} else {
// Fit as many columns as we can at the target width.
const count = Math.max(
1,
Math.floor((inner + viewer.columnGap) / (targetColumnPx + viewer.columnGap)),
);
columnWidthPx = (inner - viewer.columnGap * (count - 1)) / count;
}
columnWidthPx = Math.max(Math.floor(columnWidthPx), 80);
// Single mode uses one very tall page; multi mode uses the viewport
// height so each VDT "page" becomes one column.
const pageHeightPx =
mode === 'single' ? Math.max(rect.height * 20, 200_000) : Math.max(rect.height - PADDING_PX * 2, 400);
const override: PostextConfig = {
...config,
page: {
...config.page,
dpi: HTML_DPI,
width: { value: columnWidthPx, unit: 'px' },
height: { value: pageHeightPx, unit: 'px' },
margins: {
top: { value: 0, unit: 'px' },
bottom: { value: 0, unit: 'px' },
left: { value: 0, unit: 'px' },
right: { value: 0, unit: 'px' },
},
},
layout: { ...config.layout, layoutType: 'single' },
bodyText: {
...config.bodyText,
optimalLineBreaking: viewer.optimalLineBreaking,
},
};
const doc = buildDocument({ markdown }, override, cacheRef.current);
const { html } = renderToHtmlIndexed(doc, {
mode,
columnGap: viewer.columnGap,
padding: PADDING_PX,
background: 'transparent',
});
host.innerHTML = html;
};
relayout();
const ro = new ResizeObserver(() => relayout());
ro.observe(host);
// Re-measure when web fonts land so glyph widths aren't taken from fallbacks.
const stopWatching = watchFonts();
const off = onFontsChanged(() => relayout());
return () => {
ro.disconnect();
off();
stopWatching();
};
}, [markdown, config, mode]);
return <div ref={hostRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
}بعض الملاحظات حول ما يفعله هذا المثال:
- قياس العمود بدلًا من تقديره. لأن
maxCharsPerLineقيمة مستهدفة معبَّر عنها بالحروف، فإن العرض الفعلي بالبكسل يتوقف على خط النص الأساسي. وتعطيmeasureGlyphWidthقياسًا حقيقيًا بالخط المختار، فيبقى طول السطر ثابتًا عند تبديل الخطوط. - إعادة كتابة الصفحة. يعامل عارض HTML كل «صفحة» من VDT عمودًا واحدًا على الشاشة. ويستبدل المثال
page.widthبعرض العمود المقيس، ويجعل الهوامش صفرًا (فالحشو يقع خارج الصفحة في عنصر div.pt-docالمغلِّف)، ويستخدمHTML_DPI = 144كي يصبح النص الأساسي8ptمساويًا16px. - مراعاة تحميل الخطوط. تستمع
watchFontsإلىdocument.fonts، وتطرح مرة في كل إطار ما قيس بالعائلات التي وصلت أوجهها؛ ثم تعيدonFontsChangedإخراج العمود. ومن دون إعادة الإخراج، يستخدم العرض الأول مقاييس خط بديل ثم يقفز حين يصل الخط الحقيقي. - إعادة استخدام ذاكرة القياس المؤقتة. إنشاء الذاكرة مرة واحدة لكل مكوّن يعني أن تغيّر المقاس وتغيّر حجم الخط يعيدان استخدام قياسات العرض السابق بدلًا من إعادة قياس كل فقرة.
#أبعد من ذلك
المثال أعلاه مبسّط عن قصد. وعادة ما تضيف عمليات التكامل في بيئة الإنتاج ما يلي:
- العزل عبر Shadow DOM: ارسم داخل
host.attachShadow({ mode: 'open' })كي لا يتسرّب أي CSS من الصفحة الخارجية إلى العارض. - الترقيع التدريجي: تُعيد
renderToHtmlIndexedالقيمةpages[i].blocks، لكل منهاidثابت وHTML الخارجي للكتلة. وحين لا تختلف بين عمليتَي عرض إلا كتل قليلة، يمكنك استبدال أغلفة تلك الكتل في مكانها بدلًا من إعادة بناءinnerHTML. - الطبقات العلوية: وضع SVG ذي موضع مطلق فوق كل
.pt-pageللمؤشرات أو التحديدات أو شبكات خطوط الأساس. - الروابط: تُغلَّف كلمات رابط Markdown في
<a href="…" rel="noopener noreferrer">، الذي يأخذ لون النص بلا خط تحته؛ انظر صيغة المستند › الروابط. وفي عارض شبيه بالمحرّر، اعترض النقرات علىa[href]التي لا تبدأ بـ#وافتحها في تبويب جديد (فروابط:refتشير إلى مواضع داخل المستند). - الصور أحادية الحبر: حين يكون
diagramStyle.singleInkمفعّلًا، تحصل عناصر<img>من نوع SVG على مرشّح CSS ما لم تمرّرsingleInk: falseلعناوين URL المُعاد تلوينها أصلًا؛ انظر الحبر الواحد في Canvas وفي HTML.
ويطبّق المكوّن HtmlPreview في Sandbox (packages/postext-sandbox/src/viewport/HtmlPreview/index.tsx) كل ذلك فوق الواجهة البرمجية نفسها المعروضة هنا، ويمكن استخدامه مرجعًا. وهو يمرّر أيضًا كل عملية بناء عبر عامل إخراج مشترك (انظر تشغيل الإخراج في Web Worker) كي لا تحجب التعديلات الحيّة وتغيّرات المقاس الخيطَ الرئيسي أبدًا؛ استبدل استدعاء buildDocument(...) المباشر في المقتطف أعلاه بـlayoutWorker.build(...) حين تكون مستعدًا لنقل الإخراج بعيدًا عن الخيط الرئيسي.
#كيف يختلف إخراج HTML عن Canvas وPDF
تضع renderToHtml كل سطر وشكل وعنصر تصميم في الموضع نفسه تمامًا الذي يضعه فيه Canvas وPDF، لكنها ترسم حولها أقل:
| الميزة | Canvas (renderPage) | HTML (renderToHtml) | PDF (renderToPdf) |
|---|---|---|---|
| خلفية الصفحة | بيضاء، مع page.backgroundColor فوق حدّ القص والنزف. | شفافة، ما لم تمرّر background أو تضبط page.backgroundColor (الذي يملأ حينئذ صندوق الصفحة كله، بما في ذلك الحاشية الخارجية خلف النزف). | بيضاء، مع page.backgroundColor فوق حدّ القص والنزف. |
شبكة خطوط الأساس (page.baselineGrid) | تُرسم | لا تُرسم | تُرسم |
الخط الفاصل بين الأعمدة (layout.columnRule) | يُرسم | لا يُرسم | يُرسم |
علامات القص (page.cutLines) | تُرسم | لا تُرسم؛ لكن صندوق الصفحة لا يزال يضم الحاشية الخارجية المحيطة بحدّ القص. | تُرسم |
| الصفحة السالبة | الخيار pageNegative | غير متاحة | الخيار pageNegative |
| النص | بكسلات | نص قابل للتحديد في عناصر ذات مواضع مطلقة، منضَّد بعائلات خطوط CSS: يجب أن تحمّل الصفحة الأوجه نفسها. | خطوط مضمّنة من fontProvider الخاص بك؛ والنص قابل للتحديد والبحث وموسوم. |
النص العمودي (layout.) | تُرسم الحروف خلية خلية، مُعادةً إلى وضعها القائم؛ والأشكال العمودية عبر وجه توأم (loadVerticalAlternates). | يُدار التدفق في صندوق واحد ربع دورة؛ ويُعاد كل سطر إلى وضعه القائم ويُنضَّد بـwriting-mode: vertical-rl، فيأخذ المتصفح الأشكال العمودية ويُقيم الحروف؛ والأرقام القصيرة في text-combine-upright: all؛ والشرطة أو علامة الحذف أو النقطة الوسطى أو الشرطة المموّجة في صندوق خاص بخليتها (فالمتصفح كان سيقدّمها بعرضها الأفقي)، والشرطة تُمدّ لتملأه بحسب flow.dashAdvances. | حروف قائمة عبر توأم Identity-V لكل خط؛ انظر النص العمودي في PDF. |
| الصور | registerResourceImage | الخيار resourceImageUrl(fileId)؛ ومن دونه صندوق نائب رمادي. | الخيار resourceBytes(fileId). |
| الصيغ | مسارات متجهية | <svg> سطري | مسارات متجهية |
| الروابط | لا شيء | استشهادات :ref ترتبط بمواردها؛ أما صفوف المحتويات فلا. | استشهادات :ref وصفوف المحتويات، إضافة إلى المخطط التفصيلي (الإشارات المرجعية). |
تهمّ الصفحة الشفافة في موقع داكن: فالمعاينة دون background تُظهر نصًا أسود على الخلفية الداكنة للموقع. مرّر renderToHtml(doc, { background: '#ffffff' })، أو أعطِ المستند page.backgroundColor.
تبقى أنماط النص في الصفحة المضيفة خارجًا. يُنضّد كل سطر بالعروض التي قاسها المحرّك، لذا فإن letter-spacing أو word-spacing أو text-transform أو font-variant يرثه الإخراج من الصفحة المحيطة به سيوسّع سلاسل الحروف ويجعل الأسطر تتراكب. ولذلك يعيد الجذر .pt-doc ضبط خصائص النص الموروثة، أي تباعد الحروف والكلمات، وحالة الأحرف، والمسافة البادئة، والمسافات البيضاء، ونمط الخط ومتغيّره ووزنه وعرضه، والميزات وتقنين الأزواج (kerning)، وارتفاع السطر، والمحاذاة، وظل النص والتوكيد، والواصلات، والاتجاه، ونمط الكتابة، وحدّ النص وتعبئته، وتضخيم النص على الأجهزة المحمولة، قبل تصريحات التخطيط الخاصة به، فيبدو الإخراج متطابقًا داخل جذر ظل (shadow root) أو تحت عنصر منسَّق. وتُصدَّر هذه القائمة باسم HTML_TEXT_RESET، وهي سلسلة من تصريحات CSS: والمضيف الذي يركّب innerHtml الخاص بالصفحات (من renderToHtmlIndexed) في حاويات خاصة به يضبطها على جذرها. وحتى postext 1.4 لم يكن الجذر يعيد ضبط أي شيء؛ وكان الحل البديل غلافًا يحمل all: initial.
#كتاب ثلاثي الأبعاد (postext-folio)
تعرض postext-folio المستند المُخرَج على الشاشة كتابًا مطبوعًا مفتوحًا على مكتب: صفحات متقابلة وفق قاعدة الصفحة الفردية، وأوراق يقلبها القارئ بالزرّين ‹ ›، أو بمفاتيح الأسهم، أو بالسحب السريع، أو بالنقر على صفحة، أو بإمساك صفحة من حافتها وسحبها فوق الأخرى. تنثني كل ورقة في three.js وفق نوع ورقها، وتُلقي ظلًا حقيقيًا على الصفحات التي تحتها. ترسم لوحة WebGL الكتاب ساكنًا ومتقلّبًا على حد سواء، فلا يتغيّر مظهر الصفحة حين تستقر. وهي أداة العرض لوصفات دليل الوصفات ولتبويب Folio في Sandbox.
npm install postext postext-folio threeimport { buildDocument } from 'postext';
import { createFolioFromDocument } from 'postext-folio';
const doc = buildDocument({ markdown }, config);
const book = createFolioFromDocument(document.getElementById('book')!, doc, {
onChange: ({ pages }) => console.log('showing pages', pages),
});
// After an edit: the same viewer, on the same page.
book.setDocument(buildDocument({ markdown: edited }, config));- تُرسم الصفحات عند الحاجة إليها. ترسم
createFolioFromDocumentكل صفحة بـrenderPageToCanvasبعدد بكسلات الجهاز الدقيق لخانة الصفحة (فيعرضها WebGL بنقطة نسيج (texel) لكل بكسل، بحدة معاينة Canvas نفسها)، ولا ترسم إلا الصفحات المتقابلة المحيطة بالمفتوحة (window، ثلاث من كل جانب افتراضيًا). وتُحرَّر الصفحات التي تخرج من تلك النافذة، فلا يكلّف كتاب من ألف صفحة إلا ذاكرة بضع صفحات. والقفز إلى صفحة بعيدة يرسم تلك الصفحات المتقابلة أولًا. فإلى مسافة عشر صفحات تنقلب الأوراق واحدةً واحدة؛ وأبعد من ذلك ترتفع كتلة الصفحات الواقعة بينهما لوحًا واحدًا، بسماكة تلك الصفحات (مجموع سماكاتها)، وتستقر على الجانب الآخر. وتحتفظsetDocumentبرسم كل صفحة تبقى كما هي في الإخراج الجديد ({ repaint: true }يعيد رسمها كلها، بعد وصول صورة مثلًا). - المستند يحدّد الكتاب. تُفتح صفحته الأولى وحدها على اليمين حين تكون صفحة فردية (
pageIndexOffsetزوجي)، ويُعرض الكتاب المجلّد من اليمين (page.binding: 'right'، أو المستند العمودي) معكوسًا وتنقلب أوراقه نحو اليسار، وتأخذ الصفحات الفارغة لون خلفية الصفحة، ويضبط عرض قص الصفحة (pageWidthMm) مقياس سماكة الورق ولوحَي الغلاف. والفصل المُخرَج معcontinuationيحسب صفحات الكتاب الأخرى (pageIndexOffsetقبله، وbookPageCountبعده) في سماكة كتلتَي الصفحات دون أن يرسمها (extraPages). - المستند يحدّد المظهر. الورق والتجليد والمكتب والإضاءة هي إعدادات
folioفي المستند (doc.config.folio). والصفحة المنضَّدة داخل مقطع:::paperتحمل نوع ورقها الخاص (VDTPage.paper)، وتُرسم ورقتها بلون ذلك الورق وسطحه وسماكته وصلابته. ومعbinding.cover: 'pages'تكون الصفحة الأولى لوح الغلاف الأمامي، والأخيرة، حين تكون صفحة زوجية، لوح الغلاف الخلفي (covers). ومقاس الصحيفة ('broadsheet'و'berliner'و'tabloid'و'compact') الذي لا تحدّد إعداداته نوع الورق ولا التجليد يظهر ورقَ صحف مطويًّا، وكذلك حين يمرّر المضيفfolioخاصًّا به. - الحاوية تحدّد الحجم. يملأ الكتاب الحاوية، مع الأزرار وعدد الصفحات في الهوامش، فأعطِ الحاوية ارتفاعًا؛ وتغيير الحجم يعيد رسم الصفحات بالحجم الجديد. وتحت عرض 560 px يعرض صفحة واحدة في كل مرة (
mode: 'auto'؛ و'single'و'double'يفرضان أحد الوضعين): يمتد الكعب على طول الحافة الداخلية للصفحة وتنقلب الورقة فوقه، والسحب نحو الكعب يقلب إلى الأمام، والسحب السريع بعيدًا عنه يعود إلى الخلف، والنقرة تقلب. - ما يفعله المؤشر. يحدّد
interaction(وsetInteractionلاحقًا) ما يفعله الزر الأيسر أو الإصبع الواحدة أو القلم على الكتاب:'hand'(الافتراضي) يمسك الصفحات ويقلبها، و'orbit'يدير المنظر كما يفعل السحب بالزر الأيمن (للوحات اللمس والأجهزة اللوحية)، و'select'يترك المؤشر للمضيف، لتحديد النص مثلًا. وتعطيpageAt(event)الصفحة الواقعة تحت المؤشر والموضع عليها ({ page, x, y }، كسورًا من الصفحة بدءًا من زاويتها العلوية اليسرى)، على الكتاب كما يُرى، مائلًا أو مُدارًا؛ وتسلكpointOnScreen(point)الاتجاه المعاكس، لرسم مؤشر الكتابة (caret) أو التحديد فوق الصفحة. وتعرضrefreshPage(src)من جديد لوحة صفحة أعاد المضيف رسمها في مكانها. ويستخدمها Sandbox كلها لتحديد النص وتتبّع مؤشر الكتابة في المحرر على الصفحات ثلاثية الأبعاد. - عدسة مكبّرة للحروف الصغيرة. يضع
interaction: 'magnify'عدسة مستديرة بإطار أسود فوق الكتاب حيث المؤشر (ويرفعها الإصبع فوقه ما دام يلمس الشاشة). تُظهر الكتاب كما تراه عين القارئ، بإضاءته وانحنائه، وتكبّر الوسط أكثر ثم تنحني نحو الإطار. تغيّر العجلة و+و−درجة التكبير (setMagnification(zoom)، من 1.5 إلى 10؛ والافتراضي يُظهر الصفحة بنحو 5.5 بكسل CSS للمليمتر)، ويُبعدها مفتاح Esc؛ ويضبطmagnifier: { zoom, diameter }الاثنين من البداية. يعيدcreateFolioFromDocumentرسم الصفحات تحت العدسة بدقة تكفي وسطها، فيُقرأ متن الجريدة؛ ويأخذcreateFolioهذه الرسوم منdetail: { paint(index, deviceWidth), release() }. يضعها الـSandbox على زر العدسة المكبّرة (M). وتحدّد العدسة النصّ أيضًا: فوق الصفحات يصير المؤشّر مؤشّر نصّ، وتُعيدpageAtالنقطة التي تحت وسط العدسة (فوق الإصبع على الشاشة اللمسية)، وفي الـSandbox تضع النقرة هناك مؤشّر الكتابة ويحدّد السحب النصّ. - يستطيع القارئ أن يدور حوله. السحب بالزر الأيمن يدير المنظر حول الكتاب (حتى 70° بعيدًا عن المنظر العمودي من الأعلى)، حتى أثناء تقلّب الأوراق؛ وتعيده
resetView()تدريجيًا إلى قيمتَيtiltوyawفي الإعدادات، وتعطيgetView()المنظر كما يُرى الآن ({ tilt, yaw }، بالدرجات) لحفظه في تلك الإعدادات. ويضعها Sandbox على زرّين، إعادة ضبط العرض وحفظ بوصفه العرض الافتراضي. - مقاطع الفيديو تُشغَّل على الصفحات. يشغّل النقر على غلاف فيديو المقطعَ على الصفحة نفسها، في كل أوضاع
interaction، ويستمر تشغيله والورقة تنقلب؛ ونقرة أخرى توقفه مؤقتًا، ويتوقف حين يستقر الكتاب على صفحتين متقابلتين لا تعرضانه. والفيديو الذي يحملplayer.autoplayيبدأ وحده أول مرة تُعرض فيها صفحتاه؛ وإن كان يعمل أيضًا إلى جانب غيره (player.exclusive: false) بدأ صامتًا في كل مرة وتوقف حين تُقلب الصفحتان، وتعمل عدة مقاطع معًا. والخيارات هيvideosوvideoUrlوonVideo، ومعهاstopVideo()في أداة العرض؛ انظر صيغة المستند › مقاطع الفيديو على صفحات Folio. - الخطوط والصور أولًا. كما في
renderPage، يجب أن تكون الوجوه التي يستخدمها المستند محمّلة فيdocument.fonts، وصور موارده مسجّلة بـregisterResourceImageقبل رسم الصفحات. - ميسَّر الوصول. أداة العرض مجموعة قابلة للتركيز تستجيب لـ ←/→ (معكوستين في الكتاب المجلّد من اليمين)، وPage Up/Down، وHome وEnd؛ وأزرارها وعدد صفحاتها موسومة (ويترجمها
labels)، وتحمل كل لوحة صفحة نصًا بديلًاalt(alt: (index) => …). - دون WebGL2، أو حين يطلب القارئ تقليل الحركة، تتبدّل الصفحات المتقابلة ببساطة. كتب WebGL ثقيلة على الهاتف (نسيج لكل وجه من وجهَي الورقة): لذا لا يعرض Sandbox تبويب Folio إلا حيث يتوفر WebGL2 ولا يقل الضلع الأقصر للشاشة عن 600 px. وتخبرك
canFlip()هل ستنقلب الأوراق بثلاثة أبعاد هنا: WebGL2، ولا تقليل للحركة.
#المظهر
يتجاوز الخيار appearance، وsetAppearance لاحقًا، ما يقوله المستند. وكل ما يُترك يحتفظ بقيمة المستند:
const book = createFolioFromDocument(container, doc, {
appearance: {
folio: {
tilt: 22,
paper: { type: 'bookWove', texture: 'laid' },
binding: { type: 'hardcover', coverColor: { hex: '#5a1f1f', model: 'hex' } },
surface: { type: 'walnut' },
lighting: { environment: 'lamp' },
},
// Photographed desks: a folder laid out as postext.dev's /folio/textures/
// (manifest.json and one folder per desk). Without it, procedural maps.
textureBaseUrl: '/folio/textures',
// The picture for `folio.binding.spineImage`: the resource's URL,
// or a drawn canvas or image.
spineImage: spineUrl,
},
});
// A settings panel: the book redrawn in place, nothing painted again.
book.setAppearance({ folio: { ...folio, lighting: { environment: 'daylight' } } });
book.resetView();| الحقل | ما يفعله |
|---|---|
folio | إعدادات folio: الميل، والورق، والتجليد، والسطح، والإضاءة. تحل محل إعدادات المستند حين تُعطى. |
pageWidthMm | عرض قص الصفحة بالمليمتر، الذي تُقاس عليه سماكة الورق ولوحا الغلاف. من المستند: الصفحة المقصوصة بدقتها (dpi). الافتراضي 150 في createFolio. |
extraPages | : صفحات من الكتاب خارج الصفحات المعطاة، تُحسب في سماكة كتلتَي الصفحات ولا تُرسم أبدًا. |
covers | : الصفحة الأولى المعطاة هي الغلاف الأمامي، والأخيرة الغلاف الخلفي (حين تقع على صفحة زوجية). تنقلبان لوحَي غلاف، ولا يُرسم غلاف مقوّى حولهما. من المستند: binding.cover: 'pages' في كتاب يبدأ بصفحته الأولى وينتهي بصفحته الأخيرة. |
spineImage | الصورة المطبوعة على الكعب، عنوانَ URL أو لوحة canvas أو صورة. لا تبحث createFolioFromDocument عن الموارد: مرّر صورة المورد الذي يسمّيه folio.binding.spineImage. وتُتجاهل في التجليد بالدبّوس (saddle stitch). |
textureBaseUrl | المكان الذي تُقدَّم منه خامات المكتب المصوّرة. وإلى أن تُحمَّل، أو في غيابه، يُرسم المكتب بخرائط إجرائية. |
createFolio(container, { pages }) هي أداة العرض نفسها على أي صفحات: عناوين صور، أو عناصر <img> أو <canvas>، و"" لصفحة فارغة؛ ويمكن أن تكون الصفحة { src, alt, paper }، حيث paper نوع ورق على طريقة :::paper لتلك الورقة. وPageFlipper هو محرّك three.js وحده، لمضيف يبني بنفسه DOM الصفحات المتقابلة؛ وFlatPageFlipper هو قلب الصفحات المسطّح الذي سبق الكتاب ثلاثي الأبعاد، وأُبقي عليه لطاولة الضوء في دليل الوصفات. والقائمة الكاملة للخيارات في ملف README للحزمة.
#مثال حي: كتاب ثلاثي الأبعاد
يستورد هذا المثال على CodePen الحزمتين postext وpostext-folio من شبكة CDN، وينضّد مستندًا قصيرًا ويفتحه كتابًا. أمسك الصفحة اليمنى من حافتها واسحبها فوق الأخرى.
import { buildDocument } from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';
const paragraph = `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. The light it gave was small, but it was enough to find the step.`;
// Thirty-six short sections: about ten pages to turn.
const markdown = ['# The Lantern']
.concat(Array.from({ length: 36 }, (_, i) => `## Evening ${i + 1}\n\n${paragraph} ${paragraph}\n\n${paragraph}`))
.join('\n\n');
const config = {
page: { sizePreset: '17x24', dpi: 150 },
layout: { layoutType: 'double' },
bodyText: { fontFamily: 'EB Garamond', fontSize: { value: 10, unit: 'pt' } },
};
// Postext measures text with the fonts the browser has loaded,
// so wait for every face the document uses before laying it out.
await Promise.all([
document.fonts.load('16px "EB Garamond"'),
document.fonts.load('bold 16px "EB Garamond"'),
document.fonts.load('italic 16px "EB Garamond"'),
document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const doc = buildDocument({ markdown }, config);
const status = document.getElementById('status');
// The book: drag a page by its edge, click it, or use ← → and the buttons.
// Pages are painted at the size they are shown, around the open spread only.
createFolioFromDocument(document.getElementById('book'), doc, {
onChange: ({ pages }) => {
status.textContent = `${doc.pages.length} pages · open at ${pages.map((i) => i + 1).join('–')}`;
},
});
status.textContent = `${doc.pages.length} pages · drag a page by its edge to turn it`;index.html
<p id="status">Laying out…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}يحمّل محررًا تفاعليًا من codepen.io. يستورد المثال أحدث إصدار من postext عبر شبكة توصيل المحتوى (CDN).
#مثال حي: صور الصفحات
createFolio مع صفحات مرسومة على لوحات canvas، وصفحة أخيرة فارغة، ولون الورق.
import { createFolio } from 'https://esm.sh/postext-folio';
// Any pages will do: image URLs, <img> or <canvas> elements, and "" for a
// blank page. Here, eight pages drawn on canvases.
function drawPage(n) {
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 840;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fbf8f1';
ctx.fillRect(0, 0, 600, 840);
ctx.fillStyle = `hsl(${n * 45} 45% 45%)`;
ctx.fillRect(60, 80, 480, 320);
ctx.fillStyle = '#222';
ctx.font = 'bold 56px Georgia, serif';
ctx.fillText(`Plate ${n}`, 60, 480);
ctx.font = '22px Georgia, serif';
for (let line = 0; line < 8; line++) ctx.fillRect(60, 530 + line * 30, line === 7 ? 260 : 480, 3);
ctx.textAlign = 'center';
ctx.fillText(String(n), 300, 800);
return { src: canvas, alt: `Plate ${n}` };
}
const pages = Array.from({ length: 8 }, (_, i) => drawPage(i + 1));
// A blank page at the end, drawn as paper.
pages.push('');
const status = document.getElementById('status');
createFolio(document.getElementById('book'), {
pages,
firstPageRecto: true, // page 1 opens alone, on the right
binding: 'left', // 'right' lays a right-to-left book mirrored
paper: '#fbf8f1',
onChange: (state) => {
status.textContent = `Showing ${state.pages.map((i) => i + 1).join('–')} of ${pages.length}`;
},
});
status.textContent = 'Drag a page by its edge, click it, or use ← →';index.html
<p id="status">Drawing pages…</p>
<div id="book"></div>style.css
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #eee;
background: radial-gradient(ellipse 70% 75% at 50% 42%, #272b34 0%, #1a1d23 58%, #121418 100%);
min-height: 100vh;
}
#status {
margin: 12px 16px 0;
font-size: 14px;
opacity: 0.8;
}
/* The viewer fits the book into its container: give it a height. */
#book {
height: calc(100vh - 48px);
--postext-folio-accent: #f0b35a;
}يحمّل محررًا تفاعليًا من codepen.io. يستورد المثال أحدث إصدار من postext عبر شبكة توصيل المحتوى (CDN).
#كتب EPUB (postext-epub)
تكتب postext-epub الكتاب المُخرَج ملفَّ EPUB 3.3، في المتصفح أو في Node، بلا خادم. وتقرأ مستندات الفصول نفسها التي تأخذها renderToPdf للكتاب، فتصل أرقام الصفحات والحواشي والاستشهادات والإحالات المرجعية وجدول المحتويات والفهرس الأبجدي محلولةً، وتعيد الملف بايتات. وهي الكاتب الذي يقف وراء تبويب EPUB 3 في Sandbox.
npm install postext postext-epubpostext تبعية نظيرة، كما في postext-pdf: حدّث الحزمتين معًا، وثبّتهما على شبكة CDN على الإصدار نفسه.
#التخطيط الثابت والتخطيط المتدفق
يعرّف EPUB 3 نسختَي عرض (renditions)، تُحدَّدان بالخاصية rendition:layout في ملف الحزمة؛ ويختار layout إحداهما:
layout: 'fixed' | layout: 'reflowable' | |
|---|---|---|
| الاسم في EPUB | pre-paginated (تخطيط ثابت، FXL) | reflowable، القيمة الافتراضية في EPUB |
| مستندات المحتوى | مستند XHTML لكل صفحة مطبوعة، بمقاس الصفحة المقصوصة بوحدات px في CSS | مستند XHTML لكل فصل (والجزء يفتتح مستندًا خاصًا به) |
| ما يحتفظ به | الصفحة: الأعمدة، والعناصر العائمة، والترويسات، وصفحات الافتتاح، وفواصل الأسطر ومواضعها، بالخطوط المضمَّنة. ويبقى النص نصًا حقيقيًا: قابلًا للتحديد والبحث والقراءة بصوت عالٍ | النص وبنيته: العناوين، والفقرات مُعادًا بناؤها من الأسطر، والقوائم، والإطارات في هيئة عناصر aside، والأشكال والجداول بعد النص الذي يستشهد بها، والحواشي، والروابط، وعلامات صفحات الطبعة الورقية. وورقة أنماط مشتقة من الإعدادات |
| ما يتخلى عنه | اختيار القارئ للخط وحجمه والهوامش؛ وعلى الشاشة الصغيرة تُصغَّر الصفحة | الأعمدة، والترويسات، وتصميم الصفحات، وفواصل الأسطر الدقيقة |
| الصفحات المتقابلة والاتجاه | page-spread-left / page-spread-right وفق زوجية الصفحة والتجليد؛ والكتاب المجلّد من اليمين يُقرأ من اليمين إلى اليسار | اتجاه القراءة من التجليد؛ يحتفظ النص الصيني العمودي بـ vertical-rl، والنص العربي dir="rtl" |
| يناسب | الصفحات المصمَّمة: الكتب المصوّرة، والكتب المدرسية، والكتالوجات، والمجلات؛ والشاشات الكبيرة | النص المتصل: الروايات، والمقالات، والتقارير؛ والهواتف وأجهزة الحبر الإلكتروني |
تحمل نسختا العرض التنقل نفسه: جدول محتويات من العناوين وصفحات الأجزاء، وقائمة صفحات بأرقام الصفحات المطبوعة، ومعالم (landmarks) هي الغلاف وجدول المحتويات المطبوع وبداية المتن، وملف NCX لأنظمة القراءة الأقدم.
#كتابة كتاب
import { openBundle, buildBundle } from 'postext';
import { renderToEpub } from 'postext-epub';
const bundle = await openBundle(fileBytes);
const docs = buildBundle(bundle); // one VDTDocument per chapter, in book order
const bytes = await renderToEpub(docs, {
layout: 'reflowable',
metadata: { title: 'Lantern', creators: ['Ada Lovelace'], language: 'en' },
fonts: bundle.fonts.map((f) => ({ family: f.family, weight: f.weight, style: f.style, bytes: new Uint8Array(f.bytes), format: f.format })),
resourceBytes: (fileId) => {
const data = bundle.files.get(fileId);
return data ? { bytes: data, mediaType: '' } : undefined;
},
onWarning: (w) => console.warn(w),
});المستند الواحد كتاب من فصل واحد: renderToEpub([doc], options).
renderToEpub(docs, options): Promise<Uint8Array>تكتب الملف. وoptionsهي{ layout, metadata, fonts?, svgFonts?, resourceBytes?, cover?, onProgress?, onWarning?, signal? }.metadata: الحقلانtitleوlanguage(وسم BCP 47) مطلوبان؛ وsubtitleوcreatorsوidentifierوdateوpublisherوrightsوdescriptionوmodifiedاختيارية. ويصبح رقم ISBN المجرّدurn:isbn:…. ودونidentifierيحصل الكتاب علىurn:uuid:مشتق من عنوانه ومؤلفيه ولغته، فتحتفظ النسخة الجديدة من الكتاب نفسه بمكانها في مكتبة القارئ. ومرّرmodifiedأيضًا لتحصل على مخرجات متطابقة بايتًا ببايت.fonts: الوجوه المراد تضمينها،{ family, weight, style, bytes, format, unicodeRange? }حيثformatواحد منwoff2وwoffوttfوotf. يصبح كل وجه ملفًا وقاعدة@font-face؛ وعدة ملفات لكل منهاunicodeRangeتشكّل وجهًا واحدًا (شرائح Google Fonts). والعائلة أو الوزن أو النمط الذي تستخدمه الصفحات دون وجه مضمَّن يُبلَّغ عنه بتحذيرmissingFont، وتستبدل به أنظمة القراءة خطوطها الخاصة. ولا تضمّن إلا الخطوط التي تسمح تراخيصها بذلك: فالوجه الذي يحملredistributable: falseلا يُكتب في الملف أبدًا (قد يُنضَّد به الكتاب، لكن ملفه يبقى خارجه، وخارج صور SVG أيضًا).resourceBytes(fileId): الصور التي تضعها الصفحات، متزامنةً أو غير متزامنة، في هيئة{ bytes, mediaType }؛ وmediaTypeالفارغ يُستنتج من البايتات. أعطِ الصور النقطية كما خُزّنت، وملفات SVG بمصدرها، لا بنسخة PDF الأصلية للطباعة (svg.pdfFileId). تُخزَّن كل صورة مرة واحدة. والكتاب أحادي الحبر (diagramStyle.singleInk) يُعاد تلوين ملفات SVG فيه داخل الملف. ويُضمَّن في كل SVG أوجه الخطوط التي يسمّيها نصه، لأن نظام القراءة يعرضه صورةً لا ترى خطوط الكتاب (انظر الخطوط في نصوص SVG): منfonts(الشرائح التي تحوي حروفه)، ثم منsvgFonts.providerللعائلة التي لا يستخدمها نص الكتاب. وتبقى خارج الملف عائلات الأوجه المعلَّمة بـredistributable: falseوالعائلات التي يسمّيهاsvgFonts.withhold(family)، ويُبلَّغ عن كل منها مرة واحدة بتحذيرfontWithheld؛ ويُبلَّغ عن العائلة التي لا وجه لها بتحذيرsvgFontUnavailable، وعن الأوجه التي تتجاوزsvgFonts.maxBytes(2 MiB) بتحذيرsvgFontsTooLarge. ويُبقيsvgFonts.inline: falseوdiagramStyle.inlineFonts: falseوsvg.inlineFonts: falseالخاص بالمورد البايتاتِ كما أُعطيت. والصورة التي لا بايتات لها يُبلَّغ عنها بتحذيرmissingImageوتُترك إطارًا فارغًا.cover:{ bytes, mediaType, alt? }، صورة JPEG أو PNG أو WebP أو SVG. يُفتح الكتاب حينئذ على مستند غلاف يحملها، وتكون هيcover-imageالخاصة بالحزمة (الصورة المصغّرة في المكتبة). ودونها، يسمّي التخطيط الثابت صفحته الأولى غلافًا، ولا تكون للكتاب المتدفق صورة غلاف.onProgress({ phase, done, total }):resources(الخطوط والصور)، ثمdocuments(الصفحات في التخطيط الثابت، والفصول في المتدفق)، ثمpackage. وsignalيُلغي العملية بين الخطوات.readEpub(bytes)تقرأ الملف من جديد لأداة عرض، دونDOMParser: التخطيط، والبيانات الوصفية، واتجاه القراءة، وكل ملف بمساره، والبيان (manifest)، والعمود الفقري (spine)، وجدول المحتويات، وقائمة الصفحات، ونافذة العرض (viewport) للتخطيط الثابت، والغلاف. وقارئ Sandbox مبني عليها.
تحمل نسختا العرض كلتاهما بيانات EPUB Accessibility 1.1 الوصفية (أنماط الوصول، والميزات مثل جدول المحتويات وأرقام الصفحات المطبوعة، والمخاطر، وملخصًا)، ولا تدّعيان افتراضيًا أي مطابقة لمعايير WCAG. والقائمة الكاملة للخيارات والقيود في ملف README للحزمة.
#فحص ملف باستخدام EPUBCheck
W3C EPUBCheck هو أداة التحقق المرجعية لملفات EPUB؛ وبه تفحص متاجر الكتب الإلكترونية الملفات التي تتلقاها. بعد تثبيته (brew install epubcheck، أو إصدار Java)، يسرد epubcheck book.epub الأخطاء والتحذيرات وملاحظات الاستخدام؛ وملاحظات الاستخدام التي تتركها مخرجات Postext (CSS-028، OBS-001، HTM_062) معلوماتية فقط. وفي مستودع Postext، يفحص pnpm --filter postext-epub epubcheck كتب العينات في مجموعة الاختبارات، وينضّد node packages/postext-epub/scripts/epubcheck.mjs book.postext --layout both ملف .postext واحدًا أو مجلد إعداد مسبق ويفحص نسختَي العرض كلتيهما، ويشغّل pnpm --filter postext-epub validate المصفوفة الكاملة من الكتب (دليل Postext، والإعدادات المسبقة للعرض، والكتب الصينية والعربية، وكتب دليل الوصفات)، وكل منها يجتاز الفحص بلا أخطاء ولا تحذيرات.