Saltar al contenido principal
Receta número 4

Recetario · Capítulo 3 · Títulos y aperturas

Reportaje de revista: de la foto de apertura al signo final

Una apertura advancedDesign pone una foto a sangre y saca del título antetítulo, titular, entradilla y firma; siguen recuadros flotantes y un chip de cierre.

p. 57 · 1 de 4

  • Formato 225 × 297 mm
  • 2 columnas, medianil de 6 mm
  • Literata 9,6/13,4
  • Instrument Sans
  • Instrument Serif
  • 4 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 58 ms
  • 248 líneas de código

Lo que vas a componer

El reportaje del número de invierno de Boreal, revista de naturaleza, en página de 225 × 297 mm. Un lago de montaña a sangre ocupa lo alto de la primera página. Debajo, un antetítulo espaciado encabeza un titular en dos líneas, con la entradilla en cursiva a su derecha y la firma debajo; el crédito del fotógrafo va al pie de la foto. El titular es el texto del título de Markdown, y antetítulo, entradilla, firma y crédito, sus atributos. La doble página siguiente va a dos columnas justificadas. En la par, una cita con las comillas colgadas en el margen y un recuadro de datos en cabeza de la columna derecha; en la impar, un pinar nevado arriba y un panel oscuro de cifras al pie. Un cuadradito negro cierra el reportaje. Después, una guía de campo repite la apertura con un dibujo y el antetítulo en teja.

Esta receta responde a

  • ¿Cómo añado a una apertura una línea de autor, una entradilla o un primer párrafo con capitular?
  • ¿Cómo doy a cada capítulo su propia foto, su color o una variante de apertura?
  • ¿Cómo compongo un epígrafe, una dedicatoria, una firma o una cita destacada con una comilla grande?

La respuesta corta

script.js · líneas 40–78en el código completo
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
        lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        overflow: 'wrap', // gotcha: overflow-ellipsis-default
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}

Ingredientes

Tipografía
Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1)
Recursos
  • lake-2000.jpg
  • thaw-2000.jpg
  • Nubes reflejadas en un lago de montaña (la foto de apertura) (Ales Krivec, CC0 1.0)
  • Paseo por un bosque en deshielo (la banda de foto) (Hannah Donze, CC0 1.0)

Elaboración

#1 · Arma la apertura con la línea del título

El código es la respuesta corta de arriba. Cada {attr.<clave>} imprime el valor <clave>="…" de la línea # del reportaje, y {titleText}, el propio título (atributos de encabezado), así que cada pieza lleva sus textos en su propio Markdown y todos los títulos de nivel 1 comparten diseño. La foto cuelga del borde superior de la página y el contenedor empieza 22 mm más abajo, así que depth - TOP es el borde inferior de la foto medido desde el contenedor; el crédito va 2 mm por debajo, en la esquina superior derecha, y el antetítulo 9 mm por debajo, en la izquierda. El titular va below (debajo) del antetítulo, con los 118 mm de HEAD como medida. La entradilla va right-of (a la derecha) del titular, sin anchura propia, así que sus líneas llegan hasta el borde del contenedor; la firma va below la entradilla, de modo que una entradilla más larga la empuja hacia abajo en vez de montarse sobre ella. El nivel no fija minHeight. El titular y la firma terminan en un relleno vacío de 5 mm que cuenta en la altura de la apertura, así que el texto arranca en la primera línea de la rejilla al menos 5 mm por debajo del que acabe más abajo, ocupe cada uno las líneas que ocupe.

#2 · Declara las imágenes una vez, con sus píxeles reales

script.js · líneas 212–243en el código completo
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];

Un elemento de diseño nombra su imagen por el id del recurso, así que la foto de la apertura se declara como cualquier figura pero nunca se cita: solo la apertura la dibuja, hasta el corte, adonde no llega ningún flotante. Los mapas de bits se miden a los 150 dpi de la página, así que los 2000 px del pinar dan 339 mm y se reducen al ancho de la mancha, 195 mm, unos 260 ppp en la impresión. Las imágenes tienen un tipo propio, con el prefijo del pie y la plantilla de numeración vacíos, así que el pie del pinar no lleva número de figura. La banda es un flotante top a todo el ancho, y un flotante top abre la página siguiente a la que lo cita. Su línea ::resource queda en la página par, tras el párrafo de 1944, así que la banda encabeza la impar.

#3 · Cuelga las comillas en el margen

script.js · líneas 113–131en el código completo
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };

Un icono de glifo ocupa una columna propia junto al texto, y eso sangraría la cita. La separación (gap) negativa devuelve el texto sobre la parte derecha del cuadrado del glifo, que está vacía. Un relleno izquierdo negativo, igual a lo que queda de esa columna (tamaño + separación), la saca después al margen, y el texto vuelve a empezar en el borde de la columna. El marco de la caja se queda en la columna, así que un fondo no llegaría hasta las comillas. Las comillas latinas se asientan más abajo y son más anchas, así que esta edición compone su « a 28 pt, en un cuadrado que el relleno saca 22,5 pt; la “ inglesa va a 50 pt y sale 34 pt. La « asoma unos 4,5 mm en los 14 mm del margen exterior de la página par, y la “, unos 6.

#4 · Lleva los recuadros a una cabeza de columna y al pie de página

script.js · líneas 135–159en el código completo
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square

Con placement: 'top' el recuadro de datos flota: sale del flujo y toma la siguiente cabeza de columna libre, así que el texto sigue hasta el pie de la columna en vez de dejar un blanco donde el recuadro no cabía. El panel de cifras flota hacia el lado contrario, al pie de la página, y ocupa las dos columnas; sus tres cifras son títulos de nivel 4, un nivel que el reportaje no usa, repartidos con breaks="3,5", que cuenta bloques, no líneas. El signo final es un chip sin texto visible. Su paddingX le da al cuadrado la anchura, y el cuerpo del chip, la altura; las dos miden 1,05 em del propio chip.

#5 · Cabeceras que nombran el número y el reportaje

script.js · líneas 82–109en el código completo
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot

Los elementos de cabecera se pintan sobre la página y no reservan sitio, así que cada pieza se ancla a la página, en el margen superior, con distancias en milímetros. La página par nombra la revista y el número desde el frontmatter; la impar, el título del reportaje, y pages: 'body' las aparta de las aperturas, que solo llevan el folio, al pie. En un texto de diseño, la línea base cae a 0,8 de la altura de su caja de línea, que mide 1,2 veces el cuerpo. Con esa cuenta, baseline() coloca la etiqueta de 7,5 pt 0,34 mm más abajo que el folio de 8,5 pt y apoya el cuadrado en la misma línea. Si cambias cualquiera de los dos cuerpos, las tres piezas siguen en una sola línea base.

#6 · Reutiliza la apertura en la pieza siguiente

script.js · líneas 163–166en el código completo
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };

La apertura es una función, así que la guía de campo recibe el mismo diseño con su propia imagen y su propia altura mediante un estilo de título, # Cinco clases \\ de hielo {style="guide" …}; la \\ parte el titular por donde quiere la redacción. El dibujo baja 126 mm por la página en vez de 160; el crédito y el antetítulo se miden desde depth - TOP y los demás textos cuelgan del antetítulo, así que todos suben 34 mm y el texto de la guía empieza más arriba. La paleta del estilo cambia lake por el teja en esas páginas, así que el antetítulo, y cualquier ladillo o recuadro enlazado a lake, se vuelve teja sin añadir un argumento de color a opener(); los chips no siguen la paleta, y por eso el signo final es negro.

La receta completa

// ═══ Postext Cookbook · Nº 004 · Magazine feature: photo opener to end mark ═══════
// https://postext.dev/en/cookbook/magazine-feature-opener
// Code: MIT · Text: original (CC BY 4.0) · Photos: Ales Krivec, Hannah Donze (CC0)
// Fonts: Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1
// A nature feature from a winter issue. The level-1 heading carries its kicker, standfirst,
// byline and photo credit as attributes, and one opener design lays them out under a bleed
// photograph; the story runs on with a pull quote, a fact box, a photo band, a numbers panel
// and an end mark, and the next item reuses the opener with a drawing in place of the photo.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'magazine-feature-opener';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#15191c', // text: a blue-black
  lake: '#2d6a7d', // the accent: kickers, crossheads, the quote, the fact box's stripe
  ember: '#c8773d', // the panel's figures: 5.2:1 on ink (only 3.4:1 on paper)
  rust: '#9a5a2e', // the field guide's accent, swapped in for 'lake' by its heading style
  ice: '#dbe8ec', // the type on the dark panel and the drawing's sky
  rule: '#c7cdd1', // the drawing's far ridge and air bubbles
  muted: '#66707a', // running heads, credits, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'lake (defaults)', value: { hex: palette.lake, model: 'hex' } },
];
const TRIM = 225; // page width in mm, shared with the drawing
const INNER = 16; // inner margin in mm
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.4; // body leading in pt: the baseline grid

// #region answer: a bleed photo, then the heading's kicker, headline, standfirst, byline and credit
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
  enabled: true, // no minHeight: the story starts under the headline and byline (see air)
  slot: {
    elements: [ // the photo is an element, not a float: no float reaches the trim
      { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
        edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
      // The photo hangs from the page's top edge, the words from the container, TOP mm lower:
      // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
      { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
        fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
        placement: at('container', 'top-right', 0, depth - TOP + 2) },
      { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
        letterSpacing: pt(1.7), color: col('lake'), align: 'left',
        placement: at('container', 'top-left', 0, depth - TOP + 9) },
      { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
        fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
        lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
        placement: at('#kicker', 'below', 0, 2.5, HEAD) },
      { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
        fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
        color: col('ink'), align: 'left',
        overflow: 'wrap', // gotcha: overflow-ellipsis-default
        placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
      { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
        letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
        placement: at('#standfirst', 'below', 0, 3.5) },
    ],
  },
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto, a lake square
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
  color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER) },
  { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
    placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
      size: { width: mm(SQUARE), height: mm(SQUARE) } } },
  // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
  // box goes that much lower: folio, square and label share one baseline at any size.
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot
// #endregion

// #region quote: a pull quote whose mark hangs in the margin, outside the text's edge
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
  es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
  marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
    left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
  icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
    size: pt(MARK.size), color: col('lake') },
  titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
  body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
    textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
    italicColor: col('lake'), firstLineIndent: pt(0) } };
// #endregion

// #region boxes: a fact box at a column head, a dark panel at the page foot, the end mark
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
  backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
  padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('lake'), gap: mm(2) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
    textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
  background: col('ink'), columnGap: mm(8),
  padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
  titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
    color: col('ice'), gap: mm(1) },
  body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
    textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
  marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
  fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square
// #endregion

// #region guide: the next item reuses the opener with its own picture, depth and accent
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
  palette: { lake: palette.rust } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: [photoType], // one unnumbered type for every picture (see the resources)
  colorPalette,
  page: { width: mm(TRIM), height: mm(297), margins: { top: mm(TOP), bottom: mm(20),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // a magazine trim; left is the inner side
    dpi: 150 }, // the layout's pixels per inch, which bitmaps are measured in (see 'thaw')
  layout: { layoutType: 'double', gutterWidth: mm(6) },
  bodyText: { fontFamily: 'Literata', fontSize: pt(9.6), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.65, // a space never shrinks below 65 % (the default allows 60 %)
    runtMinCharacters: 40 }, // 40 spaces' width, about 20 letters: no one-word last lines
  // Hyphenation, optimal line breaking and widow control are on by default.
  headings: {
    fontFamily: 'Instrument Serif', fontWeight: 400, color: col('ink'),
    // Under a top photo band on a closing page, this lever can drop the shorter column a line,
    // out of line with the other (gotcha: float-stretch-closing-page). The switch covers every
    // page, not only closing ones; the shipped copy does not trip it, edited copy might.
    balancing: { stretchAfterFloats: false },
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break);
      // 'any' lets the next item open on the following page, recto or verso.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener('lake', 160) },
      { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), italic: true, color: col('lake'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
      figures,
    ],
  },
  headingStyles: [guide],
  calloutStyles: [quote, glance, numbers],
  chipStyles: [endMark],
  captionStyle: { fontFamily: 'Instrument Sans', fontSize: pt(7.6), gap: mm(2), // ink: bodyText's
    note: { fontSize: pt(6.5), color: col('muted'), gap: mm(0.6) } },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Instrument Sans', fontSize: pt(6.6),
    lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(2 * LEAD) }],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
// #region resources: two photographs and a drawing, declared once, by their real pixels
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
  shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
  resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
  { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
    bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
    altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
      es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
  { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
    // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
    bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
    // A top float opens the page after its ::resource line (gotcha: top-float-next-page):
    // the line sits on the verso, so the band heads the recto.
    placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
      + 'the sun reaches the ground, weeks before the ice lets go of the lake.',
    es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
      + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
    note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
      es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
    altText: t({ en: 'A walker on a snowy path between tall pines.',
      es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
  { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
    svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
    altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
      es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];
// #endregion

const markdown = String.raw`---
Muestra en Markdown · 107 líneas · content.es.mdtitle: "Boreal" subtitle: "Invierno 2026" --- # El lago que lleva la cuenta {kicker="Clima · Reportaje" standfirst="Durante ciento quince inviernos, una familia ha anotado el día en que su lago se helaba y el día en que se deshelaba. Sus cuadernos son hoy uno de los registros climáticos más largos de la montaña." byline="Texto de Ingrid Solberg" credit="Finales de octubre, antes de la helada · Fotografía: Ales Krivec, CC0"} El cuaderno está en una lata de galletas sobre el aparador de la cocina, entre las cerillas y el calendario de la parroquia. Es el cuarto de la serie. Los tres primeros están ahora en el archivo comarcal, envueltos en papel sin ácido, pero Hanna Brenner sigue prefiriendo la lata. «El archivo también quería este», dice mientras levanta la tapa. «Les dije que todavía lo usamos». Su bisabuelo llevaba la barca que cruzaba el lago desde 1906, y empezó el registro por un motivo práctico: quien pasa viajeros de una orilla a otra necesita saber cuándo el agua aguantará un trineo en vez de una barca. El 9 de diciembre de 1911 escribió a lápiz: *Helado. Entero.* El 4 de abril anotó que el hielo se había ido durante la noche, con un ruido «como de una puerta que se cierra de golpe en algún lugar de las montañas». Desde entonces, cada invierno, alguien de la familia ha seguido llevando la cuenta. Ciento quince líneas a lápiz, una por invierno, no parecen gran cosa. Impresas, caben en dos hojas de papel. Pero la estación meteorológica del valle vecino no se abrió hasta 1931, y pocos lagos del mundo tienen un registro de hielo tan largo. El del lago Suwa, en Japón, el más antiguo, lo empezaron unos sacerdotes sintoístas en 1443. ## Un termómetro con tapa «Un lago es un termómetro que se lee una vez al año», dice Vera Lind, limnóloga, que ha pasado seis inviernos sobre este hielo. «El aire cambia de una hora a otra. El lago lo promedia todo. Cuándo se hiela y cuándo se deshiela te dice cómo ha sido la estación entera». La razón está en una rareza del agua. Casi todos los líquidos se vuelven más densos al enfriarse. El agua dulce también, pero solo hasta unos 4 °C; por debajo vuelve a aligerarse, y por eso los lagos se hielan de arriba abajo; el hielo, más ligero aún, flota encima. En otoño, a medida que la superficie se enfría, el agua fría se hunde y la más templada sube a ocupar su lugar. El lago se mezcla una y otra vez durante semanas. Solo cuando toda la columna ha llegado a 4 °C puede la superficie seguir enfriándose sin hundirse, y solo entonces, en una noche quieta y despejada, se cubre de una piel de hielo. :::callout{type="pullquote"} *Alguien miraba la misma agua desde el mismo embarcadero.* ::: Eso explica que la helada llegue tarde y de golpe. Hanna recuerda ver de niña, desde el embarcadero, cómo se cerraba el último trozo de agua abierta en el centro del lago, «como una pupila que se encoge con la luz». A la mañana siguiente su padre salía con un hacha a medir el grosor. Diez centímetros aguantan a una persona, decía la regla de la familia; veinte, el trineo; treinta, un caballo. ## De qué está hecho el hielo El primer hielo que se forma es el hielo negro, que crece hacia abajo desde el agua y es tan transparente que a través de un palmo de él se ven las piedras del fondo. Es el hielo más resistente que da un lago. Con la nieve se forma un segundo hielo. Una nevada fuerte hunde la lámina hasta que el agua sube por las grietas y empapa la nieve, que se congela en hielo blanco, turbio de aire atrapado y con la mitad de resistencia. Lind perfora los dos cada semana del invierno. Los testigos salen con bandas, como el tronco de un árbol, y ella los lee igual: una capa negra gruesa significa un diciembre frío y seco; un montón de bandas blancas, temporal tras temporal. Bajo el hielo el lago sigue vivo. El agua del fondo se mantiene cerca de 4 °C todo el invierno. Las truchas se vuelven lentas, pero siguen comiendo. La luz aún atraviesa el hielo claro, y bajo él crecen algas en la penumbra verde, algo que sorprendió a los primeros científicos que fueron a buscarlas. :::callout{type="glance" title="De un vistazo"} **Altitud** 1540 m sobre el nivel del mar **Superficie** 3,2 km²; 63 m en el punto más hondo **Registro desde** el invierno de 1911-1912 **Hielo, 1911-1960** 118 días al año de media **Hielo, 1991-2025** 87 días al año de media **Inviernos sin hielo** 2007 y 2020 ::: ## Contar los días Durante los primeros cincuenta años del registro, el lago estuvo helado una media de 118 días por invierno. Desde 1991, la media es de 87. La helada llega ahora unas dos semanas más tarde que en tiempos del barquero, y el hielo se va más de dos semanas antes. El cambio no ha sido regular: algunos inviernos de los sesenta, y el de 2010, fueron tan largos como el que más. Pero los inviernos sin hielo son nuevos. En 2007 y de nuevo en 2020 el lago no llegó a helarse de orilla a orilla, y la familia escribió una sola palabra para ese año: *abierto*. El invierno de 1944 estuvo a punto de quedar en blanco. El hijo del barquero estaba en la guerra, y las fechas de ese año aparecen con otra letra, pequeña y derecha, con una nota al margen: *las tomó su madre*. Las había apuntado en el dorso de una cartilla de racionamiento. Lind las ha contrastado con la estación meteorológica del valle vecino. Son, dice, tan buenas como las demás. ::resource{id="thaw"} Lind es prudente con lo que dice un solo lago. Un registro aislado es una historia local: el valle tiene sus vientos, y el lago, su hondura y su forma. Pero los cuadernos coinciden con cientos de lagos del hemisferio norte, de Finlandia a Japón, donde la temporada de hielo se ha acortado semanas en el último siglo. «Me fío de este registro porque el método nunca cambió», dice. «Alguien miraba la misma agua desde el mismo embarcadero». Ha instalado instrumentos propios. Una cadena de sensores de temperatura cuelga de una boya sobre la parte más honda del lago y mide cada quince minutos de la superficie al fondo, y una cámara en el campanario fotografía el hielo cada mediodía. Pero cuando pone sus lecturas junto a los cuadernos, las fechas cuadran: la familia nunca se ha desviado más de uno o dos días de lo que marcan los sensores. ## El deshielo El hielo rara vez se va en silencio. Durante marzo el sol lo trabaja desde arriba y los arroyos más templados desde abajo, y el hielo negro se pudre en largos cristales verticales, el hielo de velas, que tintinean unos contra otros cuando el viento los mueve. Luego una lluvia templada o un viento del sur rompe la lámina, y en uno o dos días la superficie queda abierta. Los pescadores viejos aseguraban que se oía desde el pueblo. La fecha importa a algo más que a la barca. La floración de algas de primavera, que alimenta todo el lago, empieza cuando se va el hielo y entra la luz. El hielo marca el calendario de las percas, que desovan en los bajíos, y el de los insectos. Cuando el deshielo se adelanta, esos calendarios pueden desacompasarse, y un alevín puede nacer en un agua cuyo alimento ya vino y se fue. :::callout{type="numbers" title="En cifras"} :::columns{count=3 breaks="3,5"} #### 31 días menos de hielo por invierno que en los primeros cincuenta años #### 115 inviernos anotados a lápiz por cinco generaciones de una misma familia #### 4 °C la temperatura del fondo todo el invierno: la del agua más densa ::: ::: También hay calendarios humanos. El camino de hielo que cruzaba el lago, por el que pasaban el heno, la madera y el trineo del médico, no se ha abierto oficialmente desde 2014. El club de patinaje trasladó sus carreras a una pista artificial de la ciudad. Los pescadores de hielo siguen saliendo, pero más tarde, y con cuerdas. El abuelo de Hanna recordaba el otro extremo. En el duro invierno de 1963 el hielo llegó a los sesenta centímetros, y un panadero del pueblo de al lado cruzó el lago en furgoneta para ahorrarse el largo rodeo. La entrada de ese año lleva al margen un dibujito de la furgoneta, el único de los cuatro cuadernos. Hanna no se pone sentimental. Da clase de matemáticas en la escuela del valle y ha convertido los cuadernos en una lección: cada grupo representa las dos fechas de cada invierno y traza una recta entre los puntos. «Los niños siempre encuentran la tendencia por su cuenta», dice. «Yo reparto papel milimetrado y una regla, y no digo nada del clima». Una clase fue más lejos: comparó los cuadernos con el registro escolar de la floración de los cerezos del patio y vio que las dos fechas se habían movido casi los mismos días. Lind enseña ahora su gráfico en los congresos, con los nombres de los niños en una esquina. El registro seguirá. El hijo de Hanna, que tiene catorce años, se ha hecho cargo de los paseos de noviembre al embarcadero, en busca de la mañana en que desaparece la última mancha de agua oscura. Ahora usa una hoja de cálculo con copia en la universidad. Pero el día en que el lago se hiela también escribe la fecha a lápiz, en el cuaderno de la lata, porque así lo hacía su tatarabuelo. El invierno pasado el lago se heló el 21 de diciembre y se abrió el 18 de marzo: ochenta y siete días, casi exactamente la media actual. Hanna escribió *normal* junto a las fechas y luego tachó la palabra. «Normal por ahora», dice. :chip[⁠]{style="end"} # Cinco clases \\ de hielo {style="guide" kicker="Guía de campo" standfirst="Cómo leer un lago helado antes de confiarle tu peso, desde la lámina negra y clara de principios de invierno hasta las velas podridas de marzo." byline="Texto de la redacción" credit="Ilustración generada con código para Boreal"} **Hielo negro.** El primer hielo de la temporada crece hacia abajo desde el agua en calma, en cristales largos que dejan pasar la luz. Parece oscuro porque a través de él ves el lago. Diez centímetros de hielo negro nuevo aguantan a una persona a pie; es el hielo más resistente que forma un lago. **Hielo blanco.** Cuando la nieve carga la lámina, el agua sube por las grietas, empapa la nieve y se congela en una capa lechosa llena de aire. Resiste más o menos la mitad que el hielo negro, así que cuenta solo la mitad de su grosor cuando decidas si cruzar. **Nieve empapada.** Una nevada fuerte puede hundir el hielo por debajo del nivel del agua y dejar encima una capa de nieve mojada que sigue líquida bajo una costra aislante. Cuesta caminar por ella, es traicionera con esquís y es donde empieza casi todo el hielo blanco. **Hielo de orilla.** El hielo del borde es el primero en formarse y el primero en irse. Los manantiales, los juncos y el calor del suelo lo debilitan, y en marzo una franja de agua abierta, el foso, suele separar la lámina de la tierra. Puedes salir por buen hielo y descubrir que no puedes volver. **Hielo de velas.** En primavera el sol pudre el hielo negro por las juntas entre sus cristales. La lámina aún puede parecer sólida cuando ya se ha convertido en un haz de varillas verticales sueltas que ceden bajo una bota. Si la superficie se vuelve gris y granulosa, y las varillas tintinean con el viento, quédate en la orilla. No juzgues nunca el hielo solo por su color. Mídelo cada pocos pasos y pregunta a quienes viven junto a la orilla. :chip[⁠]{style="end"} :::paragraphs{style="colophon"} Compuesto en Literata, Instrument Serif e Instrument Sans (SIL Open Font License) · Texto: Recetario de Postext, CC BY 4.0 · Fotografías: Ales Krivec y Hannah Donze, CC0, vía Wikimedia Commons · El lago, la familia, los científicos y la autora son ficticios. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the guide's picture, a lake in section, drawn in code with a seeded PRNG 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; }; } function iceArt() { // in mm, TRIM × ART: sky, shore, snow, white ice, black ice, water const rand = mulberry32(14); const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; const rect = (x, y, w, h, paint) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${paint}/>`; const ridge = (base, amp, step, paint) => { // a mountain line, closed down to the shore let d = `M0 ${base}`; for (let x = 0; x <= TRIM; x += step) d += `L${x} ${(base - rand() * amp).toFixed(1)}`; return `<path d="${d}L${TRIM} 60L0 60Z" ${paint}/>`; }; const pines = Array.from({ length: 46 }, (_, i) => { // the far shore, a row of spruces const x = i * 5 + rand() * 3; const h = 4 + rand() * 5; return `<path d="M${x.toFixed(1)} ${60 - h}l${h * 0.28} ${h}h${-h * 0.56}Z" ` + `${f('ink', 0.85)}/>`; }).join(''); const bubbles = Array.from({ length: 70 }, () => { // air trapped in the white ice const r = 0.25 + rand() * 0.6; const [cx, cy] = [(rand() * TRIM).toFixed(1), (72 + rand() * 6).toFixed(1)]; return `<circle cx="${cx}" cy="${cy}" r="${r}" ${f('paper')} stroke="${palette.rule}" ` + 'stroke-width="0.15"/>'; }).join(''); // Black ice: a solid sheet on the winter side (left) that rots into candles towards spring, // ten rods evenly spaced, each shorter and thinner than the last. const candles = Array.from({ length: 10 }, (_, i) => rect(150 + i * 7.5, 79, (2.6 - i * 0.1).toFixed(2), (12.5 - i * 0.55 - rand() * 1.5).toFixed(1), f('ink'))).join(''); const clouds = [[18, 15, 52], [98, 8, 38], [146, 21, 30]].map(([x, y, w]) => [[x, y, w], [x + w * 0.22, y - 2.4, w * 0.42]].map(([cx, cy, cw]) => `<rect x="${cx}" y="${cy}" ` + `width="${cw}" height="4.4" rx="2.2" ${f('paper', 0.7)}/>`).join('')).join(''); const fish = (x, y, s) => `<path d="M${x} ${y}c${3 * s} ${-2 * s} ${7 * s} ${-2 * s} ${9 * s} 0` + `c${-2 * s} ${2 * s} ${-6 * s} ${2 * s} ${-9 * s} 0Z` // the body, then the tail + `m0 0l${-2.5 * s} ${-1.6 * s}v${3.2 * s}Z" ${f('ink', 0.55)}/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM * PX}" height="${ART * PX}" ` + `viewBox="0 0 ${TRIM} ${ART}">` + rect(0, 0, TRIM, ART, f('ice')) + clouds // winter sky + `<g transform="translate(0 ${ART - 112})">` // the lake keeps to the foot of the picture + `<circle cx="188" cy="24" r="9" ${f('ember', 0.9)}/>` // a low March sun + ridge(38, 16, 9, f('rule')) + ridge(48, 12, 6, f('muted', 0.55)) + pines + rect(0, 60, TRIM, 11, f('paper')) + rect(0, 60, TRIM, 11, f('ice', 0.3)) // snow + rect(0, 71, TRIM, 8, f('ice', 0.75)) + bubbles // white ice, cloudy with air + rect(0, 79, TRIM, 33, f('lake')) // the water, 4 °C at the floor + rect(0, 79, 146, 13, f('ink')) + candles + fish(60, 101, 1) + fish(128, 106, 0.8) + '</g></svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Literata: ['400', '400i', '700'], 'Instrument Serif': ['400', '400i'], 'Instrument Sans': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([loadImage('lake-2000.jpg', asset('lake-2000.jpg')), loadImage('thaw-2000.jpg', asset('thaw-2000.jpg')), loadSvg('ice-art.svg', iceArt())]); const continuation = { pageNumbering: { startAt: 57 } }; // pages 57–60 of the issue const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine feature: photo opener to end mark', es: 'Reportaje de revista: de la foto de apertura al signo final' }) });
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

#Abre cada pieza en página impar

Con 'odd', la guía de campo pasa a la página 61 y la 60 se queda en blanco.

- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },

#Pon el titular sobre una banda de color

Para un capítulo de libro de texto, donde una banda de color y un número grande ocupan el lugar de la foto, mira Apertura de capítulo sobre banda a sangre.

Errores frecuentes

Error frecuente

Valores de atributo: sin { ni }; comillas simples si llevan "

Un valor de atributo termina en la llave de cierre, así que no puede contener { ni }. Un valor que lleve comillas dobles va entre comillas simples; el signo de dólar no da problemas. Atributos de título →

Error frecuente

Los mapas de bits se miden en píxeles a la resolución del documento: declara el tamaño de impresión

Un recurso de mapa de bits toma su tamaño del ancho y el alto que declara, en píxeles a la resolución del documento, no del archivo. Declara los píxeles del tamaño de impresión (unos 300 ppp al ancho impreso) para que la figura salga a su tamaño y nítida. Figuras y tablas como recursos →

Error frecuente

Un flotante 'top' nunca cae en la página que lo cita

Un flotante nunca va por encima de su propia referencia, así que un flotante 'top' a todo el ancho citado en la página N abre la página N+1. Cítalo antes, o usa la posición 'auto' o 'bottom', que pueden ocupar el pie de la página que lo cita. Colocación de figuras →

Error frecuente

Un chip solo con espacios imprime su marcado

Un chip cuyo texto son solo espacios, incluidos los de no separación, imprime :chip[ ] tal cual. Para un chip de respuesta en blanco, pon dentro un unidor de palabras (U+2060). Chips en línea →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Error frecuente

Los elementos de cabecera y pie se pintan encima del texto

Los elementos de cabecera y pie se pintan sobre la página y el área de texto no les deja sitio. Mantenlos dentro de los márgenes, que son los que les reservan el espacio. Cabeceras y folios →

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

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

Un flotante superior puede bajar la última columna en una página de cierre

En postext 1.4.1, cuando un capítulo o un reportaje termina en una página que se abre con un flotante superior a todo el ancho y sus líneas se reparten de forma desigual entre las columnas, el ajuste stretchAfterFloats añade una línea en blanco bajo el flotante en la columna más corta en vez de dejar que termine antes, y las dos columnas ya no empiezan en la misma línea. Pon headings.balancing.stretchAfterFloats a false o ajusta el texto a un número par de líneas. Equilibrado de columnas →

Error frecuente

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

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

Error frecuente

El 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

:::columns solo funciona dentro de un recuadro y no se parte

:::columns se ignora fuera de un recuadro, y un recuadro que se parte nunca corta dentro de un grupo de columnas. El atributo breaks cuenta bloques hijos, y un recuadro anidado cuenta como uno. Columnas dentro de un recuadro →

Comprobación del Sandbox · bitmapTooSmall

Imagen de baja resolución

Por qué. Un mapa de bits se dibuja más de 1,5 veces más ancho que sus píxeles, así que se verá borroso al imprimir.

Solución. Proporciona unos 300 ppp al tamaño de impresión y declara el ancho y el alto reales del mapa de bits. Documentación →

  • Un elemento de imagen encaja la foto dentro de su caja y nunca la recorta. Recorta el JPEG a las proporciones de la caja, aquí 225 × 160 mm, o la foto encogerá y dejará franjas blancas en los bordes de la página.
  • Las comillas colgadas necesitan margen. Si al editar la cita pasa a una columna derecha, las comillas se meten en los 6 mm del medianil, casi pegadas al texto de la columna izquierda: adelántala o retrásala un párrafo.
  • El texto está ajustado para terminar en la página 59, encima del panel de cifras. Unas líneas más mandan la cola a una página propia, así que, después de cada cambio, vuelve a ajustar el texto de las dos ediciones.
  • runtMinCharacters: 40 evita las últimas líneas de una sola palabra. Cuando no hay manera de evitar una última línea corta, el motor compone el párrafo con una línea menos: aprieta los espacios entre palabras y, si no basta, quita hasta 0,01 em entre letras. Después de editar, busca los párrafos así de apretados y recorta o añade en ellos unas palabras; el texto publicado no necesita ese ajuste en ninguna de las dos lenguas.

Créditos

Texto
Texto original, CC BY 4.0
Imágenes
Fuentes
Literata (SIL OFL 1.1) · Instrument Serif (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)