成品一览
Irene Salcedo是马德里一位虚构的图书设计师,这是她的简历,排在一张A4纸上。一条66 mm宽的紫红色色带沿左边缘从纸张顶端一直延伸到底端。色带顶部是一摞用代码画出的布面精装书,与她姓名的基线平齐。书下方依次是联系方式、排成标签的技能与工具、语言、教育背景、教学经历和工作之外的爱好。她的姓名用46 pt的Hedvig Letters Serif排出,位于118 mm宽的正文栏顶部,下面是五行简介、三份工作和她设计过的图书列表。每份工作的标题行左边是职位,右边是起止年份,两者在同一条基线上,雇主以斜体列在下一行。侧栏的第一个标题与姓名下方的职业头衔共用一条基线。
这道食谱解答
- 怎样做一栏半版式,一个宽的正文栏加一个窄的侧栏?
- 怎样给每一页加水印、背景底色或装饰图片?
- 怎样做行内标签:键盘按键、标签、练习用的词库?
简短回答
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 } };
用料
- 类型
- Hedvig Letters Serif, Hanken Grotesk(SIL OFL 1.1)
- 素材
- The stack of books at the head of the sidebar, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
做法
#1 · 把各部分嵌套在一个侧栏框里
// The sections nest in one side box. As separate side boxes they would stand at least a line
// of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside
// its parent (gotcha: nested-callout-limits).
const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
marginTop: mm(10),
titleStyle: { ...caps('glow'), gap: mm(2.4) },
body: { ...sidebar.body, lineHeight: pt(lineHeight) } });
const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true,
background: col('chip'), color: col('paper'), borderWidth: pt(0),
borderRadius: em(1), // past half the chip's height: a pill
paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }];
在侧栏里,色带就是简短回答中的那个框。它的围栏写在姓名之前,所以span="side"把它放在纯浮动栏的顶部。各部分是嵌套在它里面的框,自身没有填充色,于是紫红色从Contact一直连到Outside work,中间不断开。如果把它们做成各自独立的侧栏框,即使marginBottom设为0,彼此之间也会隔开4.7到9.1 mm。标签高约3.9 mm。Skills和Tools用单独的部分样式chips,其16 pt行距让两行标签之间留出1.8 mm;其他部分用的12.4 pt只会留出半毫米(行内标签样式)。
#2 · 给栏外的页边上色
// Header elements are painted after the text, over it, so these stay in the margins, where
// no text runs (gotcha: header-paints-over-text). They repeat on every page.
const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge
const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them
const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box
const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') },
placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const header = { elements: [
bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin
bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom
bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it
// Painted last, over the box's top padding, where no text runs. The bottom of the stack
// sits on the name's baseline.
{ kind: 'image', id: 'books', resourceId: 'books', placement: {
...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h),
size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } },
] };
侧栏位于页边距以内,所以侧栏框离左边缘还差9 mm,离顶端还差18 mm。三个页眉框分别填满栏上方的上页边距、从顶到底的左页边距,以及栏下方的下页边距。页眉元素在文字之后绘制、盖在文字之上,所以本食谱把它们限制在没有文字的页边里。它们还会在每一页重复,如果有第二页,也会带上这条色带(页眉与页脚)。顶部和左侧的框与侧栏框重叠0.5 mm,底部的框比栏底高出1 mm,盖住侧栏框的末端,这样接缝处不会露出一丝纸色。结束侧栏框的:::space把框延伸到278.0 mm,比栏底高0.8 mm;英文版用2.76行,西班牙文版用1.76行。
#3 · 把姓名放在正文栏顶部
const nameplate = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF,
fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'),
placement: at('container', 'top-left') },
{ kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'),
placement: at('#name', 'below', 0, NAME.gap) },
] } };
const nameLevel = { level: 1, // span stays 'column': the name heads the text column only
// No break before the name: the sidebar box is already on the page, and a break would
// move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once
// `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page
// whole once that default returns.
breakBefore: { enabled: false },
marginBottom: pt(LEAD), // one grid line of air under the role
advancedDesign: nameplate };
H1保留默认的span: 'column'。设为'page'时,它会变成横跨两栏的章首标题;由于它排在侧栏框之后,就会移到第2页,而那里的侧栏要到姓名下方45.9 mm处才开始。breakBefore关闭,因为侧栏框已经在这一页上:如果分页,姓名和整个正文栏都会移到第2页。这一级标题的marginBottom为一个网格行,使简介从45.9 mm而不是41.3 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"}(标题属性)。职位和日期都锚定在同一个容器的顶部,各占一角,字号和行距相同,因此落在同一条基线上。职位限宽84 mm并允许换行:过长的职位会在碰到日期之前折到第二行,雇主随之下移。加上上方的空白,每个部分标题正好占三个网格行,所以无论工作标题跟在部分标题还是列表之后,都位于某条网格线下方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里写一次,色带框、侧栏框、标签、职业头衔、各级标题和插图都从那里取色。下面三行把色带、强调色和标签改成青绿色。
- band: '#3a2235', // 侧栏
+ band: '#1d3b3a', // 侧栏
- accent: '#8f3b62', // 用在纸面上:职业头衔和各部分标题(7.1:1)
+ accent: '#2b5f5b', // 用在纸面上:职业头衔和各部分标题(7.3:1)
- chip: '#744d6c', // 用在色带上:技能标签
+ chip: '#456d69', // 用在色带上:技能标签#把图浮动到同一栏
带边注栏的教科书把图、侧边题注和旁注放在每页外侧的一个纯浮动栏里。
常见问题
易错点
侧栏框与其围栏之后的块齐平开始
在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 mm处。设为2.96行时,框超出栏底0.1 mm,整个侧栏会移到第2页;设为2.66行时,框与底部的框之间会露出一条纸色。 - 姓名的版面设计里的框画不了这条色带。如果框结束在栏底稍上方,标题要预留265 mm,超过栏高260.8 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)


