Saltar al contenido principal
Receta número 68

Recetario · Capítulo 7 · Figuras e imágenes

Ensayo fotográfico con láminas a sangre

Cada lámina es un estilo de título generado a partir de una lista y llena su página hasta el corte; la de la tormenta cruza el medianil en dos mitades.

En esta página
Género
Fotolibros
Salida
Canvas
Postext
Probada con Postext 1.4.1
Requiere ≥ 1.4.1
Licencia
Actualizada el 27 sept 2026
Código MIT · Texto CC BY 4.0

pp. 4–5 de 9

  • Formato 280 × 210 mm
  • 1 columna
  • Andada Pro 10,5/15
  • Syne
  • Syne Mono
  • 9 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 10 ms
  • 179 líneas de código

Lo que vas a componer

Sierra, un fotolibro apaisado de 280 × 210 mm: un día en una sierra de granito en seis láminas, desde la primera luz hasta la mañana siguiente a la primera nevada. Cada lámina llena su página hasta el corte y solo lleva un número pequeño y la hora en la esquina inferior izquierda; la primera lleva además el título, SIERRA en Syne de 72 pt, calado en blanco sobre el cielo del alba. La tormenta cruza el medianil de las páginas 4 y 5 como una sola imagen. Tres textos breves, en una columna de 100 mm junto al lomo, quedan cada uno enfrente de la lámina que lo sigue. El de la tormenta termina en una lámina más pequeña, la del atardecer, que flota bajo el texto y lleva su número en el pie. La última lámina repite la segunda: el mismo circo desde el mismo bolo, nevado.

Esta receta responde a

  • ¿Cómo doy a cada capítulo su propia foto, su color o una variante de apertura?
  • ¿Cómo inserto páginas en blanco a propósito, o empiezo una sección en una doble página nueva?
  • ¿Cómo oculto las cabeceras en las aperturas y las páginas en blanco, o pinto una página par en blanco con el color de la parte?
  • ¿Cómo compongo ilustraciones sin numerar: adornos, viñetas, logotipos?

La respuesta corta

script.js · líneas 35–58en el código completo
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'mediodia', art: 'mediodia' },
  { id: 'tormenta', art: 'tormenta', half: 'verso' }, // one picture across a spread:
  { id: 'tormenta-recto', art: 'tormenta', half: 'recto' }, // the left half, then the right
  { id: 'noche', art: 'noche' },
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});

Ingredientes

Tipografía
Andada Pro, Syne, Syne Mono (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Un estilo de título por lámina, generado a partir de una lista

El código está en la respuesta corta, más arriba. Un estilo de título puede nombrar su propia imagen, así que cada lámina es un estilo que plate() genera a partir de una fila de PLATES, y el Markdown fija el orden del ensayo con una línea por lámina: # Tormenta {style="tormenta" n="III" hora="16:40"}. span: 'page' abre cada lámina en una página propia. Los márgenes a cero hacen la columna de la lámina tan alta como la página, y minHeight llena esa columna, de modo que lo que viene detrás de una lámina empieza en la página siguiente. Hacen falta los dos ajustes. Sin minHeight, la mitad derecha de la tormenta, que no dibuja ningún texto, conserva solo la línea de 7,6 mm de su propio título, y el texto siguiente empieza en esa página, encima de la imagen. Con los márgenes de las páginas de texto, la columna mide 158 mm, menos que los 210 mm de minHeight, así que postext 1.4.1 descarta la reserva entera y los tres textos se componen encima de las láminas que los preceden.

Los dos estilos de la tormenta dibujan la misma imagen de 560 mm: en la página par empieza en el borde izquierdo, y en la impar, 280 mm más a la izquierda; cada página muestra su mitad y el canvas recorta el resto en el corte. parity: 'even' mantiene enfrentadas las dos mitades. Si quitas del Markdown la lámina del mediodía y no fijas la paridad, la mitad izquierda abre la página 3, impar, y la derecha queda al dorso, en la 4; con la paridad, la página 3 se queda en blanco y la tormenta sigue en las páginas 4 y 5.

#2 · El número y la hora salen de la línea del título

script.js · líneas 62–73en el código completo
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];

El número y la hora son atributos del título, así que el contenido sigue en el Markdown y un solo diseño sirve para todas las láminas. Con right-of, la hora se coloca a la derecha del número y los bordes superiores de los dos quedan alineados. La línea base de un texto de diseño queda a 0,8 de su caja de línea por debajo del borde superior del elemento, y esa caja mide por defecto 1,2 veces el cuerpo; por eso dropTo(13, 8) baja la hora 0,8 × 1,2 × (13 − 8) pt y la hora de 8 pt se apoya en la línea base del número de 13 pt, a 193,4 mm del borde superior de la página. La hora empieza con un punto medio, como el pie de la lámina IV, porque la I de Syne es una barra lisa que, sola junto a la hora, parece un filete. La mitad derecha de la tormenta no lleva pie, y la lámina de la nieve compone el suyo en tinta porque su primer plano es blanco.

#3 · Un título y un colofón sobre las propias láminas

script.js · líneas 77–91en el código completo
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));

La primera lámina es la portada. {title} y {subtitle} leen el frontmatter, y extra en PLATES añade los dos elementos al diseño de esa lámina, en el color de su pie. El colofón va en la última lámina de la misma manera, en tinta sobre la nieve: dos elementos de 7,5 pt, uno por línea, tomados de los atributos colofon y tipos, alineados a la derecha, el segundo en la línea base del número. Un solo elemento con ajuste de línea cortaría allí donde se le acabara el ancho; con un elemento por línea, el corte cae después de la licencia.

#4 · Los textos siguen a sus láminas sin salto

script.js · líneas 95–115en el código completo
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };

La lámina que precede a un texto llena su página, así que el texto empieza en la siguiente sin salto propio. Una página cuyo primer bloque es un título con salto es una página de apertura, y pages: 'body' la deja sin folio. Sin salto, las páginas de texto conservan su folio; las láminas, abiertas por títulos con span, se quedan sin él. postext 1.4.1 ya quita el salto del nivel 1 en cuanto existe un objeto headings, así que hoy borrar breakBefore: { enabled: false } no cambia nada. Esa línea hará falta cuando una versión recupere el salto a página impar por defecto: entonces impedirá que los textos salten, y sus páginas seguirán siendo de cuerpo, con folio. FOOT pone el folio en la línea base de los números de las láminas, a 193,4 mm del borde superior, de modo que el folio de cada texto y el número de la lámina de enfrente quedan en la misma línea.

#5 · Declara todas las láminas y cita solo la que flota

script.js · líneas 196–223en el código completo
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
  shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // pixels per unit of a drawing; the page plates draw in mm
const picture =(id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'svg',
  createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX },
  altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
  picture('alba', [280, 210], { en: 'Eight ridges fading into a peach dawn haze.',
    es: 'Ocho crestas que se pierden en la bruma del alba.' }),
  picture('mediodia', [280, 210], { en: 'A granite cirque and its lake under a pale noon sky.',
    es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
  picture('tormenta', [560, 210], { en: 'A storm over the range: rain on the left, lightning '
    + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
    + 'izquierda; un rayo y un claro de sol a la derecha.' }),
  picture('noche', [280, 210], { en: 'Stars over dark ridges and two lit windows at a refuge.',
    es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
  picture('nieve', [280, 210], { en: 'The cirque the morning after, white with new snow.',
    es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
  // A 250 × 100 drawing: 2500 px, 423 mm at 150 dpi, that the float fits to the 100 mm measure.
  picture('atardecer', [250, 100], {
    en: 'A crest lit orange under strips of cloud in a violet sky.',
    es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
  { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];

Un elemento de imagen dibuja un recurso declarado, así que las seis imágenes están en resources. Solo la lámina IV se cita con :ref, y por eso es la única que flota. Ocupa el primer hueco libre tras la cita, en la página de su texto, una línea por debajo del último párrafo. Un tipo numerado le pondría el pie Lámina 1, porque es el único recurso de su tipo que flota. lamina tiene en cambio un numberingTemplate vacío, y el propio pie empieza por IV. En la frase, la lámina se nombra con el text de :ref, porque un :ref sin text imprime solo la palabra lámina.

La receta completa

// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════
// https://postext.dev/en/cookbook/photo-essay-full-bleed
// Code: MIT · Text: original (CC BY 4.0) · Plates: drawn in code (CC BY 4.0)
// Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of
// its own and one across the gutter of a spread, with three short texts between them.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'photo-essay-full-bleed';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the paper between the plates, the ink and one rust for the labels
const palette = {
  ink: '#1c1f27', // text: the blue-black of the night plate
  paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates
  accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt
  muted: '#5f646d', // the running foot and the caption of the small plate
  white: '#fbfaf7', // type set on the plates
};
// Design elements read the hex, not the palette, in 1.4.1 (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' } }));
// #endregion
const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm
const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure
const LEAD = 15; // body leading in pt
const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });

// #region answer: one heading style per plate, generated from a list
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
  { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
  { id: 'mediodia', art: 'mediodia' },
  { id: 'tormenta', art: 'tormenta', half: 'verso' }, // one picture across a spread:
  { id: 'tormenta-recto', art: 'tormenta', half: 'recto' }, // the left half, then the right
  { id: 'noche', art: 'noche' },
  { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
  id, span: 'page', // an opener: a span heading always starts a page of its own
  // The left half opens on an even page, a verso, so the right half faces it across the
  // gutter (gotcha: parity-page1-recto).
  ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
  // No margins: the plate's column is the page, and minHeight fills it, so what follows starts
  // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
  // taller than the column is dropped whole (gotcha: opener-taller-than-column).
  margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
  advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
    { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
      placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
        { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
    ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
  ] } },
});
// #endregion

// #region caption: the plate's numeral and hour, lower left, from the heading's attributes
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
  { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(NUMERAL.size), color: colour, align: 'left',
    placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
  { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
    placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
// #endregion

// #region cover: the book's title reversed out of the dawn sky of the first plate
const cover = (colour) => [
  { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
    fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
    color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
  { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
    fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
    align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
  content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
  color: colour, align: 'right',
  placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
// #endregion

// #region texts: a text between plates opens on the next page with its hours above it
const textOpener = { enabled: true, slot: { elements: [
  { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
    letterSpacing: pt(0.8), color: col('accent'), align: 'left',
    placement: at('container', 'top-left', 0, 0) },
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
    fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
    color: col('ink'), align: 'left', overflow: 'wrap',
    placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
  fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
  align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
  foot('folio', '{pageNumber}', { color: col('ink'),
    placement: at('container', 'top-left', 0, FOOT) }),
  foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  // The English sample is British English, set with the US patterns: 1.4.1 ships no en-gb.
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  resourceTypes: [lamina],
  page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150,
    backgroundColor: col('paper'), // the ground of every page; the plates cover it
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue
    referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text
    firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default
    minWordSpacing: 0.7, maxWordSpacing: 1.6, // a narrower range than the defaults, 0.6 to 2
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  // A heading's own line is measured even where its design paints the title: set it in a face
  // the page loads, or the kit fetches Open Sans for it.
  headings: { fontFamily: 'Syne', levels: [
    // A text follows its plate with no forced break (gotcha: headings-drop-h1-break): the
    // plate fills its page, so the text still starts a page, and that page stays a body page,
    // with its running foot.
    { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener },
  ] },
  headingStyles: PLATES.map(plate),
  captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 43 líneas · content.es.mdtitle: "Sierra" subtitle: "Ensayo en seis luces" --- # Alba {style="alba" n="I" hora="08:12"} # La subida {hora="06:40–14:06 · 1770–2180 m"} Salimos del aparcamiento a las seis y cuarenta, con frontales, por la calzada de losas que sube entre piornos. A esa hora el granito guarda todavía el frío de la noche y las botas suenan más de lo que deberían. Desde la primera curva se ven abajo las luces del pueblo, once, contadas dos veces; desde la segunda ya no. A las ocho y doce el sol toca la cresta más alta. Es un filo rosado que dura lo que se tarda en sacar la cámara; luego la luz baja por las canales, despacio, y tarda casi una hora en llegar al sendero. El cervunal está blanco de escarcha y cruje bajo las botas. Una cabra montés nos mira desde un bolo sin apartarse, y somos nosotros los que damos un rodeo. En el collado paramos a comer. Hay una fuente que no sale en el mapa, un caño de hierro clavado en la piedra, y el agua está tan fría que duelen los dientes. Andrés apunta en una libreta la hora y la altitud de cada foto; a la una, el termómetro que lleva colgado de la mochila marca once grados al sol. El mediodía solar cae aquí pasadas las dos, por el horario de verano. A esa hora las sombras se esconden debajo de las piedras y la laguna, vista desde el collado, parece una chapa que alguien ha dejado olvidada entre las paredes. # Mediodía {style="mediodia" n="II" hora="14:06"} # Tormenta {style="tormenta" n="III" hora="16:40"} # Tormenta {style="tormenta-recto"} # Once segundos {hora="15:00–19:50 · 2180–1950 m"} A las tres el aire se vuelve pesado. Por el oeste sube una nube que al principio parece otra montaña. Recogemos deprisa. El primer trueno llega once segundos después del relámpago: casi cuatro kilómetros, calcula Andrés, que siempre cuenta. El siguiente llega a los cuatro. Nos metemos bajo el voladizo de un bloque del tamaño de una casa. Primero cae granizo menudo y luego agua, y la pared de enfrente se borra detrás de la cortina y vuelve a salir más oscura, lavada. Nadie habla en una hora larga. Hacia las siete y media la tormenta se va valle abajo y deja el cielo roto en franjas. El sol asoma un momento por debajo de las nubes, ya pegado al horizonte, y enciende la cresta de un color que no dura ni tres minutos (:ref{id="atardecer" text="lámina IV"}). Bajamos al refugio empapados, junto al arroyo, que ahora baja lleno. # Noche {style="noche" n="V" hora="22:10"} # Primera nieve {hora="22:10–09:00 · 1950 m"} A las diez el cielo está limpio y tiene más estrellas de las que caben en la ventana del refugio. A medianoche alguien abre la puerta y dice que nieva. Salimos en calcetines. La nieve cae recta, sin viento, en copos grandes que tardan en deshacerse en las mangas, y la linterna solo alumbra un cono de puntos blancos que vienen hacia nosotros. Por la mañana el termómetro de la ventana marca dos bajo cero. Hay un palmo de nieve en la puerta y la laguna tiene una orilla de hielo fino. Las crestas que ayer eran grises son blancas con rayas negras, las aristas donde la nieve no agarra, y el cielo tiene el mismo color que el suelo. La última foto la hace Andrés a las ocho y treinta y uno, desde el mismo bolo que ayer a mediodía. Tarda un rato en encontrarlo: las piedras que le servían de referencia son ahora montones blancos iguales. Al final da con él comparando la cresta con la foto de ayer en la pantalla de la cámara. Es la primera nevada del otoño, dos semanas antes de lo habitual, según el guarda. Salimos a las nueve. La calzada de losas no se ve, y bajamos buscando los hitos de piedra. # Nieve {style="nieve" n="VI" hora="08:31" colofon="Sierra. Ensayo en seis luces · Láminas dibujadas en código · Textos y láminas: CC BY 4.0" tipos="Compuesto en Andada Pro, Syne y Syne Mono (SIL OFL)"}
`; // content.<lang>.md, inlined by the Cookbook // #region plates: every picture is a resource; only plate IV is cited, so only it floats // Plate IV is the one plate that floats, so a counter of its type would number it 1. The type // prints no number (no caption prefix, an empty template) and the caption carries the numeral. // The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else. const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const PX = 10; // pixels per unit of a drawing; the page plates draw in mm const picture =(id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * PX, height: h * PX }, altText: t(alt), ...extra }); const resources = [ // the five the heading styles draw, never cited, and plate IV picture('alba', [280, 210], { en: 'Eight ridges fading into a peach dawn haze.', es: 'Ocho crestas que se pierden en la bruma del alba.' }), picture('mediodia', [280, 210], { en: 'A granite cirque and its lake under a pale noon sky.', es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }), picture('tormenta', [560, 210], { en: 'A storm over the range: rain on the left, lightning ' + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la ' + 'izquierda; un rayo y un claro de sol a la derecha.' }), picture('noche', [280, 210], { en: 'Stars over dark ridges and two lit windows at a refuge.', es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }), picture('nieve', [280, 210], { en: 'The cirque the morning after, white with new snow.', es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }), // A 250 × 100 drawing: 2500 px, 423 mm at 150 dpi, that the float fits to the 100 mm measure. picture('atardecer', [250, 100], { en: 'A crest lit orange under strips of cloud in a violet sky.', es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' }, { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }), ]; // #endregion // #region art: six plates drawn in code: seeded ridges, flat fills and gradients // No filters, masks or markers (gotcha: svg-no-marker-filters): the haze is ridge after ridge, // each a shade darker than the one behind it, and the glows are radial gradients. function mulberry32(seed) { 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 n1 = (v) => Math.round(v * 10) / 10; const pathOf = (pts) => pts.map(([x, y], i) => `${i ? 'L' : 'M'}${n1(x)} ${n1(y)}`).join(''); const paint = (hex, a = 1) => `fill="${hex}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; // Midpoint displacement: n + 1 heights, each octave `decay` as rough as the one before. function roughness(rand, n, amp, decay = 0.6) { const a = new Array(n + 1).fill(0); for (let step = n; step > 1; step /= 2, amp *= decay) { for (let i = 0; i < n; i += step) { a[i + step / 2] = (a[i] + a[i + step]) / 2 + (rand() - 0.5) * amp; } } return a; } // A ridge line: the highest of its peaks [x, height, half-width, curve] over a base, plus rock. function ridge(rand, w, base, peaks, rough) { const n = 512; const nz = roughness(rand, n, rough); return nz.map((dy, i) => { const x = -12 + ((w + 24) * i) / n; const lift = Math.max(0, ...peaks.map(([cx, h, hw, p = 1.5]) => (Math.abs(x - cx) < hw ? h * (1 - Math.abs(x - cx) / hw) ** p : 0))); return [x, base - lift + dy]; }); } // One range of mountains in `lit`. With `shade`, each named peak gets a facet turned from the // light: from the summit along the crest to the saddle, then down to a foot under the saddle. // Each peak brings two or three lesser summits of its own, so a crest is never a triangle. function range(rand, { w, h, base, peaks, rough = 3, lit, shade, light = -1, streak }) { const all = peaks.flatMap(([cx, ph, hw, p]) => [[cx, ph, hw, p], ...Array.from( { length: 2 + Math.floor(rand() * 2) }, () => [cx + (rand() - 0.5) * hw * 1.2, ph * (0.55 + rand() * 0.3), hw * (0.25 + rand() * 0.25), 1.2])]); const line = ridge(rand, w, base, all, rough); const idx = (x) => Math.max(0, Math.min(512, Math.round(((x + 12) / (w + 24)) * 512))); let out = `<path d="${pathOf(line)}L${w + 12} ${h + 2}L-12 ${h + 2}Z" ${paint(lit)}/>`; if (!shade) return out; const side = -light; // the shaded face looks away from the light for (const [cx, ph] of peaks) { let i = idx(cx); while (i > 0 && i < 512 && line[i][1] > line[i + side][1]) i += side; // up to the summit const top = i; while (i + side >= 0 && i + side <= 512 && line[i + side][1] >= line[i][1] - 0.4) i += side; const face = side > 0 ? line.slice(top, i + 1) : line.slice(i, top + 1).reverse(); const [tx, ty] = face[0]; const [sx, sy] = face.at(-1); const foot = [tx + (sx - tx) * (0.3 + rand() * 0.2), sy + (sy - ty) * (0.6 + rand() * 0.5) + 6]; const spur = [0.8, 0.6, 0.4, 0.2].map((t) => [tx + (foot[0] - tx) * t + (rand() - 0.5) * 1.4, ty + (foot[1] - ty) * t]); const gully = [0.25, 0.5, 0.75].map((t) => [sx + (foot[0] - sx) * t + (rand() - 0.5) * 1.2, sy + (foot[1] - sy) * t]); const under = ([x, y]) => [x, Math.max(y, line[idx(x)][1] + 0.2)]; // never above the crest out += `<path d="${pathOf([...face, ...[...gully, foot, ...spur].map(under)])}Z" ` + `${paint(shade)}/>`; // Gullies on the shaded face, parallel to the arête. `false` paints none but draws the same // numbers, so plates II and VI keep one geometry. if (streak !== undefined) { const [fx, fy] = foot; for (let k = 0; k < 4; k++) { const along = 0.2 + rand() * 0.7; // where it leaves the crest, from summit to saddle const [cx0, cy0] = face[Math.floor(along * (face.length - 1))]; // Parallel to the arête, a gully leaves the face 1 - along of the way to the foot. const room = 0.85 * (1 - along); const t0 = Math.min(0.12 + rand() * 0.3, room / 2); // where it starts below the crest const t1 = Math.min(t0 + 0.12 + rand() * 0.25, room); // and where it fades out const [x0, y0] = [cx0 + (fx - tx) * t0, cy0 + (fy - ty) * t0]; const [x1, y1] = [cx0 + (fx - tx) * t1, cy0 + (fy - ty) * t1]; const wd = 0.25 + rand() * 0.4; if (streak) { out += `<path d="M${n1(x0 - wd)} ${n1(y0)}L${n1(x0 + wd)} ${n1(y0)}` + `L${n1(x1)} ${n1(y1)}Z" ${paint(streak)}/>`; } } } } return out; } // A hazy sequence of `n` ranges, far to near, their colour stepping from `far` to `near`. With // `warm` ({ id, hex, from }), each range turns towards `hex` east of `from` (a fraction of the // width), less so the nearer it is: a low sun lighting the far ridges through a gap. function hazy(rand, { w, h, n, top, bottom, far, near, rough = 5, height = 26, warm }) { const channel = (hex, k) => parseInt(hex.slice(k, k + 2), 16); const blend = (a, b, t) => `#${[1, 3, 5].map((k) => Math.round(channel(a, k) * (1 - t) + channel(b, k) * t).toString(16).padStart(2, '0')).join('')}`; let out = ''; for (let k = 0; k < n; k++) { const t = k / (n - 1); const peaks = Array.from({ length: 3 + Math.floor(rand() * 3) }, () => [rand() * w, height * (0.4 + rand() * 0.8) * (1 + t * 0.6), 30 + rand() * 60, 1 + rand()]); let lit = blend(far, near, t ** 1.3); if (warm) { const id = `${warm.id}${k}`; out += `<defs><linearGradient id="${id}" gradientUnits="userSpaceOnUse" x1="0" y1="0" ` + `x2="${w}" y2="0"><stop offset="${warm.from}" stop-color="${lit}"/><stop offset="0.94" ` + `stop-color="${blend(lit, warm.hex, 0.75 * (1 - t) ** 1.5)}"/></linearGradient></defs>`; lit = `url(#${id})`; } out += range(rand, { w, h, base: top + (bottom - top) * t, peaks, rough: rough * (1 + t), lit }); } return out; } const sky = (id, w, h, stops) => `<defs><linearGradient id="${id}" x1="0" y1="0" x2="0" y2="1">` + stops.map(([o, c]) => `<stop offset="${o}" stop-color="${c}"/>`).join('') + `</linearGradient></defs><rect width="${w}" height="${h}" fill="url(#${id})"/>`; // A bank of cloud hanging from the top edge: rounded lumps along a base that runs from y0 on // the left to y1 on the right. function ceiling(rand, w, y0, y1, hex, lump = 10) { const lumps = []; for (let x = -20; x < w + 20; x += 10 + rand() * 16) { const r = 11 + rand() * 14; // wide lumps: two narrow ones meet in a sharp cusp lumps.push([x, Math.min(r * 0.7, lump * (0.4 + rand() * 0.8)), r]); } const nz = roughness(rand, 256, 1.5); const edge = nz.map((dy, i) => { const x = -8 + ((w + 16) * i) / 256; const hang = Math.max(0, ...lumps.map(([cx, lh, r]) => (Math.abs(x - cx) < r ? lh * Math.sqrt(1 - ((x - cx) / r) ** 2) : 0))); return [x, y0 + ((y1 - y0) * (x + 8)) / (w + 16) + hang + dy]; }); return `<path d="M-8 -8${pathOf(edge).replace('M', 'L')}L${w + 8} -8Z" ${paint(hex)}/>`; } const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * PX}" ` + `height="${h * PX}" viewBox="0 0 ${w} ${h}">${body}</svg>`; // Granite boulders: rounded blocks on a flat foot, their crowns lit, or deep in snow. function boulders(rand, list, body, crown, depth) { return list.map(([x, y, r]) => { const pts = Array.from({ length: 11 }, (_, k) => { const a = Math.PI + (Math.PI * k) / 10; // the upper half, left to right const q = r * (0.85 + rand() * 0.3); return [x + Math.cos(a) * q * 1.5, y + Math.sin(a) * q]; }); const cap = pts.slice(1, 10).map(([px, py]) => [px, py - 0.4]); const low = cap.map(([px, py]) => [px, py + r * depth + rand() * r * 0.1]).reverse(); return `<path d="${pathOf(pts)}Z" ${paint(body)}/>` + `<path d="${pathOf([...cap, ...low])}Z" ${paint(crown)}/>`; }).join(''); } const stars = (rand, w, top, bottom, count, hex) => Array.from({ length: count }, () => { const x = rand() * w; const y = top + (rand() ** 1.6) * (bottom - top); // thinner towards the ridge return `<circle cx="${n1(x)}" cy="${n1(y)}" r="${n1(0.12 + rand() ** 3 * 0.55)}" ` + `${paint(hex, 0.35 + rand() * 0.65)}/>`; }).join(''); // Choughs: a gull-wing stroke each, drawn as a closed shape. const birds = (list, hex) => list.map(([x, y, s]) => `<path d="M${x - s} ${y - s * 0.3}` + `Q${x - s * 0.4} ${y - s * 0.55} ${x} ${y}` + `Q${x + s * 0.4} ${y - s * 0.55} ${x + s} ${y - s * 0.3}` + `Q${x + s * 0.4} ${y - s * 0.3} ${x} ${y + s * 0.18}` + `Q${x - s * 0.4} ${y - s * 0.3} ${x - s} ${y - s * 0.3}Z" ${paint(hex)}/>`).join(''); // A bolt: a jagged stroke with one fork, over a wider, fainter stroke. function bolt(rand, x, y0, y1, hex) { const pts = [[x, y0]]; for (let y = y0; y < y1;) { y += 3 + rand() * 5; pts.push([pts.at(-1)[0] + (rand() - 0.45) * 6, Math.min(y, y1)]); } const fork = [pts[4]]; for (let k = 0; k < 5; k++) { fork.push([fork.at(-1)[0] + 2 + rand() * 3, fork.at(-1)[1] + 3 + rand() * 3]); } return [[6, 0.14], [2.8, 0.3], [1.1, 1]].map(([sw, a]) => [pts, fork].map((p) => `<path d="${pathOf(p)}" fill="none" stroke="${hex}" stroke-opacity="${a}" ` + `stroke-width="${sw * (p === fork ? 0.6 : 1)}" stroke-linejoin="round" ` + 'stroke-linecap="round"/>').join('')).join(''); } // Rain in `n` streaks, thinning out over its last `fade` mm to the east, where the shower ends. const rain = (rand, n, x0, x1, y0, y1, hex, a, fade) => Array.from({ length: n }, () => { const x = x0 + rand() * (x1 - x0); const y = y0 + rand() * 10; const thin = Math.min(1, (x1 - x) / fade) ** 1.5; return `<path d="M${n1(x)} ${n1(y)}l${n1(-(y1 - y) * 0.18)} ${n1(y1 - y)}" stroke="${hex}" ` + `stroke-opacity="${(a * thin * (0.4 + rand() * 0.6)).toFixed(2)}" stroke-width="0.25"/>`; }).join(''); // A still lake: the water, a bright band under the far shore where the wall is mirrored, // and a few streaks of wind on the surface. function lake(rand, y, h, w, water, shine) { let out = `<rect x="-2" y="${y}" width="${w + 4}" height="${h}" ${paint(water)}/>` + `<rect x="-2" y="${y}" width="${w + 4}" height="${n1(h * 0.28)}" ${paint(shine, 0.55)}/>`; for (let k = 0; k < 7; k++) { const x = rand() * w; out += `<rect x="${n1(x)}" y="${n1(y + h * (0.35 + rand() * 0.55))}" ` + `width="${n1(8 + rand() * 30)}" height="0.35" ${paint(shine, 0.7)}/>`; } return out; } // A soft light: a radial gradient that fades to nothing, in place of a blur filter. const glow = (id, cx, cy, rx, ry, hex, a) => `<defs><radialGradient id="${id}">` + `<stop offset="0" stop-color="${hex}" stop-opacity="${a}"/>` + `<stop offset="1" stop-color="${hex}" stop-opacity="0"/></radialGradient></defs>` + `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="url(#${id})"/>`; // The corners darkened a little, as a lens does. const vignette = (id, w, h, a) => `<defs><radialGradient id="${id}" cx="0.5" cy="0.45" ` + 'r="0.75"><stop offset="0.55" stop-color="#0b0d14" stop-opacity="0"/>' + `<stop offset="1" stop-color="#0b0d14" stop-opacity="${a}"/></radialGradient></defs>` + `<rect width="${w}" height="${h}" fill="url(#${id})"/>`; // II and VI: one cirque seen from one boulder, at noon and the morning after the first snow. // The same seed draws the same ridges; only the colours change. function cirque(k, w = 280, h = 210) { const rand = mulberry32(13); const crest = [[34, 40, 50], [98, 58, 60], [150, 46, 40], [214, 62, 62], [262, 38, 40]]; const [sky0, sky1, sky2] = k.sky; return svgDoc(w, h, sky('s', w, h, [[0, sky0], [0.45, sky1], [0.62, sky2]]) + hazy(rand, { w, h, n: 2, top: 104, bottom: 112, far: k.far[0], near: k.far[1], height: 30 }) + range(rand, { w, h, base: 122, rough: 6, light: -1, peaks: crest, lit: k.crest[0], shade: k.crest[1], streak: k.crest[2] }) + range(rand, { w, h, base: 146, rough: 6, light: -1, peaks: [[20, 30, 60], [252, 34, 60]], lit: k.slope[0], shade: k.slope[1], streak: k.slope[2] }) + lake(rand, 146, 28, w, k.lake[0], k.lake[1]) + range(rand, { w, h, base: 180, rough: 6, light: -1, peaks: [[-10, 30, 90], [292, 44, 110]], lit: k.near[0], shade: k.near[1] }) + range(rand, { w, h, base: 214, rough: 8, light: -1, peaks: [[70, 26, 110], [236, 30, 90]], lit: k.ground[0], shade: k.ground[1] }) + boulders(rand, [[64, 204, 10], [120, 209, 5], [152, 205, 8], [182, 207, 5]], ...k.rock) + vignette('v', w, h, k.vignette)); } const NOON = { sky: ['#8fabc6', '#cfdbe6', '#edf1f4'], far: ['#d3dce5', '#c2ccd6'], crest: ['#b3bdc8', '#8795a6', false], slope: ['#7f8fa0', '#66778b', false], // bare rock lake: ['#7f97ad', '#c3d1dd'], near: ['#56687b', '#46566a'], ground: ['#343f4d', '#2a333f'], rock: ['#6f6c69', '#bdb7ad', 0.28], vignette: 0.3 }; const SNOW = { sky: ['#aeb8c3', '#dce2e8', '#eef2f5'], far: ['#e3e8ed', '#d6dde4'], crest: ['#f8fafc', '#c3cdd8', '#6f7985'], slope: ['#e8edf2', '#b6c2cf', '#7d8692'], lake: ['#a3b2c0', '#e3e9ee'], near: ['#f1f4f7', '#ccd6e0'], ground: ['#f5f7f9', '#dfe5eb'], rock: ['#4b515a', '#f7f9fb', 0.62], vignette: 0.18 }; const ART = { alba(w = 280, h = 210) { // I: first light; eight ranges in the haze, the title in the sky const rand = mulberry32(3); return svgDoc(w, h, sky('s', w, h, [[0, '#1f2640'], [0.34, '#4e4f6e'], [0.56, '#a97f8a'], [0.7, '#e3a07f'], [0.8, '#f4cda4']]) + glow('g', 206, 128, 70, 34, '#fbe0bb', 0.7) + birds([[64, 104, 1.5], [71, 99, 1.1], [77, 106, 1]], '#3b3550') + hazy(rand, { w, h, n: 8, top: 132, bottom: 212, far: '#dcae9f', near: '#262739', height: 22 }) + vignette('v', w, h, 0.35)); }, mediodia: () => cirque(NOON), // II: flat light over the cirque and its lake tormenta(w = 560, h = 210) { // III: the storm over two pages; far right, the sun breaks through const rand = mulberry32(21); const clouds = ceiling(rand, w, 92, 56, '#6a6878', 8) // the far bank, lit from the gap + ceiling(rand, w, 80, 32, '#4a4c5d', 10) + ceiling(rand, w, 64, 6, '#33353f', 12) + ceiling(rand, w, 40, -30, '#20222b', 14); // the storm, overhead return svgDoc(w, h, sky('s', w, h, [[0, '#1f222c'], [0.45, '#474b5e'], [0.68, '#7a7486'], [0.8, '#a99a8c']]) + glow('g', 520, 118, 120, 46, '#f3d6a4', 0.75) + clouds + rain(rand, 420, 0, 320, 88, 176, '#aeb6c6', 0.55, 120) // the last streaks cross the gutter + hazy(rand, { w, h, n: 4, top: 136, bottom: 160, far: '#8a8290', near: '#5b5d6e', height: 30, warm: { id: 'sun', hex: '#e8b98c', from: 0.6 } }) + bolt(rand, 374, 84, 160, palette.white) + hazy(rand, { w, h, n: 3, top: 172, bottom: 214, far: '#3e4252', near: '#15171d', height: 26 }) + vignette('v', w, h, 0.4)); }, atardecer(w = 250, h = 100) { // IV: the crest lit by the sun behind us, the east sky violet const rand = mulberry32(5); let strips = ''; const bands = [[-10, 130, 20, 6], [70, 262, 32, 5], [-10, 96, 42, 4], [160, 252, 12, 3.4]]; for (const [x0, x1, y, t] of bands) { // strips of cloud, lit from below const mid = (x0 + x1) / 2; const d = `M${x0} ${y}Q${mid} ${y - t} ${x1} ${y}Q${mid} ${y + t * 0.7} ${x0} ${y}Z`; strips += `<path d="${d}" ${paint('#f5b27a')}/><path d="${d}" transform="translate(0 -0.8)" ` + `${paint('#54445f')}/>`; } return svgDoc(w, h, sky('s', w, h, [[0, '#262440'], [0.4, '#4b4367'], [0.66, '#8a6889'], [0.8, '#c08d99']]) + strips + range(rand, { w, h, base: 72, rough: 5, lit: '#f29a62', shade: palette.accent, light: -1, peaks: [[70, 26, 40], [150, 36, 46], [215, 24, 36]] }) + hazy(rand, { w, h, n: 3, top: 80, bottom: 102, far: '#6d4a64', near: '#1f1827', height: 12 }) + vignette('v', w, h, 0.35)); }, noche(w = 280, h = 210) { // V: clear after the storm; two windows lit at the refuge const rand = mulberry32(12); const refuge = `<path d="M184 170h14v-6l-7-4.4l-7 4.4Z" ${paint('#0b0d15')}/>` + `<rect x="187.4" y="165.4" width="2.2" height="2" ${paint('#f2b45c')}/>` + `<rect x="192.2" y="165.4" width="2.2" height="2" ${paint('#f2b45c', 0.75)}/>` + glow('l', 190.6, 166.4, 9, 6, '#f2b45c', 0.35); return svgDoc(w, h, sky('s', w, h, [[0, '#070912'], [0.5, '#141a31'], [0.8, '#263050']]) + glow('m', 150, 60, 170, 40, '#7d8bb8', 0.12) + stars(rand, w, 0, 150, 520, palette.white) + range(rand, { w, h, base: 142, rough: 6, lit: '#28304c', shade: '#1c2238', light: -1, peaks: [[60, 40, 60], [150, 56, 56], [236, 44, 60]] }) + hazy(rand, { w, h, n: 2, top: 158, bottom: 168, far: '#20263b', near: palette.ink, height: 18 }) + refuge + hazy(rand, { w, h, n: 2, top: 188, bottom: 214, far: '#0e111c', near: '#07080d', height: 18 }) + vignette('v', w, h, 0.4)); }, nieve: () => cirque(SNOW), // VI: the same view the morning after; ink type goes on it }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all(Object.entries(ART).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape', es: 'Sierra: un ensayo fotográfico apaisado' }) });
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

#Haz flotar las láminas frente a sus fichas

Para láminas que flotan hasta la página impar, enfrente de la ficha de un catálogo, con el ancho de la figura calculado a partir de sus proporciones, consulta Fichas de catálogo frente a sus láminas.

#Abre un artículo sobre una foto a sangre

Para una foto a sangre en la cabeza de un artículo, con el titular y la entradilla encima, consulta el reportaje de revista.

Errores frecuentes

Error frecuente

Las imágenes de una apertura no cuentan para la altura que reserva

En postext 1.4.1, un título con diseño avanzado mide la altura que reserva sin contar sus imágenes: sus textos, filetes y cajas cuentan, aunque estén anclados a la página, pero una imagen, como un dibujo a sangre en la cabeza de la página, no reserva nada, así que el texto puede empezar encima de ella. Fija con minHeight dónde debe empezar el texto. Aperturas diseñadas →

Error frecuente

Una apertura más alta que su columna pierde toda la reserva

En postext 1.4.1, cuando la altura que reserva un título con diseño avanzado (su minHeight, o su elemento de diseño más bajo) supera la de la columna que abre, el título se queda solo con la altura de su propia línea, sin ningún aviso, y el texto se compone encima del diseño. Una lámina a página completa necesita una columna tan alta como la página: dale márgenes cero a su estilo de título y fija minHeight en la altura de la página. Aperturas diseñadas →

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

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

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

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

Sin <marker> ni filtros en los SVG, o pasan a mapa de bits

Una figura SVG solo sigue siendo vectorial en el PDF sin <marker>, filtros ni máscaras; si no, pasa a mapa de bits, y los filtros muy anidados pueden dejarla en blanco en Chrome. Dibuja las puntas de flecha como trazados. Figuras y tablas como recursos →

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

Solo 8 idiomas tienen separación silábica, con el código exacto

La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →

Error frecuente

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 →

  • No cites nunca una lámina que ya dibuja un diseño. Citada, flota además como figura: un :ref a la lámina del mediodía en el primer texto pone una segunda copia en la página 3, y la tormenta se retrasa dos páginas, tras una página en blanco.
  • La lámina del atardecer queda una línea por debajo de su texto, no al pie de la página. En la última página antes de una lámina, postext 1.4.1 sube hasta el texto el flotante que queda debajo de él, y position: 'bottom' no cambia nada ahí.
  • Cada texto está ajustado a una página en las dos ediciones. Si el primero pasa a la página 3, la lámina del mediodía se va a la 4 y la tormenta a las páginas 6 y 7, tras una página 5 en blanco.
  • La mitad derecha de la tormenta es un segundo título # Tormenta. La página no imprime ese título, pero un :::toc generado a partir de los títulos recoge la tormenta dos veces, en las páginas 4 y 5.
  • Las láminas terminan en el corte, que es el borde del canvas. Con page.cutLines activado, el canvas crece alrededor del corte, y la franja de sangrado de cada lámina sale del color del papel. Para imprenta, ancla las láminas a 'bleed' y agrándalas lo que mida el sangrado por cada lado.

Créditos

Texto
  • Los tres textos, los pies y el colofón, en español y en inglés, y las seis láminas, dibujadas en código · Postext Cookbook · CC BY 4.0
Fuentes
Andada Pro (SIL OFL 1.1) · Syne (SIL OFL 1.1) · Syne Mono (SIL OFL 1.1)