باختصار
افتتاح ملهاة شهيرة لأوسكار وايلد، مطبوعة للممثلين. تبرز أسماء المتكلمين بلون عريض، والإرشادات المسرحية بالرمادي فيسهل تخطّيها.
ما الذي ستنضده
افتتاح The Importance of Being Earnest لأوسكار وايلد، منضّدًا طبعةً للممثلين في خمس صفحات بقياس 140 × 216 مم. صفحة العنوان ملصق مسرحي برقوقي في إطار ذهبي مزدوج، وImportance وEarnest بحروف كبيرة سميكة، وقرنفلة ذهبية تحت العنوان. وفي مقابل الفصل الأول تذكر قائمة الشخصيات الممثل الذي أدّى كل دور أول مرة على مسرح St James’s Theatre سنة 1895. يبدأ كل قول باسم المتكلم بلون برقوقي عريض ويعلّق أسطره التالية 5 مم، فيجد الممثل إشارته التالية بنظرة. والإرشادات المسرحية، داخل القول أو بين قولين، بمائل رمادي، وتستطيع العين تخطّيها في أثناء مراجعة الأدوار. أما الطبعة الإسبانية فترجمة جديدة تتبع عرف المسرح الإسباني، بشرطة بعد كل اسم والإرشادات بين قوسين.
تجيب هذه الوصفة عن
- كيف أنضد سيناريو أو مسرحية: عناوين المشاهد، وأسماء الشخصيات، وحوار بمسافة بادئة من الجانبين؟
- كيف ألوّن المصطلحات الرئيسية (العريضة أو المائلة) في المتن أو داخل الإطارات؟
- كيف أصنع جدولًا فيه صفوف رأس، وخلايا مدمجة، وعروض أعمدة، ومحاذاة لكل خلية؟
- كيف أنضد رسومًا غير مرقّمة: زخارف، وحليات، وشعارات؟
الجواب المختصر
// Speeches are plain paragraphs, the speaker's name in bold and each stage direction in
// italic; a direction between two speeches is a paragraph of its own, in a container:
// **LANE.** Yes, sir. *[Hands them on a salver.]*
// :::paragraphs{style="direction"}
// *[Enter Lane.]*
// :::
// The body's two emphasis colours then mark who speaks (plum) and what is done (grey).
const dialogue = {
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('plum'), // **ALGERNON.**
italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own
minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2
firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung
maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
};
// Directions: smaller and centred, on the same grid. A paragraph style has no italic switch
// or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey.
const direction = { id: 'direction', fontSize: pt(8.6),
textAlign: 'center', firstLineIndent: pt(0) };
المكونات
- الميزات
- أنماط الفقراتالغامق والمائل وألوانهماجداول من البياناتالمسافات البادئة والمحاذاة والتباعد بين الفقراتمستويات العناوينفصول تبدأ في صفحة فرديةنمط الجدولأنماط الجداول المسمّاةأنواع موارد مخصّصةأشكال في هذا الموضع بالضبطأنماط العناوينالأغلفة وصفحات العنوان وبيانات النشرسمات العنوانالصور في تصاميم الصفحةالنصوص والخطوط والمربعات في تصاميم الصفحةالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةالبيانات الوصفية للمستندلون الورقلوحة ألوان دلالية
- تستخدم أيضًا
- صفحات افتتاح مصمَّمةفواصل الصفحات والأعمدةالأشكال والجداول بوصفها مواردترويسات لكل قسممسافة عمودية صريحة
- الخطوط
- Libre Baskerville, Abril Fatface, Playfair Display SC (SIL OFL 1.1)
- الأصول
- The title page’s frame and the carnation fleuron, drawn in code in the page’s palette (Ignacio Ferro, MIT)
طريقة التحضير
#1 · الفصل عنوان عادي
// Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break).
const headings = {
fontFamily: LABEL, fontWeight: 400, color: col('ink'),
textAlign: 'center', // every level: the act, the cast list's heads, the scene
lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid
levels: [
{ level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD),
color: col('plum'), textTransform: 'uppercase',
breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list
{ level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) },
{ level: 3, fontSize: pt(9.5), color: col('plum') },
],
};
عنوان الفصل عنوان من المستوى الأول بخط Abril Fatface بحجم 24 نقطة، بلا خانة تصميم. يجعله textTransform بحروف كبيرة، وأسطر شبكته الثلاثة من 13.6 نقطة تضع الحروف الكبيرة 5.5 مم تحت أعلى كتلة النص (العناوين). لا يمكن ضبط textAlign: 'center' لكل مستوى على حدة، فهو يضع في الوسط أيضًا عناوين قائمة الشخصيات وتسمية Scene. ويبدأ breakBefore مع الزوجية 'odd' الفصل على صفحة فردية، مقابل قائمة الشخصيات. ولا بد أن يعيد الإعداد ذكره، لأن أي كائن headings يوقف فاصل الصفحة للمستوى الأول (الفصل قبل العنوان).
#2 · قائمة الشخصيات جدول يُقرأ من أسطر مفصولة بعلامات جدولة
// One row per line, cells split by tabs. A line with no tab spans the table: mergeCells
// joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment.
function billTable(tsv, widths, align, headerRowCount = 0) {
let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount };
model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; }));
tsv.trim().split('\n').forEach((line, r) => {
if (line.includes('\t')) return;
model.rows[r][0].align = 'center';
const end = { row: r, col: widths.length - 1 };
model = mergeCells(model, { start: { row: r, col: 0 }, end });
});
return model;
}
// One filled header cell: two side by side would show a seam (gotcha: table-fill-seams).
const tableStyle = {
rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL,
headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself
bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller
};
const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes
ليس في Postext جداول بالخطوط العمودية، فكل جدول مورد. الصفوف في content.cast.en.md، سطر لكل صف وعلامة جدولة بين الخلايا، ويحوّلها parseTSV إلى نموذج (بناء نماذج الجداول). والسطر الوحيد الذي لا علامة جدولة فيه، الممثلون الأصليون مع المسرح والتاريخ، يصير صفًّا مدموجًا: يعطي mergeCells خليته الأولى colSpan: 2 ويُبقي الثانية في الشبكة موسومة بـhiddenBy. ويضع headerRowCount: 1 ذلك الصف في الشريط البرقوقي. ولا يُعبَّأ أي صف آخر، لأن اللوحة (Canvas) تُظهر خط التحام شاحبًا بين خليتين معبّأتين متجاورتين. ويأخذ عمود الممثلين ثلث العرض، وكل خلية فيه محاذاة إلى اليمين. وتمر المشاهد بالدالة نفسها تحت نمط مسمّى بلا خطوط (أنماط الجداول المسمّاة). والجدول ينضّد كل النص في خلاياه بلون واحد، حبر المتن ما لم يسمِّ bodyColor لونًا آخر، لذا تُطبع أسماء الشخصيات العريضة هنا بالحبر وفي الحوار بالبرقوقي.
#3 · زخارف بلا رقم ولا تعليق
// ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes).
const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement });
const resourceTypes = [
unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }),
unnumbered('bill', 'Bill', { position: 'here' }),
];
نوع المورد الذي له captionPrefix وshortLabel فارغان يطبع صوره مجرّدة (أنواع الموارد). ويضع defaultPlacement الخاص به القرنفلة حيث يقف ::resource{id="fleuron"}، في الوسط وبعرض 0.24 من عرض السطر البالغ 107 مم، أي نحو 26 مم. ويستعمل الجدولان نوعًا ثانيًا غير مرقّم، فلا يُسمّى أيٌّ منهما Table 1.
#4 · صفحة العنوان نمط عنوان
// # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …}
// Each attribute is a playbill line in its own face and size, its lineHeight a multiple
// (gotcha: design-lineheight-multiple). The ground is an image and reserves no height
// (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot
// lines move up.
const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content,
fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center',
// Centred and tracked, a line sits half its tracking left of centre: x puts it back.
placement: { anchor: { to: 'page', edge: 'top' },
offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra });
const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) });
const playbill = {
id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top)
header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1
advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'ground', resourceId: 'playbill',
placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)),
line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }),
line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }),
line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }),
line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }),
line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)),
line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)),
line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }),
] } },
};
يحمل # The Importance of Being Earnest {style="playbill" …} العنوان في أربع سمات، ينضّدها التصميم بخطين: Abril Fatface بحجمي 40 و60 نقطة للأسماء، وLibre Baskerville المائل بحجم 17 نقطة لـThe وof Being (سمات العنوان). الأرضية البرقوقية والإطار والقرنفلة ملف SVG واحد مرسوم بالشيفرة ومثبّت إلى النزف (عناصر الصورة). ويضبط النمط span: 'page' مع أن الكتاب بعمود واحد. فلو بقي التصميم في العمود لقُطع عند أعلى كتلة النص وأسفلها، أي 20 مم تحت أعلى الورقة و23.3 مم فوق أسفلها. ويُبعد header الفارغ في النمط الترويسات عن قائمة الشخصيات، التي تنتمي إلى القسم نفسه، ويُبعد footer الفارغ رقم الصفحة في الذيل عن صفحة العنوان، حيث كان سيُطبع برقوقيًّا على برقوقي (أنماط العناوين).
صور صفحة الافتتاح لا تحجز ارتفاعًا، فلا يمسك العنوان الصفحة إلا حتى أدنى سطر نص فيه، العرض الأول عند 176 مم. والمساحة الباقية تحته أقصر من أن تتسع للعنوان التالي، والصفحات الملتقطة تخرج كما هي بلا :::pagebreak بعد العنوان. الفاصل موجود للتعديلات اللاحقة: بدونه، لو رُفع المسرح والعرض الأول إلى 150 و156 مم لبدأت The Persons of the Play على الورقة البرقوقية. وفي الإصدار 1.4.1 يقع السطر الموسَّط الذي له letterSpacing يسار الوسط بنصف ذلك التباعد، لذا ينقل line() كل سطر متباعد الحروف يمينًا بالمقدار نفسه. فيصير OSCAR WILDE منحرفًا عن الوسط بـ0.07 مم بدل 0.56.
#5 · ترويسات تتخطى الصفحة الأولى من الفصل
const head = (id, content, parity, edge, x, style) => ({
kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'),
textTransform: 'uppercase', ...style,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } },
});
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') };
const header = { elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
head('verso-title', '{title}', 'even', 'top-left', OUTER + 8),
head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// The act's first page carries a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio),
pages: 'opener', align: 'center',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] };
تحمل الصفحة الزوجية عنوان المسرحية من البيانات التمهيدية (frontmatter)، والفردية الفصلَ من {chapterTitle}، كلاهما بحروف Playfair Display SC الكبيرة ورقم الصفحة بالبرقوقي على الحافة الخارجية (العناصر النصية). وتُحتسب الصفحة الأولى من الفصل صفحة افتتاح لأن أول كتلة فيها عنوان يكسر الصفحة، فيُبعد pages: 'body' الترويسات عنها، ويعطيها التذييل رقم صفحة في الذيل بدلًا منها.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 070 · Play script: cast list, speakers and stage directions ═══ // https://postext.dev/en/cookbook/stage-play // Code: MIT · Text: Oscar Wilde, 1895 (PD, Gutenberg #844), Spanish: the Cookbook · Art: in code // Fonts: Libre Baskerville, Abril Fatface, Playfair Display SC (OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'stage-play'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ink: '#221b1f', // the dialogue: a near-black with a little plum in it plum: '#5b2349', // the one accent: speakers' names, act titles, the title page's ground gold: '#b48a45', // rules and ornaments gilt: '#c9aa77', // gold lightened for small type on the plum ground (5.3:1) muted: '#6c6168', // stage directions and running heads (5.5:1 on the paper) rule: '#d8cbb7', // hairlines between the persons of the cast list paper: '#fbf6ec', // a cream stock }; // The hex travels with the id: designs do not read the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'plum (defaults)', value: { hex: palette.plum, model: 'hex' } }, ]; const [TEXT, DISPLAY, LABEL] = ['Libre Baskerville', 'Abril Fatface', 'Playfair Display SC']; const [BODY, LEAD] = [9.4, 13.6]; // pt: the dialogue and its leading, the pitch of the grid const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in, the acting-edition size const [TOP, INNER, OUTER, LINES] = [20, 18, 15, 36]; // mm, and 36 lines to a full page // #region answer: a speech is a paragraph: the name in bold, the directions in italic // Speeches are plain paragraphs, the speaker's name in bold and each stage direction in // italic; a direction between two speeches is a paragraph of its own, in a container: // **LANE.** Yes, sir. *[Hands them on a salver.]* // :::paragraphs{style="direction"} // *[Enter Lane.]* // ::: // The body's two emphasis colours then mark who speaks (plum) and what is done (grey). const dialogue = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('plum'), // **ALGERNON.** italicColor: col('muted'), // *[Languidly.]*, in a speech or a paragraph of its own minWordSpacing: 0.65, maxWordSpacing: 1.8, // justified (the default); limits inside 0.6–2 firstLineIndent: mm(5), hangingIndent: true, // the name at the margin, the turnovers hung maxRuntTracking: 0, // gotcha: runt-tracking-unpainted }; // Directions: smaller and centred, on the same grid. A paragraph style has no italic switch // or colour (gotcha: style-italic-colour): the text is written *…* and takes the grey. const direction = { id: 'direction', fontSize: pt(8.6), textAlign: 'center', firstLineIndent: pt(0) }; // #endregion // #region acts: an act title is a plain first-level heading, centred and set in capitals // Any headings object drops the H1 break: restated (gotcha: headings-drop-h1-break). const headings = { fontFamily: LABEL, fontWeight: 400, color: col('ink'), textAlign: 'center', // every level: the act, the cast list's heads, the scene lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0), // whole lines of the grid levels: [ { level: 1, fontFamily: DISPLAY, fontSize: pt(24), lineHeight: pt(3 * LEAD), color: col('plum'), textTransform: 'uppercase', breakBefore: { enabled: true, parity: 'odd' } }, // a recto, facing the cast list { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD) }, { level: 3, fontSize: pt(9.5), color: col('plum') }, ], }; // #endregion // #region cast: the cast list: a table from tab-separated lines, with merged rows // One row per line, cells split by tabs. A line with no tab spans the table: mergeCells // joins its cells (gotcha: merged-cells-hiddenby). Each column has its own alignment. function billTable(tsv, widths, align, headerRowCount = 0) { let model = { ...parseTSV(tsv.trim()), columnWidths: widths, headerRowCount }; model.rows.forEach((row) => row.forEach((cell, c) => { cell.align = align[c]; })); tsv.trim().split('\n').forEach((line, r) => { if (line.includes('\t')) return; model.rows[r][0].align = 'center'; const end = { row: r, col: widths.length - 1 }; model = mergeCells(model, { start: { row: r, col: 0 }, end }); }); return model; } // One filled header cell: two side by side would show a seam (gotcha: table-fill-seams). const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('plum'), headerColor: col('paper'), headerFontFamily: LABEL, headerFontSize: pt(8.5), headerBold: false, // small capitals from the face itself bodyFontSize: pt(8.8), cellPadding: mm(1.5), // the body's face and ink, a size smaller }; const tableStyles = [{ id: 'scenes', rules: 'none', cellPadding: mm(1) }]; // the scenes // #endregion // #region ornament: a resource type for artwork with no number and no caption // ::resource{id="fleuron"} sets it where it stands (gotcha: resource-double-quotes). const unnumbered = (id, name, defaultPlacement) => ({ id, name, shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal', defaultPlacement }); const resourceTypes = [ unnumbered('ornament', 'Ornament', { position: 'here', width: 0.24, align: 'center' }), unnumbered('bill', 'Bill', { position: 'here' }), ]; // #endregion // #region playbill: the title page, a heading style whose design fills the sheet // # The Importance of Being Earnest {style="playbill" small="The" big="Importance" …} // Each attribute is a playbill line in its own face and size, its lineHeight a multiple // (gotcha: design-lineheight-multiple). The ground is an image and reserves no height // (gotcha: opener-image-no-reserve): :::pagebreak keeps the title page alone if its foot // lines move up. const line = (id, content, font, size, y, color, extra = {}) => ({ kind: 'text', id, content, fontFamily: font, fontSize: pt(size), lineHeight: 1, color: col(color), align: 'center', // Centred and tracked, a line sits half its tracking left of centre: x puts it back. placement: { anchor: { to: 'page', edge: 'top' }, offset: { x: pt((extra.letterSpacing?.value ?? 0) / 2), y: mm(y) } }, ...extra }); const tracked = (track) => ({ textTransform: 'uppercase', letterSpacing: pt(track) }); const playbill = { id: 'playbill', span: 'page', // even in one column (gotcha: opener-clipped-at-top) header: { elements: [] }, footer: { elements: [] }, // no heads on p. 2, no folio on p. 1 advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'ground', resourceId: 'playbill', placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } }, line('kicker', '{subtitle}', LABEL, 8.5, 36, 'gilt', tracked(1.7)), line('small', '{attr.small}', TEXT, 17, 51, 'paper', { italic: true }), line('big', '{attr.big}', DISPLAY, 40, 60, 'paper', { textTransform: 'uppercase' }), line('link', '{attr.link}', TEXT, 17, 78, 'gilt', { italic: true }), line('name', '{attr.name}', DISPLAY, 60, 87, 'paper', { textTransform: 'uppercase' }), line('author', '{author}', LABEL, 13, 133, 'paper', tracked(3)), line('theatre', '{attr.theatre}', LABEL, 8, 170, 'gilt', tracked(1.6)), line('premiere', '{attr.premiere}', TEXT, 8.5, 176, 'paper', { italic: true }), ] } }, }; // #endregion // #region heads: the play on the verso, the act on the recto, folios outside const head = (id, content, parity, edge, x, style) => ({ kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages fontFamily: LABEL, fontSize: pt(8.5), letterSpacing: pt(0.8), color: col('muted'), textTransform: 'uppercase', ...style, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(11) } }, }); const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('plum') }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-act', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; // The act's first page carries a drop folio instead, centred under the text block. const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, folio), pages: 'opener', align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(8) } } }] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // hyphenation by exact code (gotcha: hyphenation-locales) colorPalette, resourceTypes, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), backgroundColor: col('paper'), margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72), left: mm(INNER), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: dialogue, headings, headingStyles: [playbill], paragraphStyles: [direction, { id: 'colophon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }], tableStyle, tableStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 126 · content.en.md
title: "The Importance of Being Earnest" subtitle: "A Trivial Comedy for Serious People" author: "Oscar Wilde" --- # The Importance of Being Earnest {style="playbill" small="The" big="Importance" link="of Being" name="Earnest" theatre="St James’s Theatre · London" premiere="First performed on 14 February 1895"} :::pagebreak ## The Persons of the Play ::resource{id="persons"} ## The Scenes of the Play ::resource{id="scenes"} :::paragraphs{style="colophon"} Text: Project Gutenberg eBook #844, in the public domain. Set in Libre Baskerville, Abril Fatface and Playfair Display SC (SIL Open Font License). ::: # First Act ::resource{id="fleuron"} ### Scene :::paragraphs{style="direction"} *Morning-room in Algernon’s flat in Half-Moon Street. The room is luxuriously and artistically furnished. The sound of a piano is heard in the adjoining room.* *[Lane is arranging afternoon tea on the table, and after the music has ceased, Algernon enters.]* ::: :::space **ALGERNON.** Did you hear what I was playing, Lane? **LANE.** I didn’t think it polite to listen, sir. **ALGERNON.** I’m sorry for that, for your sake. I don’t play accurately—any one can play accurately—but I play with wonderful expression. As far as the piano is concerned, sentiment is my forte. I keep science for Life. **LANE.** Yes, sir. **ALGERNON.** And, speaking of the science of Life, have you got the cucumber sandwiches cut for Lady Bracknell? **LANE.** Yes, sir. *[Hands them on a salver.]* **ALGERNON.** *[Inspects them, takes two, and sits down on the sofa.]* Oh!… by the way, Lane, I see from your book that on Thursday night, when Lord Shoreman and Mr. Worthing were dining with me, eight bottles of champagne are entered as having been consumed. **LANE.** Yes, sir; eight bottles and a pint. **ALGERNON.** Why is it that at a bachelor’s establishment the servants invariably drink the champagne? I ask merely for information. **LANE.** I attribute it to the superior quality of the wine, sir. I have often observed that in married households the champagne is rarely of a first-rate brand. **ALGERNON.** Good heavens! Is marriage so demoralising as that? **LANE.** I believe it *is* a very pleasant state, sir. I have had very little experience of it myself up to the present. I have only been married once. That was in consequence of a misunderstanding between myself and a young person. **ALGERNON.** *[Languidly.]* I don’t know that I am much interested in your family life, Lane. **LANE.** No, sir; it is not a very interesting subject. I never think of it myself. **ALGERNON.** Very natural, I am sure. That will do, Lane, thank you. **LANE.** Thank you, sir. *[Lane goes out.]* **ALGERNON.** Lane’s views on marriage seem somewhat lax. Really, if the lower orders don’t set us a good example, what on earth is the use of them? They seem, as a class, to have absolutely no sense of moral responsibility. :::paragraphs{style="direction"} *[Enter Lane.]* ::: **LANE.** Mr. Ernest Worthing. :::paragraphs{style="direction"} *[Enter Jack.]* *[Lane goes out.]* ::: **ALGERNON.** How are you, my dear Ernest? What brings you up to town? **JACK.** Oh, pleasure, pleasure! What else should bring one anywhere? Eating as usual, I see, Algy! **ALGERNON.** *[Stiffly.]* I believe it is customary in good society to take some slight refreshment at five o’clock. Where have you been since last Thursday? **JACK.** *[Sitting down on the sofa.]* In the country. **ALGERNON.** What on earth do you do there? **JACK.** *[Pulling off his gloves.]* When one is in town one amuses oneself. When one is in the country one amuses other people. It is excessively boring. **ALGERNON.** And who are the people you amuse? **JACK.** *[Airily.]* Oh, neighbours, neighbours. **ALGERNON.** Got nice neighbours in your part of Shropshire? **JACK.** Perfectly horrid! Never speak to one of them. **ALGERNON.** How immensely you must amuse them! *[Goes over and takes sandwich.]* By the way, Shropshire is your county, is it not? **JACK.** Eh? Shropshire? Yes, of course. Hallo! Why all these cups? Why cucumber sandwiches? Why such reckless extravagance in one so young? Who is coming to tea? **ALGERNON.** Oh! merely Aunt Augusta and Gwendolen. **JACK.** How perfectly delightful! **ALGERNON.** Yes, that is all very well; but I am afraid Aunt Augusta won’t quite approve of your being here. **JACK.** May I ask why? **ALGERNON.** My dear fellow, the way you flirt with Gwendolen is perfectly disgraceful. It is almost as bad as the way Gwendolen flirts with you. **JACK.** I am in love with Gwendolen. I have come up to town expressly to propose to her. **ALGERNON.** I thought you had come up for pleasure?… I call that business. **JACK.** How utterly unromantic you are! **ALGERNON.** I really don’t see anything romantic in proposing. It is very romantic to be in love. But there is nothing romantic about a definite proposal. Why, one may be accepted. One usually is, I believe. Then the excitement is all over. The very essence of romance is uncertainty. If ever I get married, I’ll certainly try to forget the fact. **JACK.** I have no doubt about that, dear Algy. The Divorce Court was specially invented for people whose memories are so curiously constituted. **ALGERNON.** Oh! there is no use speculating on that subject. Divorces are made in Heaven—*[Jack puts out his hand to take a sandwich. Algernon at once interferes.]* Please don’t touch the cucumber sandwiches. They are ordered specially for Aunt Augusta. *[Takes one and eats it.]*`; // content.<lang>.md, inlined by the Cookbook const cast = String.raw`The original cast · St James’s Theatre, 14 February 1895نموذج Markdown · أسطر: 9 · content.cast.en.md
**John Worthing**, J.P. *Mr. George Alexander* **Algernon Moncrieff** *Mr. Allen Aynesworth* **Rev. Canon Chasuble**, D.D. *Mr. H. H. Vincent* **Merriman**, butler *Mr. Frank Dyall* **Lane**, manservant *Mr. F. Kinsey Peile* **Lady Bracknell** *Miss Rose Leclercq* **Hon. Gwendolen Fairfax** *Miss Irene Vanbrugh* **Cecily Cardew** *Miss Evelyn Millard* **Miss Prism**, governess *Mrs. George Canninge*`; // content.cast.<lang>.md: the persons, tab-separated const scenes = String.raw`Act I Algernon Moncrieff’s Flat in Half-Moon Street, W.نموذج Markdown · أسطر: 3 · content.scenes.en.md
Act II The Garden at the Manor House, Woolton. Act III Drawing-Room at the Manor House, Woolton. *Time: The Present.*`; // content.scenes.<lang>.md: the acts and their places // #region art: the title page's ground and double frame, and the carnation fleuron, in mm let seed = 1895; // Mulberry32, a seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const f = (n) => +n.toFixed(2); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette[a].slice(i, i + 2), 16) * (1 - k) + parseInt(palette[b].slice(i, i + 2), 16) * k).toString(16).padStart(2, '0')) .join('')}`; const svgOf = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const pts = (list) => list.map(([x, y]) => `${f(x)} ${f(y)}`).join('L'); const poly = (list, fill) => `<path d="M${pts(list)}Z" fill="${fill}"/>`; const stroke = (list, color, w) => `<path d="M${pts(list)}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"/>`; const quad = ([x0, y0], [cx, cy], [x1, y1], t) => [ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1, (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1]; // A carnation `s` mm tall standing on (cx, cy), seen from the side: three fans of pinked // petals spring from the rim of a calyx, notched apart in the ground colour behind them. const DEG = Math.PI / 180; function carnation(cx, cy, s, [back, mid, front], green, ground) { const out = []; const [rx, ry] = [cx, cy - s * 0.32]; // the rim of the calyx const fan = (from, to, r, lift, petals, fill) => { // a fan from `from`° to `to`°, r mm deep const at = (deg, k) => [rx + Math.cos(deg * DEG) * r * k, ry - lift + Math.sin(deg * DEG) * r * k * 0.92]; const edge = []; for (let i = 0; i <= petals * 6; i++) { // six teeth to a petal, each petal a rounded lobe const lobe = 0.84 + 0.16 * Math.sin((Math.PI * (i % 6)) / 6); edge.push(at(from + ((to - from) * i) / (petals * 6), (i % 2 ? 0.9 : 1) * lobe * (0.98 + rand() * 0.04))); } out.push(poly([[rx, ry - lift], ...edge], fill)); const notch = (to - from) / petals / 14; // half the angle of a notch at the edge for (let p = 1; p < petals; p++) { // a thin wedge between two petals const deg = from + ((to - from) * p) / petals; out.push(poly([at(deg, 0.66), at(deg - notch, 1.1), at(deg + notch, 1.1)], ground)); } }; fan(-162, -18, s * 0.74, 0, 7, back); fan(-146, -34, s * 0.56, s * 0.06, 5, mid); fan(-124, -56, s * 0.36, s * 0.1, 3, front); // The calyx narrows to the stem; three short sepals rise over the petals, and an outline in // the ground colour keeps it clear of them. const [a, b] = [s * 0.11, s * 0.045]; // half-widths at the rim and at the stem const cup = [[rx - a, ry + s * 0.03], [rx - a * 1.25, ry - s * 0.07], [rx - a * 0.45, ry - s * 0.01], [rx, ry - s * 0.1], [rx + a * 0.45, ry - s * 0.01], [rx + a * 1.25, ry - s * 0.07], [rx + a, ry + s * 0.03], [cx + b, cy], [cx - b, cy]]; out.push(`<path d="M${pts(cup)}Z" fill="${green}" stroke="${ground}" ` + `stroke-width="${f(s * 0.03)}" stroke-linejoin="round"/>`); return out.join(''); } // A scroll: an arm out from the stem that ends in a spiral curl. function scroll(x0, y0, dir, len, curl, color, w) { const list = []; for (let i = 0; i <= 24; i++) { const t = i / 24; list.push([x0 + dir * len * t, y0 + Math.sin(t * Math.PI) * curl * 0.3 - t * curl * 0.25]); } const [ex, ey] = list[list.length - 1]; for (let i = 1; i <= 48; i++) { const a = (i / 48) * Math.PI * 1.8; const r = curl * 0.5 * Math.exp(-0.38 * a); list.push([ex + dir * Math.sin(a) * r, ey - curl * 0.5 + Math.cos(a) * r]); } return stroke(list, color, w); } // A carnation leaf: a narrow blade along a curve from its base. function blade(p0, c, p1, w, fill) { const [left, right] = [[], []]; for (let i = 0; i <= 16; i++) { const t = i / 16; const [x, y] = quad(p0, c, p1, t); const [x2, y2] = quad(p0, c, p1, Math.min(1, t + 0.01)); const [dx, dy] = [x2 - x, y2 - y]; const k = (w * Math.sin(Math.PI * Math.min(1, t * 1.15)) ** 0.7) / 2 / (Math.hypot(dx, dy) || 1); left.push([x - dy * k, y + dx * k]); right.unshift([x + dy * k, y - dx * k]); } return poly([...left, ...right], fill); } // The fleuron, w × h mm: the carnation between two leaves and two scrolls, on `ground`. function fleuron(w, h, petals, green, ground) { const [cx, base] = [w / 2, h * 0.9]; const parts = [-1, 1].map((d) => scroll(cx + d * 0.6, base, d, w * 0.38, h * 0.46, green, h * 0.035) + blade([cx + d * 0.8, base - 0.2], [cx + d * w * 0.12, base - h * 0.02], [cx + d * w * 0.24, base - h * 0.3], h * 0.07, green)); return svgOf(w, h, parts.join('') + carnation(cx, base, h * 0.88, petals, green, ground)); } // The title page: a plum sheet, a double gold frame whose rules cross at the corners // (Oxford corners), and the carnation in gold between the title and the author. const FLEURON_Y = 109; // mm: the top of the title page's carnation function playbillArt(W, H) { const out = [`<rect width="${W}" height="${H}" fill="${palette.plum}"/>`]; const frame = (inset, w, reach) => { // four rules `inset` mm in, running `reach` mm past const [a, bx, by] = [inset, W - inset, H - inset]; for (const [p, q] of [[[a - reach, a], [bx + reach, a]], [[a - reach, by], [bx + reach, by]], [[a, a - reach], [a, by + reach]], [[bx, a - reach], [bx, by + reach]]]) { out.push(stroke([p, q], palette.gold, w)); } }; frame(9, 0.55, 3.2); frame(11, 0.22, -1.2); for (const [x, y] of [[9, 9], [W - 9, 9], [9, H - 9], [W - 9, H - 9]]) { out.push(`<circle cx="${x}" cy="${y}" r="0.9" fill="${palette.gold}"/>`); } const petals = [mix('gold', 'plum', 0.35), palette.gold, mix('paper', 'gold', 0.2)]; const sprig = fleuron(46, 16, petals, palette.gold, palette.plum) .replace(/^<svg[^>]*>|<\/svg>$/g, ''); out.push(`<g transform="translate(${(W - 46) / 2} ${FLEURON_Y})">${sprig}</g>`); return svgOf(W, H, out.join('')); } await loadSvg('playbill.svg', playbillArt(TRIM.width, TRIM.height)); await loadSvg('fleuron.svg', fleuron(30, 11, [mix('plum', 'ink', 0.4), mix('plum', 'paper', 0.12), mix('plum', 'paper', 0.45)], palette.gold, palette.paper)); const svg = (id, w, h, altText) => ({ id, typeId: 'ornament', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, createdAt: 0, updatedAt: 0 }); const art = [ svg('playbill', TRIM.width, TRIM.height, 'A plum title page in a double gold frame whose ' + 'rules cross at the corners, with a gold carnation between two scrolls.'), svg('fleuron', 30, 11, 'Ornament: a plum carnation between two gold scrolls.'), ]; // #endregion const table = (id, model, styleId) => ({ id, typeId: 'bill', kind: 'table', table: { model, styleId }, createdAt: 0, updatedAt: 0 }); const resources = [...art, table('persons', billTable(cast, [2, 1], ['left', 'right'], 1)), // the first line heads it table('scenes', billTable(scenes, [3, 17], ['right', 'left']), 'scenes'), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Abril Fatface': ['400'], 'Playfair Display SC': ['400'] }; // all loaded before the first build (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + cast + scenes); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: doc.metadata.title }); // the frontmatter's titleالعُدّة · core, fonts, viewer, images: نفسها في كل وصفة · أسطر: 270
// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#حاذِ الإرشادات مع الأسطر التالية
حين تُنضَّد الإرشادات محاذاة إلى اليسار بمسافة بادئة للسطر الأول قدرها 5 مم، يبدأ الإرشاد ذو السطر الواحد حيث تبدأ الأسطر التالية من الأقوال، ويصير وصف المشهد فقرتين بمسافة بادئة تحت تسميته الموسَّطة.
const direction = { id: 'direction', fontSize: pt(8.6),
- textAlign: 'center', firstLineIndent: pt(0) };
+ textAlign: 'left', firstLineIndent: mm(5) };#اترك سطرًا بين الأقوال
يضيف paragraphSpacing: true سطر شبكة فارغًا بعد كل قول. عندئذٍ يمتد الفصل الإنجليزي إلى خمس صفحات بدل ثلاث، والإسباني إلى أربع.
maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
+ paragraphSpacing: true,
};أخطاء شائعة
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
نمط الفقرة ليس له لون للمائل
في postext 1.4.1 يضبط نمط الفقرة color وboldColor لكن لا يضبط italicColor: تأخذ مقاطعه المائلة bodyText.italicColor. فالنمط الخافت (الحرف الصغير، سطر المصدر) يطبع عناوينه المائلة أدكن من الكلمات المحيطة بها. أبقِ هذه الأنماط بحبر المتن، أو تجنّب المائل فيها. أنماط الفقرات →
خطأ شائع
الجدول 'here' لا ينقسم أبدًا
لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة. جداول عبر الصفحات →
خطأ شائع
::resource{id="…"} لا يقبل إلا علامتي الاقتباس المزدوجتين
لا يُتعرَّف على التضمين الكتلي إلا بالصيغة ::resource{id="…"} بعلامتي اقتباس مزدوجتين؛ وأي صيغة أخرى تبقى في النص سطرًا ظاهرًا. أشكال في هذا الموضع بالضبط →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا
في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
إصلاح الكلمة المعزولة قد يضيّق تتبّعًا لا يُرسَم أبدًا
في postext 1.4.1، حين تنتهي فقرة بكلمة معزولة في آخرها، يجعلها الإخراج أقصر بسطر: أولًا بتضييق المسافات بين الكلمات، ثم بتتبّع سالب يصل إلى maxRuntTracking من الألف من em. لكن مُخرِجَي Canvas وPDF لا يرسمان التتبّع إلا إذا كان فوق الصفر، فتُطبع الفقرة المتتبَّعة بلا تتبّع: تفقد أسطرها المضبوطة الفرق من مسافات كلماتها فتبدو مضغوطة، وقد يتجاوز سطرها الأخير عرض السطر فيُقصّ عند حافة العمود. اضبط bodyText.maxRuntTracking: 0، فيبقى إصلاح المسافات بين الكلمات، وأعد صياغة أي كلمة معزولة تعود. الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة →
خطأ شائع
ضع كل قيمة في الترويسة الأمامية (frontmatter) بين علامتي اقتباس
يقرأ YAML القيمة title: 1984 رقمًا، ويقرأ التاريخ كائن Date، والقيم غير النصية تُطبع فارغة في العناصر النائبة وتترك ملف PDF بلا عنوان. ضع كل قيمة بين علامتي اقتباس: title: "1984". البيانات الوصفية للمستند →
خطأ شائع
8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا
يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
خطأ شائع
الافتتاحية الباقية في عمودها تُقصّ عند أعلى كتلة النص
في postext 1.4.1 يُقصّ العنوان ذو التصميم المتقدّم الباقي في عموده عند الحافة العليا للعمود: الإطار أو الصورة المثبّتة على الصفحة أو على النزف يُرسَم في الهوامش الجانبية لكن لا في الهامش العلوي، ولا يقول ذلك أي تحذير. أعطِ هذا العنوان span: 'page'، حتى في كتاب بعمود واحد: فيُرسَم تصميمه حينئذ كاملًا شريطَ افتتاحية الصفحة. صفحات افتتاح مصمَّمة →
خطأ شائع
صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه
في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →
خطأ شائع
خليتا جدول ملوّنتان متجاورتان تُظهران فاصلًا باهتًا في Canvas
في postext 1.4.1 يملأ مُخرِج Canvas كل خلية جدول بمستطيل خاص بها عند مواضع بكسل كسرية، فحيث تلتقي خليتان ملوّنتان (خلايا صف ترويسة ملوّن، أو خلايا متن مظلّلة) يظهر بينهما خيط رفيع من الصفحة. لا تلوّن إلا الصفوف المدمجة خلاياها في خلية واحدة، أو ارسم الخطوط بلون التعبئة، أو اترك الخلايا بلا تعبئة. نمط الجدول →
- كل مقطع مائل في الحوار يأخذ
italicColor، بما فيه التوكيد، فتُطبع كلمة Lane المؤكَّدة is برمادي الإرشادات. ولا بد أن يوضع الرمادي علىbodyTextلأن نمط الفقرة في الإصدار 1.4.1 لا يستطيع أن يعطي مائله لونًا خاصًا؛ ولن يُبقي is بالحبر إلا الخط القائم. - ثلاثة من أقوال وايلد تنتهي بكلمة وحدها: information. وthat? وyou.. يضبط النص
bodyText.maxRuntTracking: 0بسبب الخطأ الشائع إصلاح الكلمة المعزولة قد يضيّق تتبّعًا لا يُرسم أبدًا. بالقيمة الافتراضية يرفع الإصدار 1.4.1 that? بتتبّع سالب قدره 2.0 مم لا يرسمه Canvas، فيُطبع السطر ومسافاته بين الكلمات بنصف عرضها. وبالمسافات بين الكلمات وحدها لا ترتفع that? إلا معminWordSpacingقدره 0.5، وهو يسحق السطر بالطريقة نفسها، أو مع عرض سطر أوسع بـ1.5 مم، وهو ينضّد عشرة أسطر أرخى من 1.5 ضعف المسافة العادية بدل اثنين. لذا تُبقي الصفحات الإنجليزية الكلمات المعزولة الثلاث بدل تغيير كلمات وايلد. أما الترجمة الإسبانية فأعيدت صياغتها حيثما انتهى سطر بكلمة من حرف واحد أو بقي مقطع وحده. - الأسماء تُقسَّم بالواصلة كأي كلمة أخرى: Au-gusta وGwen-dolen تنكسران بين سطرين في الصفحة 5. رابط الكلمات (U+2060) داخل Augusta يُبقيها كاملة، لكنه يمدّ السطر الذي فوقها إلى 1.71 ضعف المسافة العادية بين الكلمات، لذا يُبقي النص الإنجليزي الواصلات.
- يرسم Playfair Display SC الحرفين fi ربطًا بحروف صغيرة في Canvas، فكانت first ستُطبع بـfi صغيرة بين حروف كبيرة مصغّرة. لهذا يقول شريط قائمة الشخصيات The original cast.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- The Importance of Being Earnest (1895): the persons and scenes of the play, the first cast and the opening of the first act · Oscar Wilde · ملك عام
- The Spanish translation of the extract, the cast list and the scenes · Postext Cookbook · أصلي
- الصور
- The title page’s frame and the carnation fleuron, drawn in code in the page’s palette · Ignacio Ferro · MIT
- الخطوط
- Libre Baskerville (SIL OFL 1.1) · Abril Fatface (SIL OFL 1.1) · Playfair Display SC (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


