かんたんな説明
ブックデザイナーの1ページの履歴書です。左側に色の帯があり、連絡先とスキルを載せています。職歴ごとの期間は右端にそろいます。
できあがり
マドリードで働く架空のブックデザイナー、Irene SalcedoのA4判1枚の履歴書です。幅66 mmのプラム色の帯が、用紙の左端を天から地まで走ります。帯の頭には、コードで描いたクロス装の本の積み重ねが、彼女の名前のベースラインと同じ高さに立っています。その下に連絡先、ピル型に組んだスキルとツール、語学、学歴、教歴、仕事以外の活動が続きます。名前は46 ptのHedvig Letters Serifで、幅118 mmの本文の段の頭に置き、その下に5行のプロフィール、3つの職歴、手がけた本の一覧が並びます。職歴の見出しはどれも、肩書きを左に、期間を同じベースラインの右端に置き、その下に勤務先をイタリックで入れます。サイドバーの最初の見出しは、名前の下の職種と同じベースラインに並びます。
このレシピが答える質問
- 幅の広い本文の段と幅の狭いサイド段からなる1段半のレイアウトにするには?
- すべてのページに透かし、背景の色、装飾画像を加えるには?
- キーボードのキー、タグ、練習問題の語群などのインラインのチップを作るには?
手短な答え
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 } };
材料
- 機能
- フロート用の余白段1段半組み欄外の注入れ子の囲み囲み明示的な縦のアキインラインのチップページデザインのテキスト・罫・ボックスページデザインの画像デザイン要素の基準位置見出しの階層見出しの属性箇条書きとチェックリスト段落スタイルセマンティックカラーパレットキャンバス上のページ
- 併用する機能
- デザインした章扉リソースとしての図と表
- 種類
- Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1)
- 素材
- The stack of books at the head of the sidebar, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
作り方
#1 · 節を1つの脇のボックスに入れ子にする
// The sections nest in one side box. As separate side boxes they would stand at least a line
// of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside
// its parent (gotcha: nested-callout-limits).
const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
marginTop: mm(10),
titleStyle: { ...caps('glow'), gap: mm(2.4) },
body: { ...sidebar.body, lineHeight: pt(lineHeight) } });
const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true,
background: col('chip'), color: col('paper'), borderWidth: pt(0),
borderRadius: em(1), // past half the chip's height: a pill
paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }];
段の中では、帯は手短な答えのボックスです。そのフェンスは名前より前にあるので、span="side"がそれをフロート専用の段の頭に置きます。各節はその中に入れ子にしたボックスで、自分の塗りを持たないので、プラム色はContactからOutside workまで途切れずに続きます。別々の脇のボックスにすると、marginBottomを0にしても4.7〜9.1 mm離れて並びます。ピルの高さは約3.9 mmです。SkillsとToolsは専用の節スタイルchipsを使い、その16 ptの行送りで行の間に1.8 mmが空きます。ほかの節の12.4 ptでは、0.5 mmしか空きません(チップスタイル)。
#2 · 段の周りの余白を塗る
// Header elements are painted after the text, over it, so these stay in the margins, where
// no text runs (gotcha: header-paints-over-text). They repeat on every page.
const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge
const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them
const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box
const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') },
placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const header = { elements: [
bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin
bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom
bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it
// Painted last, over the box's top padding, where no text runs. The bottom of the stack
// sits on the name's baseline.
{ kind: 'image', id: 'books', resourceId: 'books', placement: {
...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h),
size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } },
] };
サイド段はページの余白の内側にあるので、サイドバーのボックスは左端の9 mm手前、天の18 mm手前で止まります。3つのヘッダーのボックスが、段の上の天の余白、天から地までの左の余白、段の下の地の余白を埋めます。ヘッダーの要素は本文の後に、本文の上へ重ねて塗られるので、このレシピでは本文の流れない余白の中にとどめています。ヘッダーの要素はすべてのページで繰り返されるので、2ページ目にも帯が付きます(柱とノンブル)。上と左のボックスはサイドバーのボックスに0.5 mm重なり、地のボックスは段の下端より1 mm上まで、サイドバーのボックスの終わりの上に伸びているので、継ぎ目に紙の細い線が見えることはありません。サイドバーのボックスを閉じる:::spaceは、英語版では2.76行、スペイン語版では1.76行で、ボックスを段の下端より0.8 mm上の278.0 mmまで下ろします。
#3 · 本文の段の頭に名前を置く
const nameplate = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF,
fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'),
placement: at('container', 'top-left') },
{ kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'),
placement: at('#name', 'below', 0, NAME.gap) },
] } };
const nameLevel = { level: 1, // span stays 'column': the name heads the text column only
// No break before the name: the sidebar box is already on the page, and a break would
// move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once
// `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page
// whole once that default returns.
breakBefore: { enabled: false },
marginBottom: pt(LEAD), // one grid line of air under the role
advancedDesign: nameplate };
H1は既定のspan: 'column'のままです。'page'にすると両方の段にまたがる章扉になり、サイドバーのボックスの後に置かれるので2ページ目へ移り、そこではサイド段が名前の下、45.9 mmの位置から始まります。サイドバーのボックスがすでにページにあるので、breakBeforeはオフにしています。改ページを入れると、名前と本文の段全体が2ページ目へ移ります。このレベルのmarginBottomをグリッド1行分にすると、プロフィールが41.3 mmではなく45.9 mmから始まります。サイドバーのボックスの上パディングはROLE_Y、つまり名前の高さとその下の間隔の合計なので、サイドバーの最初の見出しと職種は、用紙の上端から39.3 mmの同じベースラインに並びます。
#4 · 期間を肩書きの行に置く
const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading
const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS,
fontSize: pt(JOB), lineHeight: LEAD / JOB, // a multiple (gotcha: design-lineheight-multiple)
align: 'left', ...look }); // without align, design text is centred in its box
const job = { enabled: true, slot: { elements: [
jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap',
placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates
jobText('dates', '{attr.dates}', { color: col('muted'),
placement: at('container', 'top-right') }), // the title's top and size, so its baseline
jobText('org', '{attr.org}', { italic: true, color: col('muted'),
placement: at('#title', 'below') }),
] } };
const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9),
marginBottom: pt(0), advancedDesign: job };
// The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head
// starts the same 9 pt under a grid line after a section head as after a list. The 9 pt
// below it merge with a job head's 9 pt above. Under Selected books they push the list to
// the next grid line, as the default 7 pt would; with 0 it would start one line higher.
const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20),
marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead };
職歴はどれもH3で、その属性が勤務先と期間を運びます。### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"}のように書きます(見出しの属性)。肩書きと期間は同じコンテナーの上端に、それぞれ左右の角へアンカーし、同じサイズと行送りで組むので、1本のベースラインに並びます。肩書きは84 mmまでに制限して折り返しを許すので、長い肩書きは期間に届く前に2行目へ折り返し、勤務先はその下へ移ります。上の間隔を含めて各節の見出しがちょうどグリッド3行分を占めるので、職歴の見出しは、節の見出しの後でもリストの後でも、必ずグリッド線の9 pt下に来ます。
レシピの全体
// ═══ Postext Cookbook · Nº 057 · One-page CV with a sidebar ═══════════════════════ // https://postext.dev/en/cookbook/cv-with-sidebar // Code: MIT · Text: original (CC BY 4.0) · Drawing: made in code (CC BY 4.0) // Fonts: Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'cv-with-sidebar'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#231b22', // text: a plum-tinted near-black band: '#3a2235', // the sidebar accent: '#8f3b62', // on paper: the role and the section heads (7.1:1) glow: '#f1b98f', // on the band: the sidebar titles (8.3:1) chip: '#744d6c', // on the band: the skill chips rule: '#d9cdd5', // the hairlines after the section heads muted: '#6c5f69', // dates, employers, the colophon (6.0:1) paper: '#ffffff', // the page, and the text on the band (14.4:1) }; // 1.4.1 design elements paint the hex and ignore the paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [SERIF, SANS] = ['Hedvig Letters Serif', 'Hanken Grotesk']; const PT = 25.4 / 72; // mm in a point const [TRIM_W, TRIM_H] = [210, 297]; // mm: A4, printed on one side, so nothing is mirrored const [TOP, LEFT, RIGHT] = [18, 9, 16]; // mm const LEAD = 13.2; // pt: the body leading const BOTTOM = TRIM_H - TOP - 56 * LEAD * PT; // mm: the text block holds 56 lines const CONTENT = TRIM_W - LEFT - RIGHT; // 185 mm between the margins const [SIDE, GUTTER] = [57, 10]; // mm: the sidebar column, and the white after it const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const LABEL = 7.8; // pt: the role and the sidebar titles, tracked capitals const caps = (colour) => ({ fontFamily: SANS, fontSize: pt(LABEL), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(LABEL * 0.18), color: col(colour) }); const NAME = { size: 46, gap: 2.6 }; // pt and mm: the name, and the room under it const ROLE_Y = NAME.size * PT + NAME.gap; // mm under the top margin: the role's line // mm under the top margin: the name's baseline. Hedvig Letters Serif, set solid, puts it 0.795 // of the size down the line (measured on the page); another face needs its own ratio. const BASELINE = 0.795 * NAME.size * PT; const BOOKS = { w: 46, h: 18 }; // mm: the drawing at the head of the band // #region answer: a float-only column on the left, filled by one box const layout = { layoutType: 'oneAndHalf', sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins sideColumnSide: 'left', sideColumnRole: 'floats', // no body text: only boxes fenced with span="side" gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm }; // A side box stands where the text has reached at its fence, so the Markdown opens with it, // before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence): // :::callout{type="sidebar" span="side"} // :::callout{type="section" title="Contact"} … ::: ← the sections nest inside it // … // :::space{lines=2.76} ← last: runs the band to the column's foot; a fifth of a line // ::: more and the whole box moves to page 2 // # Irene Salcedo {role="Book designer and art director"} const sidebar = { id: 'sidebar', background: col('band'), // The first title stands on the role's line. The text starts 10 mm from the trim, the // left margin plus 1 mm, and stops 10 mm short of the band's right edge. padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) }, body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'), boldColor: col('paper'), paragraphSpacing: false } }; // #endregion // #region margins: header boxes paint the band into the margins around the column // Header elements are painted after the text, over it, so these stay in the margins, where // no text runs (gotcha: header-paints-over-text). They repeat on every page. const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') }, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } }); const header = { elements: [ bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it // Painted last, over the box's top padding, where no text runs. The bottom of the stack // sits on the name's baseline. { kind: 'image', id: 'books', resourceId: 'books', placement: { ...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h), size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } }, ] }; // #endregion // #region sections: boxes nested in the band, one per section; chips for the skills // The sections nest in one side box. As separate side boxes they would stand at least a line // of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside // its parent (gotcha: nested-callout-limits). const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginTop: mm(10), titleStyle: { ...caps('glow'), gap: mm(2.4) }, body: { ...sidebar.body, lineHeight: pt(lineHeight) } }); const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true, background: col('chip'), color: col('paper'), borderWidth: pt(0), borderRadius: em(1), // past half the chip's height: a pill paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }]; // #endregion // #region name: the H1 sets the name and the role at the head of the text column const nameplate = { enabled: true, slot: { elements: [ { kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF, fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'), placement: at('container', 'top-left') }, { kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'), placement: at('#name', 'below', 0, NAME.gap) }, ] } }; const nameLevel = { level: 1, // span stays 'column': the name heads the text column only // No break before the name: the sidebar box is already on the page, and a break would // move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once // `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page // whole once that default returns. breakBefore: { enabled: false }, marginBottom: pt(LEAD), // one grid line of air under the role advancedDesign: nameplate }; // #endregion const sectionHead = { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF, fontSize: pt(14), lineHeight: 20 / 14, color: col('accent'), placement: at('container', 'top-left') }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), // 3 mm after the title, level with the middle of its lower case, on to the column's edge placement: { ...at('#title', 'right-of', 3, 3.9), size: { width: 'fill' } } }, ] } }; // #region jobs: each job heading sets the title left and the dates flush right const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS, fontSize: pt(JOB), lineHeight: LEAD / JOB, // a multiple (gotcha: design-lineheight-multiple) align: 'left', ...look }); // without align, design text is centred in its box const job = { enabled: true, slot: { elements: [ jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates jobText('dates', '{attr.dates}', { color: col('muted'), placement: at('container', 'top-right') }), // the title's top and size, so its baseline jobText('org', '{attr.org}', { italic: true, color: col('muted'), placement: at('#title', 'below') }), ] } }; const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9), marginBottom: pt(0), advancedDesign: job }; // The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head // starts the same 9 pt under a grid line after a section head as after a list. The 9 pt // below it merge with a job head's 9 pt above. Under Selected books they push the list to // the next grid line, as the default 7 pt would; with 0 it would start one line higher. const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20), marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, layout, chipStyles, header, footer: { elements: [] }, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } }, bodyText: { fontFamily: SANS, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // the titles in Selected books referenceColor: col('ink'), // no :ref yet; one added later prints in ink, not default blue textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // Each level draws its own design, but 1.4.1 still measures the hidden heading text in this // face. Without it the default is Open Sans, and the page would have to load that face too. headings: { fontFamily: SANS, levels: [nameLevel, sectionLevel, jobLevel] }, unorderedLists: { bulletChar: '–', color: col('ink'), fontWeight: 400, itemSpacing: pt(0), marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [ { id: 'lead', fontSize: pt(11), lineHeight: pt(16) }, { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted'), marginTop: pt(LEAD) }, ], calloutStyles: [sidebar, section('section', 12.4), section('chips', 16)], }); // #region art: a stack of cloth-bound books, drawn in the palette function books() { // BOOKS.w × BOOKS.h mm, in tenths of a millimetre; the bottom book first const lilac = mix(palette.band, palette.paper, 0.55); const stack = [ // [left edge, length, thickness, cloth, label] [0, 430, 40, palette.glow, palette.paper], [22, 380, 32, palette.paper, palette.accent], [6, 400, 36, palette.accent, palette.glow], [40, 330, 30, lilac, palette.paper], [18, 300, 34, mix(palette.glow, palette.paper, 0.45), palette.accent], ]; let y = BOOKS.h * 10; let art = ''; for (const [x, length, thick, cloth, label] of stack) { y -= thick; const [top, h] = [y + 3, thick - 3]; // a dark hairline above each book art += `<rect x="${x}" y="${top}" width="${length}" height="${h}" rx="3" fill="${cloth}"/>` + `<rect x="${x + 14}" y="${top}" width="5" height="${h}" fill="${label}"/>` // bands + `<rect x="${x + length - 19}" y="${top}" width="5" height="${h}" fill="${label}"/>` + `<rect x="${x + length * 0.34}" y="${top + h * 0.28}" width="${length * 0.32}" ` + `height="${h * 0.44}" rx="2" fill="${label}"/>`; // the title label } return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BOOKS.w * 10} ${BOOKS.h * 10}" ` + `width="${BOOKS.w * 10}" height="${BOOKS.h * 10}">${art}</svg>`; } function mix(a, b, k) { // a blend of two palette colours, k of the way from a to b const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [p, q] = [rgb(a), rgb(b)]; return `#${p.map((v, i) => Math.round(v + (q[i] - v) * k).toString(16).padStart(2, '0')) .join('')}`; } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 89行 · content.en.md
title: "Irene Salcedo · Curriculum vitae" author: "Irene Salcedo" --- :::callout{type="sidebar" span="side"} :::callout{type="section" title="Contact"} Madrid, Spain irene@salcedo.example salcedo.example/books ::: :::callout{type="chips" title="Skills"} :chip[Book typography]{style="skill"} :chip[Series design]{style="skill"} :chip[Covers]{style="skill"} :chip[Grids and styles]{style="skill"} :chip[Typesetting]{style="skill"} :chip[Prepress]{style="skill"} :chip[Colour proofing]{style="skill"} :chip[EPUB 3]{style="skill"} :chip[Accessible PDF]{style="skill"} :chip[Art direction]{style="skill"} ::: :::callout{type="chips" title="Tools"} :chip[InDesign]{style="skill"} :chip[Illustrator]{style="skill"} :chip[Photoshop]{style="skill"} :chip[Glyphs]{style="skill"} :chip[Python]{style="skill"} ::: :::callout{type="section" title="Languages"} **Spanish** · native **Catalan** · C2 **English** · C1, working language **Italian** · B2 ::: :::callout{type="section" title="Education"} **MA in Book Design** Escuela del Libro del Turia, Valencia, 2011–2012 :::space{lines=0.5} **BA in Graphic Design** Escuela de Diseño Almenara, Castellón, 2007–2011 ::: :::callout{type="section" title="Teaching"} Guest tutor in book typography at the Escuela del Libro del Turia, spring terms 2022–2024 ::: :::callout{type="section" title="Outside work"} Letterpress printing at a shared workshop in Lavapiés; walking the long-distance paths of the Sierra de Guadarrama. ::: :::space{lines=2.76} ::: # Irene Salcedo {role="Book designer and art director"} :::paragraphs{style="lead"} Book designer with fourteen years in trade and academic publishing, the last five as art director of a house with three imprints. I work on series and covers as closely as on the page, and write the specifications printers work from. I am looking for an art director’s post with a literary or illustrated list. ::: ## Experience ### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"} - Set and maintain the house style of three imprints: 58 new titles in 2025, from pocket novels to illustrated non-fiction. - Redesigned the pocket series on a 110 × 178 mm trim. Its grid holds 32 lines a page instead of 29, so the 2024 list needed about 9 per cent fewer pages than the 2023 list for the same number of titles. - Lead two designers and a typesetter, and commission the illustrators and photographers for each season’s list. - Moved print and ebook editions to one source file, so the ebook now ships the same week as the hardback. ### Senior book designer {org="Casa Albero, Barcelona" dates="2016 – 2021"} - Designed interiors and covers for about 40 titles a year of literary fiction and essays, in Spanish and Catalan. - Wrote the typesetting rules for the Catalan list: hyphenation, the l·l, quotation marks and dialogue dashes. - Designed the twelve volumes of the collected poems of Elvira Montcada, with the notes set in the margin. ### Designer and typesetter {org="Imprenta Quirós, Valencia" dates="2012 – 2016"} - Set critical editions with notes on up to three levels, and the bibliographies and indexes that go with them, for two university presses. - Prepared files for offset printing: imposition, colour proofs and the paper and binding specifications. ## Selected books - *Los años del salitre*, Marta Ibarra. Albardín, 2023. Cover, interior and a two-colour map section. - *Cartas desde Menorca*, Miquel Truyols. Casa Albero, 2019. A bilingual edition on facing pages. - *Obra reunida*, Elvira Montcada. Casa Albero, 2018–2020. Twelve volumes in a cloth slipcase. :::paragraphs{style="colophon"} Set in Hedvig Letters Serif and Hanken Grotesk. Text: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Hedvig Letters Serif': ['400'], 'Hanken Grotesk': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('books.svg', books()); const resources = [{ id: 'books', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'books.svg', width: BOOKS.w * 10, height: BOOKS.h * 10 }, altText: t({ en: 'A stack of five cloth-bound books', es: 'Una pila de cinco libros encuadernados en tela' }) }]; const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'One-page CV with a sidebar', es: 'Currículum de una página con barra lateral' }) });キット · core, fonts, viewer, images:全レシピ共通 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#帯の色を変える
色はどれもpaletteに一度だけ書き、帯のボックス、サイドバーのボックス、チップ、職種、見出し、イラストはそこから色を取ります。次の3行で、帯、アクセント、チップがティール色になります。
- band: '#3a2235', // the sidebar
+ band: '#1d3b3a', // the sidebar
- accent: '#8f3b62', // on paper: the role and the section heads (7.1:1)
+ accent: '#2b5f5b', // on paper: the role and the section heads (7.3:1)
- chip: '#744d6c', // on the band: the skill chips
+ chip: '#456d69', // on the band: the skill chips#同じ段に図をフロートさせる
余白に段を設けた教科書は、すべてのページの小口側にあるフロート専用の段に、図、脇のキャプション、注釈を置きます。
よくあるつまずき
つまずき
サイド段の囲みは、囲みを開く位置の次のブロックと同じ高さから始まる
postext 1.4.1では、span: 'side'の囲みは、囲みを開く位置で本文が達した高さの次のグリッド行から、すでにある囲みがあればその下に、サイド段に置かれます。注釈は説明する段落の直前で開いてください。段落のあとで開くと、注釈は次の段落の横から始まります。段の末尾を越える囲みは、上の囲みが許す範囲で、下端が段の末尾にそろうまで上にずれます。それでも収まらない囲みは、次のページのサイド段まで待ちます。 欄外の注 →
つまずき
サイド段に積んだ囲みの間には、紙の地が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まで上がって職種に重なります。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- 画像
- The stack of books at the head of the sidebar, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- フォント
- Hedvig Letters Serif (SIL OFL 1.1) · Hanken Grotesk (SIL OFL 1.1)


