What you'll build
The CV of Irene Salcedo, a fictional book designer in Madrid, on one A4 sheet. A plum band 66 mm wide runs down the left edge from the top of the sheet to its foot. At its head, a stack of cloth-bound books drawn in code stands level with the baseline of her name. Below it come her contact details, her skills and tools set as pills, her languages, education and teaching, and what she does outside work. Her name, in 46 pt Hedvig Letters Serif, heads a 118 mm text column with a five-line profile, three jobs and a list of the books she designed. Each job head puts the title on the left and the dates flush right on the same baseline, with the employer in italic below. The first sidebar title shares a baseline with the role under the name.
This recipe answers
- How do I make a column-and-a-half layout, with a wide text column and a narrow side column?
- How do I add a watermark, a background tint or a decorative image on every page?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
The short answer
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 } };
A float-only column on the left, filled by one box
Ingredients
- Features
- Margin column for floatsColumn and a halfMargin notesNested boxesCallout boxesExplicit vertical spaceInline chipsText, rules and boxes in page designsPictures in page designsAnchoring design elementsHeading levelsHeading attributesBullet lists and checklistsParagraph stylesSemantic colour palettePages on a canvas
- Type
- Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1)
- Assets
- The stack of books at the head of the sidebar, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
Method
#1 · Nest the sections in one side box
// 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) }];
Inside the column, the band is the box from the short answer. Its fence comes before the name, so span="side" puts it at the head of the float-only column. The sections are boxes nested inside it, with no fill of their own, so the plum runs unbroken from Contact to Outside work. As separate side boxes they stand 4.7 to 9.1 mm apart, even with marginBottom at 0. The pills are about 3.9 mm tall. Skills and Tools use a section style of their own, chips, whose 16 pt leading leaves 1.8 mm between rows; the 12.4 pt of the other sections would leave half a millimetre (chip styles).
#2 · Paint 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) } } },
] };
The side column sits inside the page margins, so the sidebar box stops 9 mm short of the left edge and 18 mm short of the top. Three header boxes fill the top margin above the column, the left margin from top to foot and the bottom margin below the column. Header elements are painted after the text and on top of it, so the recipe keeps them in the margins, where no text runs. They also repeat on every page, and a second page would carry the band too (headers & footers). The top and left boxes overlap the sidebar box by 0.5 mm, and the foot box climbs 1 mm above the column's foot, over the end of the sidebar box, so no hairline of paper shows at the joins. The :::space that closes the sidebar box takes it down to 278.0 mm, 0.8 mm above the column's foot, with 2.76 lines in English and 1.76 in Spanish.
#3 · Set the name 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 };
The H1 keeps the default span: 'column'. With 'page' it becomes an opener across both columns; set after the sidebar box, it moves to page 2, and there the side column starts under the name, 45.9 mm down. breakBefore is off because the sidebar box is already on the page: with a break, the name and the whole text column move to page 2. The level's marginBottom of one grid line starts the profile at 45.9 mm instead of 41.3 mm. The sidebar box's top padding is ROLE_Y, the name's height plus the gap under it, so the first sidebar title and the role share a baseline, 39.3 mm from the top edge.
#4 · Put the dates on the job title's line
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 };
Each job is an H3 whose attributes carry the employer and the dates: ### Art director {org="Ediciones del Albardín, Madrid" dates="2021 – present"} (heading attributes). The title and the dates are anchored to the top of the same container, one to each corner, at the same size and leading, so they sit on one baseline. The title is held to 84 mm and set to wrap: a long one breaks onto a second line before it reaches the dates, and the employer moves down under it. With the space above it, each section head fills exactly three grid lines, which puts every job head 9 pt under a grid line whether it follows a section head or a list.
The whole recipe
// ═══ 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 sample · 89 lines · 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' }) });Kit · core, fonts, viewer, images: the same in every recipe · 270 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Recolour the band
Each colour is written once, in palette, and the band boxes, the sidebar box, the chips, the role, the heads and the drawing take it from there. These three lines turn the band, the accent and the chips teal.
- 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#Float figures into the same column
The textbook with a margin column puts figures, side captions and glosses in a float-only column on the outer edge of every page.
Pitfalls
Pitfall
A side box starts level with the block after its fence
In postext 1.4.1 a span: 'side' box stands in the side column at the height the text has reached at its fence, on the next grid line, and under any box already there. Fence a gloss just before the paragraph it explains: fenced after it, the gloss starts beside the next paragraph. A box that would run past the column's foot slides up until its foot sits on the column's foot, as far as the box above it allows; one that still does not fit waits for the side column of the next page. Margin notes →
Pitfall
Side boxes stacked in the channel keep a line of paper between them
In postext 1.4.1 a span: 'side' box leaves at least one body line under it before the next thing in the channel, rounded up to the baseline grid, whatever its marginBottom says. Boxes that share a background therefore never touch: stacked, they read as separate cards. For one continuous panel, fence a single side box and nest the sections inside it. Margin notes →
Pitfall
:::space is dropped at the top of a box or column
:::space is dropped at the top of a column, a box or a :::columns group, even when it is the box's only content, so an answer box made of space collapses. Open the box with a title or a prompt line, then add the space. Explicit vertical space →
Pitfall
Header and footer elements paint over text
Header and footer elements are painted over the page and the text area does not make room for them. Keep them within the margins, which are what reserve their space. Running heads and folios →
Pitfall
A nested box ignores span, placement and snapToGrid
A callout nested in another ignores its span, placement, snapToGrid and floatBarrier: it always flows inside its parent, at the parent's inner width. Nested boxes →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
A design text's lineHeight is a multiple, never a dimension
In a design slot, a text element's lineHeight multiplies its font size (lineHeight: 1.05). In postext 1.4.1 a dimension such as pt(15) is not rejected: the opener's height measures as NaN, the room it reserves, minHeight included, is dropped without a warning and the text runs under the title. Text, rules and boxes in page designs →
Pitfall
Ragged text is never hyphenated
Hyphenation applies to justified text only; ragged-right text breaks between words, so a narrow ragged column gets a deep rag. Justify the passage or widen the measure. Hyphenation and document language →
Pitfall
Ragged text is never checked for runts
optimalLineBreaking, avoidRunts, runtPenalty and runtMinCharacters act on the Knuth–Plass line breaker, which postext 1.4.1 runs for justified text only. A ragged paragraph is broken line by line and can end on one short word whatever those settings say. Read the last lines of ragged text and reword a paragraph that ends on a runt. Widows, orphans and runts →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
- Fit the
:::spaceto the page. In the English edition 2.76 lines end the box 0.8 mm above the column's foot. At 2.96 lines it runs 0.1 mm past the foot and the whole sidebar moves to page 2; at 2.66 a strip of paper opens between the box and the foot box. - A box in the name's design cannot paint the band. One that ends just above the column's foot makes the heading reserve 265 mm, more than the column's 260.8 mm, and the text moves to page 2; one that runs on to the foot of the sheet drops the heading's reserved height, and the profile rises to 30.3 mm, over the role.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Images
- The stack of books at the head of the sidebar, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- Fonts
- Hedvig Letters Serif (SIL OFL 1.1) · Hanken Grotesk (SIL OFL 1.1)


