En pocas palabras
Un manual breve para calcular los paneles y las baterías de una cabaña. Cuando el capítulo 3 remite a una tabla del capítulo 1, o el capítulo 1 a un apartado del 2, se imprimen el número y la página correctos, y son enlaces.
Lo que vas a componer
Un manual técnico breve, Energía para una cabaña, compuesto en IBM Plex sobre una página de 178 × 233 mm: una cubierta con las trayectorias del sol dibujadas en código, un índice y tres capítulos bajo una banda azul noche. El libro dimensiona una instalación solar pequeña, así que cada capítulo se apoya en los demás. El capítulo 3 cita la tabla de consumos del 1 y el gráfico de horas de sol del 2; el capítulo 1 manda al lector al apartado 2.3 y le da la página. Quien escribe pone @tbl:loads y @sec:array-size, como los lee pandoc-crossref, en cuatro archivos Markdown separados. buildBundle los compone como un solo libro, así que cada remisión imprime el número que recibió su destino en su propio capítulo, la página en que cayó y un enlace.
Esta receta responde a
- ¿Cómo remito a una figura, una tabla o una sección de otro capítulo, al estilo de pandoc-crossref, con los números siempre correctos?
- ¿Cómo remito a una sección y a la página en que está, y mantengo ambas correctas cuando el libro cambia?
La respuesta corta
// buildBundle lays the chapters out in order. Each chapter whose text names a target it
// does not hold (`@sec:array-size` in chapter 1, `@tbl:loads` in chapter 3) is laid out
// with the outline of the whole book, so the reference prints "section 2.3" and its page,
// and links to it. Headings carry their ids in the Markdown, `## Sizing the array
// {#sec:array-size}`; figures and tables are resources whose ids are what follows the @.
const book = () => buildBundle({ chapters, config: config(), resources });
const crossRefs = {
chapter: t({ en: 'chapter {n}', es: 'capítulo {n}' }), // @sec:array → "chapter 2"
section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
page: t({ en: 'p. {n}', es: 'pág. {n}' }), // :ref{id="sec:losses" style=page} → "p. 7"
};
// Figures and tables count per chapter ({h1}.{n}) and carry on from one document to the
// next. A reference prints the type's shortLabel: @fig:sun → "Fig. 2.1", @Tbl:loads (capital
// T, at the start of a sentence) → "Table 1.1", [-@tbl:loads] → the bare "1.1".
const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
: t({ en: 'fig.', es: 'figura' }),
...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
Ingredientes
- Funciones
- Referencias cruzadasPies numeradosLibros construidos capítulo a capítuloTítulos numeradosÍndice de contenidosAperturas diseñadasAtributos de títuloEstilos de títuloCapítulos sin númeroCabeceras y foliosCubiertas, portadas y colofonesImágenes en los diseños de páginaMárgenes simétricosPaleta de color semánticaEstilos de párrafoExportación a PDF
- También usa
- Citas en un estilo de citaCitas que colocan las figurasFigura y Tabla en tu idiomaCabeceras según el tipo de páginaFuentes incrustadas en el PDFTipos de recurso propiosFiguras y tablas como recursosCabeceras por secciónTablas a partir de datos
- Tipografía
- IBM Plex Serif, IBM Plex Sans Condensed, IBM Plex Mono (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Nombra cada destino una vez, para todo el libro
El código es la respuesta corta de arriba. Los títulos llevan un identificador de Pandoc con su prefijo, ## El tamaño del campo {#sec:array-size}. Las figuras y las tablas son recursos declarados en el script, y sus identificadores se escriben igual, fig:sun y tbl:loads, de modo que el texto dice @fig:sun tanto si la figura está dos párrafos atrás como dos capítulos atrás. Cada identificador debe ser único en todo el libro, no solo en su capítulo.
#2 · Compón los capítulos como un solo libro
// buildBundle lays the chapters out in order. Each chapter whose text names a target it
// does not hold (`@sec:array-size` in chapter 1, `@tbl:loads` in chapter 3) is laid out
// with the outline of the whole book, so the reference prints "section 2.3" and its page,
// and links to it. Headings carry their ids in the Markdown, `## Sizing the array
// {#sec:array-size}`; figures and tables are resources whose ids are what follows the @.
const book = () => buildBundle({ chapters, config: config(), resources });
const crossRefs = {
chapter: t({ en: 'chapter {n}', es: 'capítulo {n}' }), // @sec:array → "chapter 2"
section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
page: t({ en: 'p. {n}', es: 'pág. {n}' }), // :ref{id="sec:losses" style=page} → "p. 7"
};
// Figures and tables count per chapter ({h1}.{n}) and carry on from one document to the
// next. A reference prints the type's shortLabel: @fig:sun → "Fig. 2.1", @Tbl:loads (capital
// T, at the start of a sentence) → "Table 1.1", [-@tbl:loads] → the bare "1.1".
const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type,
shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
: t({ en: 'fig.', es: 'figura' }),
...(type.id === 'table' && { captionStyle: { position: 'above' } }) }));
buildBundle arrastra los contadores de figuras y tablas de un capítulo al siguiente, así que la primera tabla del capítulo 3 es la 3.1 y @tbl:loads en el capítulo 3 sigue imprimiendo tabla 1.1. Un capítulo cuyo texto nombra un título que no contiene se compone con el esquema del libro entero, y se vuelve a componer (tres pasadas como mucho) hasta que las páginas de ese esquema dejan de moverse. Por eso el capítulo 1 puede imprimir apartado 2.1 … pág. 6 antes de que el capítulo 2 exista en papel.
#3 · Elige las palabras de cada remisión
crossRefs fija capítulo, apartado y pág. (en inglés, chapter, section y p.). Una remisión a una figura o una tabla imprime el shortLabel de su tipo, aquí en minúscula, figura 2.1 y tabla 1.1, como se escriben en medio de una frase. Una mayúscula en el prefijo, @Tbl:loads o @Sec:array, pone en mayúscula la etiqueta a principio de frase, y [-@tbl:losses] imprime el número solo para las tablas 2.1 y 3.1.
#4 · Remite hacia delante a apartados y hacia atrás a figuras
Una figura o una tabla se numera, y se coloca, donde se cita por primera vez. @fig:soc en el capítulo 1 la convertiría en la figura 1.2 y la pondría en el capítulo 1. Por eso el capítulo 1 remite hacia delante a @sec:autonomy, y cada figura y cada tabla se citan primero en su propio capítulo; los capítulos siguientes remiten a ellas sin problema. La página va con el apartado, :ref{id="sec:sun-hours" style=page}: una remisión a un recurso imprime su etiqueta y su número, nunca su página.
#5 · Las aperturas y la cubierta salen de la misma banda
const BAND = 52; // mm from the trim's top
const opener = { enabled: true, minHeight: mm(54), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('night') },
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } },
{ kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...caps(8.5),
color: col('sun'), placement: at('container', 'top-left', 0, 2) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600,
fontSize: pt(30), lineHeight: 1.05, color: col('paper'), align: 'left', overflow: 'wrap',
placement: { ...at('#kicker', 'below', 0, 3), size: { width: mm(MEASURE - 26) } } },
{ kind: 'text', id: 'number', content: '{chapterNumber}', fontFamily: COND, fontWeight: 600,
fontSize: pt(84), lineHeight: 1, color: col('sun'), align: 'right',
placement: { ...at('container', 'top-right', 0, -6), size: { width: mm(40) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
fontSize: pt(11), lineHeight: 1.38, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { ...at('container', 'top-left', 0, BAND - MARGIN.top + 8),
size: { width: mm(MEASURE - 10) } } },
] } };
// The contents page wears the same band, with the book's subtitle as its kicker.
const contentsOpener = { ...opener, minHeight: mm(BAND - MARGIN.top + 4), slot: { elements:
opener.slot.elements.filter((e) => ['band', 'kicker', 'title'].includes(e.id))
.map((e) => (e.id === 'kicker' ? { ...e, content: '{subtitle}' } : e)) } };
La banda baja 55 mm desde el borde superior en cada capítulo y en el índice, que reutiliza los mismos elementos; el número es {chapterNumber} y la entradilla, un atributo del título.
const COVER_BAND = 168; // mm
const cover = { enabled: true, slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('night') },
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(COVER_BAND) } } },
{ kind: 'image', id: 'art', resourceId: 'cover',
placement: { ...at('bleed', 'top-left'), size: { width: 'fill' } } },
// Stacked upwards from the subtitle, so a title of one line or two keeps its distance.
{ kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: SERIF, italic: true,
fontSize: pt(14), color: col('tint'), align: 'left',
placement: at('page', 'top-left', MARGIN.inner, COVER_BAND - 22) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600,
fontSize: pt(52), lineHeight: 1, color: col('paper'), align: 'left', overflow: 'wrap',
placement: { ...at('#subtitle', 'above', 0, -4), size: { width: mm(140) } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...caps(8.5), color: col('sun'),
placement: at('#title', 'above', 0, -4) },
{ kind: 'text', id: 'author', content: '{author}', fontFamily: COND, fontWeight: 600,
fontSize: pt(13), color: col('ink'), align: 'left',
placement: at('page', 'top-left', MARGIN.inner, COVER_BAND + 14) },
{ kind: 'text', id: 'edition', content: '{attr.edition}', ...caps(7.5), color: col('muted'),
placement: at('#author', 'below', 0, 2.5) },
] } };
La receta completa
// ═══ Postext Cookbook · Nº 094 · A technical book whose references cross chapters ═══ // https://postext.dev/en/cookbook/technical-book-crossref-chapters // Code: MIT · Text: original (CC BY 4.0) · Charts: generated in code (CC BY 4.0) // Fonts: IBM Plex Serif, Sans Condensed and Mono (SIL OFL 1.1) · Needs postext ≥ 1.12.1 // A small handbook in four Markdown documents. The text writes @fig:sun, @tbl:loads and // @sec:array-size the way pandoc-crossref reads them, and buildBundle resolves each one to // the right number, title or page wherever in the book its target lies. import { buildBundle, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, parseTSV, setAlignment, } 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 = 'technical-book-crossref-chapters'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a night-blue band, one burnt-orange accent, an amber for the charts const palette = { ink: '#1c1f24', // text: a cool near-black night: '#1f3247', // openers, the cover, table heads accent: '#a8471a', // numbers, kickers, references (5.6:1 on paper) sun: '#e9a33a', // the charts and the cover only, never text on paper tint: '#f5ede3', // daylight in the charts rule: '#cfc7bc', // hairlines muted: '#5e636a', // running heads, colophon, chart labels paper: '#ffffff', }; // A design element paints the hex beside its paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const SERIF = 'IBM Plex Serif', COND = 'IBM Plex Sans Condensed', MONO = 'IBM Plex Mono'; const TRIM = { w: 178, h: 233 }; // mm: a technical-book trim const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 32 }; // a 126 mm measure const LEAD = 13.8; // pt: the body leading const MEASURE = TRIM.w - MARGIN.inner - MARGIN.outer; const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const caps = (size, extra = {}) => ({ fontFamily: MONO, fontSize: pt(size), fontWeight: 600, letterSpacing: pt(size * 0.14), textTransform: 'uppercase', align: 'left', ...extra }); // #region answer: one set of identifiers for the whole book, resolved across chapters // buildBundle lays the chapters out in order. Each chapter whose text names a target it // does not hold (`@sec:array-size` in chapter 1, `@tbl:loads` in chapter 3) is laid out // with the outline of the whole book, so the reference prints "section 2.3" and its page, // and links to it. Headings carry their ids in the Markdown, `## Sizing the array // {#sec:array-size}`; figures and tables are resources whose ids are what follows the @. const book = () => buildBundle({ chapters, config: config(), resources }); const crossRefs = { chapter: t({ en: 'chapter {n}', es: 'capítulo {n}' }), // @sec:array → "chapter 2" section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2" page: t({ en: 'p. {n}', es: 'pág. {n}' }), // :ref{id="sec:losses" style=page} → "p. 7" }; // Figures and tables count per chapter ({h1}.{n}) and carry on from one document to the // next. A reference prints the type's shortLabel: @fig:sun → "Fig. 2.1", @Tbl:loads (capital // T, at the start of a sentence) → "Table 1.1", [-@tbl:loads] → the bare "1.1". const resourceTypes = defaultResourceTypes(LANG).map((type) => ({ ...type, shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' }) : t({ en: 'fig.', es: 'figura' }), ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })); // #endregion // #region opener: each chapter under a night-blue band, its number large on the outer side const BAND = 52; // mm from the trim's top const opener = { enabled: true, minHeight: mm(54), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('night') }, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(BAND + 3) } } }, { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...caps(8.5), color: col('sun'), placement: at('container', 'top-left', 0, 2) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600, fontSize: pt(30), lineHeight: 1.05, color: col('paper'), align: 'left', overflow: 'wrap', placement: { ...at('#kicker', 'below', 0, 3), size: { width: mm(MEASURE - 26) } } }, { kind: 'text', id: 'number', content: '{chapterNumber}', fontFamily: COND, fontWeight: 600, fontSize: pt(84), lineHeight: 1, color: col('sun'), align: 'right', placement: { ...at('container', 'top-right', 0, -6), size: { width: mm(40) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true, fontSize: pt(11), lineHeight: 1.38, color: col('ink'), align: 'left', overflow: 'wrap', placement: { ...at('container', 'top-left', 0, BAND - MARGIN.top + 8), size: { width: mm(MEASURE - 10) } } }, ] } }; // The contents page wears the same band, with the book's subtitle as its kicker. const contentsOpener = { ...opener, minHeight: mm(BAND - MARGIN.top + 4), slot: { elements: opener.slot.elements.filter((e) => ['band', 'kicker', 'title'].includes(e.id)) .map((e) => (e.id === 'kicker' ? { ...e, content: '{subtitle}' } : e)) } }; // #endregion // #region cover: the sun's December and June paths over the panels, the title in the band const COVER_BAND = 168; // mm const cover = { enabled: true, slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('night') }, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(COVER_BAND) } } }, { kind: 'image', id: 'art', resourceId: 'cover', placement: { ...at('bleed', 'top-left'), size: { width: 'fill' } } }, // Stacked upwards from the subtitle, so a title of one line or two keeps its distance. { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: SERIF, italic: true, fontSize: pt(14), color: col('tint'), align: 'left', placement: at('page', 'top-left', MARGIN.inner, COVER_BAND - 22) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: COND, fontWeight: 600, fontSize: pt(52), lineHeight: 1, color: col('paper'), align: 'left', overflow: 'wrap', placement: { ...at('#subtitle', 'above', 0, -4), size: { width: mm(140) } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...caps(8.5), color: col('sun'), placement: at('#title', 'above', 0, -4) }, { kind: 'text', id: 'author', content: '{author}', fontFamily: COND, fontWeight: 600, fontSize: pt(13), color: col('ink'), align: 'left', placement: at('page', 'top-left', MARGIN.inner, COVER_BAND + 14) }, { kind: 'text', id: 'edition', content: '{attr.edition}', ...caps(7.5), color: col('muted'), placement: at('#author', 'below', 0, 2.5) }, ] } }; // #endregion // #region running-heads: the book on the verso, the chapter on the recto, folios in orange const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', ...caps(7), color: col('muted'), placement: at('page', edge, x, 12), ...extra }); const folio = { color: col('accent'), fontSize: pt(8) }; const header = { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio), head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 9), head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 9), { align: 'right' }), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, { ...folio, align: 'right' }), ] }; const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'top', 0, { ...folio, pages: 'opener', align: 'center', placement: at('container', 'bottom', 0, 8) })] }; const bare = { header: { elements: [] }, footer: { elements: [] } }; // #endregion const contents = { // what :::toc prints: chapters in the condensed face, sections under them levels: [ { level: 1, fontFamily: COND, fontSize: pt(13), fontWeight: 600, lineHeight: pt(18), numberFontFamily: MONO, numberFontSize: pt(10), numberFontWeight: 600, numberColor: col('accent'), numberWidth: mm(9), numberGap: mm(2), marginTop: pt(10) }, { level: 2, fontFamily: SERIF, fontSize: pt(9.5), lineHeight: pt(13.5), indent: mm(11), numberFontFamily: MONO, numberFontSize: pt(8), numberColor: col('muted'), numberWidth: mm(9), numberGap: mm(2) }, ], pageNumber: { fontFamily: MONO, fontSize: pt(8.5), fontWeight: 600, width: mm(8) }, leader: { char: '. ', gap: mm(2) }, }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales) crossRefs, resourceTypes, colorPalette, toc: contents, header, footer, page: { sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: SERIF, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'), // every reference is a link: orange says so textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: COND, color: col('ink'), fontWeight: 600, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // 'any': short chapters start on the next page, recto or verso. { level: 1, fontSize: pt(30), numberingTemplate: '{1}', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener, marginBottom: pt(0) }, { level: 2, fontSize: pt(13), lineHeight: pt(LEAD * 1.5), numberingTemplate: '{1}.{2}', numberSeparator: ' ', marginTop: pt(LEAD / 2), marginBottom: pt(0) }, ] }, // The cover and the contents take no number and no contents line, so the first // chapter is still chapter 1. headingStyles: [ { id: 'cover', numbered: false, toc: false, advancedDesign: cover, ...bare }, { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener, ...bare }, ], paragraphStyles: [ { id: 'formula', fontFamily: MONO, fontSize: pt(9), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }, { id: 'colophon', fontFamily: COND, fontSize: pt(7.5), lineHeight: pt(10.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(4), marginTop: pt(LEAD * 3) }, ], tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('night'), headerColor: col('paper'), headerFontFamily: COND, headerFontSize: pt(8.2), bodyFontFamily: COND, bodyFontSize: pt(8.6), bodyColor: col('ink'), cellPadding: mm(1.4) }, captionStyle: { fontFamily: COND, fontSize: pt(8.4), color: col('ink'), labelBold: true, labelColor: col('accent'), gap: mm(2.5) }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const front = String.raw`---Muestra en Markdown · 16 líneas · content.es.md
title: "Energía para una cabaña" subtitle: "Cómo dimensionar una pequeña instalación solar aislada" author: "Lucía Arregui" --- # Energía para una cabaña {style="cover" kicker="Cuadernos técnicos Larch Hill · n.º 4" edition="Segunda edición"} # Índice {style="contents"} :::toc :::paragraphs{style="colophon"} Los Cuadernos técnicos Larch Hill son manuales breves para quien construye y mantiene sus propios equipos. La cabaña, el valle y las cifras de este son un ejemplo calculado a mano: comprueba cada número con tus consumos, tu emplazamiento y tus proveedores antes de comprar nada. Compuesto en IBM Plex Serif, IBM Plex Sans Condensed e IBM Plex Mono (SIL OFL). Texto escrito para el Recetario de Postext, CC BY 4.0. :::`; // frontmatter, cover and contents (content.<lang>.md) const load = String.raw`# El consumo diario {#sec:load lead="Todos los componentes de una instalación aislada se dimensionan a partir de un solo número: la energía que gasta la cabaña en un día de invierno. Si ese número está mal, nada de lo que venga después lo arregla."}Muestra en Markdown · 26 líneas · content.load.es.md
Una instalación solar para una cabaña se calcula al revés. Se empieza por los enchufes, se suma lo que la cabaña consume en un día y solo entonces se decide cuántos paneles y cuántas baterías hacen falta para dar esa energía en el peor mes del año. Este capítulo llega a ese número: 1159 vatios hora al día, que el resto del libro redondea a 1160. El @sec:array lo convierte en paneles, y el @sec:battery, en baterías. El ejemplo es una cabaña de piedra de 48 m² a 1100 m de altitud, en un valle orientado al sudeste, que se usa todos los fines de semana y tres semanas en invierno. La calefacción y la cocina son de leña y de butano; la electricidad mueve las luces, un frigorífico, una bomba de agua, un portátil y un rúter. ## La lista de consumos {#sec:load-list} Recorre la cabaña con una libreta y apunta todo lo que se enchufa o va cableado, con su potencia en vatios y las horas que funciona en un día de invierno. La potencia figura en la placa de características o en el manual; para cualquier aparato con motor o compresor, un medidor de enchufe que se deja un día entero da una cifra más fiel que la placa. La @tbl:loads es la lista de la cabaña de ejemplo. Dos líneas de la tabla se olvidan con facilidad. El inversor, que convierte los 24 V de la batería en los 230 V de los enchufes, consume 8 W desde que se enciende, haya algo enchufado o no: en un día suma más que las luces. El rúter también pasa la noche encendido. A los dos les conviene un temporizador o un interruptor junto a la puerta, y el ahorro se calcula en el @sec:winter-margin. La cifra del frigorífico pide cuidado. Un arcón de 100 litros consume 55 W mientras funciona el compresor, y en una cabaña fresca el compresor trabaja unas cinco horas de cada veinticuatro. En agosto puede llegar a nueve, pero agosto no es el mes que dimensiona la instalación, como muestra el @sec:sun-hours en la :ref{id="sec:sun-hours" style=page}. ## Cuándo se gasta la energía {#sec:load-profile} El total dice cuánta energía necesita la cabaña, no cuándo. La @fig:profile reparte esos mismos 1160 Wh entre las horas de un día de invierno. El frigorífico, el rúter y el inversor forman un suelo de unos 27 W que nunca desaparece. El portátil añade un bloque por la mañana, y la tarde trae el mayor consumo del día, cuando las luces, el ventilador de la estufa y la bomba coinciden después de la puesta de sol. Ese pico de la tarde pesa más de lo que parece. Casi todo cae cuando ya no hay sol, así que nada de él puede salir directamente de los paneles: lo pone la batería y se le devuelve al día siguiente. El banco de baterías del @sec:autonomy se dimensiona justo para eso, y para los días en que el sol no lo devuelve. ## El margen de invierno {#sec:winter-margin} Una lista hecha en octubre es una suposición sobre enero. En pleno invierno las luces están encendidas más tiempo, en Año Nuevo vienen invitados y siempre hay alguien que trae un secador de pelo. En lugar de inflar cada línea de la @tbl:loads, conviene que la lista sea honrada y añadir el margen una sola vez, al final, donde se vea. Para una cabaña de fin de semana basta un margen del 15 %, y el ejemplo lo saca de los propios consumos en vez de sumarlo encima: apagar el inversor y el rúter por la noche ahorra 8 W y 8 W durante diez horas, 160 Wh al día, casi el 14 % del total. La instalación se calcula para los 1160 Wh completos, y la costumbre del interruptor junto a la puerta es el margen. Fijado el consumo, falta saber cuánto puede cubrir el sol en el mes más oscuro. De eso se ocupa el @sec:array, que parte de las horas de sol del lugar, en la :ref{id="sec:sun-hours" style=page}.`; // chapter 1: content.load.<lang>.md const array = String.raw`# El campo solar {#sec:array lead="Los paneles se dimensionan para diciembre, cuando los días son cortos y el sol va bajo. En verano el mismo campo produce el doble de lo que la cabaña necesita, y ese es el precio de un invierno que funciona."}Muestra en Markdown · 26 líneas · content.array.es.md
El @sec:load terminó con un consumo diario de 1160 Wh, el total de la @tbl:loads. Este capítulo busca el campo de paneles que entrega esa energía en un día medio de diciembre, después de todas las pérdidas entre el panel y el enchufe. ## Horas de sol por mes {#sec:sun-hours} La radiación que recibe un lugar se expresa en horas de sol pico: las horas a una intensidad estándar de 1000 W/m² que darían la misma energía que el día entero. Un panel de 300 W produce unos 300 Wh por cada hora de sol pico, antes de pérdidas. Las cifras salen de una base de datos solar para las coordenadas del lugar y la inclinación de los paneles; la @fig:sun las da para la cabaña de ejemplo, a 42° N y con los paneles inclinados 60°. Una inclinación fuerte cede algo de producción en verano a cambio del sol bajo del invierno. Aun así, diciembre rinde la mitad que julio: 2,8 horas de sol pico frente a 5,6. Diciembre es, por tanto, el mes de diseño, y todos los cálculos de este capítulo usan su cifra. ## Pérdidas entre el panel y el enchufe {#sec:losses} Cada etapa de la cadena se queda una parte, y las partes se multiplican. La @tbl:losses las recoge para la instalación de ejemplo. La mayor pérdida es la del inversor, y solo afecta a los consumos de 230 V. En una cabaña donde el frigorífico, la bomba y las luces funcionen a 24 V, el factor total sube de 0,79 a cerca de 0,85. La parte de la batería es su pérdida de ida y vuelta, la energía que entra y no vuelve a salir; es pequeña en las celdas de litio y mayor en las de plomo, como explica el @sec:chemistry en la :ref{id="sec:chemistry" style=page}. ## El tamaño del campo {#sec:array-size} El campo tiene que producir el consumo diario, dividido por el factor de pérdidas, en las horas de sol pico de diciembre: :::paragraphs{style="formula"} 1160 Wh ÷ (2,8 h × 0,79) = 524 W ::: El ejemplo usa dos paneles de 310 W en serie, 620 W en total, que dejan un margen del 18 % para un panel envejecido, una quincena nublada o un consumo que ha crecido. Los paneles trabajan de día y la cabaña gasta de noche (@fig:profile); la batería que media entre ambos es el asunto del @sec:battery.`; // chapter 2 const battery = String.raw`# El banco de baterías {#sec:battery lead="La batería sostiene la cabaña todas las noches y en los días grises en que los paneles casi no producen. Su tamaño es una decisión sobre cuántos de esos días seguidos estás dispuesto a aguantar."}Muestra en Markdown · 26 líneas · content.battery.es.md
El campo del @sec:array-size trabaja entre las diez y las cuatro, la cabaña sobre todo de noche, y en un día cubierto de diciembre las 2,8 horas de sol pico de la @fig:sun bajan a media hora o menos. El banco de baterías salva las dos distancias. ## Días de autonomía {#sec:autonomy} La autonomía es el número de días que el banco puede alimentar la cabaña sin nada de sol. El ejemplo usa tres, lo habitual en un valle con niebla: :::paragraphs{style="formula"} 3 días × 1160 Wh = 3480 Wh útiles ::: El consumo diario es el total de la @tbl:loads, sin el margen del @sec:winter-margin (:ref{id="sec:winter-margin" style=page}), que queda en reserva. ## Qué química elegir {#sec:chemistry} La @tbl:chemistry compara las tres químicas que se venden para instalaciones aisladas pequeñas. Las celdas de litio-ferrofosfato (LiFePO4) admiten descargas hasta el 20 % cada noche durante miles de ciclos. Las de plomo-ácido duran más si nunca bajan de la mitad de su carga, así que la misma energía útil exige un banco un 60 % mayor y cinco veces más pesado. La ida y vuelta también cuenta. El factor total de 0,79 de la @tbl:losses supone el 0,95 del banco de litio. Con un banco AGM de 0,85 el factor baja a 0,71, y la fórmula del @sec:array-size pide 586 W en lugar de 524 W. Los dos paneles de 310 W siguen bastando, con un margen del 6 % en vez del 18 %. Si cambia la batería, las tablas [-@tbl:losses] y [-@tbl:chemistry] se tienen que leer de nuevo juntas. La cabaña de ejemplo usa un banco LiFePO4 de 200 Ah a 25,6 V: 5,1 kWh nominales y 4,1 kWh útiles, algo más de tres días. ## Una semana de nubes {#sec:cloud-week} La @fig:soc sigue el estado de carga del banco durante cinco días de diciembre: uno despejado, tres cubiertos con media hora de sol pico y otro despejado al final. El banco empieza al 80 %, sube al 94 % la primera tarde y luego pierde un 17 % al día, con un bajón cada tarde por el pico de la @fig:profile. En su punto más bajo, la última noche de nubes, conserva el 27 %, por encima del suelo del 20 %. Ahí se ve el punto débil de dimensionar para el día medio de diciembre. Un día despejado el campo produce unos 1370 Wh después de pérdidas, solo 210 Wh más de lo que gasta la cabaña, así que un banco vaciado por tres días grises tarda unos doce días de sol en llenarse. Un tercer panel, con su propio regulador, sube el sobrante de un día despejado de 210 Wh a unos 900 Wh; un pequeño generador de gasolina con cargador llena el banco en una tarde.`; // chapter 3 const tables = String.raw`Consumo Potencia (W) Horas al día Wh al díaMuestra en Markdown · 22 líneas · content.tables.es.md
Iluminación LED, 6 puntos 30 5 150 Arcón frigorífico, 100 L 55 5 275 Bomba de agua, 24 V 120 0,5 60 Portátil 45 4 180 Rúter 8 24 192 Inversor en vacío 8 24 192 Dos teléfonos 10 2 20 Ventilador de la estufa 15 6 90 **Total** **1159** Entre el panel y el enchufe Factor Polvo, nieve y sombras 0,95 Resistencia de los cables 0,97 Regulador MPPT 0,97 Ida y vuelta de la batería (LiFePO4) 0,95 Inversor, consumos a 230 V 0,93 **Total (producto)** **0,79** Química Descarga útil Ida y vuelta Ciclos Banco para 3 días Masa LiFePO4 80 % 0,95 4000 200 Ah, 5,1 kWh 45 kg Plomo-ácido AGM 50 % 0,85 600 300 Ah, 7,2 kWh 220 kg Plomo-ácido tubular 50 % 0,80 1500 300 Ah, 7,2 kWh 260 kg`; // the three tables as TSV, blank-line separated // Four Markdown documents in reading order, one book. const chapters = [front, load, array, battery].map((markdown) => ({ markdown })); // #region tables: three tables pasted from a spreadsheet as TSV, parsed into table models function tableModel(tsv, columnWidths) { let m = Object.assign(parseTSV(tsv), { headerRowCount: 1, columnWidths }); for (let r = 0; r < m.rows.length; r++) { // figures flush right, words flush left for (let c = 1; c < m.rows[r].length; c++) m = setAlignment(m, { row: r, col: c }, 'right'); } return m; } const [loads, losses, chemistry] = tables.trim().split(/\n\s*\n/); const TABLES = { loads, losses, chemistry }; // #endregion const CAPTIONS = t({ en: { loads: 'Daily loads of the example cabin on a winter day', losses: 'Losses between the panels and the sockets, multiplied', chemistry: 'Three battery chemistries for 3,480 Wh of usable energy', profile: 'Average draw, hour by hour, on a winter day. Grey: the constant floor of fridge, ' + 'router and inverter; orange: everything else; pale band: daylight.', sun: 'Peak sun hours a day by month at 42°\u00a0N, panels tilted at 60°. December is the ' + 'design month.', soc: 'State of charge of the 5.1 kWh bank over five December days: clear, three overcast, ' + 'clear. Shaded: night. Dashed: the 20% floor.', }, es: { loads: 'Consumos diarios de la cabaña de ejemplo en un día de invierno', losses: 'Pérdidas entre los paneles y los enchufes, multiplicadas', chemistry: 'Tres químicas de batería para 3480\u00a0Wh de energía útil', profile: 'Consumo medio, hora a hora, en un día de invierno. Gris: el suelo constante de ' + 'frigorífico, rúter e inversor; naranja: todo lo demás; banda clara: horas de luz.', sun: 'Horas de sol pico al día por mes a 42°\u00a0N, con los paneles inclinados 60°. Diciembre ' + 'es el mes de diseño.', soc: 'Estado de carga del banco de 5,1 kWh durante cinco días de diciembre: despejado, tres ' + 'nublados, despejado. Sombreado: noche. Discontinua: el suelo del 20\u00a0%.', } }); const table = (id, widths) => ({ id: `tbl:${id}`, typeId: 'table', kind: 'table', caption: CAPTIONS[id], createdAt: 0, updatedAt: 0, table: { model: tableModel(TABLES[id], widths) } }); const figure = (id, [w, h]) => ({ id: `fig:${id}`, typeId: 'figure', kind: 'svg', caption: CAPTIONS[id], altText: CAPTIONS[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 } }); const resources = [ { id: 'cover', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'cover.svg', width: TRIM.w * 10, height: 110 * 10 } }, figure('profile', [MEASURE, 50]), figure('sun', [MEASURE, 46]), figure('soc', [MEASURE, 50]), table('loads', [44, 18, 18, 18]), table('losses', [70, 20]), table('chemistry', [26, 16, 15, 12, 26, 12]), ]; // #region art: the cover and three charts, drawn in code in the book's palette // An SVG loaded as an image has no access to the page's web fonts (gotcha: // svg-no-webfonts), so the charts embed the one IBM Plex Mono face their labels use. async function labelFace() { const url = 'https://cdn.jsdelivr.net/npm/@fontsource/ibm-plex-mono@5/files/' + 'ibm-plex-mono-latin-400-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (let i = 0; i < bytes.length; i += 8192) { bin += String.fromCharCode(...bytes.subarray(i, i + 8192)); } return `@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}` + `text{font-family:L;font-size:2.5px;fill:${palette.muted}}`; } const n2 = (v) => +v.toFixed(2); const sheet = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}"><style>${style}</style>` + `${body}</svg>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${n2(x)}" y="${n2(y)}" width="${n2(w)}" ` + `height="${n2(h)}" fill="${palette[fill]}" ${extra}/>`; const pathOf = (pts) => pts.map(([x, y]) => `${n2(x)} ${n2(y)}`).join('L'); const line = (pts, stroke, width, extra = '') => `<path d="M${pathOf(pts)}" fill="none" ` + `stroke="${palette[stroke]}" stroke-width="${width}" ${extra}/>`; const label = (x, y, text, anchor = 'middle') => `<text x="${n2(x)}" y="${n2(y)}" ` + `text-anchor="${anchor}">${text}</text>`; // A chart frame: plot area from x0 to w − 2, y from top 3 to the axis at h − 7. function axes(w, h, x0, max, step, unit) { const y = (v) => h - 7 - (v / max) * (h - 10); let out = ''; for (let v = 0; v <= max; v += step) { out += line([[x0, y(v)], [w - 2, y(v)]], 'rule', v ? 0.15 : 0.35) + label(x0 - 1.5, y(v) + 0.9, `${v}${v === max ? unit : ''}`, 'end'); } return { out, y }; } // The hourly profile of chapter 1, built from the same loads as its table. const FLOOR = 27.46; // W: fridge 275 Wh + router + inverter 192 Wh each, over 24 hours const EXTRA = Array.from({ length: 24 }, (_, h) => (h >= 18 && h <= 22 ? 30 : 0) // lights + (h === 7 || h === 19 ? 30 : 0) + (h >= 9 && h <= 12 ? 45 : 0) // pump, laptop + (h === 21 || h === 22 ? 10 : 0) + (h >= 17 && h <= 22 ? 15 : 0)); // phones, stove fan function profileArt(w, h) { const x0 = 12, bw = (w - 2 - x0) / 24; const { out, y } = axes(w, h, x0, 120, 30, ' W'); let bars = rect(x0 + 8.5 * bw, 3, 9.25 * bw, h - 10, 'tint') + out; // daylight, 8:30 to 17:45 EXTRA.forEach((extra, i) => { const x = x0 + i * bw + 0.35; bars += rect(x, y(FLOOR), bw - 0.7, y(0) - y(FLOOR), 'rule') + (extra ? rect(x, y(FLOOR + extra), bw - 0.7, y(FLOOR) - y(FLOOR + extra), 'accent') : ''); }); const hours = [0, 6, 12, 18, 24].map((hr) => label(x0 + hr * bw, h - 2.5, `${hr}h`)).join(''); return bars + hours; } const PSH = [3.1, 3.9, 4.6, 5.0, 5.2, 5.3, 5.6, 5.6, 5.2, 4.3, 3.3, 2.8]; // peak sun hours const MONTHS = t({ en: 'JFMAMJJASOND', es: 'EFMAMJJASOND' }); function sunArt(w, h) { const x0 = 12, bw = (w - 2 - x0) / 12; const { out, y } = axes(w, h, x0, 6, 1, ' h'); const value = (v) => (LANG === 'es' ? v.toFixed(1).replace('.', ',') : v.toFixed(1)); return out + PSH.map((v, i) => rect(x0 + i * bw + 1.6, y(v), bw - 3.2, y(0) - y(v), i === 11 ? 'accent' : 'night') + label(x0 + (i + 0.5) * bw, y(v) - 1.2, value(v)) + label(x0 + (i + 0.5) * bw, h - 2.5, MONTHS[i])).join(''); } // Five December days, hour by hour: the array (620 W × 0.79) against the load profile. function socSeries() { const bank = 5120, days = [2.8, 0.5, 0.4, 0.7, 2.8]; const sun = Array.from({ length: 24 }, (_, h) => (h >= 8 && h < 17 ? Math.sin(Math.PI * (h - 7.5) / 9) : 0)); const sum = sun.reduce((a, b) => a + b); let soc = 0.8 * bank; const out = [0.8]; days.forEach((d) => sun.forEach((s, h) => { soc = Math.min(bank, soc + d * 620 * 0.79 * s / sum - FLOOR - EXTRA[h]); out.push(soc / bank); })); return out; } function socArt(w, h) { const x0 = 12, step = (w - 2 - x0) / 120; const { out, y } = axes(w, h, x0, 100, 20, t({ en: '%', es: ' %' })); let night = ''; for (let d = 0; d < 5; d++) { night += rect(x0 + d * 24 * step, 3, 8.5 * step, h - 10, 'tint') + rect(x0 + (d * 24 + 17.75) * step, 3, 6.25 * step, h - 10, 'tint') + label(x0 + (d * 24 + 12) * step, h - 2.5, t({ en: `day ${d + 1}`, es: `día ${d + 1}` })); } const curve = line(socSeries().map((v, i) => [x0 + i * step, y(v * 100)]), 'accent', 0.6, 'stroke-linejoin="round"'); return night + out + line([[x0, y(20)], [w - 2, y(20)]], 'night', 0.35, 'stroke-dasharray="1.2 0.8"') + curve; } // The cover: the sun's paths in June and December over a tilted panel, on the night band. function coverArt(w, h) { const horizon = 86, cx = w * 0.56; const path = (r, k) => line(Array.from({ length: 41 }, (_, i) => { const a = Math.PI * (i / 40); return [cx - r * Math.cos(a), horizon - k * r * Math.sin(a)]; }), 'sun', 0.5); const sunAt = (r, k, color, size) => `<circle cx="${n2(cx)}" cy="${n2(horizon - k * r)}" ` + `r="${size}" fill="${palette[color]}"/>`; let cells = ''; // a panel tilted towards the low sun, 6 × 4 cells const px = 14, py = 84, pw = 46, ph = 30, skew = 14; for (let r = 0; r < 4; r++) { for (let c = 0; c < 6; c++) { const [x, y] = [px + c * pw / 6 + (r + 0.5) * skew / 4, py - (r + 1) * ph / 4]; const pts = [[x, y], [x + pw / 6 - 0.8, y], [x + pw / 6 - 0.8 + skew / 4 * 0.8, y - ph / 4 + 0.8], [x + skew / 4 * 0.8, y - ph / 4 + 0.8]].map(([a, b]) => [a, b + ph / 4]); cells += `<path d="M${pts.map(([a, b]) => `${n2(a)} ${n2(b)}`).join('L')}Z" ` + `fill="${palette.paper}" fill-opacity="${0.16 + ((r + c) % 3) * 0.05}"/>`; } } return path(70, 0.9) + path(54, 0.45) + sunAt(70, 0.9, 'sun', 2.2) + sunAt(54, 0.45, 'sun', 4) + line([[8, horizon], [w - 8, horizon]], 'sun', 0.35) + cells; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { 'IBM Plex Serif': ['400', '400i', '600'], 'IBM Plex Sans Condensed': ['400', '600', '700'], 'IBM Plex Mono': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const text = chapters.map((chapter) => chapter.markdown).join('\n'); await loadFonts(FONTS, text); const face = await labelFace(); await loadSvg('cover.svg', sheet(TRIM.w, 110, coverArt(TRIM.w, 110))); await loadSvg('profile.svg', sheet(MEASURE, 50, profileArt(MEASURE, 50), face)); await loadSvg('sun.svg', sheet(MEASURE, 46, sunArt(MEASURE, 46), face)); await loadSvg('soc.svg', sheet(MEASURE, 50, socArt(MEASURE, 50), face)); const docs = await buildWithFonts(book, text); // one VDTDocument per Markdown document showPages(docs, { title: t({ en: 'Power for a Cabin', es: 'Energía para una cabaña' }) }); // One PDF for the book: a reference in chapter 3 links to its table in chapter 1. offerPdf(() => renderToPdf(docs, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · 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 ↗ (se abre en una nueva pestaña)
Variantes
#Escribe «Figura 2.1» en el texto
Un libro que escribe completas sus remisiones usa el nombre del tipo en lugar de la etiqueta corta.
- shortLabel: type.id === 'table' ? t({ en: 'table', es: 'tabla' })
- : t({ en: 'fig.', es: 'figura' }),
+ shortLabel: type.id === 'table' ? t({ en: 'Table', es: 'Tabla' })
+ : t({ en: 'Figure', es: 'Figura' }),#Numera los apartados con el signo de párrafo
- section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
+ section: '§ {n}',Errores frecuentes
Error frecuente
Una figura se numera y se coloca donde se cita por primera vez
La primera referencia :ref o @fig: a una figura o una tabla le da su número y su sitio, así que una referencia en un capítulo anterior la lleva allí. Remite hacia delante a la sección que la contiene, y a la figura solo cuando ya esté colocada. Referencias cruzadas →
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
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 →
Error frecuente
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
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
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
- Una remisión a un identificador que ningún capítulo define imprime ?. Revisa los identificadores después de renombrar un título en un capítulo: las remisiones a él están en los otros.
- Componer los capítulos uno a uno con
buildDocumentpierde los contadores y el esquema del libro: cada capítulo numera sus figuras desde 1 y una remisión a otro capítulo imprime ?. UsabuildBundle, o encadenacontinuationy pasa tú eloutline. - Las ecuaciones no tienen numeración propia en Postext, así que
@eq:solo encuentra un ancla que hayas puesto e imprime su texto; este libro escribe sus dos fórmulas sin número.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- IBM Plex Serif (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


