باختصار
ورقة تجربة في الفيزياء فيها مقطعان قصيران لبندول. على الورق كل مقطع صورة ثابتة ورمز يُمسح بالهاتف؛ وعلى الشاشة وفي الكتاب الإلكتروني يُشغَّل المقطع في الصفحة.
ما الذي ستنضده
التجربة 4.2 من Bench Physics، وهي كراسة تجارب بقياس A4 لطلاب من 16 إلى 18 عامًا، وموضوعها دور البندول. يحمل شريط بلون الصدأ العنوان وخانات الاسم والمجموعة والتاريخ. وفي أسفل الصفحة الأولى مقطعان للبندول نفسه، أُفلت في أحدهما من 10° وفي الآخر من 80°، متجاوران. على الورق، كل مقطع هو أول لقطة منه مع علامة تشغيل ورمز QR يفتح ملف MP4. وفي نسخة الشاشة بجوار الصفحات، وفي ملف EPUB الذي تكتبه الشيفرة، يُشغَّل المورِدان نفساهما في مشغّل الفيديو لدى القارئ. وفي الصفحة الثانية الخطوات، وجدول يملؤه الطالب، والأدوار الدقيقة التي تحسبها الشيفرة، وأربعة أسئلة لكل منها خانة للإجابة.
تجيب هذه الوصفة عن
- كيف يشغّل إصدار HTML وملف EPUB مقاطع الفيديو في الكتاب، وكيف أخفي زر التنزيل؟
- كيف أضع فيديو في كتاب مطبوع بحيث يستطيع القارئ فتحه من الصفحة؟
الجواب المختصر
const clip = (id, file, poster, caption, altText) => ({
id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0, caption: t(caption),
note: t({ en: '8.00 s, 25 frames per second · Postext Cookbook, CC BY 4.0',
es: '8,00 s, 25 fotogramas por segundo · Postext Cookbook, CC BY 4.0' }),
altText: t(altText), placement: { position: 'bottom', span: 'column' },
video: { source: 'file', fileId: file, format: 'mp4', width: 640, height: 360, duration: 8,
url: asset(file), // the production address: printed as the QR code, linked from the PDF
poster: { fileId: poster, format: 'jpeg', width: 640, height: 360 }, // the first frame
},
});
const videoStyle = {
playMark: { shape: 'rounded', position: 'top-left', size: mm(7), inset: mm(3),
background: col('ink'), color: col('paper') },
qr: { position: 'bottom-right', size: mm(19), inset: mm(2.5), // 111 characters: 45 modules
color: col('ink'), background: col('paper'), radius: mm(1) },
// The HTML5 player of a file honours every option. download: false writes
// controlslist="nodownload": it hides the button, and the file stays at its address.
player: { download: false, loop: true, muted: true, preload: 'auto',
pictureInPicture: false, remotePlayback: false },
};
const clips = [
clip('small', 'pendulum-small.mp4', 'pendulum-small-poster.jpg',
{ en: 'Released from rest at 10°.', es: 'Soltado desde el reposo a 10°.' },
{ en: 'A rust bob on a dark string hangs a few degrees off the vertical, under a short bar.',
es: 'Una lenteja roja cuelga de un hilo oscuro a pocos grados de la vertical.' }),
clip('large', 'pendulum-large.mp4', 'pendulum-large-poster.jpg',
{ en: 'Released from rest at 80°.', es: 'Soltado desde el reposo a 80°.' },
{ en: 'The same pendulum pulled out almost level with its pivot, a pale arc below it.',
es: 'El mismo péndulo, casi a la altura de su eje, sobre un arco pálido.' }),
];
المكونات
- الميزات
- أغلفة الفيديو: علامة التشغيل ورمز QRالفيديو بوصفه موردًاالتصدير إلى EPUBطبعة HTML للقراءةتصميم أبسط للشاشةجداول من البياناتنمط الجدولإطارات التنبيهإطارات متداخلةمسافة عمودية صريحةصفحات افتتاح مصمَّمةسمات العنوانالنصوص والخطوط والمربعات في تصاميم الصفحةالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةأنواع موارد مخصّصةلوحة ألوان دلاليةالخطوط المضمَّنة في PDF
- تستخدم أيضًا
- استشهادات تضع الأشكالأشكال في هذا الموضع بالضبط«شكل» و«جدول» بلغتكشريط فصل بعرض الصفحةأنماط الفقراتالتصدير إلى PDFالأشكال والجداول بوصفها مواردالنص المرتفع والمنخفض
- الخطوط
- Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1)
- الأصول
pendulum-large-poster.jpgpendulum-large.mp4pendulum-small-poster.jpgpendulum-small.mp4- Video 1: a pendulum 1 m long released from 10°, 8 s at 25 frames per second, rendered from an RK4 solution of the pendulum equation (1 ms steps) with Python and encoded with ffmpeg (Postext Cookbook, CC BY 4.0)
- Video 2: the same pendulum released from 80°, rendered and encoded the same way (Postext Cookbook, CC BY 4.0)
- The first frame of Video 1, its printed poster (Postext Cookbook, CC BY 4.0)
- The first frame of Video 2, its printed poster (Postext Cookbook, CC BY 4.0)
طريقة التحضير
#1 · أعطِ كل مقطع عنوان نشر وملفًا
الشيفرة هي الجواب المختصر أعلاه. للمقطع الخاص عنوانان. video.url هو المكان الذي سيجد فيه الكتابُ المنشور المقطعَ: يطبعه رمز QR ويربط به ملف PDF الصورة، ولذلك هو هنا عنوان المقطع على jsDelivr، وهو عنوان يفتحه الهاتف اليوم. وvideo.fileId يسمّي الملف نفسه، ويُحفظ منفصلًا مثل الصورة النقطية، للمخرجات التي تستطيع حمله أو تشغيله من مكان آخر (انظر صيغة المستند › مقاطع الفيديو). وخيارات videoStyle.player تسري على المقطعين: يتكرران، ويبدآن بلا صوت، ويُحمَّلان كاملين (27 و61 كيلوبايت)، ولا يعرضان تنزيلًا ولا صورة داخل صورة ولا إرسالًا إلى شاشة أخرى، فيبقى المقطع في الورقة.
#2 · أخرِج نسخة الشاشة من الأصل نفسه
const [PANE_W, PANE_H] = [400, 640]; // px
const screen = {
page: { width: px(PANE_W), height: px(PANE_H), dpi: 122, // so the 9.4 pt text is 16 px
margins: { top: px(28), bottom: px(28), left: px(22), right: px(22), mirror: false } },
layout: { layoutType: 'single' }, bodyText: { textAlign: 'left' }, // ragged in a narrow pane
headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false },
fontSize: px(28), lineHeight: px(32), fontWeight: 800, marginBottom: px(16),
advancedDesign: { enabled: false } }] }, // the title alone: the band and fields are print's
header: { elements: [] }, footer: { elements: [] } };
تعيش نسخة الشاشة في الإعدادات تحت htmlViewer.overrides، ولا يقرؤها اللوح ولا ملف PDF. بدقة 122 نقطة في البوصة يخرج كل قياس بالنقاط أكبر بنحو الربع مما يكون عليه بدقة 96، فيُقرأ نص 9.4 نقطة بحجم 16 بكسل في اللوحة، وتكبر معه التعليقات والإطارات والجداول. ويحتفظ الافتتاح بالعنوان ويترك الشريط والخانات، فهي للورق.
#3 · شغّل المقطعين في اللوحة
// A shadow root keeps the page's styles out, so the pane shows the lines as they were measured.
const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' });
pane.innerHTML = renderToHtml(buildDocument({ markdown, resources },
applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff',
resourceImageUrl: imageUrl, // the posters, until a clip plays
resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here
const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player
pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48;
تضع renderToHtml كل مورد فيديو في عنصر <video> من HTML5، وتصير خيارات المشغّل سماتٍ له: controls وmuted وloop وplaysinline وpreload="auto" وdisablepictureinpicture وdisableremoteplayback وcontrolslist="nodownload". والسمة الأخيرة تخفي الزر فقط. من يعرف العنوان يستطيع حفظ الملف على أي حال، فهي ترتيب للواجهة وليست حماية. وتعطي resourceVideoUrl المحرّكَ عنوانًا قابلًا للتشغيل لكل fileId، كما تفعل resourceImageUrl مع الصور. هنا هو الملف المنشور؛ والموقع الذي يحفظ ملفاته في مكان آخر يعيد عنوانه الخاص، أو عنوان blob للبايتات. وبدونها يرجع المشغّل إلى video.url، وبدون الاثنين تعرض اللوحة الصورة.
#4 · احزم المقطعين في EPUB بتخطيط ثابت
const media = new Map(await Promise.all(clips.map(async ({ video }) =>
[video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }])));
const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts)
...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) =>
({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))),
...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first
].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) })));
const epub = await renderToEpub([doc], { layout: 'fixed', fonts,
metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG,
rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run
// The clips as video/mp4 and the posters as pictures; the book packs both under media/.
resourceBytes: (fileId) => media.get(fileId)
?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' },
});
تقوم الورقة على مقطعين متجاورين وخانات فارغة للكتابة، ولذلك يحفظها التخطيط الثابت: كل صفحة كما تُطبع، ومشغّل مكان الصورة. والملف القابل لإعادة التدفق يشغّل المقطعين أيضًا، واحدًا تحت الآخر بعرض النص. تجيب resourceBytes بـvideo/mp4 لكل مقطع وبصورة JPEG لكل لقطة، فيحزمها الكاتب تحت media/ وimages/. ولا يحتفظ EPUB إلا بالسمات التي يعرفها مخططه (controls وautoplay وmuted وloop وplaysinline وpreload)، فيُترك زر التنزيل وغيره لنظام القراءة. ويجتاز الملف EPUBCheck بلا أخطاء ولا تحذيرات.
#5 · انضد θ وπ في اللوح وملف PDF وEPUB
// Fontsource cuts each face by script, and the PDF keeps its latin file (gotcha: latin-subset).
const GREEK = 'U+0370-03FF'; // Greek and Coptic
const file = (family, subset, [weight, style]) => 'https://cdn.jsdelivr.net/npm/@fontsource/'
+ `${fontsourceId(family)}@5/files/${fontsourceId(family)}-${subset}-${weight}-${style}.woff2`;
const faceOf = (spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal'];
const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer());
const greek = FONTS[TEXT].map(faceOf).map(([weight, style]) => ({ family: TEXT, weight, style,
url: file(TEXT, 'greek', [weight, style]) }));
const loadGreek = () => Promise.all(greek.map(async ({ weight, style, url }) => document.fonts.add(
await new FontFace(TEXT, `url(${url})`, { weight: `${weight}`, style, unicodeRange: GREEK })
.load())));
// The PDF takes both files of a text face; it asks for every weight, and Source Serif 4 ships
// 200 to 900 (gotcha: pdf-provider-all-styles). A character comes from the first file with it.
const pdfFont = async (family, weight, style) => {
const latin = await fontsourceProvider(family, weight, style);
return family !== TEXT ? latin : [latin, await decompressWoff2(await bytesOf(file(TEXT,
'greek', [Math.max(weight, 200), style])))];
};
تحمّل العُدّة ملف latin من Fontsource لكل خط، ولا حروف يونانية فيه. فتضيف الشيفرة ملفات greek من Source Serif 4 خطوطًا مقصورة على الكتلة اليونانية، وتسلّم ملف PDF الملفين لكل وجه من خط النص (يؤخذ كل حرف من أول ملف يحويه)، وتعطي EPUB الملفات اليونانية بعد اللاتينية، لكل منها unicodeRange الخاص به. ويظل الالتقاط ينبّه إلى أن θ وπ خارج مجموعة latin؛ وملف PDF يحويهما.
#6 · احسب الجدول في الشيفرة
// The arithmetic-geometric mean gives the elliptic integral K of the exact period in a few steps.
const T0 = 2 * Math.PI * Math.sqrt(1 / 9.81); // s: L = 1 m, g = 9.81 m/s²
const agm = (a, b) => (Math.abs(a - b) < 1e-15 ? a : agm((a + b) / 2, Math.sqrt(a * b)));
const period = (degrees) => T0 / agm(1, Math.cos((degrees * Math.PI) / 360));
const num = (x, digits) => x.toFixed(digits).replace('.', t({ en: '.', es: ',' }));
const cell = (content, align = 'right') => ({ content, align, verticalAlign: 'middle' });
const table = (id, caption, head, rows, columnWidths) => ({ id, typeId: 'table', kind: 'table',
createdAt: 0, updatedAt: 0, caption: t(caption), placement: { position: 'here' },
table: { model: { headerRowCount: 1, columnWidths, rows: [head.map((h, i) =>
({ ...cell(h, i ? 'right' : 'left'), isHeader: true })), ...rows] } } });
const periods = table('periods', { en: 'Exact periods of a pendulum 1.00 m long.',
es: 'Periodos exactos de un péndulo de 1,00 m.' },
['Amplitude|Amplitud', 'T (s)', 'T/T~0~', 'Longer by|Aumento'].map((h) =>
t({ en: h.split('|')[0], es: h.split('|').at(-1) })),
[5, 10, 20, 30, 45, 60, 80, 90].map((d) => [cell(`${d}°`, 'left'), cell(num(period(d), 3)),
cell(num(period(d) / T0, 3)), cell(`${num((period(d) / T0 - 1) * 100, 2)} %`)]),
[1.2, 1, 1, 1.1]);
const blank = cell(' \n '); // two empty lines: room to write
const data = table('data', { en: 'Your measurements.', es: 'Tus medidas.' },
t({ en: ['Clip', 'Swings in 8 s', 'T (s)'], es: ['Vídeo', 'Oscilaciones en 8 s', 'T (s)'] }),
[10, 80].map((d, i) => [cell(`${t({ en: 'Video', es: 'Vídeo' })} ${i + 1} · ${d}°`, 'left'),
blank, blank]), [1.2, 1.5, 0.8]);
يحتاج الدور الدقيق إلى التكامل الإهليلجي التام من النوع الأول K، ويعطيه الوسط الحسابي الهندسي في خطوات قليلة: T = T~0~ / AGM(1, cos ½θ~0~). يطبع الجدول ثلاثة منازل عشرية، بفاصلة عشرية في النسخة الإسبانية، ويطابق سطرا 10° و80° المقطعين: 2.010 ث و2.282 ث، أي أطول من T~0~ = 2.006 ث بنسبة 0.2 % و13.8 %. وفي 8 ثوانٍ يكمل المقطع الأول ما يقارب أربع تأرجحات كاملة، والثاني ثلاثًا ونصفًا.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 132 · A lab sheet whose clips play on screen and in the EPUB ════ // https://postext.dev/en/cookbook/pendulum-lab-video-players // Code: MIT · Text: original (CC BY 4.0) · Clips: rendered in code (CC BY 4.0) // Fonts: Source Serif 4, Red Hat Display, Red Hat Mono (SIL OFL 1.1) · Needs postext ≥ 1.16.1 import { buildDocument, renderPageToCanvas, renderToHtml, applyHtmlViewerOverrides, clearMeasurementCache, registerResourceImage, defaultResourceTypes, resourceVideoLink, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; import { renderToEpub, readEpub } from 'https://esm.sh/postext-epub'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'pendulum-lab-video-players'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // the clips' own colours: a cream ground, the rust bob, warm black ink ink: '#22201b', // text, the play mark, the QR modules, table heads rust: '#a8432b', // the accent: the band, labels, list numbers (the bob's red, darker) cream: '#f6f1e4', rule: '#cbc2ae', // box fills, a shade deeper than the clips; hairlines muted: '#68625a', paper: '#ffffff' }; // muted: running heads, notes, prompts const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.rust }) // defaults: rust .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, MONO] = ['Source Serif 4', 'Red Hat Display', 'Red Hat Mono']; const [TOP, INNER, OUTER, BAND, LEAD] = [22, 20, 17, 84, 13.2]; // mm, A4; LEAD in pt const px = (value) => ({ value, unit: 'px' }); // the screen edition's sizes const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x, y }, size }); const label = (size, color = 'rust') => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: 600, letterSpacing: pt(size * 0.16), textTransform: 'uppercase', color: col(color) }); // #region answer: two clips of the book's own: a poster and a QR code, or a player const clip = (id, file, poster, caption, altText) => ({ id, typeId: 'video', kind: 'video', createdAt: 0, updatedAt: 0, caption: t(caption), note: t({ en: '8.00 s, 25 frames per second · Postext Cookbook, CC BY 4.0', es: '8,00 s, 25 fotogramas por segundo · Postext Cookbook, CC BY 4.0' }), altText: t(altText), placement: { position: 'bottom', span: 'column' }, video: { source: 'file', fileId: file, format: 'mp4', width: 640, height: 360, duration: 8, url: asset(file), // the production address: printed as the QR code, linked from the PDF poster: { fileId: poster, format: 'jpeg', width: 640, height: 360 }, // the first frame }, }); const videoStyle = { playMark: { shape: 'rounded', position: 'top-left', size: mm(7), inset: mm(3), background: col('ink'), color: col('paper') }, qr: { position: 'bottom-right', size: mm(19), inset: mm(2.5), // 111 characters: 45 modules color: col('ink'), background: col('paper'), radius: mm(1) }, // The HTML5 player of a file honours every option. download: false writes // controlslist="nodownload": it hides the button, and the file stays at its address. player: { download: false, loop: true, muted: true, preload: 'auto', pictureInPicture: false, remotePlayback: false }, }; const clips = [ clip('small', 'pendulum-small.mp4', 'pendulum-small-poster.jpg', { en: 'Released from rest at 10°.', es: 'Soltado desde el reposo a 10°.' }, { en: 'A rust bob on a dark string hangs a few degrees off the vertical, under a short bar.', es: 'Una lenteja roja cuelga de un hilo oscuro a pocos grados de la vertical.' }), clip('large', 'pendulum-large.mp4', 'pendulum-large-poster.jpg', { en: 'Released from rest at 80°.', es: 'Soltado desde el reposo a 80°.' }, { en: 'The same pendulum pulled out almost level with its pivot, a pale arc below it.', es: 'El mismo péndulo, casi a la altura de su eje, sobre un arco pálido.' }), ]; // #endregion // #region periods: Table 2 is worked out here, not typed: T = T0 / AGM(1, cos ½θ0) // The arithmetic-geometric mean gives the elliptic integral K of the exact period in a few steps. const T0 = 2 * Math.PI * Math.sqrt(1 / 9.81); // s: L = 1 m, g = 9.81 m/s² const agm = (a, b) => (Math.abs(a - b) < 1e-15 ? a : agm((a + b) / 2, Math.sqrt(a * b))); const period = (degrees) => T0 / agm(1, Math.cos((degrees * Math.PI) / 360)); const num = (x, digits) => x.toFixed(digits).replace('.', t({ en: '.', es: ',' })); const cell = (content, align = 'right') => ({ content, align, verticalAlign: 'middle' }); const table = (id, caption, head, rows, columnWidths) => ({ id, typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, caption: t(caption), placement: { position: 'here' }, table: { model: { headerRowCount: 1, columnWidths, rows: [head.map((h, i) => ({ ...cell(h, i ? 'right' : 'left'), isHeader: true })), ...rows] } } }); const periods = table('periods', { en: 'Exact periods of a pendulum 1.00 m long.', es: 'Periodos exactos de un péndulo de 1,00 m.' }, ['Amplitude|Amplitud', 'T (s)', 'T/T~0~', 'Longer by|Aumento'].map((h) => t({ en: h.split('|')[0], es: h.split('|').at(-1) })), [5, 10, 20, 30, 45, 60, 80, 90].map((d) => [cell(`${d}°`, 'left'), cell(num(period(d), 3)), cell(num(period(d) / T0, 3)), cell(`${num((period(d) / T0 - 1) * 100, 2)} %`)]), [1.2, 1, 1, 1.1]); const blank = cell(' \n '); // two empty lines: room to write const data = table('data', { en: 'Your measurements.', es: 'Tus medidas.' }, t({ en: ['Clip', 'Swings in 8 s', 'T (s)'], es: ['Vídeo', 'Oscilaciones en 8 s', 'T (s)'] }), [10, 80].map((d, i) => [cell(`${t({ en: 'Video', es: 'Vídeo' })} ${i + 1} · ${d}°`, 'left'), blank, blank]), [1.2, 1.5, 0.8]); // #endregion // #region opener: a rust band with the sheet's number, its title and the fields to fill in const words = (id, content, family, size, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col('paper'), align: 'left', overflow: 'wrap', placement, ...extra }); // titles wrap (gotcha: overflow-ellipsis-default) const names = t({ en: ['Name', 'Group', 'Date'], es: ['Nombre', 'Grupo', 'Fecha'] }); const fields = [[0, 86], [90, 30], [124, 49]].flatMap(([x, width], i) => [ // mm, from the margin words(`label-${i}`, names[i], MONO, 7, at('page', 'top-left', mm(INNER + x), mm(BAND - 16)), label(7, 'paper')), { kind: 'box', id: `field-${i}`, style: { backgroundColor: col('paper'), borderRadius: mm(1) }, placement: at('page', 'top-left', mm(INNER + x), mm(BAND - 12.5), { width: mm(width), height: mm(6.5) }) }]); const opener = { enabled: true, minHeight: mm(BAND - TOP + 6), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('rust') }, placement: at('bleed', 'top-left', mm(0), mm(0), { width: 'fill', height: mm(BAND) }) }, words('kicker', '{attr.kicker}', MONO, 8.5, at('page', 'top-left', mm(INNER), mm(14)), label(8.5, 'paper')), words('title', '{titleText}', DISPLAY, 46, at('#kicker', 'below', mm(0), mm(2.5), { width: mm(150) }), { fontWeight: 800, lineHeight: 0.98 }), words('lead', '{attr.lead}', TEXT, 10, at('#title', 'below', mm(0), mm(3.5), { width: mm(158) }), { lineHeight: 1.4 }), ...fields] } }; // a label over a white field for each, to fill in by hand // #endregion const head = (id, content, edge, x, extra) => ({ kind: 'text', id, content, parity: 'even', pages: 'body', ...label(7, 'muted'), fontWeight: 500, placement: at('page', edge, mm(x), mm(12)), ...extra }); const header = { elements: [ head('folio', '{pageNumber}', 'top-left', OUTER, { color: col('rust'), fontWeight: 700 }), head('title', '{title} · {attr.kicker}', 'top-left', OUTER + 7)] }; const footer = { elements: [head('drop-folio', '{title} · {pageNumber}', 'bottom-right', 0, { parity: 'all', pages: 'opener', placement: at('page', 'bottom-right', mm(-OUTER), mm(-11)) })] }; const box = (id, extra) => ({ id, background: col('cream'), borderRadius: mm(1.5), padding: { top: mm(3), right: mm(3.5), bottom: mm(3), left: mm(3.5) }, titleStyle: { ...label(7.5), gap: mm(1.2) }, body: { fontSize: pt(9.2), lineHeight: pt(LEAD), textAlign: 'left', firstLineIndent: pt(0) }, ...extra }); const config = () => ({ // a factory: the engine caches configs by identity locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) // Figure, Table, Video in the sheet's language, counted 1, 2… (gotcha: resource-types-locale) resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never', shortLabel: t({ en: type.name, es: type.name.toLowerCase() }) })), colorPalette, videoStyle, page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, margins: { top: mm(TOP), bottom: mm(22), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(7) }, // columns of 83 mm bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('rust'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: DISPLAY, color: col('ink'), fontWeight: 700, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ] }, orderedLists: { numberFormat: 'arabic', fontFamily: MONO, fontWeight: 700, color: col('rust'), marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [ box('aim', { stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rust') } }), box('method'), box('sheet'), { id: 'answer', background: col('paper'), borderRadius: mm(1), marginTop: mm(1.2), marginBottom: mm(2.5), border: { enabled: true, color: col('rule'), width: pt(0.6) }, padding: { top: mm(1), right: mm(2.5), bottom: mm(1), left: mm(2.5) }, body: { ...label(6.5, 'muted'), fontWeight: 500, lineHeight: pt(LEAD) } }, ], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: MONO, headerFontSize: pt(7.5), bodyFontFamily: MONO, bodyFontSize: pt(8.5), bodyColor: col('ink'), cellPadding: mm(1.4) }, captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), labelBold: true, labelColor: col('rust'), gap: mm(2), note: { fontSize: pt(7), color: col('muted') } }, paragraphStyles: [ { id: 'formula', fontSize: pt(11), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }, { id: 'colophon', fontFamily: MONO, fontSize: pt(6.5), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], header, footer, htmlViewer: { overrides: screen }, // canvas and PDF ignore the overrides }); // #region screen: the screen edition: one column at the pane's width, the clips as players const [PANE_W, PANE_H] = [400, 640]; // px const screen = { page: { width: px(PANE_W), height: px(PANE_H), dpi: 122, // so the 9.4 pt text is 16 px margins: { top: px(28), bottom: px(28), left: px(22), right: px(22), mirror: false } }, layout: { layoutType: 'single' }, bodyText: { textAlign: 'left' }, // ragged in a narrow pane headings: { levels: [{ level: 1, span: 'column', breakBefore: { enabled: false }, fontSize: px(28), lineHeight: px(32), fontWeight: 800, marginBottom: px(16), advancedDesign: { enabled: false } }] }, // the title alone: the band and fields are print's header: { elements: [] }, footer: { elements: [] } }; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 86 · content.en.md
title: "Bench Physics" subtitle: "A lab workbook for ages 16 to 18" --- # The period of a pendulum {kicker="Unit 4 · Oscillations · Lab sheet 4.2" lead="Two pendulums of the same length, released from 10° and from 80°. Time both on video and find out whether the period depends on the size of the swing."} :::callout{type="aim" title="Aim"} To measure the period of a pendulum 1.00 m long from two video clips, and to find out how far the small-angle formula can be trusted. **You need** the two clips, a phone or a screen to play them, and a calculator. ::: ## The two clips :ref{id="small"} and :ref{id="large"} were rendered from a numerical solution of the pendulum’s equation of motion, θ″ = −(*g*/*L*) sin θ, worked out in steps of one millisecond for a pendulum 1.00 m long. Each clip lasts 8.00 s at 25 frames per second, so one frame is 0.04 s. There is no air resistance in the model, so the swings never die away. The still printed for each clip is its first frame, and the code in its corner opens the clip itself. Scan it with a phone, or play the clips in the screen edition of this workbook. Both clips loop, start with the sound off and have a speed menu: slow them to half speed when you count. ## Background A simple pendulum is a small heavy bob on a light string that does not stretch. Pulled aside and let go, it swings to and fro through its lowest point. The **period**, *T*, is the time it takes to go out and come back to where it started. Galileo noticed that a pendulum keeps the same period as its swings die away, and clockmakers relied on that for three hundred years. For small swings the period depends only on the length *L* and on the gravitational field strength *g*: :::paragraphs{style="formula"} *T*~0~ = 2π (*L*/*g*)^1/2^ ::: With *L* = 1.00 m and *g* = 9.81 m s^−2^, the formula gives *T*~0~ = 2.006 s. Its derivation replaces sin θ by θ in radians, which is within 1 % of the truth only up to about 14°. Past that, the pull back towards the middle grows more slowly than the angle, the bob lingers near the turning points and every swing takes a little longer. With the amplitude θ~0~, the angle the bob is released from, and *K*, the complete elliptic integral of the first kind, the exact period is :::paragraphs{style="formula"} *T* = 4 (*L*/*g*)^1/2^ *K*(sin ½θ~0~) ::: ## Method :::callout{type="method"} 1. Play the first clip from the start. The bob is let go from rest in the first frame. 2. Count the complete swings, out and back to the release point, until the clip ends at 8.00 s. Estimate the last one to a quarter of a swing. 3. Divide 8.00 s by the number of swings to find the period. 4. Repeat for the second clip and fill in the table below. ::: ## Results ::resource{id="data"} The values in :ref{id="periods"} come from the exact formula, worked out to three decimals. Compare your periods with the rows for 10° and 80°. ::resource{id="periods"} ## Questions :::callout{type="sheet"} 1. Which clip has the longer period, and by what percentage? :::callout{type="answer"} Answer :::space{lines=3} ::: 2. The bob in the second clip travels a much longer arc. Why does its period not grow in the same proportion? :::callout{type="answer"} Answer :::space{lines=4} ::: 3. The first correction to the small-angle formula gives *T* = *T*~0~ (1 + θ~0~^2^/16), with θ~0~ in radians. Work it out at 80° and compare it with the table. Is one term enough? :::callout{type="answer"} Answer :::space{lines=4} ::: 4. A long-case clock swings 4° either side of the vertical. Would its maker need the exact formula? :::callout{type="answer"} Answer :::space{lines=3} ::: ::: :::paragraphs{style="colophon"} Bench Physics, lab sheet 4.2 · Set in Source Serif 4, Red Hat Display and Red Hat Mono (SIL Open Font License) · Text and clips: Postext Cookbook, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook const resources = [...clips, data, periods]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Source Serif 4': ['400', '400i', '700', '700i'], 'Red Hat Display': ['700', '800'], 'Red Hat Mono': ['400', '500', '600', '700'] }; // #region greek: θ and π come from the text face's greek file, which the kit does not load // Fontsource cuts each face by script, and the PDF keeps its latin file (gotcha: latin-subset). const GREEK = 'U+0370-03FF'; // Greek and Coptic const file = (family, subset, [weight, style]) => 'https://cdn.jsdelivr.net/npm/@fontsource/' + `${fontsourceId(family)}@5/files/${fontsourceId(family)}-${subset}-${weight}-${style}.woff2`; const faceOf = (spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']; const bytesOf = async (url) => new Uint8Array(await (await fetch(url)).arrayBuffer()); const greek = FONTS[TEXT].map(faceOf).map(([weight, style]) => ({ family: TEXT, weight, style, url: file(TEXT, 'greek', [weight, style]) })); const loadGreek = () => Promise.all(greek.map(async ({ weight, style, url }) => document.fonts.add( await new FontFace(TEXT, `url(${url})`, { weight: `${weight}`, style, unicodeRange: GREEK }) .load()))); // The PDF takes both files of a text face; it asks for every weight, and Source Serif 4 ships // 200 to 900 (gotcha: pdf-provider-all-styles). A character comes from the first file with it. const pdfFont = async (family, weight, style) => { const latin = await fontsourceProvider(family, weight, style); return family !== TEXT ? latin : [latin, await decompressWoff2(await bytesOf(file(TEXT, 'greek', [Math.max(weight, 200), style])))]; }; // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), loadGreek(), ...clips.map(({ video }) => loadImage(video.poster.fileId, asset(video.poster.fileId)))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'The period of a pendulum', es: 'El periodo de un péndulo' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: pdfFont, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #region epub: a fixed-layout EPUB that carries both MP4 files and plays them on the page const media = new Map(await Promise.all(clips.map(async ({ video }) => [video.fileId, { bytes: await bytesOf(asset(video.fileId)), mediaType: 'video/mp4' }]))); const fonts = await Promise.all([ // every face as bytes (gotcha: epub-embeds-given-fonts) ...Object.entries(FONTS).flatMap(([family, specs]) => specs.map(faceOf).map(([w, s]) => ({ family, weight: w, style: s, url: file(family, 'latin', [w, s]) }))), ...greek.map((face) => ({ ...face, unicodeRange: GREEK })), // after latin: tried first ].map(async ({ url, ...face }) => ({ ...face, format: 'woff2', bytes: await bytesOf(url) }))); const epub = await renderToEpub([doc], { layout: 'fixed', fonts, metadata: { title: doc.metadata.title, creators: ['Postext Cookbook'], language: LANG, rights: 'CC BY 4.0', modified: new Date('2026-10-05T00:00:00Z') }, // the same bytes each run // The clips as video/mp4 and the posters as pictures; the book packs both under media/. resourceBytes: (fileId) => media.get(fileId) ?? { bytes: imageBytes(fileId), mediaType: 'image/jpeg' }, }); // #endregion const [read, kb] = [readEpub(epub), (bytes) => `${Math.round(bytes.length / 1024)} KB`]; const list = (lines) => `<ul>${lines.map((line) => `<li>${line}</li>`).join('')}</ul>`; const desk = Object.assign(document.createElement('section'), { id: 'editions', innerHTML: ` <style>#editions{display:flex;flex-wrap:wrap;gap:28px;justify-content:center;align-items:start; padding:28px 16px 0;color:#d9d5cc}#editions figure{margin:0}#editions figcaption{margin-top:16px; color:#8b8f97}#screen{width:${PANE_W}px;height:${PANE_H}px;overflow:auto;border-radius:12px; box-shadow:0 0 0 8px #1d1f24}#editions article{width:min(380px,92vw);padding:4px 18px; border-radius:8px;background:#15181d;overflow-wrap:anywhere}#editions a{color:#d8a21a}</style> <figure><div id="screen" role="region" tabindex="0"></div><figcaption>${t({ en: 'Screen' + ' edition', es: 'Edición de pantalla' })} · HTML · ${PANE_W} × ${PANE_H} px</figcaption> </figure><article><h3>EPUB 3 · ${read.layout}</h3> <p>${read.spine.length} pages · ${kb(epub)}. The book carries:</p>${list([...read.manifest .values()].filter((item) => item.mediaType === 'video/mp4').map(({ path }) => `${path.slice(read.root.length)} · ${kb(read.files.get(path))}`))} <p>The QR codes and the PDF links open:</p>${list(clips.map(resourceVideoLink))} <a download="${RECIPE}.epub" href="${URL.createObjectURL(new Blob([epub], { type: 'application/epub+zip' }))}">Download ${RECIPE}.epub</a></article>` }); document.getElementById('pages').before(desk); // #region pane: the screen edition as HTML, each clip in the browser's own player // A shadow root keeps the page's styles out, so the pane shows the lines as they were measured. const pane = desk.querySelector('#screen').attachShadow({ mode: 'open' }); pane.innerHTML = renderToHtml(buildDocument({ markdown, resources }, applyHtmlViewerOverrides(config())), { mode: 'single', padding: 0, background: '#ffffff', resourceImageUrl: imageUrl, // the posters, until a clip plays resourceVideoUrl: (fileId) => asset(fileId) }); // the file each <video> plays: the asset here const top = (el) => el.getBoundingClientRect().top; // open the pane on the first player pane.host.scrollTop = top(pane.querySelector('video')) - top(pane.host) - 48; // #endregionالعُدّة · core, fonts, viewer, pdf, images: نفسها في كل وصفة · أسطر: 310
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
يعمل ملف script.js المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#اجعل مقطعًا يبدأ وحده
يُوضع player الخاص بكل مقطع فوق videoStyle.player. ويبدأ التشغيل التلقائي دائمًا بلا صوت، كما تشترط المتصفحات.
];
+clips[0].video.player = { autoplay: true }; // the 10° clip starts as the pane opens#اعرض الصورة المطبوعة على الشاشة
مع html: 'poster' تضع نسخة HTML الصورة مع علامة التشغيل ورمز QR، مربوطةً بعنوان النشر، مكان المشغّل.
const videoStyle = {
+ html: 'poster',#استعمل فيديو من YouTube أو Vimeo
source: 'youtube' مع عنوان الفيديو يضمّن مشغّل YouTube من youtube-nocookie.com (وprivacy مفعّل افتراضيًا). وهذا المشغّل يلتزم بخيارات أقل: الأزرار، وملء الشاشة، والتشغيل التلقائي، وكتم الصوت، والتكرار؛ ويضيف مشغّل Vimeo قائمة السرعة والصورة داخل الصورة، ولا يعرض أيٌّ منهما التنزيل. ولا يجوز لملف EPUB أن يضمّن مشغّل صفحة ويب، فيكون الفيديو فيه صورته مربوطةً بالفيديو. والخيارات ومدى كل منها في الإعدادات › نمط الفيديو.
أخطاء شائعة
خطأ شائع
لا تضمّن renderToEpub إلا الخطوط التي تمرّرها إليها
الخطوط التي حمّلها المتصفح ليست ملفات تستطيع postext-epub قراءتها: مرّر في options.fonts بايتات كل خط تستخدمه الصفحات (ملفات woff2 نفسها من Fontsource التي حمّلتها الصفحة). يُبلَّغ عن الخط الناقص بتحذير missingFont، ويُنضّد نظام القراءة ذلك النص بخط من عنده، وهو في التخطيط الثابت لا يتسع للأسطر التي نُضّدت بها الصفحة. لا تضمّن إلا الخطوط التي تسمح رخصتها بذلك. التصدير إلى EPUB →
خطأ شائع
ملفات latin من Fontsource تُسقط الحروف خارج اللاتينية
يضمّن مزوّد PDF ملفات latin من Fontsource، وهي تغطي الإسبانية ونصوص أوروبا الغربية لكنها لا تغطي → و≈ و✓ و★ ولا الحروف اليونانية أو حروف أوروبا الوسطى؛ فتغيب تلك الحروف عن PDF. أبقِ نص PDF ضمن نطاق latin. الخطوط المضمَّنة في PDF →
خطأ شائع
يطلب PDF كل وزن وكل نمط من كل عائلة
يطلب renderToPdf من مزوّد الخطوط الأوجه العريضة والمائلة والعريضة المائلة لكل عائلة قد تستخدمها أي كتلة، حتى التي لا تُطبع أبدًا، ورفضٌ واحد يوقف التصدير. يجب أن يلجأ المزوّد إلى أقرب وزن توفّره العائلة، وأن يعود إلى الوجه القائم حين لا يوجد وجه مائل. الخطوط المضمَّنة في PDF →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
خطأ شائع
ترجم Figure/Table بـ defaultResourceTypes(locale)
يضبط locale في الإعداد تقسيمَ الكلمات بالواصلة، لا التعليقات: من دون resourceTypes تقول الأنواع المدمجة Figure وTable بالإنجليزية. مرّر resourceTypes: defaultResourceTypes('es') للإسبانية؛ ولأي لغة أخرى اكتب الأسماء بنفسك في resourceTypes. «شكل» و«جدول» بلغتك →
خطأ شائع
8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا
يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →
خطأ شائع
فيض نص التصميم افتراضيًا 'ellipsis-end'
عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر. النصوص والخطوط والمربعات في تصاميم الصفحة →
ملفا MP4 بترميز H.264. تشغّلهما متصفحات Chrome وEdge وSafari وFirefox، وكذلك Chrome for Testing الذي يستعمله الالتقاط. أما Chromium المبني بلا مرمّزاته المملوكة، وهو الإعداد الافتراضي للمشروع المفتوح، فلا يشغّلهما؛ رمّز المقطعين بصيغة WebM (format: 'webm') إن كان ذلك يهم قرّاءك.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- Video 1: a pendulum 1 m long released from 10°, 8 s at 25 frames per second, rendered from an RK4 solution of the pendulum equation (1 ms steps) with Python and encoded with ffmpeg · Postext Cookbook · CC BY 4.0
- Video 2: the same pendulum released from 80°, rendered and encoded the same way · Postext Cookbook · CC BY 4.0
- The first frame of Video 1, its printed poster · Postext Cookbook · CC BY 4.0
- The first frame of Video 2, its printed poster · Postext Cookbook · CC BY 4.0
- الخطوط
- Source Serif 4 (SIL OFL 1.1) · Red Hat Display (SIL OFL 1.1) · Red Hat Mono (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


