# سيرة ذاتية في صفحة واحدة بشريط جانبي

> سيرة ذاتية على ورقة A4 واحدة، شريطها الجانبي البنفسجي صندوق واحد في عمود أيسر للعناصر العائمة، تحمله صناديق الترويسة إلى الهوامش، وتواريخ الوظائف إلى اليمين.

- نسخة HTML: https://postext.dev/ar/cookbook/cv-with-sidebar
- وصفة رقم 057 · الصفحة والشبكة · المستوى 2 (متوسط) · المخرجات: Canvas
- الأنواع: الأوراق المفردة والمطبوعات العابرة
- تتطلب postext ≥ 1.4.1 · اختُبرت مع 1.4.1 بتاريخ 2026-09-26
- الصفحات: [1](https://postext.dev/cookbook/cv-with-sidebar/en/p01.webp?v=6768ca91)
- افتح في Sandbox: https://postext.dev/ar/sandbox#recipe=cv-with-sidebar&lang=en (.postext: https://postext.dev/cookbook/cv-with-sidebar/en/cv-with-sidebar.postext)
- آخر تحديث: 2026-09-26
- لغات أخرى: [en](https://postext.dev/en/cookbook/cv-with-sidebar.md), [es](https://postext.dev/es/cookbook/cv-with-sidebar.md), [ca](https://postext.dev/ca/cookbook/cv-with-sidebar.md), [zh](https://postext.dev/zh/cookbook/cv-with-sidebar.md)

## باختصار

سيرة ذاتية في صفحة واحدة لمصممة كتب، فيها شريط ملوّن على الجانب الأيسر لبيانات الاتصال والمهارات. وتصطف تواريخ كل وظيفة على اليمين.

## ما الذي ستنضده

السيرة الذاتية لـ Irene Salcedo، مصممة كتب متخيَّلة في مدريد، على ورقة A4 واحدة. شريط بنفسجي داكن عرضه 66 مم يمتد على الحافة اليسرى من أعلى الورقة إلى أسفلها. وفي رأسه كومة من الكتب المجلدة بالقماش، مرسومة بالشيفرة، تقف على مستوى خط أساس اسمها. تحتها بيانات الاتصال بها، ومهاراتها وأدواتها في شارات مستديرة الأطراف، ولغاتها وتعليمها وتدريسها، وما تفعله خارج العمل. يتصدر اسمها، بحرف Hedvig Letters Serif بحجم 46 pt، عمود نص عرضه 118 مم فيه نبذة من خمسة أسطر وثلاث وظائف وقائمة بالكتب التي صممتها. يضع عنوان كل وظيفة المسمى على اليسار والتواريخ إلى اليمين على خط الأساس نفسه، واسم جهة العمل مائلًا تحته. ويشترك أول عنوان في الشريط الجانبي في خط أساس واحد مع الصفة المهنية تحت الاسم.

**تجيب هذه الوصفة عن:**

- كيف أُخرج سيرة ذاتية في صفحة واحدة بعمود جانبي ملوّن وتواريخ مصفوفة إلى اليمين؟
- كيف أضيف علامة مائية أو لونًا خفيفًا للخلفية أو صورة زخرفية في كل صفحة؟
- كيف أصنع شارات مضمّنة: مفاتيح لوحة المفاتيح، ووسوم، وبنوك كلمات للتمارين؟

## الجواب المختصر

```js
// script.js, سطرًا 47–67
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 } };
```

## المكونات

**تعلّم**

- [عمود هامشي للعناصر العائمة](https://postext.dev/ar/docs/configuration.md#التخطيط): صفحة بعمود ونصف لا يأخذ عمودها الجانبي شيئًا من نص المتن، بل الأشكال والجداول والإطارات الموضوعة فيه فقط، كما في هامش كثير من الكتب المدرسية.
- [عمود ونصف](https://postext.dev/ar/docs/configuration.md#أنواع-التخطيط): تخطيط غير متناظر: عمود رئيسي عريض وعمود جانبي ضيق، في جهة ثابتة أو على الحافة الخارجية لكل صفحة.
- [ملاحظات الهامش](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات): إطارات توضع في العمود الجانبي بمحاذاة الفقرة التي تشرحها، في تخطيط عمود ونصف مع قناة للعناصر العائمة.

**تستخدم أيضًا**

- [إطارات متداخلة](https://postext.dev/ar/docs/configuration.md#الحاوية-callout)
- [إطارات التنبيه](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات)
- [مسافة عمودية صريحة](https://postext.dev/ar/docs/document-format.md#space)
- [الشارات داخل السطر](https://postext.dev/ar/docs/configuration.md#أنماط-الشارات)
- [النصوص والخطوط والمربعات في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها)
- [الصور في تصاميم الصفحة](https://postext.dev/ar/docs/configuration.md#عناصر-الصورة)
- [تثبيت عناصر التصميم](https://postext.dev/ar/docs/configuration.md#تموضع-العناصر)
- [مستويات العناوين](https://postext.dev/ar/docs/configuration.md#تجاوزات-كل-مستوى)
- [سمات العنوان](https://postext.dev/ar/docs/document-format.md#سمات-العناوين)
- [القوائم النقطية وقوائم التحقق](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)
- [أنماط الفقرات](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات)
- [لوحة ألوان دلالية](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان)
- [صفحات على اللوحة (Canvas)](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)
- [صفحات افتتاح مصمَّمة](https://postext.dev/ar/docs/configuration.md#الامتداد-والتصميم-المتقدم)
- [الأشكال والجداول بوصفها موارد](https://postext.dev/ar/docs/document-format.md#الموارد)

**الإعدادات في لمحة**

- [`bodyText`](https://postext.dev/ar/docs/configuration.md#نص-المتن), [`calloutStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الإطارات), [`chipStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الشارات), [`colorPalette`](https://postext.dev/ar/docs/configuration.md#لوحة-الألوان), [`footer`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`header`](https://postext.dev/ar/docs/configuration.md#رؤوس-الصفحات-وتذييلاتها), [`headings`](https://postext.dev/ar/docs/configuration.md#العناوين), [`layout`](https://postext.dev/ar/docs/configuration.md#التخطيط), [`page`](https://postext.dev/ar/docs/configuration.md#الصفحة), [`paragraphStyles`](https://postext.dev/ar/docs/configuration.md#أنماط-الفقرات), [`unorderedLists`](https://postext.dev/ar/docs/configuration.md#القوائم-غير-المرقّمة)

**واجهات API**

- [`buildDocument`](https://postext.dev/ar/docs/configuration.md#بناء-مستند), [`clearMeasurementCache`](https://postext.dev/ar/docs/configuration.md#ذاكرة-القياس-المؤقتة), [`registerResourceImage`](https://postext.dev/ar/docs/architecture.md#واجهة-البرمجة-المتاحة-api), [`renderPageToCanvas`](https://postext.dev/ar/docs/configuration.md#رسم-صفحة-في-صورة-نقطية)

**الخطوط**

- Hedvig Letters Serif (OFL-1.1), Hanken Grotesk (OFL-1.1)

## طريقة التحضير

### 1 · ضع الأقسام داخل صندوق جانبي واحد

```js
// script.js, سطرًا 91–102
// 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 في الأقسام الأخرى فكانت ستترك نصف مليمتر ([أنماط الشارات](/ar/docs/configuration#أنماط-الشارات)).

### 2 · لوّن الهوامش حول العمود

```js
// script.js, سطرًا 71–87
// 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 مم. تملأ ثلاثة صناديق في الترويسة الهامشَ العلوي فوق العمود، والهامشَ الأيسر من أعلى إلى أسفل، والهامشَ السفلي تحت العمود. تُرسم عناصر الترويسة بعد النص وفوقه، ولذلك تُبقيها الوصفة في الهوامش، حيث لا يجري نص. وهي تتكرر أيضًا في كل صفحة، فالصفحة الثانية ستحمل الشريط هي الأخرى ([الترويسات والتذييلات](/ar/docs/configuration#رؤوس-الصفحات-وتذييلاتها)). يتراكب الصندوقان العلوي والأيسر مع صندوق الشريط الجانبي بمقدار 0.5 مم، ويصعد الصندوق السفلي 1 مم فوق أسفل العمود، فوق نهاية صندوق الشريط الجانبي، فلا يظهر خيط رفيع من الورق عند الوصلات. أما `:::space` الذي يُغلق صندوق الشريط الجانبي فيُنزله حتى 278.0 مم، أي 0.8 مم فوق أسفل العمود، بمقدار 2.76 سطر في الإنجليزية و1.76 في الإسبانية.

### 3 · ضع الاسم في رأس عمود النص

```js
// script.js, سطرًا 106–120
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 · ضع التواريخ على سطر مسمى الوظيفة

```js
// script.js, سطرًا 132–151
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"}` ([سمات العنوان](/ar/docs/document-format#سمات-العناوين)). يُثبَّت المسمى والتواريخ بأعلى الحاوية نفسها، كلٌّ في زاوية، بالحجم وتباعد الأسطر نفسيهما، فيقعان على خط أساس واحد. يُحصر المسمى في 84 مم ويُضبط على الالتفاف: المسمى الطويل ينكسر إلى سطر ثانٍ قبل أن يبلغ التواريخ، وتنزل جهة العمل تحته. ومع المسافة فوقه، يملأ كل عنوان قسم ثلاثة أسطر شبكة بالضبط، فيقع كل عنوان وظيفة تحت خط من الشبكة بمقدار 9 pt، سواء جاء بعد عنوان قسم أو بعد قائمة.

## الوصفة كاملة

ملف واحد، مركّب من مجلد الوصفة ومعه نص المثال وأدوات دليل الوصفات المشتركة مضمّنة؛ وهو ينشئ صفحته بنفسه. لتشغيله، ضعه داخل `<script type="module">` في صفحة فارغة أو الصقه في لوحة JS في مثال CodePen جديد (وحدةً). يستورد postext من esm.sh، فلا حاجة إلى تثبيت أو بناء.

- مجلد الوصفة: https://github.com/drnachio/postext/tree/main/cookbook/cv-with-sidebar

### script.js

```js
// ═══ 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`---
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' }) });

// ─── 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 ───────────────────────────────────────────────────────────────────────
```

## تنويعات

### أعد تلوين الشريط

كل لون مكتوب مرة واحدة، في `palette`، ومنه تأخذ صناديق الشريط وصندوق الشريط الجانبي والشارات والصفة المهنية والعناوين والرسم ألوانها. هذه الأسطر الثلاثة تجعل الشريط واللون المميز والشارات زرقاء مخضرة.

```diff
-  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
```

### عوّم الأشكال في العمود نفسه

يضع [الكتاب المدرسي ذو عمود الهامش](https://postext.dev/ar/cookbook/textbook-margin-column.md) الأشكال والتعليقات الجانبية والشروح في عمود مخصص للعناصر العائمة على الحافة الخارجية لكل صفحة.

## أخطاء شائعة

- **يبدأ الإطار الجانبي بمحاذاة الكتلة التي تلي سياجه.** في 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 ([@drnachio](https://github.com/drnachio))
- الصور: 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 (OFL-1.1), Hanken Grotesk (OFL-1.1)
- الشيفرة: MIT · محتوى المثال: CC-BY-4.0

## وصفات ذات صلة

- [رقم 001 · كتاب مدرسي بعمود في الهامش](https://postext.dev/ar/cookbook/textbook-margin-column.md): صفحة بعمود ونصف لا يحمل عمودها الخارجي إلا العناصر العائمة: تتراكم فيه الأشكال ذات span 'side' والحواشي الشارحة، وينقل captionSide إليه بقية التعليقات. · المستوى 3 (متقدم) · الكتب المدرسية
- [رقم 045 · عناوين جانبية وأرقام معلّقة وعناوين مدمجة في السطر](https://postext.dev/ar/cookbook/side-heads-hanging-numbers.md): كرّاسة شروط مسابقة تقف عناوين أقسامها في قناة هامشية على خطوط أساس النص، وأرقام أقسامها الفرعية معلّقة في الفاصل، وعناوينها الصغرى مدمجة بالأحمر. · المستوى 3 (متقدم) · التقارير
- [رقم 059 · ملصق بحثي على صفحة واحدة كبيرة](https://postext.dev/ar/cookbook/research-poster.md): ملصق مؤتمر 600 × 800 مم: شريط عنوان فوق إطار بعرض الصفحة فيه ثلاثة أعمدة من لوحات متداخلة، وخريطة حرارية مفتاحها عيّنات لونية، و−4.2 °C بمقاس 150 نقطة. · المستوى 3 (متقدم) · الأوراق البحثية والأكاديمية, الأوراق المفردة والمطبوعات العابرة
