Saltar al contingut principal
Recepta número 94

Receptari · Capítol 5 · Estructura del llibre

Un llibre tècnic amb remissions entre capítols

Un manual solar de tres capítols on el text escriu @fig:sun, @tbl:loads i @sec:array-size, i buildBundle imprimeix cada número i pàgina entre capítols.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Avançat
Postext
Provada amb Postext 1.12.1
Requereix ≥ 1.12.1 · postext-pdf ≥ 1.12.1
Llicència
Actualitzada el 1 d’oct. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 178 × 233 mm
  • 1 columna
  • IBM Plex Serif 9,6/13,8
  • IBM Plex Mono
  • IBM Plex Sans Condensed
  • 9 pàgines
  • Nivell
  • Postext 1.12.1
  • Compost en 35 ms
  • 244 línies de codi

En poques paraules

Un manual breu per calcular els panells i les bateries d'una cabana. Quan el capítol 3 remet a una taula del capítol 1, o el capítol 1 a un apartat del 2, s'imprimeixen el número i la pàgina correctes, i són enllaços.

Què compondràs

Un manual tècnic breu, Energía para una cabaña, compost en IBM Plex sobre una pàgina de 178 × 233 mm: una coberta amb les trajectòries del sol dibuixades en codi, un índex i tres capítols sota una banda blau nit. El llibre dimensiona una instal·lació solar petita, així que cada capítol es recolza en els altres. El capítol 3 cita la taula de consums de l'1 i el gràfic d'hores de sol del 2; el capítol 1 envia el lector a l'apartat 2.3 i li dona la pàgina. Qui escriu posa @tbl:loads i @sec:array-size, tal com els llegeix pandoc-crossref, en quatre fitxers Markdown separats. buildBundle els compon com un sol llibre, així que cada remissió imprimeix el número que va rebre la seva destinació al seu propi capítol, la pàgina on va caure i un enllaç.

Aquesta recepta respon a

  • Com remeto a una figura, una taula o una secció d'un altre capítol, a l'estil de pandoc-crossref, amb els números sempre correctes?
  • Com remeto a una secció i a la pàgina on és, i mantinc totes dues correctes quan el llibre canvia?

La resposta curta

script.js · línies 44–61al codi complet
// 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' } }) }));

Ingredients

Tipografia
IBM Plex Serif, IBM Plex Sans Condensed, IBM Plex Mono (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Anomena cada destinació una vegada, per a tot el llibre

El codi és la resposta curta de més amunt. Els títols porten un identificador de Pandoc amb el seu prefix, ## El tamaño del campo {#sec:array-size}. Les figures i les taules són recursos declarats a l'script, i els seus identificadors s'escriuen igual, fig:sun i tbl:loads, de manera que el text diu @fig:sun tant si la figura és dos paràgrafs enrere com dos capítols enrere. Cada identificador ha de ser únic en tot el llibre, no només al seu capítol.

#2 · Compon els capítols com un sol llibre

script.js · línies 44–61al codi complet
// 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 arrossega els comptadors de figures i taules d'un capítol al següent, així que la primera taula del capítol 3 és la 3.1 i @tbl:loads al capítol 3 continua imprimint tabla 1.1. Un capítol el text del qual anomena un títol que no conté es compon amb l'esquema del llibre sencer, i es torna a compondre (tres passades com a màxim) fins que les pàgines d'aquest esquema deixen de moure's. Per això el capítol 1 pot imprimir apartado 2.1 … pág. 6 abans que el capítol 2 existeixi en paper.

#3 · Tria les paraules de cada remissió

crossRefs fixa capítulo, apartado i pág. (en anglès, chapter, section i p.). Una remissió a una figura o una taula imprimeix el shortLabel del seu tipus, aquí en minúscula, figura 2.1 i tabla 1.1, com s'escriuen enmig d'una frase. Una majúscula al prefix, @Tbl:loads o @Sec:array, posa en majúscula l'etiqueta a començament de frase, i [-@tbl:losses] imprimeix només el número per a las tablas 2.1 y 3.1.

#4 · Remet cap endavant a apartats i cap enrere a figures

Una figura o una taula es numera, i es col·loca, on se cita per primera vegada. @fig:soc al capítol 1 la convertiria en la figura 1.2 i la posaria al capítol 1. Per això el capítol 1 remet cap endavant a @sec:autonomy, i cada figura i cada taula se citen primer al seu propi capítol; els capítols següents hi remeten sense problema. La pàgina va amb l'apartat, :ref{id="sec:sun-hours" style=page}: una remissió a un recurs imprimeix la seva etiqueta i el seu número, mai la seva pàgina.

#5 · Les obertures i la coberta surten de la mateixa banda

script.js · línies 65–85al codi complet
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 baixa 55 mm des de la vora superior a cada capítol i a l'índex, que reutilitza els mateixos elements; el número és {chapterNumber} i l'entradeta, un atribut del títol.

script.js · línies 89–109al codi complet
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 recepta completa

Sandbox
// ═══ 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`---
Mostra en Markdown · 16 línies · content.es.mdtitle: "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."}
Mostra en Markdown · 26 línies · 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."}
Mostra en Markdown · 26 línies · 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."}
Mostra en Markdown · 26 línies · 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ía
Mostra en Markdown · 22 línies · content.tables.es.mdIluminació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 a totes les receptes · 310 línies// ─── 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Escriu «Figura 2.1» al text

Un llibre que escriu completes les seves remissions fa servir el nom del tipus en lloc de l'etiqueta curta.

-  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 els apartats amb el signe de paràgraf

-  section: t({ en: 'section {n}', es: 'apartado {n}' }), // @sec:losses → "section 2.2"
+  section: '§ {n}',

Errors freqüents

Error freqüent

Una figura es numera i es col·loca on es cita per primer cop

La primera referència :ref o @fig: a una figura o una taula li dona el número i el lloc, de manera que una referència en un capítol anterior la porta allà. Remet cap endavant a la secció que la conté, i a la figura només quan ja estigui col·locada. Referències creuades →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

Error freqüent

Una paleta canviada no arriba als elements de disseny ni al color de les remissions

postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →

Error freqüent

Només 8 llengües tenen partició de mots, amb el codi exacte

La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua del document →

Error freqüent

El text dins d'un SVG <img> no pot fer servir fonts web

Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

  • Una remissió a un identificador que cap capítol no defineix imprimeix ?. Revisa els identificadors després de canviar el nom d'un títol en un capítol: les remissions que hi apunten són als altres.
  • Compondre els capítols un per un amb buildDocument perd els comptadors i l'esquema del llibre: cada capítol numera les seves figures des d'1 i una remissió a un altre capítol imprimeix ?. Fes servir buildBundle, o encadena continuation i passa tu mateix l'outline.
  • Les equacions no tenen numeració pròpia a Postext, així que @eq: només troba una àncora que hagis posat i n'imprimeix el text; aquest llibre escriu les seves dues fórmules sense número.

Crèdits

Text
Text original, CC BY 4.0
Fonts
IBM Plex Serif (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1) · IBM Plex Mono (SIL OFL 1.1)
SandboxPDF