# サイドバー付きの1ページの履歴書

> A4判1ページの履歴書。プラム色のサイドバーはフロート専用の左段に置いた1つのボックスで、ヘッダーのボックスが余白まで色を広げ、職歴の期間は右そろえです。

- HTML版: https://postext.dev/ja/cookbook/cv-with-sidebar
- レシピ No. 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/ja/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), [ar](https://postext.dev/ar/cookbook/cv-with-sidebar.md)

## かんたんな説明

ブックデザイナーの1ページの履歴書です。左側に色の帯があり、連絡先とスキルを載せています。職歴ごとの期間は右端にそろいます。

## できあがり

マドリードで働く架空のブックデザイナー、Irene SalcedoのA4判1枚の履歴書です。幅66 mmのプラム色の帯が、用紙の左端を天から地まで走ります。帯の頭には、コードで描いたクロス装の本の積み重ねが、彼女の名前のベースラインと同じ高さに立っています。その下に連絡先、ピル型に組んだスキルとツール、語学、学歴、教歴、仕事以外の活動が続きます。名前は46 ptのHedvig Letters Serifで、幅118 mmの本文の段の頭に置き、その下に5行のプロフィール、3つの職歴、手がけた本の一覧が並びます。職歴の見出しはどれも、肩書きを左に、期間を同じベースラインの右端に置き、その下に勤務先をイタリックで入れます。サイドバーの最初の見出しは、名前の下の職種と同じベースラインに並びます。

**このレシピが答える問い:**

- 色付きのサイドバーの段と右そろえの期間を持つ1ページの履歴書は、どう組みますか？
- すべてのページに透かし、背景の色、装飾画像を加えるには？
- キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには？

## 手短な答え

```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/ja/docs/configuration.md#レイアウト): サイド段に本文を流さず、そこに配置した図・表・囲みだけを置く1段半組みのページです。多くの教科書の余白と同じ形です。
- [1段半組み](https://postext.dev/ja/docs/configuration.md#レイアウトの種類): 非対称のレイアウトです。広い主段と狭いサイド段を並べ、サイド段は決まった側か、各ページの小口側に置きます。
- [欄外の注](https://postext.dev/ja/docs/configuration.md#囲みスタイル): サイド段に、注釈する段落と同じ高さで置く囲みです。フロート用チャネルを持つ1段半組みで使います。

**ほかに使うもの**

- [入れ子の囲み](https://postext.dev/ja/docs/configuration.md#calloutコンテナー)
- [囲み](https://postext.dev/ja/docs/configuration.md#囲みスタイル)
- [明示的な縦のアキ](https://postext.dev/ja/docs/document-format.md#space)
- [インラインのチップ](https://postext.dev/ja/docs/configuration.md#チップスタイル)
- [ページデザインのテキスト・罫・ボックス](https://postext.dev/ja/docs/configuration.md#柱とノンブル)
- [ページデザインの画像](https://postext.dev/ja/docs/configuration.md#画像要素)
- [デザイン要素の基準位置](https://postext.dev/ja/docs/configuration.md#要素の配置)
- [見出しの階層](https://postext.dev/ja/docs/configuration.md#レベルごとの上書き)
- [見出しの属性](https://postext.dev/ja/docs/document-format.md#見出しの属性)
- [箇条書きとチェックリスト](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)
- [段落スタイル](https://postext.dev/ja/docs/configuration.md#段落スタイル)
- [セマンティックカラーパレット](https://postext.dev/ja/docs/configuration.md#カラーパレット)
- [キャンバス上のページ](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)
- [デザインした章扉](https://postext.dev/ja/docs/configuration.md#幅と詳細デザイン)
- [リソースとしての図と表](https://postext.dev/ja/docs/document-format.md#リソース)

**設定の一覧**

- [`bodyText`](https://postext.dev/ja/docs/configuration.md#本文), [`calloutStyles`](https://postext.dev/ja/docs/configuration.md#囲みスタイル), [`chipStyles`](https://postext.dev/ja/docs/configuration.md#チップスタイル), [`colorPalette`](https://postext.dev/ja/docs/configuration.md#カラーパレット), [`footer`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`header`](https://postext.dev/ja/docs/configuration.md#柱とノンブル), [`headings`](https://postext.dev/ja/docs/configuration.md#見出し), [`layout`](https://postext.dev/ja/docs/configuration.md#レイアウト), [`page`](https://postext.dev/ja/docs/configuration.md#ページ), [`paragraphStyles`](https://postext.dev/ja/docs/configuration.md#段落スタイル), [`unorderedLists`](https://postext.dev/ja/docs/configuration.md#箇条書きリスト)

**API**

- [`buildDocument`](https://postext.dev/ja/docs/configuration.md#文書のビルド), [`clearMeasurementCache`](https://postext.dev/ja/docs/configuration.md#計測キャッシュ), [`registerResourceImage`](https://postext.dev/ja/docs/architecture.md#apiの概要), [`renderPageToCanvas`](https://postext.dev/ja/docs/configuration.md#ページをビットマップに描画する)

**書体**

- Hedvig Letters Serif (OFL-1.1), Hanken Grotesk (OFL-1.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まで途切れずに続きます。別々の脇のボックスにすると、`marginBottom`を0にしても4.7〜9.1 mm離れて並びます。ピルの高さは約3.9 mmです。SkillsとToolsは専用の節スタイル`chips`を使い、その16 ptの行送りで行の間に1.8 mmが空きます。ほかの節の12.4 ptでは、0.5 mmしか空きません（[チップスタイル](/ja/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 mm手前、天の18 mm手前で止まります。3つのヘッダーのボックスが、段の上の天の余白、天から地までの左の余白、段の下の地の余白を埋めます。ヘッダーの要素は本文の後に、本文の上へ重ねて塗られるので、このレシピでは本文の流れない余白の中にとどめています。ヘッダーの要素はすべてのページで繰り返されるので、2ページ目にも帯が付きます（[柱とノンブル](/ja/docs/configuration#柱とノンブル)）。上と左のボックスはサイドバーのボックスに0.5 mm重なり、地のボックスは段の下端より1 mm上まで、サイドバーのボックスの終わりの上に伸びているので、継ぎ目に紙の細い線が見えることはありません。サイドバーのボックスを閉じる`:::space`は、英語版では2.76行、スペイン語版では1.76行で、ボックスを段の下端より0.8 mm上の278.0 mmまで下ろします。

### 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 mmの位置から始まります。サイドバーのボックスがすでにページにあるので、`breakBefore`はオフにしています。改ページを入れると、名前と本文の段全体が2ページ目へ移ります。このレベルの`marginBottom`をグリッド1行分にすると、プロフィールが41.3 mmではなく45.9 mmから始まります。サイドバーのボックスの上パディングは`ROLE_Y`、つまり名前の高さとその下の間隔の合計なので、サイドバーの最初の見出しと職種は、用紙の上端から39.3 mmの同じベースラインに並びます。

### 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"}`のように書きます（[見出しの属性](/ja/docs/document-format#見出しの属性)）。肩書きと期間は同じコンテナーの上端に、それぞれ左右の角へアンカーし、同じサイズと行送りで組むので、1本のベースラインに並びます。肩書きは84 mmまでに制限して折り返しを許すので、長い肩書きは期間に届く前に2行目へ折り返し、勤務先はその下へ移ります。上の間隔を含めて各節の見出しがちょうどグリッド3行分を占めるので、職歴の見出しは、節の見出しの後でもリストの後でも、必ずグリッド線の9 pt下に来ます。

## レシピの全体

レシピのフォルダーから合成した1つのファイルで、サンプルのテキストとレシピ集の共通キットを埋め込んであり、自分でページを組み立てます。動かすには、空のページの`<script type="module">`に入れるか、新しいCodePenのpenのJSパネルに貼り付けます（モジュールとして）。esm.shからpostextを読み込むので、インストールもビルドも要りません。

- レシピのフォルダー: 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`に一度だけ書き、帯のボックス、サイドバーのボックス、チップ、職種、見出し、イラストはそこから色を取ります。次の3行で、帯、アクセント、チップがティール色になります。

```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/ja/cookbook/textbook-margin-column.md)は、すべてのページの小口側にあるフロート専用の段に、図、脇のキャプション、注釈を置きます。

## よくあるつまずき

- **サイド段の囲みは、囲みを開く位置の次のブロックと同じ高さから始まる.** postext 1.4.1では、span: 'side'の囲みは、囲みを開く位置で本文が達した高さの次のグリッド行から、すでにある囲みがあればその下に、サイド段に置かれます。注釈は説明する段落の直前で開いてください。段落のあとで開くと、注釈は次の段落の横から始まります。段の末尾を越える囲みは、上の囲みが許す範囲で、下端が段の末尾にそろうまで上にずれます。それでも収まらない囲みは、次のページのサイド段まで待ちます。
- **サイド段に積んだ囲みの間には、紙の地が1行分空く.** postext 1.4.1では、span: 'side'の囲みは、marginBottomの値にかかわらず、サイド段で次に来るものとの間に、ベースライングリッドに切り上げた本文の少なくとも1行分を空けます。そのため背景が同じ囲みでも互いに接することはなく、積むと別々のカードに見えます。1枚の続いたパネルにするには、サイド段の囲みを1つだけ置き、その中に各部分を入れ子にしてください。
- **:::spaceは囲みや段の頭では捨てられる.** :::spaceは段、囲み、:::columnsグループの頭では、それが囲みの唯一の中身であっても捨てられます。そのため、空きだけで作った解答欄の囲みはつぶれてしまいます。囲みはタイトルか設問の行で始め、そのあとに空きを入れてください。
- **ヘッダーとフッターの要素は本文の上に描かれる.** ヘッダーとフッターの要素はページの上に重ねて描かれ、本文の領域はそのための場所を空けません。要素は余白の中に収めてください。その場所を確保するのは余白です。
- **入れ子の囲みはspan、placement、snapToGridを無視する.** ほかの囲みの中に入れ子にした囲みは、自分のspan、placement、snapToGrid、floatBarrierを無視し、常に親の内側の幅で親の中を流れます。
- **パレットを差し替えても、デザイン要素と参照色は変わらない.** postext 1.4.1はcolorPaletteをテキストのスタイル（本文、見出し、リスト、キャプション、表、囲み）には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。
- **デザインのテキストのlineHeightは倍率で、寸法ではない.** デザインのスロットでは、テキスト要素のlineHeightはフォントサイズに掛ける倍率です（lineHeight: 1.05）。postext 1.4.1ではpt(15)のような寸法を指定しても拒否されず、章扉の高さがNaNと計測されて、minHeightを含め確保する高さが警告なしに失われ、本文がタイトルに重なって組まれます。
- **行末不ぞろいのテキストはハイフネーションされない.** ハイフネーションが効くのは両端そろえのテキストだけです。左そろえのテキストは語と語の間で改行するため、幅の狭い左そろえの段では行末の凹凸が大きくなります。その箇所を両端そろえにするか、行長を広げてください。
- **行末不ぞろいのテキストはラントを検査されない.** optimalLineBreaking、avoidRunts、runtPenalty、runtMinCharactersはKnuth–Plassの改行処理に働きますが、postext 1.4.1がこれを使うのは両端そろえのテキストだけです。左そろえの段落は1行ずつ改行されるため、これらの設定にかかわらず短い一語で終わることがあります。左そろえのテキストの最終行を読み、ラントで終わる段落は書き換えてください。
- **レイアウトの前にすべてのフォントを読み込む.** レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。
- **headingsオブジェクトを渡すとH1の改ページが消える.** 既定ではH1は奇数ページへ改ページします（always-odd）。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。

- `:::space`はページに合わせて調整します。英語版では2.76行でボックスが段の下端の0.8 mm上で終わります。2.96行にすると下端を0.1 mm越えて、サイドバー全体が2ページ目へ移ります。2.66行では、ボックスと地のボックスの間に紙の帯が空きます。
- 名前のデザインの中のボックスでは帯を塗れません。段の下端のすぐ上で終わるボックスにすると、見出しが段の260.8 mmより大きい265 mmを確保し、本文が2ページ目へ移ります。用紙の地まで伸ばすと、見出しの確保する高さがなくなり、プロフィールが30.3 mmまで上がって職種に重なります。

## クレジット

- レシピ: 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

## 関連レシピ

- [No. 001 · 余白に段を設けた教科書](https://postext.dev/ja/cookbook/textbook-margin-column.md): 小口側の段にフロートだけを入れる1段半組みのページです。span 'side'の図と傍注がそこに積み重なり、captionSideでほかの図のキャプションもそこへ移します。 · 難易度 3 (上級) · 教科書
- [No. 045 · 欄外見出し、ぶら下げ番号、追い込み見出し](https://postext.dev/ja/cookbook/side-heads-hanging-numbers.md): 設計競技の募集要項。節の題は余白の欄に本文のベースラインに合わせて立ち、小節の番号は段間にぶら下がり、追い込み見出しは赤で入ります。 · 難易度 3 (上級) · 報告書
- [No. 059 · 大判1ページの研究ポスター](https://postext.dev/ja/cookbook/research-poster.md): 600 × 800 mmの学会ポスター。題の帯の下に、入れ子のパネルを3段に並べたページ幅の囲みを1つ置き、ヒートマップには色見本の凡例、−4.2 °Cは150 ptで組みます。 · 難易度 3 (上級) · 論文・学術書, 一枚物とエフェメラ
