باختصار
كتالوج بذور من أربع صفحات فيه قائمة أسعار. لكل صنف من البذور رسم صغير لكيسه داخل الجدول، والقائمة تستمر على صفحتين متقابلتين.
ما الذي ستنضده
قائمة أسعار الربيع لـ Brindlewood Seed Co.، وهي مزرعة بذور متخيَّلة، في كتيّب من أربع صفحات على ورق بلون الجلد المدبوغ. ينضّد الغلاف Spring Seeds بخط Alfa Slab One فوق رسم ملوّن لزهرة عبّاد شمس ونبتة طماطم، وتحته الرسالة إلى المزارعين في عمودين. تحمل الصفحتان 2 و3 القائمة. لكل صنف رسم لكيس بذوره في أعلى خليته واسمه تحته، وأنواع الخضار تمتد نزولًا في عمود أول مدمج. الأسعار ملاصقة للحافة اليمنى بخط تسميات مضغوط، وشارات حمراء وخضراء تعلّم البذور الجديدة والعضوية. تنقطع القائمة بعد الفلفل المخصص للقلي وتُستأنف في الصفحة المقابلة تحت الشريط الأحمر نفسه، موسومة بـ (continued). الصفحة الأخيرة استمارة الطلب: شبكتان بزوايا مدوّرة فيهما أسطر فارغة للكتابة.
تجيب هذه الوصفة عن
- كيف أضع صورًا أو أيقونات داخل خلايا الجدول؟
- كيف أقسّم جدولًا طويلًا عبر الصفحات مع تكرار الرأس وعلامة «تابع»؟
- كيف أنسّق عدة جداول تنسيقات مختلفة (تعبئة، خلايا مخططة بالتناوب، أطر مستديرة) في مستند واحد؟
- كيف أصنع جدولًا فيه صفوف رأس، وخلايا مدمجة، وعروض أعمدة، ومحاذاة لكل خلية؟
الجواب المختصر
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm
for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
for (let r = 1; r < m.rows.length; r++) {
// The picture is a resource of its own; the cell sets it at the top, centred, with the
// variety's name under it, and the row grows to hold both.
const variety = m.rows[r][1].content;
m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
m = setCellContent(m, at(r, 1), `**${variety}**`);
m = setAlignment(m, at(r, 1), 'center');
for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a
// bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
m = setAlignment(m, at(r, c), 'right');
}
if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
let end = r;
while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
// One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
// marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
m = setAlignment(m, at(r, 0), 'center', 'middle');
m = setCellBackground(m, at(r, 0), col('cream'));
}
return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split).
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
caption: '**Vegetable Seeds**', placement: { span: 'page' },
note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
المكونات
- الميزات
- صور في خلايا الجداولجداول عبر الصفحاتجداول من البياناتأنماط الجداول المسمّاةنمط الجدولتعبئة الخلايانمط التعليقالشارات داخل السطرأنواع موارد مخصّصةالأشكال والجداول بوصفها مواردموضع الأشكالاستشهادات تضع الأشكالأنماط العناوينسمات العنوانصفحات افتتاح مصمَّمةالصور في تصاميم الصفحةالأغلفة وصفحات العنوان وبيانات النشرالترويسات وأرقام الصفحاتلون الورقلوحة ألوان دلالية
- تستخدم أيضًا
- أشكال في هذا الموضع بالضبطالترويسات بحسب دور الصفحةأنماط الفقراتهندسة القسمترويسات لكل قسم
- الخطوط
- Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1)
- الأصول
amish-paste-360.jpgblack-beauty-360.jpgbrandywine-360.jpgcherokee-purple-360.jpgcover-1500.jpgdragon-tongue-360.jpggold-medal-360.jpgjimmy-nardello-360.jpgkentucky-wonder-360.jpgwaltham-butternut-360.jpg- The cover's sunflower and tomato plant, a painting (Generated With Diffusion Models, أصلي)
- The Brandywine seed packet (Generated With Diffusion Models, أصلي)
- The Cherokee Purple seed packet (Generated With Diffusion Models, أصلي)
- The Gold Medal seed packet (Generated With Diffusion Models, أصلي)
- The Amish Paste seed packet (Generated With Diffusion Models, أصلي)
- The Jimmy Nardello seed packet (Generated With Diffusion Models, أصلي)
- The Kentucky Wonder seed packet (Generated With Diffusion Models, أصلي)
- The Dragon Tongue seed packet (Generated With Diffusion Models, أصلي)
- The Black Beauty seed packet (Generated With Diffusion Models, أصلي)
- The Waltham Butternut seed packet (Generated With Diffusion Models, أصلي)
طريقة التحضير
#1 · أضف ما يغفله ملف TSV
الشيفرة في الجواب المختصر أعلاه. لا يحمل ملف TSV إلا الكلمات والأسعار. تضع setCellImage كيس كل صنف، وهو مورد مسمّى باسم الصنف، في أعلى خليته بنسبة 55% من عرضها الداخلي، فيقع الاسم تحت صورة مقاسها 20.3 × 27.1 mm ويكبر كل صف إلى 36 mm. وتمدّ mergeCells كل نوع نزولًا في العمود الأول وتعلّم الخلايا التي تغطيها بـ hiddenBy. خلايا متن الجدول تتشارك خطًّا واحدًا، Gelasio هنا، لذلك كل سعر شارة بلا تعبئة ولا إطار ولا حشوة جانبية، منضّدة بخط Cabin Condensed العريض بحجم 1.12 em من أجل أرقامه المصطفة.
#2 · استشهد بالقائمة واقسمها على الصفحتين المتقابلتين
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
// 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
// page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
overflow: 'split', continuedSuffix: '(continued)',
continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
تستشهد الرسالة في الصفحة 1 بالقائمة بـ :ref{id="vegetables" text="the price list"}، وtext يطبع تلك الكلمات مكان الرقم. الجدول المستشهَد به يطفو إلى أول موضع فارغ بعد الاستشهاد، وهو هنا أعلى الصفحة 2. القائمة لا تتسع هناك، فيقطعها المحرّك بين الصفوف بعد Jimmy Nardello، ولا يقطع أبدًا داخل نوع مدمج. تكرّر الصفحة 3 صف الرأس وشريط التعليق مع اللاحقة؛ وتذهب العلامة تحت الجزء الأول والملاحظة تحت الأخير (الجداول الأطول من الصفحة). لا تعلّم parseTSV أي خلية بوصفها رأسًا، لذلك يُضبط headerRowCount: 1 يدويًا ليُرسم الصف الأول صفَّ رأس ويتكرر في الصفحة 3. اللاحقة والعلامة بالمائل وتأخذان خط التعليق، فيبقى الشريط بخط Gelasio؛ إذ ليس لخط Cabin Condensed مائل.
#3 · مورد لكل كيس
// Nothing cites the pictures, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap',
altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm
'A sunflower and a staked tomato plant in a ploughed field.'),
...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4
`Seed packet of ${name}.`))];
await loadFonts(FONTS, markdown + list);
await Promise.all(resources.flatMap(({ bitmap }) => bitmap
? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : []));
صورة الخلية تسمّي موردًا بمعرّفه، لذلك كل كيس مورد نقطي خاص به، ملف JPEG بمقاس 360 × 480 px مقصوص بنسبة الكيس 3 : 4، وتسجّله الدالة loadImage في الأدوات المساعدة تحت fileId بواسطة registerResourceImage. لا شيء يستشهد بالأكياس، فلا يُرقَّم أيٌّ منها ولا يوضع خارج خليته. الرسوم بلا كتابة: الاسم منضّد نصًّا في الخلية، بخطوط الصفحة.
#4 · نمط جدول ثانٍ لاستمارة الطلب
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
if (r === 0 || !cell.content || cell.hiddenBy) return;
m = setCellBackground(m, at(r, c), col('paper'));
if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
}));
return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
+ 'us before August 1 and we will send a new packet or refund what you paid for it.');
تستعمل الشبكتان النمط المسمّى form، بخطوط شبكية، ونصف قطر 2.5 mm للإطار الخارجي، وتعبئة كريمية للكتابة عليها. وتحتفظ قائمة الأسعار بـ tableStyle الخاص بالمستند (أنماط الجداول المسمّاة). الخلية الفارغة لا تحتاج إلى عنصر نائب، لأن صفها يأخذ سطرًا مع ذلك، 7.3 mm هنا. كل مجموع يدمج أربعة أعمدة في تسمية واحدة ملاصقة للحافة اليمنى. ونمط عنوان استمارة الطلب يحوّل الصفحة 4 إلى عمود واحد، فتمتد الشبكتان على عرض السطر الكامل البالغ 157 mm.
#5 · أبقِ الرسالة تحت الرسم
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the painting and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
// A style's header replaces the document's on every page of its section, so it carries
// the running heads too. The frame is drawn there: in the design it would count as
// reserved height down to the page's foot, more than the column holds, and 1.4.1 then
// drops the reservation and sets the letter over the painting (gotcha:
// opener-reserves-anchored).
header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
style: { borderColor: col('basil'), borderWidth: pt(1.2) },
placement: on('page', 'top-left', FRAME, FRAME,
{ width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] },
// The painting is an image, and images do not count towards the height an opener
// reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
left: mm(4), right: mm(4) } } }),
{ kind: 'image', id: 'art', resourceId: 'cover-art',
placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
] } },
};
رسم الغلاف عنصر صورة، والصورة لا تضيف شيئًا إلى الارتفاع الذي تحجزه الافتتاحية. من دون minHeight كانت الرسالة ستبدأ على بعد 55 mm من أعلى الصفحة، فوق زهرة عبّاد الشمس. يضيف SINK فراغًا قدره 10 mm تحت الرسم، ويقرّب العمق تحت الهامش العلوي صعودًا إلى عدد صحيح من الأسطر ذات 13 pt، أي 34 سطرًا، فيبدأ عمودا الرسالة كلاهما على الشبكة، على بعد 177.9 mm من أعلى الصفحة.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 036 · Mail-order catalogue with pictures in cells ═══════ // https://postext.dev/en/cookbook/seed-catalogue // Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models // Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A fictional seed farm's spring list: a table read from TSV, a packet pictured in each row. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells, setAlignment, setCellBackground, setCellContent, setCellImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'seed-catalogue'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // eight named colours; every colour in the config links to one of them ink: '#2d2620', paper: '#f0e2c4', // brown-black text on buff cream: '#fcf7ea', tomato: '#c23b22', // cells to write in; the accent: bands, titles, NEW basil: '#2f6b3b', sun: '#e8b53a', // rules, ORGANIC, the form's head; the cover's banner rule: '#c7ae86', muted: '#6d5f50', // hairlines; running heads and notes }; // 1.4.1 designs read the hex, not paletteId: col() sets both (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' } })); const TRIM = { w: 190, h: 250 }; // mm: a stapled mail-order booklet const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored: 157 mm wide const LEAD = 13; // pt: the body leading, the grid the headings keep to const TEXT = 'Gelasio', DISPLAY = 'Alfa Slab One', LABEL = 'Cabin Condensed'; const PACKET = 0.55; // the packet picture's share of its cell's inner width const caps = (size, fontWeight = 600) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight, letterSpacing: pt(size * 0.18), textTransform: 'uppercase' }); const on = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement, overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default) const chip = (id, size, ink, box) => ({ id, fontFamily: LABEL, bold: true, fontSize: em(size), color: col(ink), borderRadius: pt(1.2), ...box }); const rule = (id, below, gap, thickness) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(thickness), color: col('basil'), placement: { ...on(`#${below}`, 'below', 0, gap), size: { width: 'fill' } } }); // #region answer: a price list from TSV: a packet in each variety's cell, kinds merged down const at = (row, c) => ({ row, col: c }); const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal' function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right'); for (let r = 1; r < m.rows.length; r++) { // The picture is a resource of its own; the cell sets it at the top, centred, with the // variety's name under it, and the row grows to hold both. const variety = m.rows[r][1].content; m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET }); m = setCellContent(m, at(r, 1), `**${variety}**`); m = setAlignment(m, at(r, 1), 'center'); for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash) m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`); m = setAlignment(m, at(r, c), 'right'); } if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above let end = r; while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++; // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid, // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it. m = mergeCells(m, { start: at(r, 0), end: at(end, 0) }); m = setAlignment(m, at(r, 0), 'center', 'middle'); m = setCellBackground(m, at(r, 0), col('cream')); } return m; } // The letter on page 1 cites the list, so it floats to the first free slot after the // letter, page 2, and is cut between rows where the page ends; span 'page' gives it the // full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split). const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table', caption: '**Vegetable Seeds**', placement: { span: 'page' }, note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.', table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 }); // #endregion // #region split: the list's style says what a continued part prints; the forms have their own const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'), rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5), // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts. overflow: 'split', continuedSuffix: '(continued)', continuesMarker: 'Continued on the facing page' }; const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'), bodyFontFamily: LABEL, bodyFontSize: pt(8.5), bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }]; // The caption sits above the table on a tomato band and is repeated on every part. Its note // and the marker take the caption's face, so the caption keeps the body's Gelasio, which has // the italic the suffix and the marker are set in; Cabin Condensed has none. const captionStyle = { fontSize: pt(12), color: col('cream'), position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2), gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } }; // #endregion const run = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity, pages: 'body', ...caps(7.5), color: col('muted'), placement: on('page', edge, x, 12.5), ...extra }); const folio = { fontWeight: 700, color: col('ink') }; const header = { elements: [ // folios outside, the booklet's name on the verso, its issue opposite run('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio), run('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 6), run('recto-title', '{subtitle}', 'odd', 'top-right', -(MARGIN.outer + 6)), run('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio), ] }; const footer = { elements: [{ ...run('drop-folio', '{pageNumber}', 'all', 'bottom', 0, folio), pages: 'opener', placement: on('page', 'bottom', 0, -11) }] }; // #region cover: the frontmatter and the heading set the type; the painting fills the middle const ART = { w: 150, h: 104, y: 60 }; // mm: the cover painting, and its top on the page const FRAME = 8; // mm: the cover's frame, in from the trim const AIR = 10; // mm: at least this much between the painting and the letter const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines const cover = { id: 'cover', span: 'page', footer: { elements: [] }, // A style's header replaces the document's on every page of its section, so it carries // the running heads too. The frame is drawn there: in the design it would count as // reserved height down to the page's foot, more than the column holds, and 1.4.1 then // drops the reservation and sets the letter over the painting (gotcha: // opener-reserves-anchored). header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener', style: { borderColor: col('basil'), borderWidth: pt(1.2) }, placement: on('page', 'top-left', FRAME, FRAME, { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] }, // The painting is an image, and images do not count towards the height an opener // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it. advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [ text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }), text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY, fontSize: pt(58), lineHeight: 1, color: col('tomato') }), text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'), box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1), left: mm(4), right: mm(4) } } }), { kind: 'image', id: 'art', resourceId: 'cover-art', placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) }, ] } }, }; // #endregion // #region heads: slab-serif section heads over a basil double rule, and the order sheet const h2 = { level: 2, marginTop: pt(0), marginBottom: pt(0), advancedDesign: { // both open a enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [ // column: no space above text('title', '{titleText}', on('container', 'top-left', 0, 0), { fontFamily: DISPLAY, fontSize: pt(14), lineHeight: 1.1, color: col('tomato') }), rule('thick', 'title', 1.4, 1.2), rule('thin', 'thick', 0.7, 0.4), ] } } }; const order = { id: 'order', span: 'page', layout: { layoutType: 'single' }, // one wide column advancedDesign: { enabled: true, slot: { elements: [ text('address', '{attr.address}', on('container', 'top-left', 0, 0), { ...caps(8), color: col('basil') }), text('title', '{titleText}', on('#address', 'below', 0, 1.5), { fontFamily: DISPLAY, fontSize: pt(34), lineHeight: 1, color: col('tomato') }), rule('thick', 'title', 2.2, 1.6), rule('thin', 'thick', 0.8, 0.5), text('how', '{attr.how}', { ...on('#thin', 'below', 0, 3), size: { width: mm(150) } }, { fontFamily: TEXT, italic: true, fontSize: pt(10.5), lineHeight: 1.35, align: 'left', color: col('ink') }), ] } } }; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) colorPalette, page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(7) }, // two 75 mm columns of letter text bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceBold: false, referenceItalic: true, firstLineIndent: mm(4), indentAfterHeading: false, // ~48 characters to a 75 mm column, so maxWordSpacing: 1.6 }, // a cap under the default 2; Knuth–Plass can exceed it, so reword to fit headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, h2] }, headingStyles: [cover, order], paragraphStyles: [ { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], chipStyles: [ // a price is a bare chip: the label face, bold, a little larger than the text chip('price', 1.12, 'ink', { backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }), chip('new', 0.8, 'cream', { background: col('tomato'), borderWidth: pt(0) }), chip('organic', 0.8, 'basil', { backgroundEnabled: false, borderColor: col('basil'), borderWidth: pt(0.6) })], resourceTypes: [{ id: 'list', name: 'Price list', shortLabel: 'List', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no prefix: the // caption is the list's name alone, with no "Table 1". tableStyle, tableStyles, captionStyle, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 35 · content.en.md
title: "Brindlewood Seed Co." subtitle: "Catalog No. 12 · Spring 2027" --- # Spring Seeds {style="cover"} ## To Our Growers This is our twelfth spring catalog, and it lists only the vegetables we grew out ourselves last summer at Brindlewood Farm, nine of them, each tested for germination in the packing shed this January. You will find each of them, with a picture of its packet, in :ref{id="vegetables" text="the price list"} on the next two pages. Tomatoes open the list, as they have done since our first catalog in 2016. Two varieties are new to it this spring and carry a red :chip[NEW]{style="new"} tag, while four were grown on the certified organic field behind the barn and are marked :chip[ORGANIC]{style="organic"}. The rest came from farms we have bought seed from for years, and each one was grown out beside our own before we listed it. Order early. Last spring the Brandywine and the Jimmy Nardello were sold out by March. We mail seed three days a week from February 1 to May 31. The order sheet is on the back page; tear it out, or copy it by hand and keep the catalog whole. :::paragraphs{style="signature"} *Ada Brindle and Tom Okafor* Brindlewood Farm, Marrow Creek ::: ## From Our Trial Garden Every variety on these pages spent last summer in our trial rows. We set out twenty plants of each and score them for how quickly they come up, how long they bear and whether the fruit comes true to type. A tomato that throws odd fruit on two of its twenty plants is dropped from the list. Each lot was tested in January: a hundred seeds on damp paper in a warm room, counted after one week and again after two, and the figure is printed on the packet. Stored cool and dry in a sealed jar, tomato and squash seed stays good for four years, bean seed for three and pepper seed for two. Onion and parsnip seed keeps for only one year, which is why this list carries neither. # Order Sheet {style="order" address="Mail to Brindlewood Seed Co. · Box 12 · Marrow Creek" how="Write one line for each variety: its number, its name and how many packets or ounces you want. Add the postage, and mail the sheet with a check or money order."} ::resource{id="ship-to"} ::resource{id="order"} :::paragraphs{style="colophon"} Set in Gelasio, Alfa Slab One and Cabin Condensed (SIL OFL) · Text: CC BY 4.0 · Pictures: generated · A fictional seed house :::`; // the letter, the trial notes and the order sheet const list = String.raw`KIND VARIETY DESCRIPTION PACKET OUNCEنموذج Markdown · أسطر: 9 · content.list.en.md
**Heirloom tomatoes** Brandywine *No. 101 · 90 days.* The pink beefsteak a Philadelphia seed house first listed in 1889. Fruit of a pound or more on tall, potato-leaved vines, sweet but with enough bite to taste of tomato. Stake the vines well. :chip[ORGANIC]{style="organic"} 3.95 42.00 Cherokee Purple *No. 102 · 80 days.* Dusky rose fruit of 10 to 12 ounces with green shoulders and brick-red flesh, kept by a Tennessee family long before it reached seed lists in the 1990s. Rich and smoky; slice it thick. 3.95 42.00 Gold Medal *No. 103 · 85 days.* A yellow beefsteak streaked red inside and out, so every slice comes out marbled. Mild and low in acid. Give it the longest season you have. :chip[NEW]{style="new"} 4.25 46.00 Amish Paste *No. 104 · 80 days.* Heart-shaped fruit of 8 to 12 ounces, meaty enough for sauce and juicy enough to eat off the vine. A paste tomato holds less water and fewer seeds, so it cooks down faster. 3.95 38.00 **Frying pepper** Jimmy Nardello *No. 201 · 80 days.* A long, thin-walled Italian frying pepper, sweet and red when ripe; fry it whole in olive oil. Brought from Basilicata to Connecticut in 1887 by the Nardello family. :chip[ORGANIC]{style="organic"} 4.25 48.00 **Beans** Kentucky Wonder *No. 301 · 65 days.* Pole bean. Meaty green pods 7 to 9 inches long, stringless if you pick them young, and in American gardens since the 1870s. Give it a pole or a fence 6 feet tall. :chip[ORGANIC]{style="organic"} 3.50 4.75 Dragon Tongue *No. 302 · 60 days.* A bush bean from the Netherlands. Flat, pale yellow pods streaked violet, tender up to 7 inches long. Cooking turns them plain yellow. :chip[NEW]{style="new"} 3.75 5.25 **Squash** Black Beauty *No. 401 · 50 days.* Zucchini. Glossy green-black fruit on an open bush that is easy to pick over. Cut them at 6 to 8 inches, every other day in July, and the plants keep bearing. On seed lists since the 1920s. 3.50 9.50 Waltham Butternut *No. 402 · 100 days.* Winter squash of 4 to 5 pounds with sweet orange flesh. Its hard, solid stems resist the vine borer that kills other squash, and it keeps until March in a cool room. :chip[ORGANIC]{style="organic"} 3.75 11.00`; // the price list: TSV, as a spreadsheet exports it // #region forms: the order sheet: empty rows to write in, labels on buff, totals merged function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) }; for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) }); m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff if (r === 0 || !cell.content || cell.hiddenBy) return; m = setCellBackground(m, at(r, c), col('paper')); if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels })); return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' }, table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 }; } const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'], ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]); const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces', 'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'], ['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]), 'We guarantee every packet to grow. If a variety fails to come up in your garden, write to ' + 'us before August 1 and we will send a new packet or refund what you paid for it.'); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Gelasio: ['400', '400i', '700'], // text, captions, notes and the list 'Alfa Slab One': ['400'], // display: the cover, section heads, the order sheet 'Cabin Condensed': ['400', '600', '700'], // labels: heads, prices, chips, the forms }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: every picture is a resource of its own, a JPEG in assets/ at its pixels // Nothing cites the pictures, so none is numbered or placed: the cover design and the cells // draw them by id, and their typeId is never looked up. const picture = (id, fileId, [w, h], altText) => ({ id, typeId: 'figure', kind: 'bitmap', altText, bitmap: { fileId, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0 }); const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content); const resources = [vegetableList(list), shipTo, orderForm, picture('cover-art', 'cover-1500.jpg', [1500, 1040], // the cover's 150 × 104 mm 'A sunflower and a staked tomato plant in a ploughed field.'), ...varieties.map((name) => picture(slug(name), `${slug(name)}-360.jpg`, [360, 480], // 3 : 4 `Seed packet of ${name}.`))]; await loadFonts(FONTS, markdown + list); await Promise.all(resources.flatMap(({ bitmap }) => bitmap ? [loadImage(bitmap.fileId, asset(bitmap.fileId))] : [])); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown + list); showPages(doc, { title: 'Brindlewood Seed Co. · Spring 2027' });العُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#أبقِ ما يتسع فقط
مع overflow: 'clip' تتوقف القائمة بعد الأصناف الخمسة التي تتسع في الصفحة 2، والملاحظة تحتها، ولا تحمل الصفحة 3 إلا ملاحظات حديقة التجارب، في عمودها الأول.
- overflow: 'split', continuedSuffix: '(continued)',
+ overflow: 'clip', continuedSuffix: '(continued)',#صغّر الأكياس
بنسبة 40% تنزل الصفوف إلى 29 mm، لكن صنفي الفاصولياء يتشاركان خلية مدمجة ويحتاجان معًا إلى 57 mm، أكثر من الـ 44 mm الباقية في الصفحة 2. فتصعد ملاحظات حديقة التجارب إلى تلك المساحة، ولا تحمل الصفحة 3 إلا بقية القائمة، والصفحة فارغة تحت 157 mm.
-const PACKET = 0.55; // the packet picture's share of its cell's inner width
+const PACKET = 0.4; // the packet picture's share of its cell's inner widthأخطاء شائعة
خطأ شائع
الجدول 'here' لا ينقسم أبدًا
لا تنقسم عبر الأعمدة والصفحات إلا الجداول العائمة؛ أما الجدول الموضوع 'here' فينتقل كاملًا. اجعل الجدول الطويل يطفو، أو أبقِ الجداول داخل النص قصيرة. جداول عبر الصفحات →
خطأ شائع
تحجز الافتتاحية ارتفاعًا يصل إلى أدنى عناصرها المثبّتة على الصفحة
تحجز الافتتاحية ذات التصميم المتقدّم ارتفاع أدنى عناصرها، وتُحتسب كذلك العناصر المثبّتة على الصفحة أو على النزف تحت العنوان، فالزخرفة في أسفل الصفحة تدفع النص إلى الصفحة التالية. أبقِ هذه الزخرفة فوق العنوان، أو انقلها إلى خانة في الترويسة أو التذييل، أو حدّد الحجز بـ minHeight. صفحات افتتاح مصمَّمة →
خطأ شائع
صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه
في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →
خطأ شائع
النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب
يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
فيض نص التصميم افتراضيًا 'ellipsis-end'
عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
خطأ شائع
خلية الجدول تحتفظ بالشرطة المائلة العكسية في \$
لا يقرأ postext 1.4.1 خلايا الجداول بحثًا عن الرياضيات ولا يفكّ الهروب \$ فيها: الخلية المكتوبة \$4.50 تطبع \$4.50، بالشرطة المائلة العكسية، بينما \* في الخلية نفسها تطبع نجمة عادية. أما الفقرة فتحتاج إلى الهروب، لأن علامة $ المجرّدة تفتح الرياضيات هناك. اكتب $ مجرّدة في محتوى الخلايا و\$ في الفقرات؛ فالنص المستخدم في الموضعين يُطبع خطأً في أحدهما. جداول من البيانات →
- المورد الذي لا يستشهد به أحد لا يوضع أبدًا. انزع
:refمن الرسالة فيبقى الكتالوج بلا قائمة أسعار. - في أعمدة عرضها 75 mm قد تترك خوارزمية Knuth–Plass سطرًا مضبوطًا تتمدد مسافاته أبعد من
maxWordSpacing. أُعيدت صياغة الرسالة وملاحظات حديقة التجارب حتى صار أوسع سطر يمدّ مسافاته 1.33 ضعف عرضها العادي. والملاحظات مضبوطة أيضًا على 14 سطرًا: مع 13 تضع موازنة الأعمدة في الصفحة الختامية خطًّا فارغًا من الشبكة فوق From Our Trial Garden، فيبدأ العنوان أدنى بسطر من النص في العمود الآخر.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- The cover's sunflower and tomato plant, a painting · Generated With Diffusion Models · أصلي
- The Brandywine seed packet · Generated With Diffusion Models · أصلي
- The Cherokee Purple seed packet · Generated With Diffusion Models · أصلي
- The Gold Medal seed packet · Generated With Diffusion Models · أصلي
- The Amish Paste seed packet · Generated With Diffusion Models · أصلي
- The Jimmy Nardello seed packet · Generated With Diffusion Models · أصلي
- The Kentucky Wonder seed packet · Generated With Diffusion Models · أصلي
- The Dragon Tongue seed packet · Generated With Diffusion Models · أصلي
- The Black Beauty seed packet · Generated With Diffusion Models · أصلي
- The Waltham Butternut seed packet · Generated With Diffusion Models · أصلي
- الخطوط
- Gelasio (SIL OFL 1.1) · Alfa Slab One (SIL OFL 1.1) · Cabin Condensed (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


