باختصار
سيرة ذاتية في صفحة واحدة لمصممة كتب، فيها شريط ملوّن على الجانب الأيسر لبيانات الاتصال والمهارات. وتصطف تواريخ كل وظيفة على اليمين.
ما الذي ستنضده
السيرة الذاتية لـ Irene Salcedo، مصممة كتب متخيَّلة في مدريد، على ورقة A4 واحدة. شريط بنفسجي داكن عرضه 66 مم يمتد على الحافة اليسرى من أعلى الورقة إلى أسفلها. وفي رأسه كومة من الكتب المجلدة بالقماش، مرسومة بالشيفرة، تقف على مستوى خط أساس اسمها. تحتها بيانات الاتصال بها، ومهاراتها وأدواتها في شارات مستديرة الأطراف، ولغاتها وتعليمها وتدريسها، وما تفعله خارج العمل. يتصدر اسمها، بحرف Hedvig Letters Serif بحجم 46 pt، عمود نص عرضه 118 مم فيه نبذة من خمسة أسطر وثلاث وظائف وقائمة بالكتب التي صممتها. يضع عنوان كل وظيفة المسمى على اليسار والتواريخ إلى اليمين على خط الأساس نفسه، واسم جهة العمل مائلًا تحته. ويشترك أول عنوان في الشريط الجانبي في خط أساس واحد مع الصفة المهنية تحت الاسم.
تجيب هذه الوصفة عن
- كيف أصمم تخطيط عمود ونصف، فيه عمود نص عريض وعمود جانبي ضيق؟
- كيف أضيف علامة مائية أو لونًا خفيفًا للخلفية أو صورة زخرفية في كل صفحة؟
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟
الجواب المختصر
const layout = {
layoutType: 'oneAndHalf',
sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins
sideColumnSide: 'left',
sideColumnRole: 'floats', // no body text: only boxes fenced with span="side"
gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm
};
// A side box stands where the text has reached at its fence, so the Markdown opens with it,
// before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence):
// :::callout{type="sidebar" span="side"}
// :::callout{type="section" title="Contact"} … ::: ← the sections nest inside it
// …
// :::space{lines=2.76} ← last: runs the band to the column's foot; a fifth of a line
// ::: more and the whole box moves to page 2
// # Irene Salcedo {role="Book designer and art director"}
const sidebar = { id: 'sidebar', background: col('band'),
// The first title stands on the role's line. The text starts 10 mm from the trim, the
// left margin plus 1 mm, and stops 10 mm short of the band's right edge.
padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) },
body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'),
boldColor: col('paper'), paragraphSpacing: false } };
المكونات
- الميزات
- عمود هامشي للعناصر العائمةعمود ونصفملاحظات الهامشإطارات متداخلةإطارات التنبيهمسافة عمودية صريحةالشارات داخل السطرالنصوص والخطوط والمربعات في تصاميم الصفحةالصور في تصاميم الصفحةتثبيت عناصر التصميممستويات العناوينسمات العنوانالقوائم النقطية وقوائم التحققأنماط الفقراتلوحة ألوان دلاليةصفحات على اللوحة (Canvas)
- الخطوط
- Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1)
- الأصول
- The stack of books at the head of the sidebar, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
طريقة التحضير
#1 · ضع الأقسام داخل صندوق جانبي واحد
// The sections nest in one side box. As separate side boxes they would stand at least a line
// of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside
// its parent (gotcha: nested-callout-limits).
const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
marginTop: mm(10),
titleStyle: { ...caps('glow'), gap: mm(2.4) },
body: { ...sidebar.body, lineHeight: pt(lineHeight) } });
const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true,
background: col('chip'), color: col('paper'), borderWidth: pt(0),
borderRadius: em(1), // past half the chip's height: a pill
paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }];
داخل العمود، الشريط هو الصندوق الوارد في الجواب المختصر. يأتي سياجه قبل الاسم، فيضعه span="side" في رأس العمود المخصص للعناصر العائمة. والأقسام صناديق متداخلة داخله، بلا تعبئة خاصة بها، فيمتد البنفسجي متصلًا من Contact إلى Outside work. ولو كانت صناديق جانبية منفصلة لوقفت متباعدة بين 4.7 و9.1 مم، حتى مع marginBottom على 0. ارتفاع الشارات نحو 3.9 مم. ويستعمل قسما Skills وTools نمط أقسام خاصًا بهما، chips، تباعد أسطره 16 pt يترك 1.8 مم بين الصفوف؛ أما 12.4 pt في الأقسام الأخرى فكانت ستترك نصف مليمتر (أنماط الشارات).
#2 · لوّن الهوامش حول العمود
// Header elements are painted after the text, over it, so these stay in the margins, where
// no text runs (gotcha: header-paints-over-text). They repeat on every page.
const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge
const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them
const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box
const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') },
placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const header = { elements: [
bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin
bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom
bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it
// Painted last, over the box's top padding, where no text runs. The bottom of the stack
// sits on the name's baseline.
{ kind: 'image', id: 'books', resourceId: 'books', placement: {
...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h),
size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } },
] };
يقع العمود الجانبي داخل هوامش الصفحة، فيتوقف صندوق الشريط الجانبي قبل الحافة اليسرى بمقدار 9 مم وقبل الحافة العليا بمقدار 18 مم. تملأ ثلاثة صناديق في الترويسة الهامشَ العلوي فوق العمود، والهامشَ الأيسر من أعلى إلى أسفل، والهامشَ السفلي تحت العمود. تُرسم عناصر الترويسة بعد النص وفوقه، ولذلك تُبقيها الوصفة في الهوامش، حيث لا يجري نص. وهي تتكرر أيضًا في كل صفحة، فالصفحة الثانية ستحمل الشريط هي الأخرى (الترويسات والتذييلات). يتراكب الصندوقان العلوي والأيسر مع صندوق الشريط الجانبي بمقدار 0.5 مم، ويصعد الصندوق السفلي 1 مم فوق أسفل العمود، فوق نهاية صندوق الشريط الجانبي، فلا يظهر خيط رفيع من الورق عند الوصلات. أما :::space الذي يُغلق صندوق الشريط الجانبي فيُنزله حتى 278.0 مم، أي 0.8 مم فوق أسفل العمود، بمقدار 2.76 سطر في الإنجليزية و1.76 في الإسبانية.
#3 · ضع الاسم في رأس عمود النص
const nameplate = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF,
fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'),
placement: at('container', 'top-left') },
{ kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'),
placement: at('#name', 'below', 0, NAME.gap) },
] } };
const nameLevel = { level: 1, // span stays 'column': the name heads the text column only
// No break before the name: the sidebar box is already on the page, and a break would
// move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once
// `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page
// whole once that default returns.
breakBefore: { enabled: false },
marginBottom: pt(LEAD), // one grid line of air under the role
advancedDesign: nameplate };
يحتفظ H1 بالقيمة الافتراضية span: 'column'. ومع 'page' يصير صفحة افتتاح عبر العمودين؛ وحين يأتي بعد صندوق الشريط الجانبي ينتقل إلى الصفحة 2، وهناك يبدأ العمود الجانبي تحت الاسم، على بعد 45.9 مم من الأعلى. وbreakBefore معطّل لأن صندوق الشريط الجانبي موجود في الصفحة أصلًا: مع الانتقال ينتقل الاسم وعمود النص كله إلى الصفحة 2. يبدأ marginBottom الخاص بالمستوى، وهو سطر شبكة واحد، النبذةَ عند 45.9 مم بدلًا من 41.3 مم. والحشوة العلوية لصندوق الشريط الجانبي هي ROLE_Y، أي ارتفاع الاسم مضافًا إليه الفجوة تحته، فيشترك أول عنوان في الشريط الجانبي والصفة المهنية في خط أساس واحد، على بعد 39.3 مم من الحافة العليا.
#4 · ضع التواريخ على سطر مسمى الوظيفة
const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading
const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS,
fontSize: pt(JOB), lineHeight: LEAD / JOB, // a multiple (gotcha: design-lineheight-multiple)
align: 'left', ...look }); // without align, design text is centred in its box
const job = { enabled: true, slot: { elements: [
jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap',
placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates
jobText('dates', '{attr.dates}', { color: col('muted'),
placement: at('container', 'top-right') }), // the title's top and size, so its baseline
jobText('org', '{attr.org}', { italic: true, color: col('muted'),
placement: at('#title', 'below') }),
] } };
const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9),
marginBottom: pt(0), advancedDesign: job };
// The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head
// starts the same 9 pt under a grid line after a section head as after a list. The 9 pt
// below it merge with a job head's 9 pt above. Under Selected books they push the list to
// the next grid line, as the default 7 pt would; with 0 it would start one line higher.
const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20),
marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead };
كل وظيفة عنوان H3 تحمل سماته جهة العمل والتواريخ: ### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"} (سمات العنوان). يُثبَّت المسمى والتواريخ بأعلى الحاوية نفسها، كلٌّ في زاوية، بالحجم وتباعد الأسطر نفسيهما، فيقعان على خط أساس واحد. يُحصر المسمى في 84 مم ويُضبط على الالتفاف: المسمى الطويل ينكسر إلى سطر ثانٍ قبل أن يبلغ التواريخ، وتنزل جهة العمل تحته. ومع المسافة فوقه، يملأ كل عنوان قسم ثلاثة أسطر شبكة بالضبط، فيقع كل عنوان وظيفة تحت خط من الشبكة بمقدار 9 pt، سواء جاء بعد عنوان قسم أو بعد قائمة.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 057 · One-page CV with a sidebar ═══════════════════════ // https://postext.dev/en/cookbook/cv-with-sidebar // Code: MIT · Text: original (CC BY 4.0) · Drawing: made in code (CC BY 4.0) // Fonts: Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'cv-with-sidebar'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#231b22', // text: a plum-tinted near-black band: '#3a2235', // the sidebar accent: '#8f3b62', // on paper: the role and the section heads (7.1:1) glow: '#f1b98f', // on the band: the sidebar titles (8.3:1) chip: '#744d6c', // on the band: the skill chips rule: '#d9cdd5', // the hairlines after the section heads muted: '#6c5f69', // dates, employers, the colophon (6.0:1) paper: '#ffffff', // the page, and the text on the band (14.4:1) }; // 1.4.1 design elements paint the hex and ignore the paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [SERIF, SANS] = ['Hedvig Letters Serif', 'Hanken Grotesk']; const PT = 25.4 / 72; // mm in a point const [TRIM_W, TRIM_H] = [210, 297]; // mm: A4, printed on one side, so nothing is mirrored const [TOP, LEFT, RIGHT] = [18, 9, 16]; // mm const LEAD = 13.2; // pt: the body leading const BOTTOM = TRIM_H - TOP - 56 * LEAD * PT; // mm: the text block holds 56 lines const CONTENT = TRIM_W - LEFT - RIGHT; // 185 mm between the margins const [SIDE, GUTTER] = [57, 10]; // mm: the sidebar column, and the white after it const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const LABEL = 7.8; // pt: the role and the sidebar titles, tracked capitals const caps = (colour) => ({ fontFamily: SANS, fontSize: pt(LABEL), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(LABEL * 0.18), color: col(colour) }); const NAME = { size: 46, gap: 2.6 }; // pt and mm: the name, and the room under it const ROLE_Y = NAME.size * PT + NAME.gap; // mm under the top margin: the role's line // mm under the top margin: the name's baseline. Hedvig Letters Serif, set solid, puts it 0.795 // of the size down the line (measured on the page); another face needs its own ratio. const BASELINE = 0.795 * NAME.size * PT; const BOOKS = { w: 46, h: 18 }; // mm: the drawing at the head of the band // #region answer: a float-only column on the left, filled by one box const layout = { layoutType: 'oneAndHalf', sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins sideColumnSide: 'left', sideColumnRole: 'floats', // no body text: only boxes fenced with span="side" gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm }; // A side box stands where the text has reached at its fence, so the Markdown opens with it, // before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence): // :::callout{type="sidebar" span="side"} // :::callout{type="section" title="Contact"} … ::: ← the sections nest inside it // … // :::space{lines=2.76} ← last: runs the band to the column's foot; a fifth of a line // ::: more and the whole box moves to page 2 // # Irene Salcedo {role="Book designer and art director"} const sidebar = { id: 'sidebar', background: col('band'), // The first title stands on the role's line. The text starts 10 mm from the trim, the // left margin plus 1 mm, and stops 10 mm short of the band's right edge. padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) }, body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'), boldColor: col('paper'), paragraphSpacing: false } }; // #endregion // #region margins: header boxes paint the band into the margins around the column // Header elements are painted after the text, over it, so these stay in the margins, where // no text runs (gotcha: header-paints-over-text). They repeat on every page. const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') }, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } }); const header = { elements: [ bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it // Painted last, over the box's top padding, where no text runs. The bottom of the stack // sits on the name's baseline. { kind: 'image', id: 'books', resourceId: 'books', placement: { ...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h), size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } }, ] }; // #endregion // #region sections: boxes nested in the band, one per section; chips for the skills // The sections nest in one side box. As separate side boxes they would stand at least a line // of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside // its parent (gotcha: nested-callout-limits). const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginTop: mm(10), titleStyle: { ...caps('glow'), gap: mm(2.4) }, body: { ...sidebar.body, lineHeight: pt(lineHeight) } }); const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true, background: col('chip'), color: col('paper'), borderWidth: pt(0), borderRadius: em(1), // past half the chip's height: a pill paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }]; // #endregion // #region name: the H1 sets the name and the role at the head of the text column const nameplate = { enabled: true, slot: { elements: [ { kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF, fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'), placement: at('container', 'top-left') }, { kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'), placement: at('#name', 'below', 0, NAME.gap) }, ] } }; const nameLevel = { level: 1, // span stays 'column': the name heads the text column only // No break before the name: the sidebar box is already on the page, and a break would // move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once // `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page // whole once that default returns. breakBefore: { enabled: false }, marginBottom: pt(LEAD), // one grid line of air under the role advancedDesign: nameplate }; // #endregion const sectionHead = { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF, fontSize: pt(14), lineHeight: 20 / 14, color: col('accent'), placement: at('container', 'top-left') }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), // 3 mm after the title, level with the middle of its lower case, on to the column's edge placement: { ...at('#title', 'right-of', 3, 3.9), size: { width: 'fill' } } }, ] } }; // #region jobs: each job heading sets the title left and the dates flush right const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS, fontSize: pt(JOB), lineHeight: LEAD / JOB, // a multiple (gotcha: design-lineheight-multiple) align: 'left', ...look }); // without align, design text is centred in its box const job = { enabled: true, slot: { elements: [ jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates jobText('dates', '{attr.dates}', { color: col('muted'), placement: at('container', 'top-right') }), // the title's top and size, so its baseline jobText('org', '{attr.org}', { italic: true, color: col('muted'), placement: at('#title', 'below') }), ] } }; const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9), marginBottom: pt(0), advancedDesign: job }; // The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head // starts the same 9 pt under a grid line after a section head as after a list. The 9 pt // below it merge with a job head's 9 pt above. Under Selected books they push the list to // the next grid line, as the default 7 pt would; with 0 it would start one line higher. const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20), marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, layout, chipStyles, header, footer: { elements: [] }, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } }, bodyText: { fontFamily: SANS, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // the titles in Selected books referenceColor: col('ink'), // no :ref yet; one added later prints in ink, not default blue textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // Each level draws its own design, but 1.4.1 still measures the hidden heading text in this // face. Without it the default is Open Sans, and the page would have to load that face too. headings: { fontFamily: SANS, levels: [nameLevel, sectionLevel, jobLevel] }, unorderedLists: { bulletChar: '–', color: col('ink'), fontWeight: 400, itemSpacing: pt(0), marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [ { id: 'lead', fontSize: pt(11), lineHeight: pt(16) }, { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted'), marginTop: pt(LEAD) }, ], calloutStyles: [sidebar, section('section', 12.4), section('chips', 16)], }); // #region art: a stack of cloth-bound books, drawn in the palette function books() { // BOOKS.w × BOOKS.h mm, in tenths of a millimetre; the bottom book first const lilac = mix(palette.band, palette.paper, 0.55); const stack = [ // [left edge, length, thickness, cloth, label] [0, 430, 40, palette.glow, palette.paper], [22, 380, 32, palette.paper, palette.accent], [6, 400, 36, palette.accent, palette.glow], [40, 330, 30, lilac, palette.paper], [18, 300, 34, mix(palette.glow, palette.paper, 0.45), palette.accent], ]; let y = BOOKS.h * 10; let art = ''; for (const [x, length, thick, cloth, label] of stack) { y -= thick; const [top, h] = [y + 3, thick - 3]; // a dark hairline above each book art += `<rect x="${x}" y="${top}" width="${length}" height="${h}" rx="3" fill="${cloth}"/>` + `<rect x="${x + 14}" y="${top}" width="5" height="${h}" fill="${label}"/>` // bands + `<rect x="${x + length - 19}" y="${top}" width="5" height="${h}" fill="${label}"/>` + `<rect x="${x + length * 0.34}" y="${top + h * 0.28}" width="${length * 0.32}" ` + `height="${h * 0.44}" rx="2" fill="${label}"/>`; // the title label } return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BOOKS.w * 10} ${BOOKS.h * 10}" ` + `width="${BOOKS.w * 10}" height="${BOOKS.h * 10}">${art}</svg>`; } function mix(a, b, k) { // a blend of two palette colours, k of the way from a to b const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [p, q] = [rgb(a), rgb(b)]; return `#${p.map((v, i) => Math.round(v + (q[i] - v) * k).toString(16).padStart(2, '0')) .join('')}`; } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---نموذج Markdown · أسطر: 89 · content.en.md
title: "Irene Salcedo · Curriculum vitae" author: "Irene Salcedo" --- :::callout{type="sidebar" span="side"} :::callout{type="section" title="Contact"} Madrid, Spain irene@salcedo.example salcedo.example/books ::: :::callout{type="chips" title="Skills"} :chip[Book typography]{style="skill"} :chip[Series design]{style="skill"} :chip[Covers]{style="skill"} :chip[Grids and styles]{style="skill"} :chip[Typesetting]{style="skill"} :chip[Prepress]{style="skill"} :chip[Colour proofing]{style="skill"} :chip[EPUB 3]{style="skill"} :chip[Accessible PDF]{style="skill"} :chip[Art direction]{style="skill"} ::: :::callout{type="chips" title="Tools"} :chip[InDesign]{style="skill"} :chip[Illustrator]{style="skill"} :chip[Photoshop]{style="skill"} :chip[Glyphs]{style="skill"} :chip[Python]{style="skill"} ::: :::callout{type="section" title="Languages"} **Spanish** · native **Catalan** · C2 **English** · C1, working language **Italian** · B2 ::: :::callout{type="section" title="Education"} **MA in Book Design** Escuela del Libro del Turia, Valencia, 2011–2012 :::space{lines=0.5} **BA in Graphic Design** Escuela de Diseño Almenara, Castellón, 2007–2011 ::: :::callout{type="section" title="Teaching"} Guest tutor in book typography at the Escuela del Libro del Turia, spring terms 2022–2024 ::: :::callout{type="section" title="Outside work"} Letterpress printing at a shared workshop in Lavapiés; walking the long-distance paths of the Sierra de Guadarrama. ::: :::space{lines=2.76} ::: # Irene Salcedo {role="Book designer and art director"} :::paragraphs{style="lead"} Book designer with fourteen years in trade and academic publishing, the last five as art director of a house with three imprints. I work on series and covers as closely as on the page, and write the specifications printers work from. I am looking for an art director’s post with a literary or illustrated list. ::: ## Experience ### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"} - Set and maintain the house style of three imprints: 58 new titles in 2025, from pocket novels to illustrated non-fiction. - Redesigned the pocket series on a 110 × 178 mm trim. Its grid holds 32 lines a page instead of 29, so the 2024 list needed about 9 per cent fewer pages than the 2023 list for the same number of titles. - Lead two designers and a typesetter, and commission the illustrators and photographers for each season’s list. - Moved print and ebook editions to one source file, so the ebook now ships the same week as the hardback. ### Senior book designer {org="Casa Albero, Barcelona" dates="2016 – 2021"} - Designed interiors and covers for about 40 titles a year of literary fiction and essays, in Spanish and Catalan. - Wrote the typesetting rules for the Catalan list: hyphenation, the l·l, quotation marks and dialogue dashes. - Designed the twelve volumes of the collected poems of Elvira Montcada, with the notes set in the margin. ### Designer and typesetter {org="Imprenta Quirós, Valencia" dates="2012 – 2016"} - Set critical editions with notes on up to three levels, and the bibliographies and indexes that go with them, for two university presses. - Prepared files for offset printing: imposition, colour proofs and the paper and binding specifications. ## Selected books - *Los años del salitre*, Marta Ibarra. Albardín, 2023. Cover, interior and a two-colour map section. - *Cartas desde Menorca*, Miquel Truyols. Casa Albero, 2019. A bilingual edition on facing pages. - *Obra reunida*, Elvira Montcada. Casa Albero, 2018–2020. Twelve volumes in a cloth slipcase. :::paragraphs{style="colophon"} Set in Hedvig Letters Serif and Hanken Grotesk. Text: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Hedvig Letters Serif': ['400'], 'Hanken Grotesk': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('books.svg', books()); const resources = [{ id: 'books', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'books.svg', width: BOOKS.w * 10, height: BOOKS.h * 10 }, altText: t({ en: 'A stack of five cloth-bound books', es: 'Una pila de cinco libros encuadernados en tela' }) }]; const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'One-page CV with a sidebar', es: 'Currículum de una página con barra lateral' }) });العُدّة · 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 ↗ (يفتح في تبويب جديد)
تنويعات
#أعد تلوين الشريط
كل لون مكتوب مرة واحدة، في palette، ومنه تأخذ صناديق الشريط وصندوق الشريط الجانبي والشارات والصفة المهنية والعناوين والرسم ألوانها. هذه الأسطر الثلاثة تجعل الشريط واللون المميز والشارات زرقاء مخضرة.
- band: '#3a2235', // the sidebar
+ band: '#1d3b3a', // the sidebar
- accent: '#8f3b62', // on paper: the role and the section heads (7.1:1)
+ accent: '#2b5f5b', // on paper: the role and the section heads (7.3:1)
- chip: '#744d6c', // on the band: the skill chips
+ chip: '#456d69', // on the band: the skill chips#عوّم الأشكال في العمود نفسه
يضع الكتاب المدرسي ذو عمود الهامش الأشكال والتعليقات الجانبية والشروح في عمود مخصص للعناصر العائمة على الحافة الخارجية لكل صفحة.
أخطاء شائعة
خطأ شائع
يبدأ الإطار الجانبي بمحاذاة الكتلة التي تلي سياجه
في postext 1.4.1 يقف الإطار span: 'side' في العمود الجانبي على الارتفاع الذي بلغه النص عند سياجه، على سطر الشبكة التالي، وتحت أي إطار موجود هناك. ضع سياج الحاشية الجانبية قبل الفقرة التي تشرحها مباشرةً: إذا وضعته بعدها، بدأت الحاشية بجانب الفقرة التالية. والإطار الذي يتجاوز أسفل العمود ينزلق صعودًا حتى يستقر أسفله على أسفل العمود، بقدر ما يسمح الإطار الذي فوقه؛ وما لا يتسع بعد ذلك ينتظر العمود الجانبي في الصفحة التالية. ملاحظات الهامش →
خطأ شائع
الإطارات الجانبية المكدّسة في القناة تُبقي بينها سطرًا من الورق
في postext 1.4.1 يترك الإطار span: 'side' سطر متن واحدًا على الأقل تحته قبل الشيء التالي في القناة، مقرَّبًا صعودًا إلى شبكة خطوط الأساس، أيًّا كانت قيمة marginBottom. لذلك لا تتلامس أبدًا الإطارات التي تتشارك خلفية: تبدو مكدّسةً بطاقاتٍ منفصلة. للحصول على لوحة واحدة متصلة، ضع إطارًا جانبيًا واحدًا وأدرج الأقسام داخله. ملاحظات الهامش →
خطأ شائع
يُحذف :::space في أعلى الإطار أو العمود
يُحذف :::space في أعلى العمود أو الإطار أو مجموعة :::columns، حتى إن كان المحتوى الوحيد للإطار، فينطوي إطار الإجابة المكوَّن من فراغ فقط. افتتح الإطار بعنوان أو بسطر سؤال، ثم أضف الفراغ. مسافة عمودية صريحة →
خطأ شائع
عناصر الترويسة والتذييل تُرسَم فوق النص
تُرسَم عناصر الترويسة والتذييل فوق الصفحة، ولا تُفسح منطقة النص لها مكانًا. أبقِها داخل الهوامش، فالهوامش هي التي تحجز مساحتها. الترويسات وأرقام الصفحات →
خطأ شائع
الإطار المتداخل يتجاهل span وplacement وsnapToGrid
الإطار المتداخل في إطار آخر يتجاهل span وplacement وsnapToGrid وfloatBarrier الخاصة به: يجري دائمًا داخل الإطار الأم، بالعرض الداخلي للأم. إطارات متداخلة →
خطأ شائع
لوحة الألوان المستبدلة لا تصل إلى عناصر التصميم ولا إلى لون الإحالة
يقرأ postext 1.4.1 الإعداد colorPalette في أنماط النص (المتن والعناوين والقوائم والتعليقات والجداول والإطارات) لكن لا في عناصر الترويسات والتذييلات والافتتاحيات وصفحات الأجزاء، ولا في bodyText.referenceColor: تحتفظ بالقيمة الست عشرية المكتوبة بجانب paletteId الخاص بها. حين تستبدل لوحة الألوان، لنسخة شاشة داكنة أو لإعادة تلوين، أعِد كتابة كل لون مرتبط من colorPalette قبل البناء. لوحة ألوان دلالية →
خطأ شائع
lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا
في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
النص غير المضبوط لا يُقسَّم بالواصلة أبدًا
لا يُطبَّق تقسيم الكلمات بالواصلة إلا على النص المضبوط؛ أما النص ذو الحافة الحرّة فينكسر بين الكلمات، فيصير التفاوت كبيرًا في العمود الضيق غير المضبوط. اضبط المقطع أو وسّع عرض السطر. تقسيم الكلمات ولغة المستند →
خطأ شائع
النص غير المضبوط لا يُفحص أبدًا بحثًا عن الكلمات المعزولة
تعمل optimalLineBreaking وavoidRunts وruntPenalty وruntMinCharacters على كاسر الأسطر Knuth–Plass، الذي لا يشغّله postext 1.4.1 إلا للنص المضبوط. الفقرة غير المضبوطة تُكسر سطرًا سطرًا وقد تنتهي بكلمة قصيرة واحدة أيًّا كانت تلك الإعدادات. اقرأ الأسطر الأخيرة من النص غير المضبوط وأعد صياغة الفقرة التي تنتهي بكلمة معزولة. الأسطر الأرامل والأسطر اليتيمة والكلمات المعزولة →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
- اضبط
:::spaceعلى الصفحة. في الطبعة الإنجليزية تُنهي 2.76 سطر الصندوقَ 0.8 مم فوق أسفل العمود. ومع 2.96 سطر يتجاوز أسفل العمود بمقدار 0.1 مم وينتقل الشريط الجانبي كله إلى الصفحة 2؛ ومع 2.66 ينفتح شريط من الورق بين الصندوق والصندوق السفلي. - الصندوق في تصميم الاسم لا يستطيع رسم الشريط. فالصندوق الذي ينتهي فوق أسفل العمود بقليل يجعل العنوان يحجز 265 مم، أي أكثر من ارتفاع العمود البالغ 260.8 مم، فينتقل النص إلى الصفحة 2؛ والصندوق الذي يمتد حتى أسفل الورقة يُسقط الارتفاع المحجوز للعنوان، فتصعد النبذة إلى 30.3 مم، فوق الصفة المهنية.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- The stack of books at the head of the sidebar, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- الخطوط
- Hedvig Letters Serif (SIL OFL 1.1) · Hanken Grotesk (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


