Saltar al contenido principal
Receta número 38

Recetario · Capítulo 9 · Publicaciones completas

Novela en rústica: aperturas hundidas y capítulos en impar

The Awakening en rústica de 140 × 216 mm: cubierta dibujada, colofón sin cabeceras y cada capítulo hundido en página impar bajo un número romano en cursiva.

En esta página
  • Muestra en inglés: aún no hay edición en español
  • Formato 140 × 216 mm
  • 1 columna
  • Crimson Pro 10,5/14
  • Cormorant Garamond
  • Cormorant SC
  • 8 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 22 ms
  • 179 líneas de código

Lo que vas a componer

Los dos primeros capítulos de The Awakening (1899), de Kate Chopin, ocupan ocho páginas de rústica de 140 × 216 mm. La cubierta, dibujada en código, muestra el golfo desde Grand Isle: un lugre en el horizonte entre los robles y la sombrilla blanca que sube entre la manzanilla. El colofón va al pie de su dorso. Cada capítulo empieza en página impar, con el texto a 81 mm del borde superior, bajo un número romano en cursiva de 48 pt, un filete rojo óxido de 18 mm y la primera frase en versalitas, partida en dos líneas centradas. El capítulo I acaba en la 5 con un florón de manzanilla, así que la 6 queda en blanco, sin cabecera ni folio, y el II empieza en la 7. Las cabeceras llevan el nombre de la autora en las pares y el título en cursiva en las impares.

Esta receta responde a

  • ¿Cómo fuerzo un salto de página o de columna, y hago que cada capítulo empiece en página impar?
  • ¿Cómo añado a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?
  • ¿Cómo inserto páginas en blanco a propósito, o empiezo una sección en una doble página nueva?
  • ¿Cómo pongo cabeceras: el título del libro en la página izquierda, el del capítulo en la derecha y el folio por fuera?
  • ¿Cómo hago una cubierta, una portadilla, una portada y un colofón?
  • ¿Cómo añado espacio vertical entre dos bloques, si las líneas en blanco no hacen nada?
  • ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?

La respuesta corta

script.js · líneas 48–80en el código completo
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };

Ingredientes

Tipografía
Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1)
Recursos
  • La vista del golfo de la cubierta y el florón de manzanilla, dibujados en código con la paleta de la página (Ignacio Ferro, MIT)

Elaboración

#1 · La apertura sale del título y de su atributo

El código es la respuesta corta de arriba. Los capítulos de Chopin solo llevan número, así que el título de cada uno en Markdown es ese número (# I), y {titleText} lo imprime. {attr.lead} imprime el atributo lead del título, que contiene la primera frase del capítulo; el párrafo de Markdown que va debajo empieza por la frase siguiente (atributos de encabezado). El texto de un diseño no se puede justificar, así que la primera frase va centrada, y el \n de cada atributo la corta por el sentido, tras cage y tras bright;. Ese \n solo corta la línea si paragraphIndent es mayor que cero; con 0 se imprimen tal cual los dos caracteres. La frase empieza nueve líneas de la rejilla por debajo del borde superior de la caja de texto, y un minHeight de 12 líneas deja una línea en blanco debajo; sin minHeight, el texto arranca pegado a la frase (span y diseño avanzado). marginBottom: pt(0) sustituye el margen de medio cuerpo que el nivel trae por defecto y que bajaría el texto una línea.

breakBefore con paridad 'odd' lleva cada capítulo a una página impar. La configuración lo repite porque cualquier objeto headings anula el salto que el nivel trae por defecto (saltar antes). Para forzar una página impar donde no hay título, escribe :::pagebreak{parity="odd"} en el Markdown; en una maquetación de dos o más columnas, :::columnbreak pasa el texto a la columna siguiente (:::pagebreak).

#2 · Una página de rústica con líneas enteras

script.js · líneas 34–44en el código completo
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};

El margen inferior es lo que queda de los 216 mm de alto tras el margen superior y 35 líneas de 14 pt, así que todas las páginas llenas acaban en la misma línea, como la 3 y la 4. Con mirror: true, el margen interior de 20 mm queda del lado del lomo tanto en las impares como en las pares (márgenes simétricos). La medida de 104,5 mm admite unos 69 caracteres de Crimson Pro de 10,5 pt. El texto base admite espacios entre palabras de 0,7 a 1,9 veces el normal; con el mínimo por defecto, 0,6, una línea de estas páginas se cierra hasta 0,62. También fija maxRuntTracking: 0. Con el valor por defecto, el párrafo que acaba en nodding good-by to him. se compone con una línea menos mediante un interletraje negativo que el lienzo no pinta, y sus espacios salen aplastados.

#3 · Cabeceras que se saltan las aperturas y las páginas en blanco

script.js · líneas 84–104en el código completo
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };

parity: 'even' pone el nombre de la autora, en versalitas de Cormorant SC, en las páginas pares, y 'odd', el título en cursiva en las impares; los folios van en el borde exterior de la caja de texto. pages: 'body' aparta los cuatro elementos de las aperturas y de la página 6, la par en blanco que añade el salto con paridad. Con pages: 'all', esa página imprimiría 6 y Kate Chopin. Las aperturas llevan en su lugar un folio al pie, un elemento del footer que pages: 'opener' reserva para ellas (elementos de texto).

#4 · La cubierta es una sección sin cabeceras

script.js · líneas 108–133en el código completo
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COVER_ART = { w: TRIM.width, h: 134 }; // mm: the drawing fills the width, 134 mm deep
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };

La cubierta es un título, # The Awakening {style="cover"}. Su estilo dibuja la ilustración en los 134 mm superiores, el título del frontmatter en 50 pt y el nombre de la autora en versalitas. El estilo rige además la sección que abre ese título, las páginas 1 y 2, hasta el capítulo I. Su header y su footer vacíos quitan el folio al pie de la cubierta y las cabeceras del colofón. Su margen superior coloca el colofón en las seis últimas líneas de la caja de texto, así que la última línea del colofón queda a la altura de la última de la página 3 (estilos de encabezado). El estilo necesita span: 'page' aunque el libro tenga una sola columna. Un diseño que se queda en la columna se recorta en su borde superior, aquí a 165 mm; sin span: 'page', la ilustración no se imprimiría y el título perdería la parte de arriba.

Página 2: el colofón al pie del dorso de la cubierta, sin cabecera ni folio.

#5 · Un florón cierra cada capítulo

script.js · líneas 137–151en el código completo
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [
  svg('cover', 'cover.svg', COVER_ART.w, COVER_ART.h, 'The gulf seen between the trunks of '
    + 'water-oaks, a lugger on the horizon; a white sunshade comes up from the beach through '
    + 'the camomile.'),
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];

El tipo de adorno no tiene prefijo de pie, así que la flor de manzanilla se imprime sin rótulo, con un ancho del 17 % de la medida, en el lugar de ::resource{id="tailpiece-1"} (tipos de recurso). Una inserción en línea deja una línea de la rejilla por encima, y el :::space que la precede añade otra entre la última línea del capítulo y la flor (:::space). Los dos florones usan el mismo archivo, fleuron.svg, con dos identificadores distintos, porque cada recurso se coloca una sola vez.

La receta completa

// ═══ Postext Cookbook · Nº 038 · Trade paperback: sunk openers and recto chapters ═══
// https://postext.dev/en/cookbook/trade-paperback-novel
// Code: MIT · Text: Kate Chopin, The Awakening, 1899 (PD, Gutenberg #160) · Art: drawn in code
// Fonts: Crimson Pro, Cormorant Garamond, Cormorant SC (SIL OFL 1.1) · Needs postext ≥ 1.4.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document (this recipe is English only)
const RECIPE = 'trade-paperback-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#231d18', // the text: a warm near-black
  rubric: '#8e3b22', // the one accent: the opener's hairline, the author, the tailpiece
  muted: '#6e655b', // running heads, folios on openers, the colophon
  paper: '#fbf7ef', // a cream book paper
  sea: '#2f5d6b', shallows: '#7fa6a3', sand: '#e8d9b8', camomile: '#d8b04a', // the cover
};
// Each colour names its palette entry and carries its hex (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults (headings, lists, callouts, captions) link to 'main-color', the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];
const TEXT = 'Crimson Pro'; // the text face
const DISPLAY = 'Cormorant Garamond'; // italic numerals and the cover's title
const LABEL = 'Cormorant SC'; // small capitals: the lead, the author's name
const [BODY, LEAD] = [10.5, 14]; // pt: the body size and its leading, the grid's pitch
const line = (n) => pt(n * LEAD); // n grid lines

// #region page: a 140 × 216 mm trade page whose text block holds 35 whole lines
const TRIM = { width: 140, height: 216 }; // mm: 5½ × 8½ in
const [TOP, INNER, OUTER] = [22, 20, 15.5]; // mm; mirrored, so INNER is left on a recto
const LINES = 35; // every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 104.5 mm: about 69 characters of Crimson Pro
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * MM_PER_PT),
    left: mm(INNER), right: mm(OUTER), mirror: true },
};
// #endregion

// #region answer: a sunk opener: the numeral, a hairline, and the first sentence in small caps
// # I {lead="A green and yellow parrot, … kept repeating over and over:"}
// The heading's text is the numeral, printed by {titleText}; the chapter's first sentence
// travels as the heading's lead attribute, printed by {attr.lead} in small capitals and
// centred, because design text is never justified (gotcha: design-text-ragged).
const [LEAD_AT, BODY_AT] = [9, 12]; // grid lines: the lead's top; where the text starts
const NUMERAL = 48; // pt
const RULE_Y = (LEAD_AT - 1.5) * LEAD * MM_PER_PT; // mm: a line and a half above the lead
const NUMERAL_Y = RULE_Y - NUMERAL * MM_PER_PT - 3; // mm: the numeral's box ends 3 mm above it
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y } });
const opener = {
  enabled: true,
  // The two-line lead ends on line LEAD_AT + 2; minHeight leaves a blank line under it and
  // starts the text on line BODY_AT (a one-line lead would leave two).
  minHeight: line(BODY_AT),
  slot: { elements: [
    { kind: 'text', id: 'numeral', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(NUMERAL), lineHeight: 1, color: col('ink'),
      align: 'center', placement: centred(mm(NUMERAL_Y)) },
    { kind: 'rule', id: 'hairline', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...centred(mm(RULE_Y)), size: { width: mm(18) } } },
    { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(0.6), color: col('ink'), align: 'center',
      lineHeight: LEAD / 9.5, // a multiple, never pt() (gotcha: design-lineheight-multiple)
      overflow: 'wrap', // wrap, not '…' (gotcha: overflow-ellipsis-default)
      paragraphIndent: pt(0.01), // a \n in the lead breaks the line (gotcha: design-text-newline)
      placement: { ...centred(line(LEAD_AT)), size: { width: mm(MEASURE) } } },
  ] },
};
// Every chapter opens on a recto: 'odd' adds a blank verso only after a chapter that ends on
// a recto (restated: gotcha headings-drop-h1-break). marginBottom 0: the level's default
// 0.5 em would start the text a line lower.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
  marginBottom: pt(0), italic: true, advancedDesign: opener };
// #endregion

// #region heads: the author on the verso, the title on the recto, folios outside
// {author} and {title} come from the frontmatter, every value quoted (gotcha: quote-frontmatter).
const HEAD_Y = 12; // mm from the top edge to the top of the running heads
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
const head = (id, content, parity, edge, x, style) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers or blank pages
  fontSize: pt(9), color: col('muted'), ...style,
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD_Y) } },
});
const smallCaps = { fontFamily: LABEL, fontWeight: 600, letterSpacing: pt(1.2) };
const italic = { fontFamily: DISPLAY, italic: true, fontWeight: 500, fontSize: pt(9.75) };
const folio = { fontFamily: TEXT, color: col('ink') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('verso-author', '{author}', 'even', 'top', -SHIFT, smallCaps),
  head('recto-title', '{title}', 'odd', 'top', SHIFT, italic),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
  pages: 'opener', fontFamily: TEXT, fontSize: pt(9), color: col('muted'), align: 'center',
  placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(7) } } }] };
// #endregion

// #region cover: a section of its own: art, title, author, no heads, a colophon on its verso
// Page 1 is a recto (gotcha: parity-page1-recto): the cover, the colophon on its verso, and
// chapter I on page 3. # The Awakening {style="cover"} opens the section; chapter I closes it.
// The colophon: 2 + 2 + 1 lines of 11 pt and two gaps of 14.5 pt make 84 pt, six grid lines.
const COLOPHON_LINES = 6;
const COVER_ART = { w: TRIM.width, h: 134 }; // mm: the drawing fills the width, 134 mm deep
const COLOPHON_TOP = TOP + (LINES - COLOPHON_LINES) * LEAD * MM_PER_PT; // mm: the section's margin
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } }); // y mm down
const cover = {
  id: 'cover',
  span: 'page', // kept in the column, the design is clipped at the column top, 165 mm down
  header: { elements: [] }, footer: { elements: [] }, // no running heads on p. 1 or p. 2
  margins: { top: mm(COLOPHON_TOP) },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'cover',
      placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill' } } },
    { kind: 'text', id: 'title', content: '{title}', fontFamily: DISPLAY, italic: true,
      fontWeight: 500, fontSize: pt(50), lineHeight: 1, color: col('ink'), align: 'center',
      placement: onPage(155) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('rubric'), placement: { ...onPage(179), size: { width: mm(18) } } },
    { kind: 'text', id: 'author', content: '{author}', ...smallCaps, fontSize: pt(12),
      letterSpacing: pt(2.4), color: col('rubric'), align: 'center', placement: onPage(184) },
  ] } },
};
const colophon = { id: 'colophon', fontSize: pt(8), lineHeight: pt(11), color: col('muted'),
  textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(14.5) };
// #endregion

// #region tailpiece: an ornament type that prints no caption, where ::resource stands
// At a chapter's end: :::space, then ::resource{id="tailpiece-1"} (double quotes: gotcha
// resource-double-quotes). The space adds a line to the gap the embed keeps above it.
const ornament = { id: 'ornament', name: 'Ornament', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal',
  defaultPlacement: { position: 'here', width: 0.17, align: 'center' } };
const svg = (id, fileId, width, height, altText) => ({ id, typeId: 'ornament', kind: 'svg',
  svg: { fileId, width, height }, altText, createdAt: 0, updatedAt: 0 });
const resources = [
  svg('cover', 'cover.svg', COVER_ART.w, COVER_ART.h, 'The gulf seen between the trunks of '
    + 'water-oaks, a lugger on the horizon; a white sunshade comes up from the beach through '
    + 'the camomile.'),
  // ::resource places each ornament once, so each chapter end has its own id.
  ...['I', 'II'].map((n, i) => svg(`tailpiece-${i + 1}`, 'fleuron.svg', 40, 12,
    `A camomile flower between two sprigs closes chapter ${n}.`)),
];
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette,
  resourceTypes: [ornament],
  page,
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'),
    firstLineIndent: mm(4), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.9, // at the default 0.6 a line closes to 0.62
    maxRuntTracking: 0, // gotcha: runt-tracking-unpainted
  },
  // The designs paint every heading; the level's own face is the numeral's (500 italic), so the
  // kit has no unused face to load.
  headings: { fontFamily: DISPLAY, fontWeight: 500, levels: [chapter] },
  headingStyles: [cover],
  paragraphStyles: [colophon],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 79 líneas · content.en.mdtitle: "The Awakening" author: "Kate Chopin" --- # The Awakening {style="cover"} :::paragraphs{style="colophon"} First published by Herbert S. Stone & Company, Chicago, in April 1899. Chapters I and II follow Project Gutenberg eBook #160, with Chopin’s spelling and punctuation. Set in Crimson Pro, Cormorant Garamond and Cormorant SC (SIL Open Font License). The cover, drawn in code, shows the gulf from the Lebrun cottages on Grand Isle. A Postext Cookbook edition. The text is in the public domain. ::: # I {lead="A green and yellow parrot, which hung in a cage\noutside the door, kept repeating over and over:"} “*Allez vous-en! Allez vous-en! Sapristi!* That’s all right!” He could speak a little Spanish, and also a language which nobody understood, unless it was the mocking-bird that hung on the other side of the door, whistling his fluty notes out upon the breeze with maddening persistence. Mr. Pontellier, unable to read his newspaper with any degree of comfort, arose with an expression and an exclamation of disgust. He walked down the gallery and across the narrow “bridges” which connected the Lebrun cottages one with the other. He had been seated before the door of the main house. The parrot and the mocking-bird were the property of Madame Lebrun, and they had the right to make all the noise they wished. Mr. Pontellier had the privilege of quitting their society when they ceased to be entertaining. He stopped before the door of his own cottage, which was the fourth one from the main building and next to the last. Seating himself in a wicker rocker which was there, he once more applied himself to the task of reading the newspaper. The day was Sunday; the paper was a day old. The Sunday papers had not yet reached Grand Isle. He was already acquainted with the market reports, and he glanced restlessly over the editorials and bits of news which he had not had time to read before quitting New Orleans the day before. Mr. Pontellier wore eye-glasses. He was a man of forty, of medium height and rather slender build; he stooped a little. His hair was brown and straight, parted on one side. His beard was neatly and closely trimmed. Once in a while he withdrew his glance from the newspaper and looked about him. There was more noise than ever over at the house. The main building was called “the house,” to distinguish it from the cottages. The chattering and whistling birds were still at it. Two young girls, the Farival twins, were playing a duet from “Zampa” upon the piano. Madame Lebrun was bustling in and out, giving orders in a high key to a yard-boy whenever she got inside the house, and directions in an equally high voice to a dining-room servant whenever she got outside. She was a fresh, pretty woman, clad always in white with elbow sleeves. Her starched skirts crinkled as she came and went. Farther down, before one of the cottages, a lady in black was walking demurely up and down, telling her beads. A good many persons of the *pension* had gone over to the *Chênière Caminada* in Beaudelet’s lugger to hear mass. Some young people were out under the water-oaks playing croquet. Mr. Pontellier’s two children were there—sturdy little fellows of four and five. A quadroon nurse followed them about with a faraway, meditative air. Mr. Pontellier finally lit a cigar and began to smoke, letting the paper drag idly from his hand. He fixed his gaze upon a white sunshade that was advancing at snail’s pace from the beach. He could see it plainly between the gaunt trunks of the water-oaks and across the stretch of yellow camomile. The gulf looked far away, melting hazily into the blue of the horizon. The sunshade continued to approach slowly. Beneath its pink-lined shelter were his wife, Mrs. Pontellier, and young Robert Lebrun. When they reached the cottage, the two seated themselves with some appearance of fatigue upon the upper step of the porch, facing each other, each leaning against a supporting post. “What folly! to bathe at such an hour in such heat!” exclaimed Mr. Pontellier. He himself had taken a plunge at daylight. That was why the morning seemed long to him. “You are burnt beyond recognition,” he added, looking at his wife as one looks at a valuable piece of personal property which has suffered some damage. She held up her hands, strong, shapely hands, and surveyed them critically, drawing up her fawn sleeves above the wrists. Looking at them reminded her of her rings, which she had given to her husband before leaving for the beach. She silently reached out to him, and he, understanding, took the rings from his vest pocket and dropped them into her open palm. She slipped them upon her fingers; then clasping her knees, she looked across at Robert and began to laugh. The rings sparkled upon her fingers. He sent back an answering smile. “What is it?” asked Pontellier, looking lazily and amused from one to the other. It was some utter nonsense; some adventure out there in the water, and they both tried to relate it at once. It did not seem half so amusing when told. They realized this, and so did Mr. Pontellier. He yawned and stretched himself. Then he got up, saying he had half a mind to go over to Klein’s hotel and play a game of billiards. “Come go along, Lebrun,” he proposed to Robert. But Robert admitted quite frankly that he preferred to stay where he was and talk to Mrs. Pontellier. “Well, send him about his business when he bores you, Edna,” instructed her husband as he prepared to leave. “Here, take the umbrella,” she exclaimed, holding it out to him. He accepted the sunshade, and lifting it over his head descended the steps and walked away. “Coming back to dinner?” his wife called after him. He halted a moment and shrugged his shoulders. He felt in his vest pocket; there was a ten-dollar bill there. He did not know; perhaps he would return for the early dinner and perhaps he would not. It all depended upon the company which he found over at Klein’s and the size of “the game.” He did not say this, but she understood it, and laughed, nodding good-by to him. Both children wanted to follow their father when they saw him starting out. He kissed them and promised to bring them back bonbons and peanuts. :::space ::resource{id="tailpiece-1"} # II {lead="Mrs. Pontellier’s eyes were quick and bright;\nthey were a yellowish brown, about the color of her hair."} She had a way of turning them swiftly upon an object and holding them there as if lost in some inward maze of contemplation or thought. Her eyebrows were a shade darker than her hair. They were thick and almost horizontal, emphasizing the depth of her eyes. She was rather handsome than beautiful. Her face was captivating by reason of a certain frankness of expression and a contradictory subtle play of features. Her manner was engaging. Robert rolled a cigarette. He smoked cigarettes because he could not afford cigars, he said. He had a cigar in his pocket which Mr. Pontellier had presented him with, and he was saving it for his after-dinner smoke. This seemed quite proper and natural on his part. In coloring he was not unlike his companion. A clean-shaved face made the resemblance more pronounced than it would otherwise have been. There rested no shadow of care upon his open countenance. His eyes gathered in and reflected the light and languor of the summer day. Mrs. Pontellier reached over for a palm-leaf fan that lay on the porch and began to fan herself, while Robert sent between his lips light puffs from his cigarette. They chatted incessantly: about the things around them; their amusing adventure out in the water—it had again assumed its entertaining aspect; about the wind, the trees, the people who had gone to the *Chênière;* about the children playing croquet under the oaks, and the Farival twins, who were now performing the overture to “The Poet and the Peasant.” Robert talked a good deal about himself. He was very young, and did not know any better. Mrs. Pontellier talked a little about herself for the same reason. Each was interested in what the other said. Robert spoke of his intention to go to Mexico in the autumn, where fortune awaited him. He was always intending to go to Mexico, but some way never got there. Meanwhile he held on to his modest position in a mercantile house in New Orleans, where an equal familiarity with English, French and Spanish gave him no small value as a clerk and correspondent. He was spending his summer vacation, as he always did, with his mother at Grand Isle. In former times, before Robert could remember, “the house” had been a summer luxury of the Lebruns. Now, flanked by its dozen or more cottages, which were always filled with exclusive visitors from the “*Quartier Français*,” it enabled Madame Lebrun to maintain the easy and comfortable existence which appeared to be her birthright. Mrs. Pontellier talked about her father’s Mississippi plantation and her girlhood home in the old Kentucky blue-grass country. She was an American woman, with a small infusion of French which seemed to have been lost in dilution. She read a letter from her sister, who was away in the East, and who had engaged herself to be married. Robert was interested, and wanted to know what manner of girls the sisters were, what the father was like, and how long the mother had been dead. When Mrs. Pontellier folded the letter it was time for her to dress for the early dinner. “I see Léonce isn’t coming back,” she said, with a glance in the direction whence her husband had disappeared. Robert supposed he was not, as there were a good many New Orleans club men over at Klein’s. When Mrs. Pontellier left him to enter her room, the young man descended the steps and strolled over toward the croquet players, where, during the half-hour before dinner, he amused himself with the little Pontellier children, who were very fond of him. :::space ::resource{id="tailpiece-2"}
`; // content.en.md, inlined by the Cookbook // #region art: the cover and the fleuron, drawn in millimetres in the page's palette function mulberry32(seed) { // a seeded PRNG: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round( parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k) .toString(16).padStart(2, '0')).join('')}`; const n2 = (v) => +v.toFixed(2); // A wavy band edge from x = 0 to W at height y, amplitude a: the top of a filled band. function edge(rnd, W, y, a, steps = 14) { let d = `M0 ${n2(y)}`; for (let i = 1; i <= steps; i++) { const x = (W * i) / steps; d += `Q${n2(x - W / steps / 2)} ${n2(y + (rnd() - 0.5) * 2 * a)} ` + `${n2(x)} ${n2(y + (rnd() - 0.5) * a)}`; } return d; } function drawCover(W, H) { // chapter I: the gulf, far and hazy, seen from the cottage porch const rnd = mulberry32(1899); const P = palette; const out = []; const shape = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`); const band = (y, a, color) => shape(`${edge(rnd, W, y, a)}L${W} ${H}L0 ${H}Z`, color); const bez = (a, b, c, d, t) => (1 - t) ** 3 * a + 3 * (1 - t) ** 2 * t * b + 3 * (1 - t) * t * t * c + t ** 3 * d; // The sky: pale blue overhead, warming into haze at the horizon; a white noon sun. const sky = mix(P.shallows, P.paper, 0.5); out.push(`<rect width="${W}" height="${H}" fill="${sky}"/>`); out.push(`<circle cx="47" cy="20" r="6" fill="${mix(P.sand, P.paper, 0.7)}"/>`); [[40, 0.7, 0.3], [52, 0.8, 0.55], [62, 0.6, 0.8]].forEach(([y, a, k]) => band(y, a, mix(sky, mix(P.sand, P.paper, 0.45), k))); // The gulf: melting into the haze at the horizon, deep further out, green over the shallows. const HORIZON = 70; out.push(`<rect y="${HORIZON}" width="${W}" height="${H - HORIZON}" ` + `fill="${mix(P.shallows, P.paper, 0.35)}"/>`); band(HORIZON + 4, 0.2, mix(P.sea, P.shallows, 0.35)); band(HORIZON + 11, 0.5, P.sea); band(HORIZON + 18, 0.8, mix(P.sea, P.shallows, 0.55)); for (let i = 0; i < 70; i++) { // glints on the water, longer in the foreground const y = HORIZON + 5 + rnd() * 17; const [x, len] = [rnd() * W, 1 + ((y - HORIZON) / 20) * 5 * (0.5 + rnd())]; out.push(`<path d="M${n2(x)} ${n2(y)}h${n2(len)}" stroke="${mix(P.shallows, P.paper, 0.5)}" ` + `stroke-width="${n2(0.25 + (y - HORIZON) / 60)}" stroke-linecap="round"/>`); } // Beaudelet's lugger on the horizon, on its way to the Chênière. shape(`M84 ${HORIZON + 0.6}h6.5l-1 1.3h-4.6z`, P.ink); shape(`M86.2 ${HORIZON + 0.4}L86.8 ${HORIZON - 7.2}L89.9 ${HORIZON - 5.4}` + `L89.2 ${HORIZON + 0.4}Z`, mix(P.rubric, P.sand, 0.3)); // The surf, the beach and the stretch of yellow camomile. band(HORIZON + 24, 0.7, P.paper); band(HORIZON + 25.3, 0.9, P.sand); band(HORIZON + 31, 1.2, mix(P.camomile, P.sand, 0.35)); band(HORIZON + 38, 1.6, mix(P.camomile, P.shallows, 0.2)); band(HORIZON + 50, 2, mix(P.camomile, P.sea, 0.25)); // The path up from the beach, widening as it comes near. const PATH_TOP = HORIZON + 30; const [left, right] = [[66, 68, 76, 70], [69.6, 74, 88, 96]]; // its edges, top to bottom shape(`M${left[0]} ${PATH_TOP}C${left[1]} 112 ${left[2]} 124 ${left[3]} ${H}H${right[3]}` + `C${right[2]} 124 ${right[1]} 112 ${right[0]} ${PATH_TOP}Z`, mix(P.sand, P.camomile, 0.2)); const onPath = (x, y) => { const t = (y - PATH_TOP) / (H - PATH_TOP); // the edges' y runs evenly return t >= 0 && x > bez(...left, t) - 1 && x < bez(...right, t) + 1; }; for (let i = 0; i < 560; i++) { // camomile heads, bigger and sparser towards the viewer const y = HORIZON + 32 + rnd() ** 0.8 * (H - HORIZON - 32); const x = rnd() * W; const r = 0.25 + ((y - HORIZON - 32) / (H - HORIZON)) * 1.4; if (onPath(x, y)) continue; out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r)}" fill="${P.paper}"/>` + `<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(r * 0.45)}" fill="${P.camomile}"/>`); } // Edna and Robert under the white, pink-lined sunshade. const [sx, sy] = [70.5, HORIZON + 33]; shape(`M${sx - 1.3} ${sy}v4.2h1.1v-4.2z`, P.paper); shape(`M${sx + 0.4} ${sy}v4.2h1.1v-4.2z`, mix(P.ink, P.sea, 0.4)); shape(`M${sx - 3.2} ${sy - 0.2}Q${sx} ${sy - 3.6} ${sx + 3.2} ${sy - 0.2}Z`, P.paper); shape(`M${sx - 3.2} ${sy - 0.2}Q${sx} ${sy + 0.6} ${sx + 3.2} ${sy - 0.2}Z`, mix(P.rubric, P.paper, 0.55)); // The gaunt trunks of the water-oaks and their limbs: tapered curves, wide at the base. const bark = mix(P.ink, P.sea, 0.35); const bend = ([x0, y0], [cx, cy], [x1, y1], t) => [ (1 - t) ** 2 * x0 + 2 * (1 - t) * t * cx + t * t * x1, (1 - t) ** 2 * y0 + 2 * (1 - t) * t * cy + t * t * y1]; const limb = (p0, c, p1, w0, w1) => { const [l, r] = [[], []]; // the limb's two outlines for (let i = 0; i <= 12; i++) { const t = i / 12; const [x, y] = bend(p0, c, p1, t); const [dx, dy] = [(1 - t) * (c[0] - p0[0]) + t * (p1[0] - c[0]), (1 - t) * (c[1] - p0[1]) + t * (p1[1] - c[1])]; const k = (w0 + (w1 - w0) * t) / 2 / Math.hypot(dx, dy); l.push(`${n2(x - dy * k)} ${n2(y + dx * k)}`); r.unshift(`${n2(x + dy * k)} ${n2(y - dx * k)}`); } shape(`M${l.join('L')}L${r.join('L')}Z`, bark); }; // Each oak: a trunk (foot, bend, top; widths) and limbs that leave it at t along its length. [[[108, H + 1], [106, 70], [110, -1], 1.8, 1, []], // a third oak, further back [[9, H + 1], [4, 70], [17, -1], 4.6, 2.2, [[0.63, [22, 30], [40, 9], 2.4, 0.7], [0.78, [5, 18], [-2, 12], 1.6, 0.6]]], [[125, H + 1], [131, 70], [117, -1], 4.2, 2, [[0.66, [110, 30], [97, 10], 2.2, 0.7]]], ].forEach(([foot, c, top, w0, w1, limbs]) => { limb(foot, c, top, w0, w1); limbs.forEach(([t, lc, tip, l0, l1]) => limb(bend(foot, c, top, t), lc, tip, l0, l1)); }); const crown = (cx, cy, rx, ry, n) => { for (let i = 0; i < n; i++) { const a = rnd() * Math.PI * 2; const d = Math.sqrt(rnd()); const [x, y] = [cx + Math.cos(a) * rx * d, cy + Math.sin(a) * ry * d]; const shade = rnd() < 0.3 ? mix(P.sea, P.shallows, 0.4) : mix(P.sea, P.ink, 0.45); out.push(`<ellipse cx="${n2(x)}" cy="${n2(y)}" rx="${n2(1.8 + rnd() * 2.4)}" ` + `ry="${n2(1.2 + rnd() * 1.4)}" fill="${shade}"/>`); } }; crown(12, 3, 36, 13, 170); crown(128, 4, 32, 14, 150); // Spanish moss, in clumps hanging from the limbs. [[24, 20], [31, 14], [36, 11], [4, 15], [104, 16], [110, 21], [99, 12], [14, 13], [126, 13]] .forEach(([x, y]) => { for (let j = 0; j < 6; j++) { const [dx, len] = [(rnd() - 0.5) * 3.2, 4 + rnd() * 9]; out.push(`<path d="M${n2(x + dx)} ${n2(y)}c${n2(rnd() - 0.5)} ${n2(len / 3)} ` + `${n2(rnd() - 0.5)} ${n2((2 * len) / 3)} ${n2((rnd() - 0.5) * 1.5)} ${n2(len)}" ` + `stroke="${mix(P.shallows, P.sea, 0.2)}" stroke-width="0.5" fill="none" ` + 'stroke-linecap="round"/>'); } }); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" ` + `viewBox="0 0 ${W} ${H}">${out.join('')}</svg>`; } function drawFleuron(color) { // a camomile head between two leafy sprigs const petals = Array.from({ length: 10 }, (_, i) => `<ellipse cx="22.1" cy="6" rx="1.55" ` + `ry="0.62" transform="rotate(${i * 36} 20 6)" fill="${color}"/>`).join(''); const sprig = `<path d="M17.2 6.3C14 7.4 10 7.2 6.4 5.6C5.2 5.1 4.2 5.2 3.4 5.9" fill="none" ` + `stroke="${color}" stroke-width="0.55" stroke-linecap="round"/>` + `<path d="M13.4 6.9C12.6 4.9 10.8 4 9 4.2C10 5.6 11.4 6.6 13.4 6.9Z" fill="${color}"/>` + `<path d="M9.2 6.5C8.7 7.9 7.4 8.7 5.9 8.8C6.6 7.5 7.7 6.7 9.2 6.5Z" fill="${color}"/>` + `<circle cx="2.8" cy="6.3" r="0.7" fill="${color}"/>`; return '<svg xmlns="http://www.w3.org/2000/svg" width="40mm" height="12mm" ' + `viewBox="0 0 40 12">${petals}<circle cx="20" cy="6" r="1.05" fill="${color}"/>${sprig}` + `<g transform="translate(40 0) scale(-1 1)">${sprig}</g></svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Crimson Pro': ['400', '400i'], // text, colophon, folios 'Cormorant Garamond': ['500i'], // numerals, cover title, the recto's running head 'Cormorant SC': ['600'], // the leads, the author on the cover and the verso }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('cover.svg', drawCover(COVER_ART.w, COVER_ART.h)); await loadSvg('fleuron.svg', drawFleuron(palette.rubric)); const doc = await buildWithFonts( () => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Trade paperback: sunk openers and recto chapters' });
Kit · core, fonts, viewer, images: igual en todas las recetas · 270 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗

Variantes

#Deja que el capítulo II siga en la página siguiente

Con la paridad 'any', el capítulo II empieza en la página 6, que es par, y el libro termina en la 7; el folio al pie se centra en la caja de texto, no en la página, así que en esa par sigue quedando bajo el centro del texto.

-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },

#Marca un cambio de escena dentro de un capítulo

Entre dos líneas :::space, la misma flor marca un cambio de escena, y el párrafo siguiente empieza sin sangría porque indentAfterHeading está desactivado. Tras seemed long to him., la flor cae al pie de la página 4; el segundo espacio ya no cabe allí y desaparece. El cambio añade cuatro líneas, que empujan el florón del capítulo I, solo, a la página 6, bajo una cabecera, así que tendrás que volver a ajustar el capítulo cuando añadas uno.

 const resources = [
+  svg('break-1', 'fleuron.svg', 40, 12, 'A scene break.'),
That was why the morning seemed long to him.
 
:::space
 
::resource{id="break-1"}
 
:::space
 
“You are burnt beyond recognition,” he added, …

Errores frecuentes

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

La página 1 es impar: planifica con números físicos

La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue. Saltos de página y de columna →

Error frecuente

\n en un atributo solo corta líneas con paragraphIndent > 0

En un elemento de texto de diseño, un \n escrito en el valor de un atributo solo abre línea nueva si paragraphIndent es mayor que cero o hay capitular; si no, el texto sigue en una línea. Pon en paragraphIndent un valor mínimo (0,01 pt) o usa un atributo por línea. Textos, filetes y cajas en los diseños de página →

Error frecuente

El lineHeight de un texto de diseño es un múltiplo, nunca una medida

En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →

Error frecuente

El texto de diseño nunca se justifica, así que la entradilla de una capitular va en bandera

En postext 1.4.1, un elemento de texto de diseño se alinea a la izquierda, al centro o a la derecha y corta palabra a palabra: no hay alineación justificada, y hyphenate: true solo parte una palabra que no cabe en una línea entera. Las líneas de una entradilla junto a una dropCap acaban, por tanto, en bandera al lado del texto justificado. Limita la entradilla a las líneas que acompañan a la capitular y ajústalas a mano: con lines: 1 (una capitular alzada) la entradilla es una sola línea, que el gap de la dropCap puede dejar a ras; el párrafo sigue en el Markdown. Capitulares en las aperturas →

Error frecuente

El desbordamiento del texto de diseño es 'ellipsis-end' por defecto

Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →

Error frecuente

El arreglo de las líneas cortas puede apretar un interletraje que nunca se pinta

En postext 1.4.1, cuando un párrafo acaba en una línea corta, el motor lo compone con una línea menos: primero aprieta el espacio entre palabras y luego aplica hasta maxRuntTracking milésimas de em de interletraje negativo. Los renderizadores de canvas y PDF solo pintan el interletraje mayor que cero, así que el párrafo se imprime sin él: sus líneas justificadas pierden esa diferencia en los espacios entre palabras, que salen aplastados, y su última línea puede pasarse de la medida y quedar cortada en el borde de la columna. Pon bodyText.maxRuntTracking: 0, que conserva el arreglo por el espacio entre palabras, y reescribe los párrafos que vuelvan a acabar en una línea corta. Viudas, huérfanas y líneas cortas →

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

::resource{id="…"} solo admite comillas dobles

Una inserción de bloque solo se reconoce como ::resource{id="…"} con comillas dobles; cualquier otra forma se queda en el texto como una línea visible. Figuras justo aquí →

Error frecuente

Entrecomilla cada valor del frontmatter

YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →

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 →

Comprobación del Sandbox · parityCascade

Cascada de paridad

Por qué. Los saltos con paridad se acumulan y producen más de dos páginas en blanco seguidas.

Solución. Revisa los saltos always-odd y las paridades de breakBefore; en capítulos cortos, la paridad any suele evitar esas páginas en blanco. Documentación →

  • Con 'always-odd', detrás de cada sección va una página separadora en blanco antes del relleno de paridad, así que el capítulo I pasaría a la página 5, tras dos páginas en blanco, y el libro llegaría a diez páginas.
  • Estas páginas están ajustadas a mano. Con un margen exterior de 16 mm en lugar de 15,5, la página 7 parte after-din-ner por un segundo guion y acaba una línea antes, la 8 cierra un párrafo con el fragmento spondent. y el segundo florón pasa a una novena página. Revisa el pie de todas las páginas cuando cambies la medida, el cuerpo o el hundimiento.
  • Mr. y Mrs. van unidos al nombre con un espacio de no separación (U+00A0). En 1.4.1 se mantiene en los párrafos sin cursivas ni negritas, pero en uno con cursivas se corta como un espacio normal. El párrafo que acaba en seemed long to him. lleva un espacio corriente, porque unir ahí el nombre abre su primera línea hasta 2,2 veces el espacio normal entre palabras.

Créditos

Texto
Imágenes
  • La vista del golfo de la cubierta y el florón de manzanilla, dibujados en código con la paleta de la página · Ignacio Ferro · MIT
Fuentes
Crimson Pro (SIL OFL 1.1) · Cormorant Garamond (SIL OFL 1.1) · Cormorant SC (SIL OFL 1.1)