باختصار
مجلة صغيرة عن الحروف، منضّدة في أعمدة ضيقة بحافتي نص مستويتين. تبيّن كيف تبقى الفجوات بين الكلمات متساوية، بجوار نسخة عُلّمت أسطرها غير المتساوية بالأصفر.
ما الذي ستنضده
العدد 12 من Galley، مجلة مختبر حروف، منضّدًا على قياس A5 في عمودين من نحو أربعين حرفًا، حيث لا يملك السطر المضبوط إلا خمس مسافات أو ستًّا بين الكلمات لتمتص الفائض. ترسم صفحة الافتتاح نموذج Knuth–Plass على شريط رمادي غرافيتي: سطر من مربعات الكلمات تتمدد نوابضه الصفراء من «الغراء» لتملأ عرض السطر. وتشرح المقالة لماذا ترتخي الأعمدة الضيقة، وتطبع الإعدادات التي وراء كل قاعدة بخط أحادي المسافة بين فقراتها. وفي أسفل الصفحة 3 ينضّد اختبار مقارنة فقرة واحدة بحافة حرّة، ومضبوطة بلا واصلات، ومضبوطة بها. وينضّد المثال العدد أيضًا بالتقسيم بأول ملاءمة مع إيقاف حماية الأسطر الأرامل واليتيمة، ويعرض الصفحتين الثانيتين متجاورتين فوق الصفحات، كما في بطاقة هذه الوصفة، والأسطر المتخلخلة مغسولة بأصفر قلم التظليل، والأسطر المعزولة والكلمات المعزولة موسومة في الهامش.
تجيب هذه الوصفة عن
- كيف أحصل على ضبط أسطر وتقسيم كلمات جيدين لنص إسباني أو فرنسي أو ألماني؟
- كيف أتجنب الأسطر الأرامل والأسطر اليتيمة والأسطر الأخيرة ذات الكلمة الواحدة، وأبقي العنوان مع نصه؟
- لماذا يُنضَّد سطر واحد غير مضبوط أو متمددًا أكثر من اللازم (عناوين URL، كلمات مركّبة طويلة، كلمات طويلة في الخلايا)؟
- كيف أعرف ما الخطأ في مستندي (تحذيرات، فيض، إخراج لا يستقر)؟
الجواب المختصر
// Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by
// default. A narrow column also needs a tighter fence round the glue, in multiples of a
// normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any
// hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far.
const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 };
const bodyText = { // config().bodyText
fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
...FENCES,
};
// Hyphenation follows the document's locale, by exact code (gotcha: hyphenation-locales):
const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
// The control: first-fit breaking, which sets each line once and moves on, with the widow and
// orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure;
// a fresh object on every call, like config() itself, because the engine caches resolved
// configs by identity (gotcha: config-cache-identity).
const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false };
const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } });
المكونات
- الميزات
- التقسيم الأمثل للأسطر (Knuth–Plass)الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولةالتحذيرات والتشخيصتقسيم الكلمات ولغة المستندالمسافات البادئة والمحاذاة والتباعد بين الفقراتأعمدة داخل الإطارإطارات متداخلةإطارات التنبيهصفحات افتتاح مصمَّمةالصور في تصاميم الصفحةسمات العنوانأنماط الفقراتصفحات على اللوحة (Canvas)الأشكال والجداول بوصفها مواردلوحة ألوان دلاليةالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحة
- تستخدم أيضًا
- شريط فصل بعرض الصفحة
- الخطوط
- Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1)
- الأصول
- لا شيء: كل صورة مرسومة بالكود
طريقة التحضير
#1 · ضيّق الحدود، وأبقِ الحماية
شيفرة هذه الخطوة هي الجواب المختصر أعلاه. Knuth–Plass، وتقسيم الكلمات بالواصلة، وجزاءات الأرملة واليتيمة والكلمة المعزولة، وإبقاء العناوين مع ما يليها، كلها مفعّلة افتراضيًا، فلا تفعل الطبعة المنشورة سوى تضييق حدود المسافات بين الكلمات إلى 0.8 و1.6 من المسافة العادية (القيمتان الافتراضيتان 0.6 و2). السطر الذي يتجاوز الحد الأعلى يكلّف أكثر من أي واصلة أو كلمة معزولة، فيجرّب المقسِّم مواضع كسر أخرى أولًا؛ احذف الحدين فتنضّد هذه المقالة سطرين يتجاوزان 1.6. ويتبع تقسيم الكلمات locale بالرمز الدقيق: 'es' و'fr' و'de' تأخذ أنماط TeX الخاصة بها، أما 'es-ES' فيُقسَّم كالإنجليزية الأمريكية بلا أي تحذير.
#2 · علّم الأسطر المتخلخلة من شجرة الإخراج
// debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the
// VDT: justified lines carry justifiedSpaceRatio; a paragraph cut by a column is two blocks.
function marks(doc, page) {
const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) =>
b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify');
const out = [];
for (const column of page.columns) {
for (const b of column.blocks.filter((x) => body.includes(x))) {
const parts = body.filter((o) => o.contentIndex === b.contentIndex);
b.lines.forEach((line) => {
const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column };
if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it ragged
}
if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay):
out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head
} else if (line.isLastLine && !/\s/.test(line.text.trim())) {
out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line
}
});
}
}
return out;
}
const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' },
es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } });
function paintMarks(canvas, list, scale) {
const ctx = canvas.getContext('2d');
ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on
for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin
const loose = m.kind === 'loose';
ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through
ctx.fillStyle = loose ? palette.marker : palette.graphite;
if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; }
ctx.font = `600 ${m.h * 0.48}px "${MONO}"`;
const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5;
const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35;
ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8);
ctx.fillStyle = palette.marker;
ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7);
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
لا يحترم debug.looseLineHighlight إلا Sandbox، ولا يسرد doc.warnings أي سطر متخلخل، لذا يقرأ marks() الـVDT بنفسه. كل سطر مضبوط يحمل justifiedSpaceRatio، والفقرة المنقسمة بين عمودين تعود كتلةً لكل جزء، والسطر الذي يتجاوز 3 أضعاف لا نسبة له، لأن المحرّك ينضّده بحافة حرّة ويوسمه بـline.ragged بدلًا من ذلك. عند أربعين حرفًا لا تكفي الحدود وحدها لإبقاء كل سطر تحت 1.6؛ وفي أثناء ضبط نص المقالة على الصفحات، أظهرت العلامات أي الجمل ينبغي إعادة صياغتها.
#3 · ضع النسخة الضابطة بجوار الصفحة المنشورة
function compare(pairs) {
document.head.insertAdjacentHTML('beforeend', `<style>
#compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px;
font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; }
#compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff;
margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; }
#compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; }
#compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
#compare canvas { width: 100%; display: block; }
#compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; }
#compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0;
font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; }
@media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`);
const section = Object.assign(document.createElement('section'), { id: 'compare' });
section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2',
es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({
en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`;
for (const [name, doc] of pairs) {
const page = doc.pages[1];
const list = marks(doc, page); // one walk per edition: the counts and the paint share it
const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length;
const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · `
+ `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · `
+ `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`;
const figure = document.createElement('figure');
figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`;
const canvas = figure.appendChild(document.createElement('canvas'));
canvas.setAttribute('role', 'img');
canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`);
renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width });
paintMarks(canvas, list, 1000 / page.width);
section.querySelector('.pair').append(figure);
}
document.getElementById('pages').before(section); // #pages: the desk showPages() builds
}
الطبعتان تتقاسمان تصميمًا واحدًا، فكل فرق بين الصفحتين الثانيتين يأتي من مقسِّم الأسطر وحمايته. بالإنجليزية تترك النسخة الجشعة الضابطة 47 من أسطرها المضبوطة الـ110 فوق 1.6 و30 فوق 2، ثلاثة منها فوق 3 أضعاف ينضّدها المحرّك بحافة حرّة، بينما تُبقي طبعة Knuth–Plass أسطرها الـ105 كلها بين 0.80 و1.60. وفي الصفحة 2 تحمل النسخة الضابطة أيضًا السطر الأخير من فقرة وحده إلى رأس عمود، وهو ما يسمح به avoidOrphans: false، وتنهي فقرة أخرى بكلمة معزولة.
#4 · أعطِ كل إعداد إطارًا خاصًا به
// A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3"
// counts a nested box as one block (gotcha: callout-columns). The bench floats to a page foot.
const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them
const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP),
padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) },
titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'),
gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN
body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } });
const calloutStyles = [
{ id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'),
columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped
padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) },
titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) },
body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left',
firstLineIndent: pt(0) } },
slip('ragged', { textAlign: 'left' }), // never hyphenated (gotcha: ragged-no-hyphenation)
slip('unhyphenated', { hyphenation: false }), // justified, like the body text
slip('justified', {}), // justified and hyphenated: the body text's own settings
{ id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config
stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') },
padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) },
body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'),
firstLineIndent: pt(0) } },
];
ينضّد الإطار كل أعمدة مجموعة :::columns بنمط متن واحد، لذا كل قصاصة إطار متداخل له body خاص به، ويرصّ breaks="3" القصاصة ذات الحافة الحرّة والقصاصة غير المقسّمة بالواصلة في العمود الأول. ولا تحتاج القصاصة ذات الحافة الحرّة إلى hyphenation: false، لأن النص ذا الحافة الحرّة لا يُقسَّم بالواصلة أبدًا. ويطفو اختبار المقارنة إلى أسفل الصفحة 3 بـplacement: 'bottom'، ويكرر حشوه السفلي الإطار البالغ 4 مم لأن marginBottom للقصاصة الأخيرة يُسقَط في نهاية الإطار.
#5 · ارسم النموذج في صفحة الافتتاح
const BAND = 104; // mm from the trim's top edge to the band's foot
const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height
const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits
const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content,
fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) },
size: { width: mm(MEASURE) } }, ...extra });
const opener = () => ({ // a function: the diagram's constants are defined further down
enabled: true,
slot: { elements: [
// The band box reserves the opener's height, and the H1's default marginBottom adds a line
// of white: the body starts on the second grid line under the band. The diagram could not
// reserve it, as images never count (gotcha: opener-image-no-reserve).
{ kind: 'box', id: 'band', style: { backgroundColor: col('graphite') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)),
text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both
{ fontWeight: 800, lineHeight: 1.02 }), // a multiple (gotcha: design-lineheight-multiple)
text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19,
{ italic: true, lineHeight: 1.3 }),
{ kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page',
edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) },
size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } },
// An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text.
...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze',
INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })),
] },
});
الرسم البياني عنصر صورة في تصميم الـH1. لو كان شكلًا لكان عنصرًا عائمًا top بعرض الصفحة، والعنصر العائم top المستشهَد به في الصفحة 1 يفتتح الصفحة 2. والشريط إطار، والإطارات تُحتسب في الارتفاع الذي تحجزه صفحة الافتتاح أما الصور فلا، فيبدأ النص تحت الشريط بلا minHeight، أدنى بسطر بسبب marginBottom الافتراضي للـH1. والتسميات نص تصميم موضوع على إحداثيات الرسم نفسه، لأن ملف SVG المرسوم صورةً لا يستطيع استعمال خطوط الويب في الصفحة.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 014 · Justification lab ════════════════════════════════ // https://postext.dev/en/cookbook/justification-lab // Code: MIT · Text: original (CC BY 4.0) · Diagram: generated in code (CC BY 4.0) // Fonts: Petrona, Bricolage Grotesque, Source Code Pro (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A type journal's essay set twice from one design, Knuth–Plass and greedy: the two page 2s // side by side with their loose lines marked from the layout tree, then the published pages. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseMarkdownWithIssues, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'justification-lab'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Graphite, paper, one highlighter yellow; col() writes hex too (gotcha: palette-skips-designs). const palette = { ink: '#1f2124', // text: a graphite near-black graphite: '#2e3136', // the accent: the opener band, the bench box, folios marker: '#ffe14d', // highlighter yellow: glue and loose lines, never type on white haze: '#c3c7cc', // type on graphite: the standfirst, the diagram's labels muted: '#66686c', // running heads, settings lines, the colophon paper: '#ffffff', }; 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' } })), { id: 'main-color', name: 'defaults', value: { hex: palette.graphite, model: 'hex' } }, // no blue ]; const [TEXT, DISPLAY, MONO] = ['Petrona', 'Bricolage Grotesque', 'Source Code Pro']; const [BODY, LEAD] = [9.5, 13]; // pt: body size and leading, the grid both columns share // mm: an A5 trim, mirrored margins and a narrow gutter: two columns of about 40 characters const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [148, 210, 20, 20, 16, 13, 5]; const MEASURE = TRIM_W - INNER - OUTER; // mm: 119, the text width the opener aligns to const caps = (size, weight = 500) => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: weight, letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); // tracked mono labels // #region answer: one design, two line breakers: Knuth–Plass inside fences, greedy without // Knuth–Plass breaking, hyphenation and the widow, orphan and runt penalties are all on by // default. A narrow column also needs a tighter fence round the glue, in multiples of a // normal word space (defaults 0.6 and 2): lines past the upper fence cost more than any // hyphen, so the breaker hyphenates or re-breaks the paragraph before it stretches that far. const FENCES = { minWordSpacing: 0.8, maxWordSpacing: 1.6 }; const bodyText = { // config().bodyText fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default ...FENCES, }; // Hyphenation follows the document's locale, by exact code (gotcha: hyphenation-locales): const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English // The control: first-fit breaking, which sets each line once and moves on, with the widow and // orphan guards off (runts are priced inside Knuth–Plass only). Same text, fonts and measure; // a fresh object on every call, like config() itself, because the engine caches resolved // configs by identity (gotcha: config-cache-identity). const GREEDY = { optimalLineBreaking: false, avoidWidows: false, avoidOrphans: false }; const control = () => ({ ...config(), bodyText: { ...bodyText, ...GREEDY } }); // #endregion // #region opener: the title on a graphite band, with the diagram drawn in as an image element const BAND = 104; // mm from the trim's top edge to the band's foot const DIAGRAM = { y: 64, w: MEASURE + 8, h: 34 }; // mm: its top on the page, width, height const LABEL = 7; // pt: the diagram's labels, tracked less than caps() so the legend fits const text = (id, content, family, size, color, x, y, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(x), y: mm(y) }, size: { width: mm(MEASURE) } }, ...extra }); const opener = () => ({ // a function: the diagram's constants are defined further down enabled: true, slot: { elements: [ // The band box reserves the opener's height, and the H1's default marginBottom adds a line // of white: the body starts on the second grid line under the band. The diagram could not // reserve it, as images never count (gotcha: opener-image-no-reserve). { kind: 'box', id: 'band', style: { backgroundColor: col('graphite') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, text('kicker', '{attr.kicker}', MONO, 7.5, 'marker', INNER, TOP, caps(7.5, 600)), text('title', '{titleText}', DISPLAY, 29, 'paper', INNER, TOP + 5, // one line in both { fontWeight: 800, lineHeight: 1.02 }), // a multiple (gotcha: design-lineheight-multiple) text('standfirst', '{attr.standfirst}', TEXT, 10.5, 'haze', INNER, TOP + 19, { italic: true, lineHeight: 1.3 }), { kind: 'image', id: 'diagram', resourceId: 'diagram', placement: { anchor: { to: 'page', edge: 'top-left' }, offset: { x: mm(INNER), y: mm(DIAGRAM.y) }, size: { width: mm(DIAGRAM.w), height: mm(DIAGRAM.h) } } }, // An SVG image cannot use web fonts (gotcha: svg-no-webfonts): its labels are design text. ...diagramLabels().map(([id, words, x, y]) => text(id, words, MONO, LABEL, 'haze', INNER + x, DIAGRAM.y + y, { ...caps(LABEL), letterSpacing: pt(0.5) })), ] }, }); // #endregion const HEAD_Y = 11; // mm from the top (bottom) edge: running heads in the margin, folios outside const head = (id, content, parity, edge, x, extra) => ({ ...text(id, content, MONO, 7.5, 'muted', 0, 0, caps(7.5)), parity, pages: 'body', align: edge.split('-')[1], // left | right placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(edge.startsWith('top') ? HEAD_Y : -HEAD_Y) } }, ...extra }); const folio = { fontFamily: DISPLAY, fontWeight: 800, fontSize: pt(8), letterSpacing: pt(0), color: col('graphite') }; const header = { elements: [ head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio), head('v-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8), head('r-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8)), head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -OUTER, { ...folio, pages: 'opener' })] }; // the opener's folio drops to its foot // #region bench: three slips in a 2 × 2 grid, each a nested box with a body style of its own // A box sets all its :::columns in one body style, so each setting is a nested box; breaks="3" // counts a nested box as one block (gotcha: callout-columns). The bench floats to a page foot. const [SLIP_GAP, FRAME] = [3, 4]; // mm: between slips; the bench's frame round them const slip = (id, body) => ({ id, background: col('paper'), marginBottom: mm(SLIP_GAP), padding: { top: mm(2.2), right: mm(2.6), bottom: mm(2.4), left: mm(2.6) }, titleStyle: { fontFamily: MONO, fontSize: pt(6.6), fontWeight: 600, color: col('muted'), gap: mm(1.6) }, // code keeps its case: textAlign, not TEXTALIGN body: { fontSize: pt(8.6), lineHeight: pt(11.6), firstLineIndent: pt(0), ...body } }); const calloutStyles = [ { id: 'bench', span: 'page', placement: 'bottom', background: col('graphite'), columnGap: mm(FRAME), // the foot needs a FRAME too: the last slip's marginBottom is dropped padding: { top: mm(3.4), right: mm(FRAME), bottom: mm(FRAME), left: mm(FRAME) }, titleStyle: { ...caps(7.5, 600), color: col('marker'), gap: mm(2.4) }, body: { fontSize: pt(8.6), lineHeight: pt(11.6), color: col('paper'), textAlign: 'left', firstLineIndent: pt(0) } }, slip('ragged', { textAlign: 'left' }), // never hyphenated (gotcha: ragged-no-hyphenation) slip('unhyphenated', { hyphenation: false }), // justified, like the body text slip('justified', {}), // justified and hyphenated: the body text's own settings { id: 'settings', backgroundEnabled: false, marginTop: pt(3), marginBottom: pt(3), // config stripe: { enabled: true, side: 'left', width: pt(2), color: col('graphite') }, padding: { top: pt(1), right: pt(0), bottom: pt(1), left: mm(3) }, body: { fontFamily: MONO, fontSize: pt(7), lineHeight: pt(9.5), color: col('graphite'), firstLineIndent: pt(0) } }, ]; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale, colorPalette, page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(GUTTER) }, bodyText, headings: { fontFamily: DISPLAY, fontWeight: 800, color: col('ink'), levels: [ // restated: a headings object drops the H1 break (gotcha: headings-drop-h1-break) { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener() }, { level: 2, fontSize: pt(11.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, paragraphStyles: [ { id: 'colophon', fontFamily: MONO, fontSize: pt(6.6), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], calloutStyles, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 86 · content.en.md
title: "Galley" subtitle: "Notes from the type bench · No. 12" author: "Galley" --- # The river problem {kicker="Galley · No. 12 · Justification" standfirst="Justify a narrow column and its word spaces are the first thing to open up. How a line breaker that weighs the whole paragraph keeps the grey even, and the eight settings that govern it."} Hold a newspaper page at arm’s length and half close your eyes. In a good column the text turns into an even grey. In a bad one, pale channels wander down through it, from a gap in one line to the gap below it. Printers call them rivers. They form when the spaces open up on several lines at once and happen to fall in line, and nothing opens spaces faster than a narrow measure. A column of forty characters has five or six word spaces to a line. Carry one long word on to the next line and those few spaces must share its whole width between them: each may have to grow to twice its width, or more. In a column twice as wide, twice as many spaces take up the same width, and each grows half as much. ## Boxes, glue and penalties In 1981, Donald E. Knuth and Michael F. Plass described a paragraph the way TeX still sees it. Words are boxes, fixed in width. The spaces between them are glue, with a natural width and a limit to how far it may stretch or shrink. Penalties mark the places where a line may end, each with a price: ending on a hyphen costs something, ending between two words costs nothing. The drawing at the head of this article shows one line in those terms, as measured and as set, with its glue stretched until the line fills the measure and the word that ran past it broken at a hyphen. The breaker then prices every line by how far its glue had to move, cubing the figure so one very loose line costs more than a string of slightly loose ones, and then adds the penalties. Of all the ways to break the paragraph, it keeps the one whose total is lowest, looking back as far as the first line to find it. ## Greedy and total fit The older method, first fit, survives on the web. It fills each line with as many words as will fit before moving on, and a line once set stays set, so one more short word taken now can leave the next line with a gap that no later break can close. The whole-paragraph method sets one line a little looser when that spares the next one a gap. Across a whole column the trade leaves fewer loose lines than first fit, and so fewer gaps that can line up into rivers. ## Fences for the glue Two numbers fence the glue in: a space may shrink to 80 per cent of its natural width and grow to 160 per cent. Much tighter, and the breaker runs out of ways to fill a line; any looser, and the eye sees the gaps. Past the upper fence, every extra stretch costs more than any hyphen or short last line, so the breaker looks for another way to fill the line first. :::callout{type="settings"} minWordSpacing: 0.8 maxWordSpacing: 1.6 ::: :::callout{type="bench" title="Bench test · one paragraph, three settings"} :::columns{count=2 breaks="3"} :::callout{type="ragged" title="ragged · textAlign: 'left'"} Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice. ::: :::callout{type="unhyphenated" title="justified · hyphenation: false"} Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice. ::: :::callout{type="justified" title="justified · hyphenation: true"} Unhyphenated justification in a narrow measure hands every shortfall to the few word spaces on the line. With hyphenation the line breaker can end a line inside a word too, and the slack is shared out in amounts too small to notice. ::: The same words set three ways, in a measure a little narrower than these columns. Ragged text breaks only between words, whatever the hyphenation setting. Justified without hyphens, a few spaces take up all the slack. With hyphens, the spaces stay even at the cost of a few broken words. ::: ::: ## Hyphens Hyphens hand the breaker more places to end a line, and in a narrow measure they are not optional. Postext hyphenates with the TeX patterns of the document’s language, which it takes from an exact code: *en-us* for this issue and *es* for its Spanish edition. A code it lacks, such as *es-ES*, falls back to American English without notice. The patterns serve justified text only; ragged lines break only between words, so a narrow ragged column gets a deep rag. ## Widows, orphans and runts A paragraph that breaks across columns leaves a line behind or carries one over. A lone first line at the foot of a column and a lone last line at the head of the next are the pair the style manuals forbid, though printers have never agreed which of the two is the widow. Postext settles it by position: avoidWidows applies at the foot of the column, avoidOrphans at its head. :::callout{type="settings"} widowPenalty: 1000 orphanPenalty: 1000 slackWeight: 10 ::: Each rule is a price in the breaker’s sums, weighed against the white space that obeying it would leave: a lone line costs its penalty, and the empty lines a split would leave at the foot of a column cost ten times the square of their number. The breaker takes the cheaper way. A runt is a last line too short to stand alone: one word, or the tail of one, under a full paragraph. Postext prices it inside the line breaker, so a set of breaks that brings a second word down wins whenever the fences allow. When no other set of breaks stays inside the fences, it sets the paragraph one line shorter, tightening the spaces first and then, if it has to, the letters, by no more than ten thousandths of an em. :::callout{type="settings"} runtMinCharacters: 20 runtPenalty: 1000 maxRuntTracking: 10 ::: ## What the eye forgives A long word can still leave one line of a narrow column a shade looser than its neighbours, and a paragraph that must end somewhere will now and then end on a short line. The breaker can move the extra space from one line to another but cannot remove it. Near a long word it adds a trace to each of five lines rather than leave the whole amount in a single gap, where it would show as a hole in the grey of the column. A line that still gapes is left to the editor, who can usually close it by changing one word in the sentence. :::paragraphs{style="colophon"} Galley is set in Petrona, Bricolage Grotesque and Source Code Pro (SIL Open Font License). Text and diagram: original, CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: boxes, glue and penalties: one line as measured and as set, and its resource const resources = [{ id: 'diagram', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'diagram.svg', width: DIAGRAM.w * 10, height: DIAGRAM.h * 10 }, // 10 px a mm altText: t({ en: 'A line of word boxes whose last word overruns the measure; then the same ' + 'line hyphenated, its springs stretched until it fills the measure exactly.', es: 'Una línea de cajas cuya última palabra rebasa la medida; después, la misma línea ' + 'partida, con los muelles estirados hasta llenar la medida justa.' }) }]; // Word boxes in mm; the long last word may break at a hyphenation penalty after its first part. const WORDS = [[14], [8], [17], [6.5], [13.5], [10], [16.7, 16]]; const [ROW_H, GLUE, HYPHEN, ROWS] = [4.4, 3.6, 2, [7, 20.5]]; // mm; ROWS: the rows' tops const SET = WORDS.reduce((sum, w) => sum + w[0], HYPHEN); // the set line: boxes to the hyphen const STRETCH = (MEASURE - SET) / (WORDS.length - 1) / GLUE; // what fills the measure: ×1.45 const R = (v) => Math.round(v * 100) / 100; function spring(x, y, w) { // a zigzag of eight turns: glue, stretched or at rest const pts = Array.from({ length: 9 }, (_, i) => `${R(x + (i * w) / 8)} ${R(y + (i % 2 ? -1 : 1) * 0.9)}`); return `<path d="M${R(x)} ${R(y)}L${pts.join('L')}L${R(x + w)} ${R(y)}" fill="none" ` + `stroke="${palette.marker}" stroke-width="0.45" stroke-linejoin="round"/>`; } function row(y, glue, broken) { // one line of boxes; `broken`: set up to the penalty let [x, out] = [0, '']; const box = (w, h = ROW_H) => { out += `<rect x="${R(x)}" y="${R(y + (ROW_H - h) / 2)}" ` + `width="${R(w)}" height="${h}" rx="0.5" fill="${palette.paper}"/>`; x += w; }; WORDS.forEach((word, i) => { if (i) { out += spring(x, y + ROW_H / 2, glue); x += glue; } box(word[0]); if (word.length === 1) return; // The penalty: a flagged break inside the word, marked by a yellow wedge. Taken, it sets // a hyphen (a short bar); passed over, the rest of the word runs on past the measure. out += `<path d="M${R(x - 1.1)} ${y - 2.6}h2.2l-1.1 1.9z" fill="${palette.marker}"/>`; if (broken) box(HYPHEN, 1); else box(word[1]); }); return out; } function diagram() { const measure = `<path d="M${MEASURE - 0.2} 3V${ROWS[1] + ROW_H + 2}" ` // stops over the legend + `stroke="${palette.haze}" stroke-width="0.4" stroke-dasharray="0.8 0.8"/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${DIAGRAM.w * 10}" ` + `height="${DIAGRAM.h * 10}" viewBox="0 0 ${DIAGRAM.w} ${DIAGRAM.h}">` + `${row(ROWS[0], GLUE, false)}${row(ROWS[1], GLUE * STRETCH, true)}${measure}</svg>`; } function diagramLabels() { // [id, text, x, y] in mm from the diagram's top-left corner const k = STRETCH.toFixed(2).replace('.', t({ en: '.', es: ',' })); return [ ['l-natural', t({ en: 'As measured: the word overruns', es: 'Medida natural: la palabra no cabe' }), 0, ROWS[0] - 5.5], ['l-set', t({ en: `As set: hyphenated, each space ×${k}`, es: `Compuesta: partida, cada espacio ×${k}` }), 0, ROWS[1] - 5.5], ['l-legend', t({ en: 'Box: a word · glue: a space · penalty: a break · dashes: the measure', es: 'Caja: palabra · cola: espacio · penalización: corte · trazos: la medida' }), 0, ROWS[1] + ROW_H + 3.5], ]; } // #endregion // #region marks: the highlighter: loose lines, lone lines and runts read from the layout tree // debug.looseLineHighlight is Sandbox-only (gotcha: sandbox-only-warnings), so the pen reads the // VDT: justified lines carry justifiedSpaceRatio; a paragraph cut by a column is two blocks. function marks(doc, page) { const body = doc.pages.flatMap((p) => p.columns.flatMap((c) => c.blocks)).filter((b) => b.type === 'paragraph' && b.containerId === undefined && b.textAlign === 'justify'); const out = []; for (const column of page.columns) { for (const b of column.blocks.filter((x) => body.includes(x))) { const parts = body.filter((o) => o.contentIndex === b.contentIndex); b.lines.forEach((line) => { const at = { x: b.bbox.x, y: line.bbox.y, w: b.bbox.width, h: line.bbox.height, column }; if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) { out.push({ ...at, kind: 'loose' }); // ragged: past 3×, so the engine set it ragged } if (b.lines.length === 1 && parts.length > 1) { // Postext's names (see the essay): out.push({ ...at, kind: b === parts[0] ? 'widow' : 'orphan' }); // foot : head } else if (line.isLastLine && !/\s/.test(line.text.trim())) { out.push({ ...at, kind: 'runt' }); // one word alone on a paragraph's last line } }); } } return out; } const TAGS = t({ en: { widow: 'widow', orphan: 'orphan', runt: 'runt' }, es: { widow: 'viuda', orphan: 'huérfana', runt: 'corta' } }); function paintMarks(canvas, list, scale) { const ctx = canvas.getContext('2d'); ctx.setTransform(scale, 0, 0, scale, 0, 0); // page px from here on for (const m of list) { // loose lines: a wash; lone lines and runts: a tag in the margin const loose = m.kind === 'loose'; ctx.globalCompositeOperation = loose ? 'multiply' : 'source-over'; // the ink shows through ctx.fillStyle = loose ? palette.marker : palette.graphite; if (loose) { ctx.fillRect(m.x - 2, m.y + 1, m.w + 4, m.h - 1); continue; } ctx.font = `600 ${m.h * 0.48}px "${MONO}"`; const w = ctx.measureText(TAGS[m.kind]).width + m.h * 0.5; const x = m.column.index === 0 ? m.x - w - m.h * 0.35 : m.x + m.w + m.h * 0.35; ctx.fillRect(x, m.y + m.h * 0.12, w, m.h * 0.8); ctx.fillStyle = palette.marker; ctx.fillText(TAGS[m.kind], x + m.h * 0.25, m.y + m.h * 0.7); } ctx.setTransform(1, 0, 0, 1, 0, 0); } // #endregion // #region compare: the control beside the published page, each with its count of marks function compare(pairs) { document.head.insertAdjacentHTML('beforeend', `<style> #compare { background: ${palette.graphite}; color: ${palette.haze}; padding: 36px 24px 44px; font: 500 12px/1.4 "${MONO}", monospace; } #compare > * { max-width: 860px; margin: 0 auto; } #compare h2 { font: 800 clamp(30px, 6vw, 72px)/0.95 "${DISPLAY}", sans-serif; color: #fff; margin: 6px auto 26px; letter-spacing: -0.01em; } #compare figure { margin: 0; } #compare .kicker { color: ${palette.marker}; letter-spacing: .16em; text-transform: uppercase; } #compare .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; } #compare canvas { width: 100%; display: block; } #compare figcaption { margin-bottom: 12px; text-transform: uppercase; letter-spacing: .12em; } #compare figcaption b { display: block; margin-bottom: 6px; color: #fff; letter-spacing: 0; font: 800 clamp(18px, 2.4vw, 26px)/1 "${DISPLAY}"; text-transform: none; } @media (max-width: 640px) { #compare .pair { grid-template-columns: 1fr; } }</style>`); const section = Object.assign(document.createElement('section'), { id: 'compare' }); section.innerHTML = `<p class="kicker">${t({ en: 'Same text · same design · page 2', es: 'El mismo texto · el mismo diseño · página 2' })}</p><h2>${t({ en: 'Two line breakers', es: 'Dos formas de cortar' })}</h2><div class="pair"></div>`; for (const [name, doc] of pairs) { const page = doc.pages[1]; const list = marks(doc, page); // one walk per edition: the counts and the paint share it const n = (...kinds) => list.filter((m) => kinds.includes(m.kind)).length; const counts = `${t({ en: 'loose', es: 'flojas' })} ${n('loose')} · ` + `${t({ en: 'lone', es: 'solas' })} ${n('widow', 'orphan')} · ` + `${t({ en: 'runts', es: 'cortas' })} ${n('runt')}`; const figure = document.createElement('figure'); figure.innerHTML = `<figcaption><b>${name}</b><span>${counts}</span></figcaption>`; const canvas = figure.appendChild(document.createElement('canvas')); canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `${name}, ${t({ en: 'page', es: 'página' })} 2: ${counts}`); renderPageToCanvas(page, doc, canvas, { scale: 1000 / page.width }); paintMarks(canvas, list, 1000 / page.width); section.querySelector('.pair').append(figure); } document.getElementById('pages').before(section); // #pages: the desk showPages() builds } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages and the comparison paint, loaded before the build (gotcha: fonts-first). const FONTS = { Petrona: ['400', '400i', '700', '700i'], 'Bricolage Grotesque': ['800'], 'Source Code Pro': ['400', '500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('diagram.svg', diagram()); const build = (cfg) => buildWithFonts(() => buildDocument({ markdown, resources }, cfg()), markdown); const greedy = await build(control); // first: the control, for the comparison only const doc = await build(config); // last: the published pages showPages(doc, { title: t({ en: 'Justification lab', es: 'Laboratorio de justificación' }) }); compare([[t({ en: 'Greedy, no guards', es: 'Voraz, sin protecciones' }), greedy], ['Knuth–Plass', doc]]); // The engine's own report: parse issues (a ::: left open) and layout warnings, never loose lines. const { issues } = parseMarkdownWithIssues(markdown); kitStatus(t({ en: `${doc.pages.length} pages · parse issues ${issues.length} · layout warnings `, es: `${doc.pages.length} páginas · problemas de análisis ${issues.length} · avisos ` }) + (doc.warnings?.length ?? 0));العُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#قسّم نصًّا ألمانيًّا بالواصلة
يُبحث عن الأنماط بالرمز الدقيق، فيحتاج النص الألماني إلى 'de'.
-const locale = t({ en: 'en-us', es: 'es' }); // config().locale; 'es-ES' would be English
+const locale = 'de'; // German patterns; 'de-DE' would hyphenate as English#علّم أسوأ الأسطر فقط
ارفع العتبة من 1.6 إلى 2 فلا يعلّم قلم التظليل إلا الأسطر التي تضاعفت مسافاتها أكثر من مرتين.
- if (line.justifiedSpaceRatio > FENCES.maxWordSpacing || line.ragged) {
+ if (line.justifiedSpaceRatio > 2 || line.ragged) {أخطاء شائعة
خطأ شائع
avoidWidows يحرس أسفل العمود، وavoidOrphans أعلاه
يسمّي Postext السطرين المنفردين على طريقته: avoidWidows (widowMinLines وwidowPenalty) يمنع السطر الأول من الفقرة من أن يقف وحده في أسفل العمود، وavoidOrphans (orphanMinLines وorphanPenalty) يمنع سطرها الأخير من أن يقف وحده في أعلى العمود التالي. كثير من أدلة الأسلوب تعطي الاسمين بالعكس، فاختر الإعداد بحسب الموضع الذي يعمل فيه. كلاهما مفعَّل افتراضيًا ويعمل عقوبةً: يوازن الإخراج كلًّا منهما بالأسطر الفارغة التي يتركها الالتزام به. الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة →
خطأ شائع
النص غير المضبوط لا يُقسَّم بالواصلة أبدًا
لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر. تقسيم الكلمات ولغة المستند →
خطأ شائع
8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا
يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →
خطأ شائع
معظم التحذيرات موجودة في Sandbox وحده
المعرّفات والأنماط والتوجيهات المجهولة، والخطوط المفقودة، والأسطر المتخلخلة يفحصها Sandbox لا المحرّك: مثال CodePen لا يحصل إلا على doc.warnings وparseMarkdownWithIssues. النمط المجهول يعود إلى البديل دون تنبيه، والتوجيه المجهول يُطبع نصًا، فتحقّق من معرّفاتك. التحذيرات والتشخيص →
خطأ شائع
يُخزَّن الإعداد مؤقتًا بحسب هويته: ابنِ كائنًا جديدًا
يخزّن المحرّك الإعدادات المحسوبة مؤقتًا بحسب هوية الكائن، فتعديل الإعداد في مكانه ثم البناء مجددًا يعيد استخدام النتيجة القديمة. ابنِ كائنًا جديدًا في كل بناء، ولهذا يكون إعداد الوصفة دالة مصنِّعة: config(). صفحات على اللوحة (Canvas) →
خطأ شائع
لا يعمل :::columns إلا داخل إطار ولا ينقسم أبدًا
يُتجاهَل :::columns خارج الإطار، والإطار الذي ينقسم لا يقطع أبدًا داخل مجموعة أعمدة. السمة breaks تعدّ الكتل الأبناء، والإطار المتداخل يُعدّ كتلة واحدة. أعمدة داخل الإطار →
خطأ شائع
صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه
في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →
خطأ شائع
العنصر العائم 'top' لا يقع أبدًا في صفحة الإحالة إليه
لا يصعد العنصر العائم أبدًا فوق الإحالة إليه، فالعنصر العائم 'top' الممتد بعرض الصفحة والمُحال إليه في الصفحة N يفتتح الصفحة N+1. أحِل إليه في موضع أبكر، أو استخدم الموضع 'auto' أو 'bottom' الذي يمكنه أن يشغل أسفل صفحة الإحالة. موضع الأشكال →
خطأ شائع
النص داخل SVG في <img> لا يستطيع استخدام خطوط الويب
يُرسَم SVG صورةً، والصورة لا تصل إلى خطوط الويب في الصفحة، فتعود تسمياته إلى خط من النظام. حوّل النص إلى مسارات، أو ضمّن مجموعة فرعية بـ @font-face داخل SVG، أو انقل التسميات إلى التعليق. الأشكال والجداول بوصفها موارد →
خطأ شائع
lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا
في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
فحص Sandbox · looseLine
سطر متباعد
السبب. سطر مضبوط تتمدد مسافاته فوق الحد المسموح، وسبب ذلك عادةً كلمة طويلة أو عنوان URL أو سطر ضيق.
الحل. فعّل تقسيم الكلمات بالواصلة باللغة الصحيحة، أو وسّع عرض السطر، أو أعد صياغة الجملة، أو اجعل المقطع غير مضبوط. التوثيق →
- الإطار المتروك في نهاية عمود ينزل إلى أسفل العمود حين تُوازَن الأعمدة، بعيدًا عن الفقرة التي ينتمي إليها. اضبط النص حتى يمتلئ العمود، كما احتاج إطار الإعدادات تحت «Fences for the glue».
- في postext 1.4.1 لا يُنضَّد سطر داخل إطار بحافة حرّة أبدًا، مهما تمدّد: تحتفظ القصاصة غير المقسّمة بالواصلة بأسطر تتجاوز ثلاثة أضعاف المسافة العادية (أحدها عند ستة)، وهي أسطر كان نص المتن سينضّدها بحافة حرّة.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- The essay “The river problem” and its Spanish version “El problema de los ríos”, the bench test and the diagram, written and drawn for this recipe · Postext Cookbook · CC BY 4.0
- الخطوط
- Petrona (SIL OFL 1.1) · Bricolage Grotesque (SIL OFL 1.1) · Source Code Pro (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


