What you'll build
Part IV of Principles of Human Physiology, a textbook on a 210 × 277 mm page: chapter 14, The heart as a pump, and chapter 15, Vessels and blood pressure, open on rectos under a red band and set their clinical notes and captions in an outer channel. The part closes with an index of about ninety entries and thirty sub-entries in two columns, the way a medical textbook sets one: lower-case entries, letter heads in the accent, sub-entries one em in, the defining page in bold, see and see also in italic. No page number in it was typed. Each term is marked in the chapter where the text explains it, and the index reads the pages those marks landed on, folios 297 to 304.
This recipe answers
- How do I build a back-of-book index whose page numbers change by themselves when the text moves?
The short answer
// The chapters mark each term where the text discusses it:
// the :index[stroke volume]{main} prints the words, files them, bold page
// mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
// ## Venous return :index{term="venous return" range="start" main} … range="end"
// :index{term="inotropy" see="contractility"} a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
id: 'index', numbered: false, toc: false, // not counted: no chapter 16
layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
// English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
// writes both numbers in full and joins them with a hyphen (301-303).
rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
main: { bold: true }, // the defining page in bold
see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
Marks in the chapters, and an index chapter set in two columns
Ingredients
- Features
- Back-of-book indexBooks built chapter by chapterHeading stylesDesigned openersHeading attributesNumbered headingsRunning heads and foliosHeads by page roleMirrored marginsMargin column for floatsMargin notesSide captionsUnnumbered chaptersFloated boxesCallout boxesNumbered captionsPDF export
- Also uses
- Citations that place figuresColumn and a halfFigure and Table in your languageFull-width chapter bandParagraph stylesFonts embedded in the PDFCustom resource typesFigures and tables as resourcesRoman front matterSection geometryRunning heads per sectionSuperscripts and subscripts
- Type
- Literata, Libre Franklin (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Mark the passage, not the word
// The chapters mark each term where the text discusses it:
// the :index[stroke volume]{main} prints the words, files them, bold page
// mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
// ## Venous return :index{term="venous return" range="start" main} … range="end"
// :index{term="inotropy" see="contractility"} a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
id: 'index', numbered: false, toc: false, // not counted: no chapter 16
layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
// English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
// writes both numbers in full and joins them with a hyphen (301-303).
rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
main: { bold: true }, // the defining page in bold
see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
An indexer marks the places where a term is explained, not every place the word appears. The two chapters use stroke volume twelve times, and the index sends the reader to two places: page 298, where the term is defined, and 299–300, where the text explains what regulates it. A visible mark, :index[cardiac output]{main}, prints its words and files them. An invisible one, mitral:index{term="heart!valves!mitral"}, prints nothing and takes the page of the word before it, which is how the passage on the four valves files mitral, tricuspid, aortic and pulmonary under heart without rewording the sentence. The ! separates levels, so heart gets three: valves, then each valve. A mark also works in a heading (the ranges of 14.1 and 15.6 start there) and in a callout (heart failure is defined in the side note on page 300).
#2 · Main pages, ranges and cross-references
// The chapters mark each term where the text discusses it:
// the :index[stroke volume]{main} prints the words, files them, bold page
// mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
// ## Venous return :index{term="venous return" range="start" main} … range="end"
// :index{term="inotropy" see="contractility"} a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
id: 'index', numbered: false, toc: false, // not counted: no chapter 16
layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
// English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
// writes both numbers in full and joins them with a hyphen (301-303).
rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
main: { bold: true }, // the defining page in bold
see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
main sets the page that defines a term in bold, so a student looking up preload goes to page 299 first. range="start" and range="end", with the same term, bound a discussion that runs over a page break: stroke volume, regulation, 299–300, baroreceptor reflex, 303–4. A main on the start makes the whole range bold. A page marked again inside its own range folds into it, and a main there makes the range bold. see stands in for the page numbers (inotropy. See contractility), and seealso follows them (heart failure, 300. See also ejection fraction). A target with levels is written with ! and printed with a colon: See blood pressure: mean arterial.
#3 · An index chapter in two columns
// The chapters mark each term where the text discusses it:
// the :index[stroke volume]{main} prints the words, files them, bold page
// mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it
// ## Venous return :index{term="venous return" range="start" main} … range="end"
// :index{term="inotropy" see="contractility"} a cross-reference, no page
// The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every
// chapter's marks with the pages they landed on, and lays the book out again until those
// pages stop moving.
const indexStyle = {
id: 'index', numbered: false, toc: false, // not counted: no chapter 16
layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only
advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number
header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })),
};
const index = {
fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4),
indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em
// English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish
// writes both numbers in full and joins them with a hyphen (301-303).
rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
main: { bold: true }, // the defining page in bold
see: { italic: true }, // See / See also, Véase / Véase también by the document's locale
groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
};
The index is the last Markdown document of the book, # Index {style="index"} followed by :::index. The heading style swaps the column and a half of the chapters for two columns 6 mm apart, only in its own section, and gives the index its own opener and running head. The index is set in the text face at 8.6/11.4 pt, one em per sub-level, and a turnover line hangs two ems, so a wrapped line never lines up with a sub-entry. rangeFormat: 'chicago' drops the digits a range repeats (303–4, but 299–300). The Spanish edition writes ranges in full with a hyphen (303-304), the usual practice in Spanish books.
#4 · Why the numbers follow the text
const text = chapters.map((chapter) => chapter.markdown).join('\n');
await loadFonts(FONTS, text);
await loadSvg('pv-loop.svg', pvLoop());
const docs = await buildWithFonts(() => buildBundle({ chapters, config: config(), resources }),
text);
showPages(docs, { title: t({ en: 'Principles of Human Physiology',
es: 'Principios de fisiología humana' }) });
offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }),
`${RECIPE}.pdf`);
buildBundle lays out the chapters in order and finds that the third one holds :::index. After the first pass it collects every mark of every chapter with the page it landed on, and hands that list to the index chapter as its outline. Then it lays the book out again, up to three passes, until no page number moves. Rewrite a paragraph of chapter 14, change the trim or the body size, and the index reprints with the new pages. In the PDF each page number links to its page.
#5 · Openers and running heads
function opener(band = BAND, kicker = '') {
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' }; // wrap: never '…'
const inBand = band - MARGIN.top - 8; // the title's box: it stands on a line 8 mm above the foot
return { enabled: true, minHeight: mm(inBand + 8 + 31), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
placement: { ...at('bleed', 'top-left', 0, 0), size: { height: mm(band) } } },
{ kind: 'text', id: 'kicker', content: kicker, ...label, ...onBand, fontSize: pt(8.5),
placement: at('container', 'top-left', 0, -6) },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SANS,
fontWeight: 800, fontSize: pt(30), lineHeight: 1.05, // a multiple, never pt()
verticalAlign: 'bottom', // one line or two, the title sits on the same line
placement: { ...at('container', 'top-left', 0, 0),
size: { width: mm(170), height: mm(inBand) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
fontSize: pt(10.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap',
inlineMarks: true, // *See* in the index's lead
placement: { ...at('container', 'top-left', 0, inBand + 15),
size: { width: mm(118) } } },
] } };
}
The chapters and the index share one opener: a band from the trim to 62 mm (46 mm for the index), the title set on its foot by a bottom-aligned box, so a two-line title and a one-line title end on the same line. The kicker prints {chapterNumber}, which the heading attribute {startAt=14} sets to 14, and page.pageNumbering.startAt: 297 places the part where it would sit in the book. The index style is numbered: false, so it does not become chapter 16.
The whole recipe
// ═══ Postext Cookbook · Nº 072 · A textbook index that follows the text ═══════════════ // https://postext.dev/en/cookbook/back-of-book-index // Code: MIT · Text: original (CC BY 4.0) · Drawing: generated in code (CC BY 4.0) // Fonts: Literata, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.7.0 // Two chapters of a physiology textbook and the index that closes them, laid out by // buildBundle as one book: the terms are marked where the text discusses them, and the // index chapter prints them with the pages they land on. import { buildBundle, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'back-of-book-index'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // every colour in the config links to one of these ids ink: '#1f1a1c', // text: a warm near-black accent: '#9e1b32', // the one accent (7.4:1 on paper): bands, letter heads, numbers, labels tint: '#f7ebe9', // the loop in Figure 14.1 rule: '#d9c6c3', // hairlines muted: '#6b5f61', // running heads, notes, the colophon paper: '#ffffff', // type on the bands }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // A textbook trim, mirrored, in mm. The body is a column and a half: text in the main column, // figures' captions and clinical notes in the outer channel. const TRIM = { width: 210, height: 277 }; const MARGIN = { top: 24, bottom: 22, inner: 20, outer: 14 }; const LEAD = 13.6; // body leading in pt: the baseline grid const [SERIF, SANS] = ['Literata', 'Libre Franklin']; const [BAND, INDEX_BAND] = [62, 46]; // mm from the trim to the foot of the opener bands const HEAD_Y = 13; // mm from the top trim to the running heads const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const label = { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700, letterSpacing: pt(1.3), textTransform: 'uppercase' }; // #region answer: marks in the chapters, and an index chapter set in two columns // The chapters mark each term where the text discusses it: // the :index[stroke volume]{main} prints the words, files them, bold page // mitral:index{term="heart!valves!mitral"} prints nothing, files the word before it // ## Venous return :index{term="venous return" range="start" main} … range="end" // :index{term="inotropy" see="contractility"} a cross-reference, no page // The last chapter is `# Index {style="index"}` and `:::index`. buildBundle hands it every // chapter's marks with the pages they landed on, and lays the book out again until those // pages stop moving. const indexStyle = { id: 'index', numbered: false, toc: false, // not counted: no chapter 16 layout: { layoutType: 'double', gutterWidth: mm(6) }, // two columns in this section only advancedDesign: opener(INDEX_BAND), // the chapters' band, shallower and with no number header: runningHeads(t({ en: 'Index', es: 'Índice analítico' })), }; const index = { fontFamily: SERIF, fontSize: pt(8.6), lineHeight: pt(11.4), indent: em(1), turnoverIndent: em(2), // sub-entries step in 1 em; wrapped lines hang 2 em // English: Chicago's short ranges (301–3; 298–300 keeps the digit that changes). Spanish // writes both numbers in full and joins them with a hyphen (301-303). rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }), main: { bold: true }, // the defining page in bold see: { italic: true }, // See / See also, Véase / Véase también by the document's locale groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') }, }; // #endregion // #region opener: a band across the top of the page: kicker, title and lead from the heading function opener(band = BAND, kicker = '') { const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' }; // wrap: never '…' const inBand = band - MARGIN.top - 8; // the title's box: it stands on a line 8 mm above the foot return { enabled: true, minHeight: mm(inBand + 8 + 31), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('accent') }, placement: { ...at('bleed', 'top-left', 0, 0), size: { height: mm(band) } } }, { kind: 'text', id: 'kicker', content: kicker, ...label, ...onBand, fontSize: pt(8.5), placement: at('container', 'top-left', 0, -6) }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: SANS, fontWeight: 800, fontSize: pt(30), lineHeight: 1.05, // a multiple, never pt() verticalAlign: 'bottom', // one line or two, the title sits on the same line placement: { ...at('container', 'top-left', 0, 0), size: { width: mm(170), height: mm(inBand) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true, fontSize: pt(10.5), lineHeight: 1.4, color: col('ink'), align: 'left', overflow: 'wrap', inlineMarks: true, // *See* in the index's lead placement: { ...at('container', 'top-left', 0, inBand + 15), size: { width: mm(118) } } }, ] } }; } // #endregion // #region running-heads: book title on the verso, chapter on the recto, folios outside function runningHeads(recto) { const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', ...label, fontWeight: 600, color: col('muted'), // never on openers placement: at('page', edge, x, HEAD_Y), ...extra }); const folio = { fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(0), color: col('accent') }; const { outer } = MARGIN; return { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', outer, folio), head('verso-title', '{title}', 'even', 'top-left', outer + 10), head('recto-title', recto, 'odd', 'top-right', -(outer + 10)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -outer, folio), ] }; } const header = runningHeads(t({ en: 'Chapter {chapterNumber} · {chapterTitle}', es: 'Capítulo {chapterNumber} · {chapterTitle}' })); // Openers carry a drop folio at the foot, on the outer edge. const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...label, fontSize: pt(8.5), color: col('accent'), align: 'right', placement: { ...at('page', 'bottom-right', -MARGIN.outer, -12) } }] }; // #endregion const note = { fontFamily: SANS, fontSize: pt(8), lineHeight: pt(11.3), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0) }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-gb', es: 'es' }), // hyphenation and the index's sort order // Figura and Tabla in Spanish (gotcha: resource-types-locale); captions stand in the channel. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, defaultPlacement: { captionSide: true } })), colorPalette, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, pageNumbering: { startAt: 297 }, // this part of the book opens on page 297 margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'oneAndHalf', sideColumnPercent: 28, sideColumnRole: 'floats', sideColumnSide: 'outer', gutterWidth: mm(7) }, // main column 119.7 mm, channel 49.3 mm bodyText: { fontFamily: SERIF, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, headings: { fontFamily: SANS, color: col('ink'), fontWeight: 700, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(0), numberingTemplate: '{1}', advancedDesign: opener(BAND, t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' })) }, { level: 2, fontSize: pt(12), lineHeight: pt(LEAD * 1.5), numberingTemplate: '{1}.{2}', color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, headingStyles: [indexStyle], index, paragraphStyles: [{ id: 'formula', textAlign: 'center', firstLineIndent: pt(0), italic: true, marginTop: pt(LEAD * 0.5), marginBottom: pt(LEAD * 0.5) }], calloutStyles: [ { id: 'clinical', span: 'side', backgroundEnabled: false, padding: { top: mm(2.4), right: pt(0), bottom: pt(0), left: pt(0) }, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') }, titleStyle: { ...label, color: col('accent'), gap: mm(1.6) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD) }, { id: 'colophon', span: 'page', placement: 'bottom', backgroundEnabled: false, padding: { top: mm(2), right: pt(0), bottom: pt(0), left: pt(0) }, stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, body: { ...note, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted') } }, ], captionStyle: { fontFamily: SANS, fontSize: pt(7.8), labelColor: col('accent'), gap: mm(2) }, tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: SANS, bodyFontFamily: SANS, bodyFontSize: pt(8.2), bodyColor: col('ink'), cellPadding: mm(1.4) }, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const heart = String.raw`---Markdown sample · 68 lines · content.en.md
title: "Principles of Human Physiology" subtitle: "Part IV · The Circulation" author: "Elena Marsh and Tomás Ibarra" --- # The heart as a pump {startAt=14 lead="Two muscular pumps in series move the whole blood volume round the body about once a minute at rest. This chapter follows one heartbeat from the first electrical signal to the last drop of blood ejected, and then asks what sets the size of each beat."} The human heart is a pair of pumps built into a single organ. The right side receives venous blood from the body and drives it through the lungs; the left side receives the oxygenated blood and drives it round the systemic circulation. Because the two are connected in series, they must, over any period longer than a few beats, move exactly the same volume of blood. Most of what follows concerns the left ventricle, but the principles apply to both sides of the heart: the right ventricle does the same work against a pressure about one-fifth as high.:index{term="ventricle!right"} ## The cardiac cycle :index{term="cardiac cycle" range="start" main} The sequence of events from the beginning of one heartbeat to the beginning of the next is the cardiac cycle. At a resting heart rate of 72 beats per minute each cycle lasts 0.83 s, of which ventricular :index[systole] (contraction) takes about 0.3 s and :index[diastole] (relaxation) the remaining 0.5 s. When the heart rate rises, diastole shortens far more than systole: at 180 beats per minute the whole cycle lasts 0.33 s and less than half of it is left for filling. The flow of blood through the heart is governed entirely by pressure differences and by four one-way valves. The mitral:index{term="heart!valves!mitral"} and tricuspid:index{term="heart!valves!tricuspid"} valves separate atria from ventricles; the aortic:index{term="heart!valves!aortic"} and pulmonary:index{term="heart!valves!pulmonary"} valves guard the exits of the ventricles. A valve opens when the pressure behind it exceeds the pressure ahead of it and closes when the gradient reverses. Nothing else moves them. It is convenient to divide the cycle into five phases. **Atrial systole.**:index{term="cardiac cycle!atrial systole"} Contraction of the atria pushes a final volume of blood into ventricles that are already mostly full. At rest this atrial kick:index{term="atrial kick" see="cardiac cycle!atrial systole"} adds only 10–20 % of ventricular filling; it matters more at high heart rates and in a stiff ventricle, and its loss in atrial fibrillation:index{term="atrial fibrillation"} is felt mainly on exertion. **Isovolumetric contraction.**:index{term="cardiac cycle!isovolumetric contraction"} As the ventricle begins to contract, its pressure rises above atrial pressure and the mitral valve closes, producing the first heart sound.:index{term="heart!sounds"} For about 0.05 s all four valves are shut. The ventricle contracts around a fixed volume of blood and its pressure climbs steeply. **Ejection.**:index{term="cardiac cycle!ejection"} When left ventricular pressure exceeds aortic pressure, about 80 mmHg, the aortic valve opens and blood leaves the ventricle, rapidly at first and then more slowly. Ventricular and aortic pressures rise together to a peak of about 120 mmHg. **Isovolumetric relaxation.**:index{term="cardiac cycle!isovolumetric relaxation"} As the muscle relaxes, ventricular pressure falls below aortic pressure; the aortic valve closes and gives the second heart sound, and a brief notch, the incisura,:index{term="incisura"} appears on the aortic pressure trace.:index{term="heart!sounds"} Again the ventricle is a closed chamber, now relaxing at constant volume. **Ventricular filling.**:index{term="cardiac cycle!filling"} Once ventricular pressure drops below atrial pressure, the mitral valve opens and blood that has collected in the atrium during systole rushes in. Most filling occurs in this first third of diastole; the middle third adds little, and atrial systole completes the process. ## The pressure–volume loop Plotting left ventricular pressure against volume turns the cycle into a single closed curve, the :index[pressure–volume loop]{main} (:ref{id="pv-loop"}). The ventricle ends diastole holding about 120 mL, the :index[end-diastolic volume]{main}, and ends systole with about 50 mL, the :index[end-systolic volume]. The difference, some 70 mL, is the :index[stroke volume]{main}. The fraction of the end-diastolic volume that is ejected, here 70/120 or 58 %, is the :index[ejection fraction]{main}; values between 55 and 70 % are normal, and the ejection fraction is the single number most often used to describe the pumping performance of a ventricle. The area enclosed by the loop is the external work done by the ventricle in one beat, the :index[stroke work]. Each side of the loop corresponds to one phase of the cycle, and each corner to the opening or closing of a valve, so a single loop records the whole beat.:index{term="cardiac cycle" range="end"} ## Cardiac output The volume of blood pumped by each ventricle per minute is the :index[cardiac output]{main}. It is the product of stroke volume and heart rate: 70 mL × 72 beats per minute gives about 5 L/min in a resting adult, which means that the entire blood volume passes through each side of the heart roughly once a minute. Because output scales with body size, it is often divided by body surface area;:index{term="body surface area"} the resulting :index[cardiac index] is about 3 L/min per square metre. During maximal exercise cardiac output rises four- to fivefold in an untrained young adult and to 30–35 L/min in an endurance athlete,:index{term="athletes, endurance"} whose larger heart achieves this mainly through a greater stroke volume.:index{term="exercise!cardiac output"} Every change in cardiac output is a change in :index[heart rate] or in stroke volume, or in both. The rest of this chapter considers each in turn. ## The sinoatrial node and the conduction system :index{term="heart!conduction system" range="start"} Cardiac muscle does not need a nerve to contract. The beat starts in the :index[sinoatrial node]{main}, a small strip of specialised muscle in the wall of the right atrium near the entry of the superior vena cava. Its cells have no stable resting potential: after each action potential the membrane depolarises slowly, largely through the so-called :index[funny current], until it reaches threshold and fires again. Isolated from all nervous influence, the node fires about 100 times a minute.:index{term="pacemaker" see="sinoatrial node"} The resting rate of 60–80 beats per minute is lower because the :index[vagus nerve] continuously releases :index[acetylcholine] onto the node and slows this pacemaker depolarisation. From the node the impulse spreads through both atria and converges on the :index[atrioventricular node]{main}, which conducts at only about 0.05 m/s.:index{term="AV node" see="atrioventricular node"} The resulting delay of roughly 0.1 s lets the atria finish emptying before the ventricles contract. Beyond the node the impulse runs down the :index[bundle of His] and its branches into the :index[Purkinje fibres], which conduct at up to 4 m/s and activate the whole ventricular mass within about 0.08 s. The sinoatrial node sets the pace only because it is the fastest. If it fails, the atrioventricular node takes over at 40–60 beats per minute, and the Purkinje fibres, if conduction through the node is blocked, at 15–40.:index{term="heart block"}:index{term="heart!conduction system" range="end"} ## Regulating stroke volume Three factors determine how much blood the ventricle ejects with each beat: the volume it holds before it contracts, the pressure it must overcome, and the strength of its contraction. :index{term="stroke volume!regulation" range="start"} **Preload and the Frank–Starling mechanism.** The more a ventricle is filled during diastole, the more forcefully it contracts and the more blood it ejects. This intrinsic property of heart muscle is the :index[Frank–Starling mechanism]{main}, after the German physiologist Otto Frank:index{term="Frank, Otto"} and the British physiologist Ernest Starling,:index{term="Starling, Ernest"} who described it in 1895 and 1914. Stretching a cardiac muscle fibre brings its :index[sarcomeres]{term="sarcomere"} towards an optimal length of about 2.2 µm, at which the overlap of actin and myosin filaments, and the sensitivity of the filaments to calcium, are greatest.:index{term="calcium!contraction"} The degree of stretch before contraction is the :index[preload]{main}, usually taken as the end-diastolic volume. The mechanism keeps the two ventricles in step: if the right ventricle pumps a little more for a few beats, more blood reaches the left ventricle, which stretches and ejects the extra volume. **Afterload.** The :index[afterload]{main seealso="blood pressure"} is the load the ventricle works against once it starts to eject, which for the left ventricle is essentially the aortic pressure. A sudden rise in afterload means the aortic valve opens later and closes earlier, so the ventricle ejects less and is left with a larger end-systolic volume. Over the next few beats the Frank–Starling mechanism restores the stroke volume at the cost of a larger heart. :::callout{type="clinical" title="Heart failure"} In :index[heart failure]{main} the heart cannot pump enough blood for the needs of the body at normal filling pressures. When the ejection fraction falls to 40 % or less, the condition is called heart failure with reduced ejection fraction;:index{term="heart failure!reduced ejection fraction"} many patients, however, have a normal ejection fraction and a stiff ventricle that fills poorly.:index{term="heart failure!preserved ejection fraction"} Older texts use the term cardiac insufficiency.:index{term="cardiac insufficiency" see="heart failure"}:index{term="heart failure" seealso="ejection fraction"} ::: **Contractility.** A ventricle can also eject more blood from the same end-diastolic volume if its fibres contract more strongly. This property, :index[contractility]{main} or inotropy,:index{term="inotropy" see="contractility"} is raised above all by the :index[sympathetic nervous system]{term="sympathetic nervous system!heart"}. Noradrenaline:index{term="noradrenaline"} released from sympathetic nerves acts on beta-1 :index[adrenergic receptors]{term="adrenergic receptors!beta-1"} of the muscle cells, increases calcium entry:index{term="calcium!contraction"} during each action potential, and makes each contraction stronger and shorter. The same transmitter acting on the sinoatrial node increases the heart rate, so sympathetic stimulation raises cardiac output through both of its factors at once.:index{term="norepinephrine" see="noradrenaline"} These three mechanisms rarely act alone. During exercise, venous return increases the preload, sympathetic activity raises contractility and heart rate, and dilatation of the muscle arterioles keeps the afterload from rising in proportion to the flow.:index{term="stroke volume!regulation" range="end"}:index{term="exercise!stroke volume"} ## Listening to the heart :index{term="heart!sounds" range="start" main} The closure of the valves can be heard through the chest wall, and auscultation with the :index[stethoscope], which the French physician René Laennec:index{term="Laennec, René"} introduced in 1816, remains the first examination of the heart. The first heart sound, low-pitched and relatively long, marks the closure of the mitral and tricuspid valves at the start of systole; it is best heard over the apex. The second, shorter and sharper, marks the closure of the aortic and pulmonary valves at the end of ejection. During inspiration the fall in intrathoracic pressure increases venous return to the right ventricle, which then ejects for slightly longer, so the pulmonary valve closes a few hundredths of a second after the aortic valve: the second sound is heard split.:index{term="heart!sounds!splitting of the second sound"} A third heart sound in early diastole comes from the rapid inflow of blood into the ventricle. It is normal in children and young athletes, but after the age of about forty it usually means a dilated, failing ventricle.:index{term="heart failure"} A fourth sound, just before the first, is produced by atrial systole against a stiff ventricle.:index{term="heart!sounds!third and fourth"} Between the sounds the heart is normally silent, because blood flows smoothly through open valves. A :index[murmur]{term="murmurs" seealso="turbulent flow"} is the noise of turbulent flow: through a narrowed valve that must still open (stenosis) or through a valve that fails to close (regurgitation). Its timing in the cycle tells the examiner which valve is at fault; an aortic stenosis, for example, produces a murmur during ejection, between the first and the second sounds.:index{term="heart!sounds" range="end"}:index{term="heart!valves!stenosis and regurgitation"}`; // chapter 14, with the book's frontmatter (content.<lang>.md) const vessels = String.raw`# Vessels and blood pressure {lead="The heart supplies the pressure; the vessels decide where the blood goes. This chapter explains how a few micrometres of change in the radius of small arteries redistribute the cardiac output, and how the brainstem keeps arterial pressure steady from one heartbeat to the next."}Markdown sample · 54 lines · content.vessels.en.md
Blood leaves the left ventricle in pulses and reaches the capillaries as a steady stream. Between the two lies a branching system of tubes whose walls differ in thickness, in elastic tissue and in smooth muscle, and whose properties explain most of what follows: why arterial pressure has a systolic and a diastolic value, why pressure falls so steeply in the smallest arteries, and why most of the blood in the body is found in the veins. ## The vascular tree The :index[aorta] and its large branches are :index[arteries]{term="arteries!elastic"} rich in elastic fibres. They expand as each stroke volume enters and recoil during diastole, so that flow continues between beats. Smaller :index[arteries]{term="arteries!muscular"} carry more smooth muscle and distribute blood to the organs. The :index[arterioles]{main}, 10–100 µm across, have the thickest walls for their size and are the main site of resistance to flow: as :ref{id="pressures"} shows, mean pressure falls from about 85 to 35 mmHg across them. By contracting or relaxing their smooth muscle, the arterioles set both the total resistance of the circulation and the share of the cardiac output that each organ receives. The :index[capillaries]{main} are tubes of a single layer of endothelial cells,:index{term="endothelium"} 5–8 µm in diameter, just wide enough for a red cell to squeeze through. There are some ten billion of them, with a total exchange surface of 500–700 m², and blood moves through them at about 0.3 mm/s, a thousandth of its mean velocity in the aorta.:index{term="blood flow!velocity"} Blood returns through :index[venules] and :index[veins]{main}, whose walls are thin and easily distended. At rest the systemic veins hold about 64 % of the blood volume, which is why they are called capacitance vessels.:index{term="capacitance vessels" see="veins"} ## Flow, pressure and resistance Flow through any vessel obeys a relation analogous to :index[Ohm's law]: flow equals the pressure difference between the two ends divided by the resistance, *Q* = (*P*~1~ − *P*~2~)/*R*. Applied to the whole systemic circulation, with a mean arterial pressure of 93 mmHg, a right atrial pressure close to zero and a cardiac output of 5 L/min, it gives a :index[total peripheral resistance]{term="resistance, vascular!total peripheral"} of about 19 mmHg·min/L. What determines the resistance of a vessel? For steady, streamlined flow of a fluid through a rigid tube, the answer is :index[Poiseuille's law]{main}, derived in the 1840s by the French physician Jean Poiseuille:index{term="Poiseuille, Jean"} from experiments on water flowing through glass capillaries. The resistance is proportional to the length of the tube and to the :index[viscosity] of the fluid, and inversely proportional to the fourth power of the radius, *r*^4^. Halving the radius of an arteriole multiplies its resistance by sixteen; a rise in radius of only 19 % doubles the flow through it at the same pressure. This extreme sensitivity is what gives the arterioles their control over the circulation. The viscosity of blood, about three to four times that of water, depends mainly on the haematocrit, and it rises steeply when the haematocrit exceeds 60 %.:index{term="haematocrit"} Poiseuille's law assumes :index[laminar flow], in which the blood moves in concentric layers, fastest at the centre. When velocity is high, the vessel wide or the wall irregular, the layers break into eddies. The tendency to such :index[turbulent flow]{seealso="Reynolds number"} is expressed by the :index[Reynolds number], the product of the density of blood, its velocity and the diameter of the vessel divided by the viscosity. Above about 2000 flow becomes turbulent, and turbulence is audible: a murmur:index{term="murmurs"} over a narrowed valve, a :index[bruit] over a stenosed artery, and the :index[Korotkoff sounds] heard when blood pressure is measured with a cuff. Vessels arranged in series add their resistances; vessels arranged in parallel add their conductances, so the total resistance of a parallel network is lower than that of any one of its branches.:index{term="resistance, vascular!series and parallel"} The organs of the body are supplied in parallel, which allows each to adjust its own blood flow without much effect on the others. ## Arterial pressure :index{term="blood pressure" range="start"} In a healthy young adult arterial pressure rises to about 120 mmHg with each ejection, the :index[systolic pressure]{term="blood pressure!systolic"}, and falls to about 80 mmHg before the next, the :index[diastolic pressure]{term="blood pressure!diastolic"}. The difference, 40 mmHg, is the :index[pulse pressure]{term="blood pressure!pulse pressure"}. Because diastole lasts about twice as long as systole at rest, the average pressure over the cycle lies closer to the diastolic value. It is estimated as the diastolic pressure plus one-third of the pulse pressure: :::paragraphs{style="formula"} MAP = DBP + (SBP − DBP)/3 = 80 + 40/3 = 93 mmHg ::: This :index[mean arterial pressure]{term="blood pressure!mean arterial" main} is the pressure that drives blood through the systemic circulation. At high heart rates diastole shortens and the true mean moves towards the arithmetic mean of systolic and diastolic pressure.:index{term="mean arterial pressure" see="blood pressure!mean arterial"} The pulse pressure depends mainly on two quantities: the stroke volume and the :index[arterial compliance]{term="compliance, arterial" main}, the change in arterial volume per unit change in pressure. The elastic arteries store about half of each stroke volume during systole and return it during diastole, an arrangement known as the :index[Windkessel effect]{main} after the air chamber of eighteenth-century fire engines, which turned the strokes of the pump into a steady jet. With age the aorta stiffens,:index{term="ageing!arteries"} compliance falls, and the same stroke volume produces a higher systolic and a lower diastolic pressure. :::callout{type="clinical" title="Hypertension"} Sustained :index[hypertension]{main} is diagnosed when the arterial pressure measured at rest on repeated occasions is 140/90 mmHg or higher; American guidelines since 2017 use 130/80 mmHg. In older people a high systolic pressure with a normal or low diastolic pressure, isolated systolic hypertension, reflects the loss of aortic compliance.:index{term="hypertension!isolated systolic"}:index{term="ageing!arteries"} ::: ## Capillary exchange Across the capillary wall, fluid movement is determined by the balance between hydrostatic pressure, which pushes fluid out, and the :index[colloid osmotic pressure]{main} of the plasma proteins,:index{term="oncotic pressure" see="colloid osmotic pressure"} which holds it in. These :index[Starling forces]{term="capillaries!Starling forces"} favour filtration at the arterial end of the capillary, where the hydrostatic pressure:index{term="capillaries!hydrostatic pressure"} is about 35 mmHg against an oncotic pressure of 25 mmHg, and reabsorption at the venous end, where the hydrostatic pressure has fallen to about 15 mmHg. Of the 20 L or so filtered each day in the systemic capillaries, some 17 L return directly to the blood; the rest is carried back by the :index[lymph]{main} vessels. When filtration outstrips this return, fluid gathers in the tissues as oedema.:index{term="oedema"}:index{term="Starling forces" see="capillaries!Starling forces"} ## The baroreceptor reflex :index{term="baroreceptor reflex" range="start" main} Arterial pressure is held within a few millimetres of mercury of its set point from moment to moment by the baroreceptor reflex. :index[Baroreceptors]{term="baroreceptors"} are stretch-sensitive nerve endings in the wall of the :index[carotid sinus]{term="baroreceptors!carotid sinus"}, at the bifurcation of each common carotid artery, and in the :index[aortic arch]{term="baroreceptors!aortic arch"}. Their firing rises with each systolic distension. Signals from the carotid sinus travel in the :index[glossopharyngeal nerve] and those from the aortic arch in the :index[vagus nerve]{seealso="baroreceptor reflex"}; both end in the :index[nucleus tractus solitarius] of the medulla.:index{term="medulla oblongata"} A rise in pressure increases baroreceptor firing, which inhibits the sympathetic outflow to the heart and vessels and increases vagal activity. Heart rate and contractility fall, the arterioles dilate, and pressure returns towards normal.:index{term="sympathetic nervous system!vessels"} A fall in pressure has the opposite effects. The carotid receptors respond between about 60 and 180 mmHg and are most sensitive around the normal mean arterial pressure. Within one or two days, however, they reset to any pressure that is sustained, so the reflex corrects rapid changes but cannot, by itself, set the long-term level of arterial pressure, which depends on the handling of salt and water by the kidneys.:index{term="kidneys!long-term control of pressure"}:index{term="blood pressure!long-term control"}:index{term="blood pressure" range="end"} The reflex is tested every time a person stands up.:index{term="standing, circulatory effects of"} Some 500 mL of blood pools in the veins of the legs, venous return and stroke volume fall, and arterial pressure starts to drop; within a few seconds the reflex raises the heart rate and constricts the arterioles. When it fails, as it may in the elderly or in diseases of the autonomic nerves, standing causes a fall in systolic pressure of 20 mmHg or more, :index[orthostatic hypotension]{term="hypotension, orthostatic" main}, with dizziness or fainting.:index{term="fainting" see="hypotension, orthostatic"}:index{term="baroreceptor reflex" range="end"}:index{term="venous return"} ## Venous return :index{term="venous return" range="start" main} Whatever the heart pumps out must first come back to it. The flow of blood from the veins into the right atrium, the venous return, is driven by a small pressure difference: about 7 mmHg between the venules and the right atrium, where the :index[central venous pressure] is close to zero. Because the veins are so compliant, a small change in their tone moves a large volume of blood. Sympathetic constriction of the veins can shift several hundred millilitres from the peripheral veins towards the heart within seconds, raising the preload and, through the Frank–Starling mechanism, the stroke volume.:index{term="sympathetic nervous system!vessels"} Two pumps outside the heart help. In the legs, the deep veins run between the muscles and contain one-way valves every few centimetres.:index{term="veins!valves"} Each contraction of the calf squeezes the veins and drives blood upwards, and the valves stop it from falling back; this :index[skeletal muscle pump]{main} can lower the venous pressure at the ankle of a walking person from about 90 mmHg to 25 mmHg or less. When the valves fail, the veins of the legs dilate and become tortuous, as :index[varicose veins].:index{term="veins!varicose" see="varicose veins"} In the chest, each inspiration lowers the intrathoracic pressure and raises the abdominal pressure, drawing blood from the abdominal veins into the thorax: the :index[respiratory pump]. Over any period longer than a few beats, venous return and cardiac output are equal. This is why the heart, although it generates the pressure, is not the only organ that sets the output: in a healthy person at rest, the cardiac output follows the venous return, and the venous return follows the metabolic needs of the tissues, each of which controls its own blood flow through its arterioles.:index{term="venous return" range="end"}:index{term="cardiac output!venous return"}`; // chapter 15 (content.vessels.<lang>.md) const indexChapter = String.raw`# Index {style="index" lead="Numbers in bold mark the principal discussion of a term. A range such as 301–3 means that the discussion runs across those pages. *See* sends you to the entry that holds the page numbers; *see also* to a related entry."}Markdown sample · 6 lines · content.index.en.md
:::index :::callout{type="colophon"} *Principles of Human Physiology*, Part IV, chapters 14 and 15, a sample set with Postext. Set in Literata and Libre Franklin (SIL OFL 1.1). Text and drawing: original, CC BY 4.0. The book and its authors are fictional. :::`; // # Index and :::index (content.index.<lang>.md) const chapters = [heart, vessels, indexChapter].map((markdown) => ({ markdown })); // #region art: the pressure–volume loop of Figure 14.1, drawn in code // No text in the drawing: an SVG image cannot use the page's web fonts (gotcha: svg-no-webfonts). const PX = 10; // SVG pixels per unit const [W, H] = [100, 52]; const vx = (volume) => 12 + volume * 0.56; // 0–150 mL across const py = (pressure) => 47 - pressure * 0.3; // 0–140 mmHg up const pv = (v, p) => `${vx(v).toFixed(1)} ${py(p).toFixed(1)}`; function pvLoop() { const stroke = (id, w, extra = '') => `fill="none" stroke="${palette[id]}" stroke-width="${w}" ${extra}`; const loop = `M${pv(50, 6)}Q${pv(88, 2)} ${pv(120, 10)}L${pv(120, 80)}` + `C${pv(108, 128)} ${pv(70, 132)} ${pv(50, 100)}Z`; const arrow = (x, y, dx, dy) => `<path d="M${x} ${y}l${dx} ${dy}" ${stroke('ink', 0.5)}/>` + `<path d="M${x + dx} ${y + dy}${dx ? 'l-2.6 -1.2v2.4z' : 'l-1.2 2.6h2.4z'}"` + ` fill="${palette.ink}"/>`; const guide = (v, p) => `<path d="M${pv(v, p)}V${py(0)}" ${stroke('muted', 0.35, 'stroke-dasharray="1.2 1"')}/>`; const dot = (v, p) => `<circle cx="${vx(v)}" cy="${py(p)}" r="1.5" fill="${palette.ink}"/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * PX}" height="${H * PX}" ` + `viewBox="0 0 ${W} ${H}"><path d="${loop}" fill="${palette.tint}"/>` + `<path d="M${pv(10, 0)}L${pv(62, 130)}" ` + `${stroke('muted', 0.45, 'stroke-dasharray="2 1.4"')}/>` + guide(50, 6) + guide(120, 10) + `<path d="${loop}" ${stroke('accent', 1.1, 'stroke-linejoin="round"')}/>` + [[50, 6], [120, 10], [120, 80], [50, 100]].map(([v, p]) => dot(v, p)).join('') + arrow(vx(0), py(0), W - 16, 0) + arrow(vx(0), py(0), 0, -(py(0) - 3)) + '</svg>'; } // #endregion const TABLE = t({ en: [['Segment', 'Blood volume (%)', 'Mean pressure (mmHg)'], ['Arteries', '13', '100 to 85'], ['Arterioles', '7', '85 to 35'], ['Capillaries', '', '35 to 15'], ['Venules and veins', '64', '15 to 0'], ['Heart', '7', '–'], ['Pulmonary circulation', '9', '15 to 8']], es: [['Segmento', 'Volumen de sangre (%)', 'Presión media (mmHg)'], ['Arterias', '13', '100 a 85'], ['Arteriolas', '7', '85 a 35'], ['Capilares', '', '35 a 15'], ['Vénulas y venas', '64', '15 a 0'], ['Corazón', '7', '–'], ['Circulación pulmonar', '9', '15 a 8']], }); // Arterioles and capillaries share one volume cell: the cell under a rowSpan stays in the row, // marked hiddenBy (gotcha: merged-cells-hiddenby). const rows = TABLE.map((row, r) => row.map((content, c) => ({ content, ...(r === 0 && { isHeader: true }), ...(r === 2 && c === 1 && { rowSpan: 2 }), ...(r === 3 && c === 1 && { hiddenBy: { row: 2, col: 1 } }) }))); const resources = [ { id: 'pv-loop', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'pv-loop.svg', width: W * PX, height: H * PX }, caption: t({ en: 'Pressure–volume loop of the left ventricle at rest: volume from 0 to 150 mL across, ' + 'pressure from 0 to 140 mmHg up. The dots, anticlockwise from bottom right: mitral ' + 'valve closes (120 mL), aortic valve opens (80 mmHg), aortic valve closes, mitral ' + 'valve opens (50 mL). Dashed: the end-systolic pressure–volume relation.', es: 'Bucle presión-volumen del ventrículo izquierdo en reposo: volumen de 0 a 150 mL en ' + 'horizontal, presión de 0 a 140 mmHg en vertical. Los puntos, en sentido antihorario ' + 'desde abajo a la derecha: cierre de la válvula mitral (120 mL), apertura de la aórtica ' + '(80 mmHg), cierre de la aórtica y apertura de la mitral (50 mL). A trazos, la relación ' + 'presión-volumen telesistólica.' }), altText: t({ en: 'A closed loop of ventricular pressure against volume.', es: 'Un bucle cerrado de presión ventricular frente a volumen.' }) }, { id: 'pressures', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, caption: t({ en: 'Blood volume and mean pressure along the circulation of a resting adult.', es: 'Volumen de sangre y presión media a lo largo de la circulación de un adulto en ' + 'reposo.' }), table: { model: { headerRowCount: 1, columnWidths: [2.2, 1.4, 1.6], rows } } }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { Literata: ['400', '400i', '700', '700i'], 'Libre Franklin': ['400', '600', '700', '800'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region build: one book of three documents; one PDF whose index numbers are links const text = chapters.map((chapter) => chapter.markdown).join('\n'); await loadFonts(FONTS, text); await loadSvg('pv-loop.svg', pvLoop()); const docs = await buildWithFonts(() => buildBundle({ chapters, config: config(), resources }), text); showPages(docs, { title: t({ en: 'Principles of Human Physiology', es: 'Principios de fisiología humana' }) }); offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // #endregionKit · core, fonts, viewer, pdf, images: the same in every recipe · 310 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── 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
#Write every range in full
Some house styles print both numbers of a range in full.
- rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
+ rangeFormat: 'full', rangeSeparator: t({ en: '–', es: '-' }),#An index without letter heads
A short index often runs its groups together, separated only by a blank line.
- groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
+ groups: { enabled: false },Pitfalls
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 →
Pitfall
Localise Figure/Table with defaultResourceTypes(locale)
The config's locale sets hyphenation, not captions: without resourceTypes the built-in types say Figure and Table in English. Pass resourceTypes: defaultResourceTypes('es') for Spanish; for any other language, write the names yourself in resourceTypes. Figure and Table in your language →
Pitfall
Text inside an SVG <img> cannot use web fonts
An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →
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 →
- The capture of this recipe does not report
indexSeeUnknown: a cross-reference whose target is spelled differently from the entry (Reynolds number against number, Reynolds) prints without complaint. Read every See in the finished index against its target. - A sub-entry list that breaks across columns does not repeat its main heading as a heart (cont.) line; an entry with no page of its own always stays with its first sub-entry, so a heading never ends a column alone.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Literata (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)


