Lo que vas a componer
La parte IV de Principios de fisiología humana, un manual en página de 210 × 277 mm: el capítulo 14, El corazón como bomba, y el 15, Los vasos y la presión arterial, se abren en página impar bajo una banda roja y llevan los recuadros clínicos y los pies en una columna lateral exterior. La parte se cierra con un índice analítico de unas noventa entradas y treinta subentradas a dos columnas, como lo compone un manual de medicina: entradas en minúscula, letras de grupo en el color de acento, subentradas a un cuadratín, la página que define el término en negrita, véase y véase también en cursiva. Ningún número de página se ha tecleado. Cada término está marcado en el capítulo, donde el texto lo explica, y el índice lee las páginas en que han caído esas marcas, de la 297 a la 304.
Esta receta responde a
- ¿Cómo hago un índice analítico cuyos números de página cambien solos cuando el texto se mueve?
La respuesta corta
// 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') },
};
Ingredientes
- Funciones
- Índice analíticoLibros construidos capítulo a capítuloEstilos de títuloAperturas diseñadasAtributos de títuloTítulos numeradosCabeceras y foliosCabeceras según el tipo de páginaMárgenes simétricosColumna al margen para flotantesNotas al margenPies lateralesCapítulos sin númeroRecuadros flotantesRecuadrosPies numeradosExportación a PDF
- También usa
- Citas que colocan las figurasColumna y mediaFigura y Tabla en tu idiomaBanda de capítulo a todo el anchoEstilos de párrafoFuentes incrustadas en el PDFTipos de recurso propiosFiguras y tablas como recursosPreliminares en romanosGeometría por secciónCabeceras por secciónSuperíndices y subíndices
- Tipografía
- Literata, Libre Franklin (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Marca el pasaje, no la palabra
// 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') },
};
Quien hace un índice marca los lugares donde se explica un término, no cada vez que aparece la palabra. Los dos capítulos dicen volumen sistólico una docena de veces, y el índice manda al lector a dos sitios: la página 298, donde se define, y la 299-300, donde se explica qué lo regula. Una marca visible, :index[gasto cardíaco]{main}, imprime sus palabras y las archiva. Una invisible, mitral:index{term="corazón!válvulas!mitral"}, no imprime nada y toma la página de la palabra anterior; así el pasaje de las cuatro válvulas archiva mitral, tricúspide, aórtica y pulmonar bajo corazón sin tocar la frase. El ! separa niveles: corazón tiene tres, válvulas y luego cada válvula. Una marca funciona también en un título (los intervalos de 14.1 y 15.6 empiezan ahí) y en un recuadro (insuficiencia cardíaca se define en la nota lateral de la página 300).
#2 · Páginas principales, intervalos y remisiones
// 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 pone en negrita la página que define un término, de modo que quien busca precarga va primero a la 299. range="start" y range="end", con el mismo término, acotan una explicación que cruza un salto de página: volumen sistólico, regulación, 299-300. Un main en el inicio pone en negrita todo el intervalo. Una página marcada otra vez dentro de su propio intervalo se funde con él, y un main ahí pone el intervalo en negrita. see sustituye a los números de página (inotropismo. Véase contractilidad) y seealso va detrás de ellos (insuficiencia cardíaca, 300. Véase también fracción de eyección). Un destino con niveles se escribe con ! y se imprime con dos puntos: Véase presión arterial: media.
#3 · Un capítulo de índice a dos columnas
// 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') },
};
El índice es el último documento Markdown del libro: # Índice analítico {style="index"} y, debajo, :::index. El estilo de título cambia la columna y media de los capítulos por dos columnas separadas 6 mm, solo en su sección, y le da al índice su propia apertura y su cabecera. Se compone en la letra del texto a 8,6/11,4 pt, un cuadratín por nivel de subentrada, y las líneas que se parten sangran dos cuadratines, para que nunca queden alineadas con una subentrada. En español los intervalos se escriben completos y con guion (303-304), como en los libros en castellano; la edición inglesa usa rangeFormat: 'chicago', que suprime las cifras que se repiten (303–4, pero 299–300).
#4 · Por qué los números siguen al texto
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 compone los capítulos en orden y ve que el tercero contiene :::index. Tras la primera pasada reúne las marcas de todos los capítulos con la página en que ha caído cada una y se las da al capítulo del índice como esquema. Después vuelve a componer el libro, tres pasadas como mucho, hasta que ningún número se mueve. Reescribe un párrafo del capítulo 14, cambia el formato o el cuerpo de letra, y el índice se reimprime con las páginas nuevas. En el PDF cada número de página es un enlace a su página.
#5 · Aperturas y cabeceras
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) } } },
] } };
}
Capítulos e índice comparten una apertura: una banda desde el corte hasta 62 mm (46 mm en el índice), con el título apoyado en su pie mediante una caja alineada abajo, de modo que un título de dos líneas y uno de una acaban en la misma línea. El antetítulo imprime {chapterNumber}, que el atributo {startAt=14} del título fija en 14, y page.pageNumbering.startAt: 297 coloca la parte donde estaría en el libro. El estilo del índice lleva numbered: false, así que no se convierte en el capítulo 16.
La receta completa
// ═══ 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 = 'es'; // @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`---Muestra en Markdown · 66 líneas · content.es.md
title: "Principios de fisiología humana" subtitle: "Parte IV · La circulación" author: "Elena Marsh y Tomás Ibarra" --- # El corazón como bomba {startAt=14 lead="Dos bombas musculares en serie mueven todo el volumen de sangre por el cuerpo aproximadamente una vez por minuto en reposo. Este capítulo sigue un latido desde la primera señal eléctrica hasta la última gota expulsada, y después se pregunta qué fija el tamaño de cada latido."} El corazón humano son dos bombas reunidas en un solo órgano. El lado derecho recibe la sangre venosa del cuerpo y la impulsa a través de los pulmones; el izquierdo recibe la sangre oxigenada y la impulsa por la circulación sistémica. Como ambas bombas están conectadas en serie, en cualquier intervalo más largo que unos pocos latidos tienen que mover exactamente el mismo volumen de sangre. Lo que sigue se centra en el ventrículo izquierdo; el derecho hace el mismo trabajo contra una presión unas cinco veces menor.:index{term="ventrículo!derecho"} ## El ciclo cardíaco :index{term="ciclo cardíaco" range="start" main} La secuencia de acontecimientos que va desde el comienzo de un latido hasta el comienzo del siguiente es el ciclo cardíaco. Con una frecuencia de 72 latidos por minuto, cada ciclo dura 0,83 s: la :index[sístole] ventricular (contracción) ocupa unos 0,3 s y la :index[diástole] (relajación), los 0,5 s restantes. Cuando la frecuencia aumenta, la diástole se acorta mucho más que la sístole: a 180 latidos por minuto el ciclo entero dura 0,33 s y queda menos de la mitad para el llenado. El paso de la sangre por el corazón depende solo de diferencias de presión y de cuatro válvulas que se abren en un único sentido. Las válvulas mitral:index{term="corazón!válvulas!mitral"} y tricúspide:index{term="corazón!válvulas!tricúspide"} separan aurículas y ventrículos; la aórtica:index{term="corazón!válvulas!aórtica"} y la pulmonar:index{term="corazón!válvulas!pulmonar"} guardan la salida de los ventrículos. Una válvula se abre cuando la presión que tiene detrás supera a la que tiene delante, y se cierra cuando el gradiente se invierte. Conviene dividir el ciclo en cinco fases. **Sístole auricular.**:index{term="ciclo cardíaco!sístole auricular"} La contracción de las aurículas empuja un último volumen de sangre hacia unos ventrículos que ya están casi llenos. En reposo esta contribución auricular:index{term="contribución auricular" see="ciclo cardíaco!sístole auricular"} aporta solo el 10-20 % del llenado ventricular; pesa más con frecuencias altas y en un ventrículo rígido, y su pérdida en la fibrilación auricular:index{term="fibrilación auricular"} se nota sobre todo con el esfuerzo. **Contracción isovolumétrica.**:index{term="ciclo cardíaco!contracción isovolumétrica"} Al comenzar la contracción, la presión ventricular supera a la auricular, la válvula mitral se cierra y se oye el primer ruido cardíaco.:index{term="corazón!ruidos"} Durante unos 0,05 s las cuatro válvulas están cerradas: el ventrículo se contrae alrededor de un volumen fijo de sangre y su presión sube con rapidez. **Eyección.**:index{term="ciclo cardíaco!eyección"} Cuando la presión del ventrículo izquierdo supera a la aórtica, unos 80 mmHg, la válvula aórtica se abre y la sangre sale del ventrículo, deprisa al principio y más despacio después. Las presiones ventricular y aórtica suben juntas hasta un máximo de unos 120 mmHg. **Relajación isovolumétrica.**:index{term="ciclo cardíaco!relajación isovolumétrica"} Al relajarse el músculo, la presión ventricular cae por debajo de la aórtica; la válvula aórtica se cierra, se oye el segundo ruido y aparece en el trazado de la presión aórtica una breve muesca, la incisura.:index{term="incisura"}:index{term="corazón!ruidos"} El ventrículo vuelve a ser una cámara cerrada, que ahora se relaja a volumen constante. **Llenado ventricular.**:index{term="ciclo cardíaco!llenado"} En cuanto la presión ventricular baja de la auricular, la válvula mitral se abre y entra de golpe la sangre que se ha acumulado en la aurícula durante la sístole. La mayor parte del llenado ocurre en este primer tercio de la diástole; el tercio medio añade poco, y la sístole auricular completa el proceso. ## El bucle presión-volumen Si se representa la presión del ventrículo izquierdo frente a su volumen, el ciclo se convierte en una sola curva cerrada, el :index[bucle presión-volumen]{main} (:ref{id="pv-loop"}). El ventrículo termina la diástole con unos 120 mL, el :index[volumen telediastólico]{main}, y la sístole con unos 50 mL, el :index[volumen telesistólico]. La diferencia, unos 70 mL, es el :index[volumen sistólico]{main}. La fracción del volumen telediastólico que se expulsa, aquí 70/120, o un 58 %, es la :index[fracción de eyección]{main}; los valores entre el 55 y el 70 % son normales, y es la cifra que más se usa para describir cómo bombea un ventrículo. El área que encierra el bucle es el trabajo externo que realiza el ventrículo en un latido, el :index[trabajo sistólico]. Cada lado del bucle corresponde a una fase del ciclo, y cada vértice a la apertura o el cierre de una válvula, de modo que un solo bucle recoge el latido entero.:index{term="ciclo cardíaco" range="end"} ## El gasto cardíaco El volumen de sangre que bombea cada ventrículo por minuto es el :index[gasto cardíaco]{main}. Es el producto del volumen sistólico por la frecuencia cardíaca: 70 mL × 72 latidos por minuto dan unos 5 L/min en un adulto en reposo. Como el gasto crece con el tamaño corporal, suele dividirse por la superficie corporal;:index{term="superficie corporal"} el :index[índice cardíaco] resultante es de unos 3 L/min por metro cuadrado. En un ejercicio máximo, el gasto se multiplica por cuatro o cinco en un adulto joven no entrenado y llega a 30-35 L/min en un deportista de fondo,:index{term="deportistas de fondo"} cuyo corazón, más grande, lo consigue sobre todo con un volumen sistólico mayor.:index{term="ejercicio!gasto cardíaco"} Todo cambio del gasto cardíaco es un cambio de la :index[frecuencia cardíaca], del volumen sistólico o de ambos. ## El nódulo sinoauricular y el sistema de conducción :index{term="corazón!sistema de conducción" range="start"} El músculo cardíaco no necesita un nervio para contraerse. El latido nace en el :index[nódulo sinoauricular]{main}, una pequeña franja de músculo especializado en la pared de la aurícula derecha, junto a la desembocadura de la vena cava superior. Sus células no tienen un potencial de reposo estable: después de cada potencial de acción la membrana se despolariza despacio, en buena parte por la llamada :index[corriente funny], hasta alcanzar el umbral y disparar de nuevo. Aislado de toda influencia nerviosa, el nódulo descarga unas 100 veces por minuto.:index{term="marcapasos" see="nódulo sinoauricular"} La frecuencia de reposo, de 60-80 latidos por minuto, es menor porque el :index[nervio vago] libera sin cesar :index[acetilcolina] sobre el nódulo y frena esa despolarización. Desde el nódulo, el impulso recorre las dos aurículas y converge en el :index[nódulo auriculoventricular]{main}, que conduce a solo unos 0,05 m/s. El retraso resultante, de alrededor de 0,1 s, deja que las aurículas terminen de vaciarse antes de que se contraigan los ventrículos. Pasado el nódulo, el impulso baja por el :index[haz de His] y sus ramas hasta las :index[fibras de Purkinje], que conducen hasta a 4 m/s y activan toda la masa ventricular en unos 0,08 s. El nódulo sinoauricular marca el ritmo solo porque es el más rápido. Si falla, el nódulo auriculoventricular toma el relevo a 40-60 latidos por minuto, y las fibras de Purkinje, si la conducción por el nódulo está bloqueada, a 15-40.:index{term="bloqueo cardíaco"}:index{term="corazón!sistema de conducción" range="end"} ## La regulación del volumen sistólico Tres factores deciden cuánta sangre expulsa el ventrículo en cada latido: el volumen que contiene antes de contraerse, la presión que debe vencer y la fuerza de su contracción. :index{term="volumen sistólico!regulación" range="start"} **Precarga y mecanismo de Frank-Starling.** Cuanto más se llena un ventrículo durante la diástole, con más fuerza se contrae y más sangre expulsa. Esta propiedad intrínseca del músculo cardíaco es el :index[mecanismo de Frank-Starling]{term="Frank-Starling, mecanismo de" main}, por el fisiólogo alemán Otto Frank:index{term="Frank, Otto"} y el británico Ernest Starling,:index{term="Starling, Ernest"} que lo describieron en 1895 y 1914. Al estirarse, una fibra muscular cardíaca lleva sus :index[sarcómeros]{term="sarcómero"} hacia una longitud óptima de unos 2,2 µm, en la que son máximos el solapamiento de los filamentos de actina y miosina y su sensibilidad al calcio.:index{term="calcio!contracción"} El grado de estiramiento previo a la contracción es la :index[precarga]{main}, que suele medirse como el volumen telediastólico. **Poscarga.** La :index[poscarga]{main seealso="presión arterial"} es la carga contra la que trabaja el ventrículo una vez empieza a expulsar sangre; para el ventrículo izquierdo es, en esencia, la presión aórtica. Si la poscarga sube de repente, la válvula aórtica se abre más tarde y se cierra antes, de modo que el ventrículo expulsa menos y se queda con un volumen telesistólico mayor. En los latidos siguientes el mecanismo de Frank-Starling recupera el volumen sistólico a costa de un corazón más grande. :::callout{type="clinical" title="Insuficiencia cardíaca"} En la :index[insuficiencia cardíaca]{main} el corazón no puede bombear la sangre que el organismo necesita con presiones de llenado normales. Cuando la fracción de eyección baja al 40 % o menos se habla de insuficiencia cardíaca con fracción de eyección reducida;:index{term="insuficiencia cardíaca!fracción de eyección reducida"} muchos pacientes, sin embargo, tienen una fracción de eyección normal y un ventrículo rígido que se llena mal.:index{term="insuficiencia cardíaca!fracción de eyección conservada"} En el lenguaje clínico se dice también fallo cardíaco.:index{term="fallo cardíaco" see="insuficiencia cardíaca"}:index{term="insuficiencia cardíaca" seealso="fracción de eyección"} ::: **Contractilidad.** Un ventrículo también puede expulsar más sangre con el mismo volumen telediastólico si sus fibras se contraen con más fuerza. Esta propiedad, la :index[contractilidad]{main} o inotropismo,:index{term="inotropismo" see="contractilidad"} la eleva sobre todo el :index[sistema nervioso simpático]{term="sistema nervioso simpático!corazón"}. La :index[noradrenalina] liberada por los nervios simpáticos actúa sobre los :index[receptores adrenérgicos]{term="receptores adrenérgicos!beta-1"} beta-1 de las células musculares, aumenta la entrada de calcio en cada potencial de acción:index{term="calcio!contracción"} y hace cada contracción más fuerte y más breve. El mismo transmisor, al actuar sobre el nódulo sinoauricular, acelera la frecuencia, de modo que la estimulación simpática aumenta el gasto cardíaco por sus dos factores a la vez; en el ejercicio, los tres mecanismos actúan juntos.:index{term="norepinefrina" see="noradrenalina"}:index{term="volumen sistólico!regulación" range="end"}:index{term="ejercicio!volumen sistólico"} ## La auscultación del corazón :index{term="corazón!ruidos" range="start" main} El cierre de las válvulas se oye a través de la pared torácica, y la auscultación con el :index[estetoscopio], que el médico francés René Laennec:index{term="Laennec, René"} introdujo en 1816, sigue siendo la primera exploración del corazón. El primer ruido, grave y relativamente largo, señala el cierre de las válvulas mitral y tricúspide al comienzo de la sístole; se oye mejor sobre la punta. El segundo, más corto y seco, señala el cierre de las válvulas aórtica y pulmonar al final de la eyección. En la inspiración, la caída de la presión intratorácica aumenta el retorno venoso al ventrículo derecho, que tarda un poco más en vaciarse, y la válvula pulmonar se cierra unas centésimas de segundo después que la aórtica: el segundo ruido se oye desdoblado.:index{term="corazón!ruidos!desdoblamiento del segundo ruido"} Un tercer ruido al comienzo de la diástole se debe a la entrada rápida de sangre en el ventrículo. Es normal en niños y deportistas jóvenes, pero pasados los cuarenta años suele indicar un ventrículo dilatado e insuficiente.:index{term="insuficiencia cardíaca"} Un cuarto ruido, justo antes del primero, lo produce la sístole auricular contra un ventrículo rígido.:index{term="corazón!ruidos!tercero y cuarto"} Entre los ruidos, el corazón está normalmente en silencio, porque la sangre atraviesa sin obstáculo las válvulas abiertas. Un :index[soplo]{term="soplos" seealso="flujo turbulento"} es el ruido de un flujo turbulento: a través de una válvula estrechada que aun así debe abrirse (estenosis) o de una válvula que no cierra (insuficiencia). El momento del ciclo en que aparece dice al explorador qué válvula falla; una estenosis aórtica, por ejemplo, produce un soplo durante la eyección, entre el primer y el segundo ruido.:index{term="corazón!ruidos" range="end"}:index{term="corazón!válvulas!estenosis e insuficiencia"}`; // chapter 14, with the book's frontmatter (content.<lang>.md) const vessels = String.raw`# Los vasos y la presión arterial {lead="El corazón pone la presión; los vasos deciden adónde va la sangre. Este capítulo explica cómo unas micras de cambio en el radio de las arterias pequeñas reparten el gasto cardíaco, y cómo el tronco del encéfalo mantiene estable la presión arterial de un latido al siguiente."}Muestra en Markdown · 50 líneas · content.vessels.es.md
La sangre sale del ventrículo izquierdo a impulsos y llega a los capilares como una corriente continua. Las propiedades de los vasos que hay entre ambos extremos explican casi todo lo que sigue: por qué la presión arterial tiene un valor sistólico y otro diastólico, por qué la presión cae tanto en las arterias más pequeñas y por qué la mayor parte de la sangre del cuerpo está en las venas. ## El árbol vascular La :index[aorta] y sus grandes ramas son :index[arterias]{term="arterias!elásticas"} ricas en fibras elásticas. Se distienden al entrar cada volumen sistólico y se retraen durante la diástole, de modo que el flujo continúa entre latido y latido. Las :index[arterias]{term="arterias!musculares"} más pequeñas tienen más músculo liso y reparten la sangre entre los órganos. Las :index[arteriolas]{main}, de 10 a 100 µm de calibre, tienen las paredes más gruesas en proporción a su tamaño y son el lugar principal de resistencia al flujo: como muestra la :ref{id="pressures"}, la presión media cae en ellas de unos 85 a 35 mmHg. Al contraer o relajar su músculo liso, las arteriolas fijan a la vez la resistencia total de la circulación y la parte del gasto cardíaco que recibe cada órgano. Los :index[capilares]{main} son tubos de una sola capa de células endoteliales,:index{term="endotelio"} de 5 a 8 µm de diámetro, lo justo para que pase un hematíe deformándose. Hay unos diez mil millones, con una superficie total de intercambio de 500 a 700 m², y la sangre los recorre a unos 0,3 mm/s, la milésima parte de su velocidad media en la aorta.:index{term="flujo sanguíneo!velocidad"} La sangre vuelve por las :index[vénulas] y las :index[venas]{main}, de paredes finas y fáciles de distender. En reposo, las venas sistémicas contienen alrededor del 64 % del volumen de sangre; por eso se las llama vasos de capacitancia.:index{term="vasos de capacitancia" see="venas"} ## Flujo, presión y resistencia El flujo por cualquier vaso obedece una relación análoga a la :index[ley de Ohm]{term="Ohm, ley de"}: el flujo es igual a la diferencia de presión entre los dos extremos dividida por la resistencia, *Q* = (*P*~1~ − *P*~2~)/*R*. Aplicada a toda la circulación sistémica, con una presión arterial media de 93 mmHg, una presión en la aurícula derecha cercana a cero y un gasto cardíaco de 5 L/min, da una :index[resistencia periférica total]{term="resistencia vascular!periférica total"} de unos 19 mmHg·min/L. ¿De qué depende la resistencia de un vaso? Para el flujo estable y ordenado de un fluido por un tubo rígido, la respuesta es la :index[ley de Poiseuille]{term="Poiseuille, ley de" main}, que el médico francés Jean Poiseuille:index{term="Poiseuille, Jean"} dedujo en la década de 1840 de sus experimentos con agua en capilares de vidrio. La resistencia es proporcional a la longitud del tubo y a la :index[viscosidad] del fluido, e inversamente proporcional a la cuarta potencia del radio, *r*^4^. Si el radio de una arteriola se reduce a la mitad, su resistencia se multiplica por dieciséis; si aumenta solo un 19 %, el flujo que la atraviesa se duplica a la misma presión. Esa sensibilidad extrema es la que da a las arteriolas el control de la circulación. La ley de Poiseuille supone un :index[flujo laminar], en el que la sangre avanza en capas concéntricas, más deprisa en el centro. Cuando la velocidad es alta, el vaso ancho o la pared irregular, las capas se rompen en remolinos. La tendencia a ese :index[flujo turbulento]{seealso="Reynolds, número de"} se expresa con el :index[número de Reynolds]{term="Reynolds, número de"}, el producto de la densidad de la sangre, su velocidad y el diámetro del vaso dividido por la viscosidad. Por encima de unos 2000 el flujo se vuelve turbulento, y la turbulencia se oye: un soplo:index{term="soplos"} sobre una válvula estrechada, un :index[soplo vascular] sobre una arteria estenosada y los :index[ruidos de Korotkoff]{term="Korotkoff, ruidos de"} que se escuchan al medir la presión con un manguito. ## La presión arterial :index{term="presión arterial" range="start"} En un adulto joven y sano, la presión arterial sube a unos 120 mmHg con cada eyección, la :index[presión sistólica]{term="presión arterial!sistólica"}, y baja a unos 80 mmHg antes de la siguiente, la :index[presión diastólica]{term="presión arterial!diastólica"}. La diferencia, 40 mmHg, es la :index[presión diferencial]{term="presión arterial!diferencial"} o presión de pulso.:index{term="presión de pulso" see="presión arterial!diferencial"} Como en reposo la diástole dura aproximadamente el doble que la sístole, el promedio de la presión a lo largo del ciclo queda más cerca del valor diastólico. Se estima sumando a la presión diastólica un tercio de la diferencial: :::paragraphs{style="formula"} PAM = PAD + (PAS − PAD)/3 = 80 + 40/3 = 93 mmHg ::: Esta :index[presión arterial media]{term="presión arterial!media" main} es la que impulsa la sangre por la circulación sistémica.:index{term="presión arterial media" see="presión arterial!media"} La presión diferencial depende sobre todo de dos magnitudes: el volumen sistólico y la :index[distensibilidad arterial]{term="distensibilidad arterial" main}, el cambio de volumen arterial por unidad de cambio de presión. Las arterias elásticas almacenan cerca de la mitad de cada volumen sistólico durante la sístole y lo devuelven en la diástole, un mecanismo que se conoce como :index[efecto Windkessel]{term="Windkessel, efecto" main} por la cámara de aire de las bombas contra incendios del siglo XVIII, que convertía los golpes de la bomba en un chorro continuo. Con la edad la aorta se vuelve rígida,:index{term="envejecimiento!arterias"} la distensibilidad disminuye y el mismo volumen sistólico produce una presión sistólica más alta y una diastólica más baja. :::callout{type="clinical" title="Hipertensión"} Se diagnostica :index[hipertensión arterial]{main} cuando la presión medida en reposo en varias ocasiones es de 140/90 mmHg o más; las guías estadounidenses usan desde 2017 el umbral de 130/80 mmHg. En las personas mayores, una presión sistólica alta con una diastólica normal o baja, la hipertensión sistólica aislada, refleja la pérdida de distensibilidad de la aorta.:index{term="hipertensión arterial!sistólica aislada"}:index{term="envejecimiento!arterias"} ::: ## El intercambio capilar El movimiento de líquido a través de la pared capilar depende del equilibrio entre la presión hidrostática, que lo empuja hacia fuera, y la :index[presión coloidosmótica]{main} de las proteínas del plasma, que lo retiene.:index{term="presión oncótica" see="presión coloidosmótica"} Estas :index[fuerzas de Starling]{term="capilares!fuerzas de Starling"} favorecen la filtración en el extremo arterial del capilar, donde la presión hidrostática:index{term="capilares!presión hidrostática"} es de unos 35 mmHg frente a una presión oncótica de 25 mmHg, y la reabsorción en el extremo venoso, donde la hidrostática ha bajado a unos 15 mmHg. De los 20 L que se filtran al día en los capilares sistémicos, unos 17 L vuelven directamente a la sangre; el resto lo devuelven los vasos :index[linfáticos]{term="linfa" main}. Cuando la filtración supera ese retorno, el líquido se acumula en los tejidos: es el edema.:index{term="edema"}:index{term="Starling, fuerzas de" see="capilares!fuerzas de Starling"} ## El reflejo barorreceptor :index{term="reflejo barorreceptor" range="start" main} De un momento a otro, el reflejo barorreceptor mantiene la presión arterial a pocos milímetros de mercurio de su valor de referencia. Los :index[barorreceptores] son terminaciones nerviosas sensibles al estiramiento situadas en la pared del :index[seno carotídeo]{term="barorreceptores!seno carotídeo"}, en la bifurcación de cada carótida común, y en el :index[cayado aórtico]{term="barorreceptores!cayado aórtico"}. Su frecuencia de descarga sube con cada distensión sistólica. Las señales del seno carotídeo viajan por el :index[nervio glosofaríngeo] y las del cayado por el :index[nervio vago]{seealso="reflejo barorreceptor"}; ambas terminan en el :index[núcleo del tracto solitario] del bulbo raquídeo.:index{term="bulbo raquídeo"} Una subida de la presión aumenta la descarga de los barorreceptores, lo que inhibe la actividad simpática sobre el corazón y los vasos y aumenta la vagal. Bajan la frecuencia y la contractilidad, las arteriolas se dilatan y la presión vuelve hacia su valor normal.:index{term="sistema nervioso simpático!vasos"} Una caída de la presión tiene los efectos contrarios. Los receptores carotídeos responden entre unos 60 y 180 mmHg y son más sensibles en torno a la presión arterial media normal. En uno o dos días, sin embargo, se reajustan a cualquier presión que se mantenga, así que el reflejo corrige los cambios rápidos pero no puede fijar por sí solo el nivel de la presión a largo plazo, que depende de cómo manejan los riñones la sal y el agua.:index{term="riñones!control de la presión a largo plazo"}:index{term="presión arterial!control a largo plazo"}:index{term="presión arterial" range="end"} El reflejo se pone a prueba cada vez que una persona se levanta.:index{term="bipedestación, efectos circulatorios"} Unos 500 mL de sangre se estancan en las venas de las piernas, bajan el retorno venoso y el volumen sistólico, y la presión arterial empieza a caer; en pocos segundos el reflejo acelera el corazón y contrae las arteriolas. Cuando falla, como puede ocurrir en ancianos o en enfermedades de los nervios autónomos, ponerse de pie hace caer la presión sistólica 20 mmHg o más: es la :index[hipotensión ortostática]{main}, con mareo o desmayo.:index{term="desmayo" see="hipotensión ortostática"}:index{term="reflejo barorreceptor" range="end"}:index{term="retorno venoso"} ## El retorno venoso :index{term="retorno venoso" range="start" main} Todo lo que el corazón expulsa tiene que volver antes a él. El flujo de sangre de las venas a la aurícula derecha, el retorno venoso, lo impulsa una diferencia de presión pequeña: unos 7 mmHg entre las vénulas y la aurícula derecha, donde la :index[presión venosa central] es casi nula. Como las venas son tan distensibles, un pequeño cambio de su tono desplaza un volumen de sangre grande. La constricción simpática de las venas puede llevar en segundos varios cientos de mililitros desde las venas periféricas hacia el corazón, lo que aumenta la precarga y, por el mecanismo de Frank-Starling, el volumen sistólico.:index{term="sistema nervioso simpático!vasos"} Dos bombas ajenas al corazón ayudan. En las piernas, las venas profundas discurren entre los músculos y tienen válvulas de un solo sentido cada pocos centímetros.:index{term="venas!válvulas"} Cada contracción de la pantorrilla comprime las venas y empuja la sangre hacia arriba, y las válvulas impiden que vuelva a caer; esta :index[bomba muscular]{main} puede bajar la presión venosa en el tobillo de una persona que camina de unos 90 mmHg a 25 mmHg o menos. Cuando las válvulas fallan, las venas de las piernas se dilatan y se vuelven tortuosas: son las :index[varices].:index{term="venas!varicosas" see="varices"} En el tórax, cada inspiración baja la presión intratorácica y sube la abdominal, lo que aspira sangre de las venas del abdomen hacia el tórax: es la :index[bomba respiratoria].:index{term="retorno venoso" range="end"}:index{term="gasto cardíaco!retorno venoso"}`; // chapter 15 (content.vessels.<lang>.md) const indexChapter = String.raw`# Índice analítico {style="index" lead="Los números en negrita remiten a la explicación principal de un término. Un intervalo como 301-303 indica que la explicación ocupa esas páginas. *Véase* lleva a la entrada que reúne los números de página; *véase también*, a una entrada relacionada."}Muestra en Markdown · 6 líneas · content.index.es.md
:::index :::callout{type="colophon"} *Principios de fisiología humana*, parte IV, capítulos 14 y 15, una muestra compuesta con Postext. Tipografías: Literata y Libre Franklin (SIL OFL 1.1). Texto y dibujo: originales, CC BY 4.0. El libro y sus autores son ficticios. :::`; // # 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: igual en todas las recetas · 310 líneas
// ─── 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 ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗
Variantes
#Escribe todos los intervalos completos
Algunos libros ingleses también escriben completos los dos números de un intervalo.
- rangeFormat: t({ en: 'chicago', es: 'full' }), rangeSeparator: t({ en: '–', es: '-' }),
+ rangeFormat: 'full', rangeSeparator: t({ en: '–', es: '-' }),#Un índice sin letras de grupo
Un índice corto suele encadenar sus grupos, separados solo por una línea en blanco.
- groups: { ...label, fontSize: pt(9.5), letterSpacing: pt(0), color: col('accent') },
+ groups: { enabled: false },Errores frecuentes
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Traduce Figura y Tabla con defaultResourceTypes(locale)
El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →
Error frecuente
El texto dentro de un SVG <img> no puede usar fuentes web
Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
- La captura de esta receta no avisa de
indexSeeUnknown: una remisión cuyo destino está escrito de otra forma que la entrada (número de Reynolds frente a Reynolds, número de) se imprime sin queja. Coteja cada Véase del índice terminado con su destino. - Una lista de subentradas que se parte entre columnas no repite su entrada principal en una línea corazón (cont.); una entrada sin página propia va siempre con su primera subentrada, así que un encabezado nunca cierra una columna solo.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Literata (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
- Código
- MIT, como Postext


