跳到主要内容
食谱编号72

排版食谱 · 第5章 · 图书结构

随正文变动的教科书索引

在正文讲解术语的地方做标记,由:::index分两栏印出,页码由buildBundle找出,支持页码范围和交叉引用。

本页内容
类型
教科书
输出
Canvas · PDF
难度
高级
Postext
已用Postext 1.7.0测试
需要≥ 1.7.0 · postext-pdf ≥ 1.7.0
许可证
更新于2026年9月28日
代码MIT · 文本CC BY 4.0

页码304–305 · 第8–9页,共10页

  • 英文样例:尚无中文版本
  • 成品尺寸210 × 277 mm
  • 一栏半, 栏间距7 mm
  • Literata 9.6/13.6
  • Libre Franklin
  • 10页
  • 难度
  • Postext 1.7.0
  • 排版用时59 ms
  • 212行代码

成品一览

这是Principles of Human Physiology的第四篇,一本210 × 277 mm开本的教科书:第14章The heart as a pump和第15章Vessels and blood pressure都从右页开始,页首有红色横带,临床提示和题注排在外侧的边栏里。这一篇以一份索引结束,约九十个条目、三十个子条目,按医学教科书的惯例分两栏排:条目用小写,字母标题用强调色,子条目缩进一个em,给出定义的页码用粗体,see和see also用斜体。索引里没有一个页码是手工输入的。每个术语都在正文讲解它的那一章里做了标记,索引读取这些标记落在的页码,即第297页到第304页。

这道食谱解答

  • 怎样做一份页码随正文移动自动更新的书末索引?

简短回答

script.js · 第45–68行在完整代码中
// 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') },
};

用料

类型
Literata, Libre Franklin(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 标记段落,而不是词

script.js · 第45–68行在完整代码中
// 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') },
};

编索引的人标记的是讲解术语的地方,而不是这个词出现的每一处。两章里stroke volume用了十二次,索引只把读者引向两处:给出定义的第298页,以及讲解其调节机制的299–300页。可见标记:index[cardiac output]{main}会印出它的文字并收入索引。不可见标记mitral:index{term="heart!valves!mitral"}什么也不印,只取它前面那个词所在的页码;讲四个瓣膜的那段文字就是这样把mitral、tricuspid、aortic和pulmonary归到heart之下,而不必改写句子。!用来分隔层级,所以heart下有三级:valves,再到各个瓣膜。标记也可以放在标题里(14.1和15.6的页码范围就从标题开始),或者放在标注框里(heart failure在第300页的旁注中定义)。

#2 · 主要页码、页码范围和交叉引用

script.js · 第45–68行在完整代码中
// 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让定义术语的那一页用粗体印出,学生查preload时会先翻到第299页。对同一术语使用range="start"和range="end",可以框住一段跨过分页的论述:stroke volume, regulation, 299–300、baroreceptor reflex, 303–4。起点上加main,整个范围都用粗体。在范围内部再次标记的页码会并入这个范围,如果那里带main,范围也用粗体。see取代页码(inotropy. See contractility),seealso跟在页码之后(heart failure, 300. See also ejection fraction)。有层级的目标用!书写,印出时用冒号:See blood pressure: mean arterial。

#3 · 分两栏的索引章

script.js · 第45–68行在完整代码中
// 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') },
};

索引是全书最后一个Markdown文档,内容是# Index {style="index"}加上:::index。这个标题样式只在它自己的部分里把各章的一栏半换成两栏、栏间距6 mm,并给索引单独的章首页和书眉。索引用正文字体排,8.6/11.4 pt,每一级子条目缩进一个em,转行悬挂缩进两个em,这样转行永远不会和子条目对齐。rangeFormat: 'chicago'省去页码范围中重复的数字(303–4,但299–300保持不变)。西班牙语版按西班牙语图书的通常做法,用连字符写出完整的范围(303-304)。

#4 · 页码为什么能跟上正文

script.js · 第376–384行在完整代码中
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按顺序排各章,发现第三个文档含有:::index。第一遍排完后,它收集每一章的每个标记及其所在页码,把这份列表作为大纲交给索引章。然后重新排整本书,最多三遍,直到没有页码再变动。改写第14章的一段、改变成品尺寸或正文字号,索引都会按新页码重新印出。在PDF里,每个页码都链接到对应的页面。

#5 · 章首页和书眉

script.js · 第72–91行在完整代码中
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) } } },
  ] } };
}

各章和索引共用一种章首页:一条从裁切边延伸到62 mm处的横带(索引为46 mm),标题由一个底部对齐的框排在横带底边上,所以两行标题和一行标题结束在同一条线上。眉题印出{chapterNumber},由标题属性{startAt=14}设为14;page.pageNumbering.startAt: 297把这一篇放在它在整本书中应处的页码位置。索引样式设为numbered: false,所以它不会成为第16章。

完整食谱

沙盒
// ═══ 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样例 · 68行 · content.en.mdtitle: "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样例 · 54行 · 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样例 · 6行 · 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`); // #endregion
工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行// ─── 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 ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗

变化

#页码范围全部写全

有些出版社的体例要求范围的两个页码都完整印出。

-  rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
+  rangeFormat: 'full', rangeSeparator: t({ en: '–', es: '-' }),

#不带字母标题的索引

篇幅短的索引常把各组连排,只用一个空行隔开。

-  groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
+  groups: { enabled: false },

常见问题

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

合并单元格需要hiddenBy占位:使用mergeCells

单元格按其在行数组中的位置排布,所以合并单元格延伸到的地方需要标记hiddenBy的占位单元格;像HTML那样省略它们,后面的每一列都会错位。用mergeCells来合并。 由数据生成的表 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

  • 本食谱的截图不会报告indexSeeUnknown:交叉引用的目标拼写与条目不一致时(Reynolds number对number, Reynolds),照样印出,不会提示。要对照目标核对成品索引里的每一个See。
  • 跨栏断开的子条目列表不会以*heart (cont.)*这样的行重复主标题;没有自身页码的条目总是和它的第一个子条目排在一起,所以标题永远不会单独落在栏尾。

致谢

文本
原创文字, CC BY 4.0
字体
Literata (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
沙盒PDF