Saltar al contenido principal
Receta número 23

Recetario · Capítulo 9 · Publicaciones completas

Portada de revista e índice por secciones

Una portada cuyas llamadas cuelgan del nombre de la revista y un sumario generado con una fila de color por sección. Cada sección es una parte sin portadilla.

  • Formato 230 × 300 mm
  • 2 columnas, medianil de 6 mm
  • Spectral 9,5/13,5
  • Bodoni Moda
  • Jost
  • 6 páginas
  • Nivel
  • Postext 1.4.1
  • Compuesto en 75 ms
  • 250 líneas de código

Lo que vas a componer

Seis páginas de 230 × 300 mm de un número de FALLOW, revista trimestral imaginaria de comida y tierra. La portada es un valle toscano a sangre bajo FALLOW en Bodoni de 118 pt, con tres llamadas sobre un panel de papel, cada antetítulo en el color de su sección, y el precio y el código de barras en la esquina. :::toc compone el sumario de la página 2: bajo la pestaña de color de cada sección, el título, la página y la entradilla del artículo; a la derecha, la carta de la editora, los colaboradores y el colofón. Siguen tres secciones, en teja, oliva y granate, cuyos artículos abren con una imagen a sangre en la cabeza de la página y un panel de su color montado encima; folios, viñetas y recuadros llevan ese color. Lo pone la valla :::part que precede a cada artículo, sin abrir portadilla.

Esta receta responde a

  • ¿Cómo hago una cubierta, una portadilla, una portada y un colofón?
  • ¿Cómo añado un índice que se actualice solo (líneas de puntos, números de página, autores, filas de parte)?
  • ¿Cómo divido un libro en partes o secciones, cada una con su color y su portadilla?
  • ¿Cómo doy a cada capítulo su propia foto, su color o una variante de apertura?

La respuesta corta

script.js · líneas 39–74en el código completo
const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block
const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph
const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows
// A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one
// width. Chained with 'below' they read as one panel, and a longer line pushes the rest down.
const sheet = (top, bottom) => ({ backgroundColor: col('paper'),
  padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } });
const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') };
const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [
  text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour),
    box: sheet(n === 1 ? 5 : 4, 1.5) },
  { ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }),
  text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15),
    lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel
  { ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }),
];
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto
  text('issue', '{attr.issue}', { ...small, align: 'right' },
    at('page', 'top-right', -MARGIN.outer, 12)),
  text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118),
    lineHeight: 0.86, // a multiple of the size (gotcha: design-lineheight-multiple)
    letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') },
  { ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }),
  ...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'),
  // The price and a made-up barcode sit in the corner, anchored to the page, not the words.
  text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) },
    { ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }),
  { kind: 'image', id: 'barcode', resourceId: 'barcode',
    placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } },
] } };
// hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
// footer: { elements: [] } }, with no folio; the Markdown's first line is
// # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"}

Ingredientes

Tipografía
Spectral, Bodoni Moda, Jost (SIL OFL 1.1)
Recursos
  • castellina-cover-1150.jpg
  • castellina-farm-1530.jpg
  • flasks-1920.jpg
  • harvest-1840.jpg
  • Castellina in Chianti (la portada y su copia en el sumario) (Rowan Heuvel, CC0 1.0)
  • Castellina in Chianti, un detalle (la foto del reportaje) (Rowan Heuvel, CC0 1.0)
  • Plantas en matraces (la apertura del Cuaderno de campo) (chuttersnap, CC0 1.0)
  • Cosecha de trigo (la apertura de Cocina) (meriç tuna, CC0 1.0)

Elaboración

#1 · Cuelga la portada de su nombre

El código es la respuesta corta de arriba. Solo la foto, las dos líneas de cabeza y el precio se anclan a la página. Todo lo demás se ancla a otro elemento (posicionamiento de elementos): el nombre de la revista, bajo la línea superior; el primer antetítulo, 8 mm por debajo del nombre, y a partir de ahí cada llamada bajo su antetítulo y cada antetítulo bajo la llamada anterior. Así, si una llamada ocupa más líneas o el nombre crece, empuja lo que tiene debajo en vez de pisarlo. Cada antetítulo y cada llamada llevan su propia caja de papel de 84 mm de ancho, y las seis cajas apiladas forman el panel, que crece con el texto. Cada caja, salvo la primera, se solapa 0,3 mm (SEAM) con la de encima, igual que el código de barras con el precio, porque en el canvas dos fondos que solo se tocan dejan ver entre ellos una raya finísima de la foto. Una revista no lleva la anteportada ni la portada de un libro; su nombre va en la portada, y el número, la fecha y el colofón, en el sumario. Los preliminares de un libro están en Preliminares con folios romanos y luego la página 1.

#2 · Haz de cada sección una parte sin página

script.js · líneas 78–87en el código completo
const parts = { page: false }; // a :::part now only sets the section's title and palette
const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) });
const headingStyles = () => [
  { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
    footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere)
  { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener,
    footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead
  // One opener for every story; the style names its picture, the part its colour.
  story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'),
];

Con parts.page: false, una valla como :::part{title="Reportajes" palette="band=#5d6a2b"} no abre portadilla y solo fija el título y la paleta de la sección desde el bloque siguiente (el contenedor :::part). El H1 de cada artículo usa un estilo de título con numbered: false, así que el sumario lo recoge sin número. El estilo elige la imagen de la apertura, y el color lo pone la parte.

#3 · Enlaza todos los colores de sección a una entrada

script.js · líneas 14–27en el código completo
const palette = {
  ink: '#1c1a16', // text: a warm near-black
  paper: '#fbf8f1', // the page, and type set on colour
  band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band'
  muted: '#6b6358', // credits and the standfirsts in the contents
  olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both
};
// A part overrides by id; designs paint the hex 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' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];

Una parte redefine entradas de la paleta por su id, así que todo lo que cambia con la sección se enlaza a band: el panel de la apertura, la pestaña del folio, las viñetas y los números de las listas, la cita destacada, y el filete y el título del recuadro de datos. En el texto, la redefinición va por valor: cualquier color cuyo hex coincida con el hex base de band (#8b6a3e) toma el de la sección, y por eso ninguna otra entrada usa ese hex. En los elementos de diseño es al revés. Postext 1.4.1 pinta el hex escrito junto a su paletteId y no consulta colorPalette, pero la redefinición de una parte les llega por el paletteId; por eso col() escribe los dos.

#4 · Deja que el sumario se agrupe solo

script.js · líneas 91–124en el código completo
const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title
const contents = { // passed to the config as `toc`
  levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600,
    marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading
  pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni
  leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic
    fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') },
  // A part row is a design as wide as the column; its 'band' takes the part's palette. No
  // {pageNumber}: a part with no divider page has no page (gotcha: toc-part-rows-no-label).
  parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (1.4.1's default: 2 em)
    { kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab
      placement: { ...mid, size: { width: 'fill' } } },
    text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
      color: col('paper'), box: { backgroundColor: col('band'),
        padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } },
    mid),
  ] } },
};
// The colophon closes the contents page; the Markdown calls it with :::paragraphs.
const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10),
  color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) };
const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower
const contentsOpener = { enabled: true, slot: { elements: [
  { kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'),
    placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
  text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
    color: col('band') }, at('#rule', 'below', 0, 5)),
  text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05,
    color: col('ink') }, at('#kicker', 'below', 0, 2)),
  text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11),
    color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)),
  text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1,
    align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)),
] } };

:::toc recoge los artículos y da a cada :::part una fila compuesta con toc.parts.design y la paleta de esa parte (índice de contenidos), así que un solo diseño de fila imprime tres pestañas de tres colores. El filete y la pestaña cuelgan de mid, el centro de la fila subido 1 mm para que la pestaña no roce el título de debajo. Sin línea de puntos, entre cada título y su página (3, 4 y 6) solo queda blanco, y subtitle.attr pone debajo, en cursiva, el atributo standfirst de la apertura. El colofón que cierra la columna derecha es un estilo de párrafo en Jost de 7 pt y color muted que el Markdown aplica con :::paragraphs{style="colophon"}.

#5 · Da a todos los artículos la misma apertura

script.js · líneas 128–152en el código completo
const PHOTO = 150; // mm from the top edge to the foot of the picture
const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin
const LIFT = 34; // mm: how far the panel rises into the picture
const WIDE = 136; // mm: the panel's width
const panel = (top, bottom) => ({ backgroundColor: col('band'),
  padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } });
const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } });
const opener = (resourceId) => ({ enabled: true, slot: { elements: [
  { kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
  text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'),
    letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)),
  text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7),
    color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT),
    size: { width: mm(WIDE) } }),
  text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02,
    color: col('paper'), box: panel(0, 3) }, below('section')),
  text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true,
    fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left',
    color: col('paper'), box: panel(0, 6) }, below('title')),
  // The picture reserves nothing (gotcha: opener-image-no-reserve); the panel and the byline
  // reach below it, and the byline's empty padding keeps the story 5 mm under it.
  text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3),
    box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')),
] } });

La imagen, a sangre, ocupa los 150 mm de arriba de la página. Un panel en band monta 34 mm sobre su pie: son tres cajas de texto de 136 mm, cada una anclada below (debajo) de la anterior, con el nombre de la sección que da {partTitle}, el título y la entradilla. La imagen no reserva altura, así que el texto empieza bajo el elemento más bajo que sí la reserva, la firma, cuyo relleno inferior vacío de 5 mm lo mantiene al menos a esa distancia.

#6 · Nombra la sección en el folio

script.js · líneas 156–171en el código completo
const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it
const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]]
  .flatMap(([parity, side, s]) => {
    const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter
    const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side },
      { ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically
    return [
      text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'),
        align: 'center', box: { backgroundColor: col('band') } },
      { ...at('page', `bottom-${side}`, s * MARGIN.outer, -11),
        size: { width: mm(TAB), height: mm(TAB) } }),
      beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3),
      beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true,
        fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5),
    ].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too
  }) });

El número de página va en un cuadrado de band de 6,5 mm, al pie y por el lado exterior. El nombre de la sección se ancla a su lado, hacia el medianil, y el título del artículo junto al nombre; las tres cajas miden 6,5 mm de alto y llevan el texto centrado en vertical, así que comparten eje. Las revistas también folian las páginas de apertura, por eso el pie no lleva filtro pages. El estilo de la portada deja el pie vacío, y el del sumario pone el nombre de la revista y la fecha del número ({title} y {publishDate}) donde van la sección y el artículo.

La receta completa

// ═══ Postext Cookbook · Nº 023 · Magazine cover and sectioned contents ═══════════════
// https://postext.dev/en/cookbook/magazine-cover-and-contents
// Code: MIT · Text: original (CC BY 4.0) · Photos: R. Heuvel, chuttersnap, m. tuna (CC0)
// Fonts: Spectral, Bodoni Moda, Jost (SIL OFL 1.1) · Needs postext ≥ 1.4.1
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-cover-and-contents';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: one house colour, 'band', that every section overrides
const palette = {
  ink: '#1c1a16', // text: a warm near-black
  paper: '#fbf8f1', // the page, and type set on colour
  band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band'
  muted: '#6b6358', // credits and the standfirsts in the contents
  olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both
};
// A part overrides by id; designs paint the hex 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' } })),
  // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
// #endregion
const TRIM = { width: 230, height: 300 }; // an independent-magazine trim, in mm
const MARGIN = { top: 22, bottom: 20, inner: 16, outer: 14 }; // mirrored
const LEAD = 13.5; // body leading in pt: the baseline grid
const caps = { fontFamily: 'Jost', fontWeight: 600, textTransform: 'uppercase' }; // the label face
const label = { ...caps, align: 'left' }; // design text is centred by default
const bodoni = { fontFamily: 'Bodoni Moda', overflow: 'wrap', align: 'left' };
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, placement, ...look });

// #region answer: the cover, one design slot whose words hang from each other
const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block
const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph
const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows
// A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one
// width. Chained with 'below' they read as one panel, and a longer line pushes the rest down.
const sheet = (top, bottom) => ({ backgroundColor: col('paper'),
  padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } });
const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') };
const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [
  text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour),
    box: sheet(n === 1 ? 5 : 4, 1.5) },
  { ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }),
  text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15),
    lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel
  { ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }),
];
const cover = { enabled: true, slot: { elements: [
  { kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
  text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto
  text('issue', '{attr.issue}', { ...small, align: 'right' },
    at('page', 'top-right', -MARGIN.outer, 12)),
  text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118),
    lineHeight: 0.86, // a multiple of the size (gotcha: design-lineheight-multiple)
    letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') },
  { ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }),
  ...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'),
  // The price and a made-up barcode sit in the corner, anchored to the page, not the words.
  text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) },
    { ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }),
  { kind: 'image', id: 'barcode', resourceId: 'barcode',
    placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } },
] } };
// hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
// footer: { elements: [] } }, with no folio; the Markdown's first line is
// # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"}
// #endregion

// #region sections: parts with no divider page, and a heading style for each kind of page
const parts = { page: false }; // a :::part now only sets the section's title and palette
const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) });
const headingStyles = () => [
  { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
    footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere)
  { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener,
    footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead
  // One opener for every story; the style names its picture, the part its colour.
  story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'),
];
// #endregion

// #region contents: generated from the headings, with a row in each section's colour
const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title
const contents = { // passed to the config as `toc`
  levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600,
    marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading
  pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni
  leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic
    fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') },
  // A part row is a design as wide as the column; its 'band' takes the part's palette. No
  // {pageNumber}: a part with no divider page has no page (gotcha: toc-part-rows-no-label).
  parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (1.4.1's default: 2 em)
    { kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab
      placement: { ...mid, size: { width: 'fill' } } },
    text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
      color: col('paper'), box: { backgroundColor: col('band'),
        padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } },
    mid),
  ] } },
};
// The colophon closes the contents page; the Markdown calls it with :::paragraphs.
const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10),
  color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) };
const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower
const contentsOpener = { enabled: true, slot: { elements: [
  { kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'),
    placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
  text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
    color: col('band') }, at('#rule', 'below', 0, 5)),
  text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05,
    color: col('ink') }, at('#kicker', 'below', 0, 2)),
  text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11),
    color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)),
  text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1,
    align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)),
] } };
// #endregion

// #region opener: one story opener; its colour comes from the part, not from the design
const PHOTO = 150; // mm from the top edge to the foot of the picture
const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin
const LIFT = 34; // mm: how far the panel rises into the picture
const WIDE = 136; // mm: the panel's width
const panel = (top, bottom) => ({ backgroundColor: col('band'),
  padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } });
const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } });
const opener = (resourceId) => ({ enabled: true, slot: { elements: [
  { kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150
    placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
  text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'),
    letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)),
  text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7),
    color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT),
    size: { width: mm(WIDE) } }),
  text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02,
    color: col('paper'), box: panel(0, 3) }, below('section')),
  text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true,
    fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left',
    color: col('paper'), box: panel(0, 6) }, below('title')),
  // The picture reserves nothing (gotcha: opener-image-no-reserve); the panel and the byline
  // reach below it, and the byline's empty padding keeps the story 5 mm under it.
  text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3),
    box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')),
] } });
// #endregion

// #region folios: a folio tab in the section's colour, then the section and the story
const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it
const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]]
  .flatMap(([parity, side, s]) => {
    const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter
    const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side },
      { ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically
    return [
      text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'),
        align: 'center', box: { backgroundColor: col('band') } },
      { ...at('page', `bottom-${side}`, s * MARGIN.outer, -11),
        size: { width: mm(TAB), height: mm(TAB) } }),
      beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3),
      beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true,
        fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5),
    ].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too
  }) });
// #endregion

const flush = { marginTop: pt(0), marginBottom: pt(0) }; // lists sit on the grid
const marker = { fontFamily: 'Jost', fontWeight: 600, color: col('band'), ...flush }; // list marks
const boxText = { textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) };
const boxStyle = { backgroundEnabled: false, ...flush, // one device: a stripe in 'band'
  stripe: { enabled: true, side: 'top', width: pt(2), color: col('band') },
  padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) } };
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette, resourceTypes: [photoType],
  page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom),
      left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner
  layout: { gutterWidth: mm(6) }, // two columns, the default
  bodyText: { fontFamily: 'Spectral', fontSize: pt(9.5), lineHeight: pt(LEAD), // justified
    color: col('ink'), boldColor: col('band'), italicColor: col('ink'),
    referenceColor: col('ink'), firstLineIndent: mm(3.5), indentAfterHeading: false,
    minWordSpacing: 0.7, maxWordSpacing: 1.7, // tighter than the 0.6–2 defaults
    runtMinCharacters: 40, // counted in word spaces: last lines under about 20 letters cost
    maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted)
  headings: { fontFamily: 'Bodoni Moda', marginBottom: pt(0), levels: [
    // Restated (gotcha: headings-drop-h1-break); 'any': a story opens on the very next page.
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), fontWeight: 400, italic: true,
      color: col('band'), marginTop: pt(LEAD) },
  ] },
  headingStyles: headingStyles(), parts, toc: contents,
  unorderedLists: { ...marker, bulletChar: '–' }, orderedLists: marker, // in the label face
  calloutStyles: [{ id: 'quote', ...boxStyle, marginTop: pt(LEAD), // the floated box has none
    body: { fontFamily: 'Bodoni Moda', fontSize: pt(16), lineHeight: pt(1.5 * LEAD),
      italicColor: col('band'), ...boxText } },
  { id: 'facts', ...boxStyle, placement: 'top', // floats to the next column head
    titleStyle: { ...caps, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('band') },
    body: { fontFamily: 'Jost', fontSize: pt(8.5), lineHeight: pt(12), ...boxText } }],
  captionStyle: { fontFamily: 'Jost', fontSize: pt(7.5), gap: mm(2),
    note: { fontSize: pt(6.5), color: col('muted') } },
  paragraphStyles: [{ id: 'bios', firstLineIndent: pt(0), spaceBetween: pt(LEAD / 2) }, colophon],
  header: { elements: [] }, footer: folioLine('{partTitle}', '{chapterTitle}'), // at the foot
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const photoType = { id: 'photo', // no figure number: an empty caption prefix and template
  name: t({ en: 'Photograph', es: 'Fotografía' }), shortLabel: t({ en: 'photo', es: 'foto' }),
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const picture = (id, fileId, [width, height], alt, extra = {}) => { // alt: { en, es }
  const kind = fileId.endsWith('.svg') ? 'svg' : 'bitmap';
  return { id, kind, typeId: 'photo', createdAt: 0, updatedAt: 0, altText: t(alt), ...extra,
    [kind]: { fileId, width, height, ...(kind === 'bitmap' && { format: 'jpeg' }) } };
};
const resources = [ // alt texts and captions in both languages; t() picks the edition's
  picture('cover', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'Vines, cypresses and a '
    + 'farmhouse below hazy hills.', es: 'Viñas, cipreses y una casa bajo colinas brumosas.' }),
  picture('thumb', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'The cover of this issue.',
    es: 'La portada de este número.' }, { placement: { position: 'here', width: 0.45 },
    caption: t({ en: 'On the cover: the hills below Castellina in Chianti, where our feature '
      + 'is set.', es: 'En la portada: las colinas de Castellina in Chianti, escenario del '
      + 'reportaje.' }),
    note: t({ en: 'Photograph: Rowan Heuvel, CC0', es: 'Fotografía: Rowan Heuvel, CC0' }) }),
  picture('flasks', 'flasks-1920.jpg', [1920, 1252], { en: 'Glass flasks holding green shoots '
    + 'on a shelf.', es: 'Matraces de vidrio con brotes verdes en una balda.' }),
  picture('fields', 'fields.svg', [TRIM.width * 10, PHOTO * 10], { en: 'Fields from above, one '
    + 'unsown and in flower.', es: 'Campos vistos desde arriba; uno, sin sembrar y en flor.' }),
  picture('harvest', 'harvest-1840.jpg', [1840, 1200], { en: 'Ripe wheat, a combine harvester '
    + 'blurred behind it.', es: 'Trigo maduro y, desenfocada detrás, una cosechadora.' }),
  picture('barcode', 'barcode.svg', [320, 140], { en: 'A barcode.', es: 'Un código de barras.' }),
  // 1530 px at 150 dpi would print 259 mm: it shrinks to the measure (gotcha: bitmap-print-size).
  // A top float opens the page after its ::resource line (gotcha: top-float-next-page).
  picture('vines', 'castellina-farm-1530.jpg', [1530, 900], { en: 'Rows of vines, cypresses and '
    + 'a farmhouse on a Tuscan slope.', es: 'Hileras de viñas, cipreses y una casa de campo en '
    + 'una ladera toscana.' }, { placement: { position: 'top', span: 'page' },
    caption: t({ en: 'Vines and cypresses by a farmhouse near Castellina in Chianti, in summer.',
      es: 'Viñas y cipreses junto a una casa de campo de Castellina in Chianti, en verano.' }),
    note: t({ en: 'Photograph: Rowan Heuvel, CC0, via Wikimedia Commons',
      es: 'Fotografía: Rowan Heuvel, CC0, vía Wikimedia Commons' }) }),
];

const markdown = String.raw`---
Muestra en Markdown · 138 líneas · content.es.mdtitle: "Fallow" subtitle: "Revista trimestral de comida y tierra" publishDate: "Otoño 2026" --- # Fallow {style="cover" issue="N.º 14 · Otoño 2026" price="12 € · N.º 14" kicker1="Reportajes" line1="El año del descanso: en una finca del Chianti, cada campo tiene un año libre" kicker2="Cuaderno de campo" line2="El archivo del grano: doscientos trigos antiguos, vivos en una escuela de pueblo" kicker3="Cocina" line3="El pan de la trilla, y una hogaza para la última gavilla"} # Sumario {style="contents" number="14" issue="Otoño 2026"} :::toc ::resource{id="thumb"} :::columnbreak ## Carta de la editora Un campo que se deja un año sin sembrar tiene un problema de imagen. Al conductor que pasa le parece abandonado; para el contable es una línea vacía. Los agricultores de este número usan otra palabra. Dicen que el campo descansa, como descansa la masa que el panadero deja levar bajo un paño. Nuestro reportaje cuenta los siete años de rotación de una finca por encima de Castellina in Chianti, incluido el año en que uno de sus campos solo cría malas hierbas y al dueño le llega una carta del ayuntamiento por los cardos. En el Cuaderno de campo, una biblioteca de semillas instalada en una antigua escuela mantiene vivos doscientos trigos antiguos sembrando unos cuantos cada otoño. Y en la Cocina, el pan que se horneaba el día en que entraba la última gavilla, con algo de aquel trigo antiguo en la masa. Hace catorce números llamamos *Fallow* a esta revista (en inglés, *fallow* es el barbecho), medio en broma. Una revista trimestral se hace despacio, y hubo temporadas en que el nombre sonaba más bien a advertencia. Lo mantuvimos porque los textos de los que estamos más orgullosos tardaron un año o más, y durante casi todo ese tiempo parecía que no pasaba nada. *Clara Ashdown, editora* ## Colaboradores :::paragraphs{style="bios"} **Tomás Arrieta** escribe sobre las semillas y la gente que las guarda. Vive en Poggibonsi, donde cuida abejas, y mal. **Lucia Fenn** se crio en una granja lechera de Somerset y lleva doce años escribiendo sobre el campo italiano. **Nell Harrow** hornea y da clases en Siena. Su libro sobre panes de campo sale la próxima primavera. ::: :::paragraphs{style="colophon"} FALLOW es una revista trimestral imaginaria de comida y tierra, hecha para el Recetario de Postext. Compuesta en Spectral, Bodoni Moda y Jost (SIL Open Font License). Texto: CC BY 4.0. Fotografías: Rowan Heuvel, chuttersnap y meriç tuna, CC0, vía Wikimedia Commons; los campos y el código de barras están dibujados con código. ::: :::part{title="Cuaderno de campo" palette="band=#a3472a"} ::: # El archivo del grano {style="grain" standfirst="En una antigua escuela del Val d’Elsa, una biblioteca de semillas siembra cada otoño unos cuantos de sus doscientos trigos antiguos y guarda en los tarros el grano nuevo." byline="Texto de Tomás Arrieta" credit="Fotografía: chuttersnap, CC0"} El aula conserva su pizarra. En ella, alguien ha escrito con tiza la lista de siembra de la semana: Verna, Gentil Rosso, Frassineto y un trigo sin nombre, solo con un pueblo y un año. Detrás de los pupitres, donde se colgaban los abrigos, están las estanterías: doscientos tarros de grano, cada uno sellado con cera y etiquetado a mano. La biblioteca nació en 2009, cuando una maestra jubilada encontró un saco de semillas en el pajar de su difunto tío y no se decidió a echárselo a las gallinas. Hoy tiene cuarenta socios con carné de préstamo y la norma de que quien se lleve un tarro en otoño devuelva el doble de grano el verano siguiente. El sábado de nuestra visita, los socios fueron llegando con sus sacos. Un panadero de Colle se llevó dos tarros de Verna para un campo que tiene arrendado; una mujer que cultiva judías para el mercado buscaba un trigo bajo que aguantara el viento de su colina, y se fue con uno del Casentino que nadie había sembrado desde 1987. Cada préstamo queda apuntado a lápiz: el tarro, el nombre, el campo y una línea en blanco para la cosecha. La norma existe porque la semilla envejece. En un tarro fresco y seco, un trigo conserva su vigor unos pocos años; después germinan cada vez menos granos, hasta que una primavera no germina ninguno. Los bancos de germoplasma retrasan ese envejecimiento guardando la semilla seca a dieciocho grados bajo cero. Una biblioteca de pueblo no tiene un congelador así, de modo que siembra sus trigos, unas hileras cada vez, y devuelve semilla fresca a la estantería. En el cuarto del fondo, bajo unas lámparas, una hilera de matraces de vidrio guarda los casos difíciles: granos tan viejos que ya nadie se atreve a confiarlos a un campo. La mayoría no germinará. Los pocos que crezcan irán en marzo a la parcela de ensayo de detrás de la escuela, donde en junio los niños de la escuela nueva vienen a contar las espigas. Cada tarro tiene una ficha que dice quién cultivó el trigo, en qué ladera y para qué servía. Algunas notas son más antiguas que la biblioteca. Una, con letra temblorosa en el dorso de una cartilla de racionamiento, dice solo: bueno para paja, malo para pan, el abuelo no quería otro. Aquí nadie dice que los trigos antiguos sean mejores. Como son altos, se tumban con cualquier tormenta, y rinden menos que los modernos. Pero algunos aguantan una primavera seca o una helada tardía, y el año pasado una genetista de Florencia se llevó tres en préstamo para sus ensayos de sequía. :::part{title="Reportajes" palette="band=#5d6a2b"} ::: # El año del descanso {style="rest" standfirst="En una finca por encima de Castellina in Chianti, siete campos se turnan entre el trigo, las habas, la zulla y la cebada. Cada año uno se queda sin sembrar, y el trigo siguiente apenas necesita abono." byline="Texto de Lucia Fenn" credit="Dibujo: hecho con código para FALLOW"} Marco Rinaldi tiene un mapa de su finca en la pared de la cocina, dibujado en el dorso del calendario de un almacén de piensos. Muestra siete campos, numerados a lápiz, y junto a cada uno una columna de cultivos que se remonta a 1998, cuando tomó el relevo de su padre. Leídas en horizontal, las columnas forman una escalera: trigo, habas, otra vez trigo, dos años de zulla para las ovejas, uno de cebada y, por último, un año en que la casilla se queda en blanco. —Ese —dice, dando golpecitos en la casilla vacía de este año— es el campo por el que todo el mundo pregunta. Es el campo cuatro, doce hectáreas de arcilla en la ladera, por debajo de la casa. En mayo se aró una vez, sin profundizar, y desde entonces nadie lo ha tocado. A finales de agosto, cuando lo recorrimos, lo cubría hasta la altura de la espinilla todo lo que había brotado por su cuenta: avena loca, amapolas ya granadas, achicoria con las flores azules cerradas contra el calor y los cardos que hacen negar con la cabeza a los vecinos. ::resource{id="vines"} Dejar la tierra sin sembrar es una de las prácticas más antiguas de la agricultura, y antes de los abonos químicos casi ningún agricultor podía prescindir de ella. Las aldeas medievales del norte de Europa trabajaban sus campos abiertos en tres hojas: una sembrada en otoño, otra en primavera y otra en descanso. Aquí la palabra antigua es *maggese*, de *maggio*, porque el campo en descanso se araba en mayo, y en las colinas de Siena los agricultores mantuvieron la costumbre mucho después de que llegaran los tractores. El descanso actúa despacio. Un año sin cultivo corta el ciclo de las plagas y enfermedades que siguen al trigo de campo en campo. Las hierbas que nacen se pastan o se entierran con el arado antes de que echen semilla, y sus raíces abren la arcilla. Las habas y la zulla de los años anteriores ya han dejado nitrógeno en el suelo, y el barbecho le da tiempo para convertirse en algo que el trigo siguiente pueda aprovechar. También cuesta dinero, y por eso la mayoría de las fincas lo abandonaron. Un campo que no produce nada no gana nada, y a una finca que paga el tractor todos los meses le cuesta dejar en paz una séptima parte de su tierra. Rinaldi hizo las cuentas como todos. —Sobre el papel, pierdo un séptimo de la finca cada año —dice—. Sobre el papel. :::callout{type="facts" title="Siete campos, siete años"} Cada otoño, cada campo baja un puesto en la lista, así que siempre hay uno de los siete en descanso. 1. Trigo 2. Habas 3. Trigo 4. Zulla, para las ovejas 5. Zulla, para las ovejas 6. Cebada 7. Barbecho ::: El papel se olvida del trigo siguiente. En los campos que han descansado, las cosechas del año siguiente se acercan a las de los vecinos que abonan cada primavera, y lo que gasta en abonos y tratamientos es una fracción de lo que gastan ellos. En el otoño lluvioso de 2023, cuando el trigo del valle se sembró tarde y nació a rodales, el campo seis, el del año después de su descanso, salió tan parejo que un vecino paró el coche para mirarlo. Eso sí, un campo en barbecho parece descuidado, y en un paisaje que se vende en postales, el descuido se nota. Rinaldi ha recibido una carta del ayuntamiento por sus cardos y la visita de un vendedor de herbicidas que creía que la finca estaba abandonada. —Les digo que está de vacaciones —cuenta—. No se ríen. Una pareja joven que cultiva por debajo de Radda ha puesto en marcha su propia rotación de cinco hojas, y la biblioteca de semillas del Val d’Elsa envía ahora a sus socios una hoja sobre cómo dejar descansar una parcela. En junio, el campo en descanso fue el único de la ladera donde anidaron las alondras, y las lechuzas de la casa en ruinas de la colina lo cazan cada atardecer, porque los topillos se han mudado allí. :::callout{type="quote"} *«Sobre el papel, pierdo un séptimo de la finca cada año. Sobre el papel».* ::: Hacia el final de nuestra visita, Rinaldi nos llevó de nuevo al campo cuatro. Cerca del seto, la tierra se había agrietado con el calor; se arrodilló y partió un terrón. Por dentro era oscuro y esponjoso, cosido de raíces, y una lombriz se retiró de la luz. —Mire esto —dijo, como otro agricultor le enseñaría a uno su mejor novilla—. Es el trabajo de un año. En octubre volverá a arar el campo y lo sembrará de trigo, y el verano que viene la casilla del calendario tendrá un cultivo. Le tocará descansar a otro campo. Para entonces, dice, los vecinos habrán encontrado otra cosa de la que hablar. :::part{title="Cocina" palette="band=#7a2c3a"} ::: # El pan de la trilla {style="bread" standfirst="Cuando entraba la última carga de grano, las cocinas de las granjas horneaban con la harina nueva. Esta hogaza lleva en la masa una quinta parte de un trigo antiguo." byline="Receta de Nell Harrow" credit="Fotografía: meriç tuna, CC0"} En toda Europa, el final de la cosecha tenía sus ritos. La última gavilla se trenzaba en forma de muñeca, se empapaba de agua o se llevaba a casa en el último carro, y en la cocina se horneaba con grano de la cosecha nueva. Esta hogaza se inspira en aquellas, con una quinta parte de la harina molida de un trigo toscano antiguo como el Verna. La harina de trigo antiguo tiene menos fuerza, así que la masa es más húmeda de lo que parece; pliégala en vez de añadirle harina. Salen dos hogazas, una para guardar y otra para regalar. ## Ingredientes - 800 g de harina de fuerza - 200 g de harina integral de un trigo antiguo, molida a la piedra - 700 g de agua tibia - 200 g de masa madre activa - 20 g de sal marina fina :::columnbreak ## Elaboración 1. Mezcla las harinas con 650 g del agua y deja reposar una hora. 2. Añade la masa madre, la sal y el resto del agua, e intégralos apretando con la mano mojada hasta que la masa quede lisa. 3. Durante las dos horas siguientes, pliega la masa sobre sí misma cada media hora. Después déjala tapada hasta que haya crecido la mitad, de tres a cuatro horas en una cocina templada. 4. Divide la masa, forma con cada mitad una bola bien tensa y colócala con el cierre hacia arriba en una cesta enharinada. Tápalas y déjalas en la nevera toda la noche. 5. Calienta el horno a 250 °C con una olla de hierro fundido tapada dentro. Vuelca en ella una hogaza, hazle un corte y hornéala 20 minutos con la tapa; después, de 20 a 25 minutos más sin ella a 230 °C, hasta que esté bien dorada. Repite con la segunda. 6. Déjalas enfriar una hora antes de cortarlas. El día de la trilla, la primera rebanada es para quien trajo la última gavilla. :::space Si no encuentras trigo antiguo, sirve cualquier harina integral molida a la piedra; la hogaza sale algo más oscura y de miga más cerrada.
`; // content.<lang>.md, inlined by the Cookbook // #region art: the fields from above and a made-up barcode, 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; }; } const n1 = (v) => v.toFixed(2); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k // The part of the line p + t·d inside a convex polygon (Cyrus–Beck), or null. function clipLine(poly, p, d) { let lo = -1e9; let hi = 1e9; for (let i = 0; i < poly.length; i++) { const [a, b] = [poly[i], poly[(i + 1) % poly.length]]; const nx = a[1] - b[1]; const ny = b[0] - a[0]; // the inward normal of a clockwise polygon (y points down) const den = nx * d[0] + ny * d[1]; const num = nx * (p[0] - a[0]) + ny * (p[1] - a[1]); if (Math.abs(den) < 1e-9) { if (num < 0) return null; continue; } const tt = -num / den; if (den > 0) lo = Math.max(lo, tt); else hi = Math.min(hi, tt); } if (lo >= hi) return null; return [[p[0] + lo * d[0], p[1] + lo * d[1]], [p[0] + hi * d[0], p[1] + hi * d[1]]]; } function fieldsArt() { // TRIM.width × PHOTO mm: the valley from above, one field resting const rand = mulberry32(1998); const [W, H] = [TRIM.width, PHOTO]; const hedge = mix(palette.olive, palette.ink, 0.5); const crops = { // [fill, furrow] per crop, all mixed from the page's palette wheat: [mix(palette.band, '#f0c95a', 0.6), mix(palette.band, '#f0c95a', 0.25)], barley: [mix(palette.band, palette.paper, 0.55), mix(palette.band, palette.paper, 0.3)], beans: [mix(palette.olive, palette.paper, 0.2), mix(palette.olive, palette.ink, 0.25)], sulla: [mix(palette.wine, palette.paper, 0.5), mix(palette.wine, palette.paper, 0.25)], earth: [mix(palette.band, palette.paper, 0.1), mix(palette.band, palette.ink, 0.3)], }; const kinds = Object.keys(crops); const [cols, rows, REST] = [7, 5, [4, 1]]; // REST: the column and row of the resting field const [cx, cy, ang] = [W / 2, H / 2, (-14 * Math.PI) / 180]; const grid = []; // a jittered lattice over a larger area, turned 14° for (let j = 0; j <= rows; j++) { grid.push([]); for (let i = 0; i <= cols; i++) { const edge = i === 0 || j === 0 || i === cols || j === rows; const x = -30 + (i * (W + 60)) / cols + (edge ? 0 : (rand() - 0.5) * 14); const y = -40 + (j * (H + 80)) / rows + (edge ? 0 : (rand() - 0.5) * 12); const [dx, dy] = [x - cx, y - cy]; grid[j].push([cx + dx * Math.cos(ang) - dy * Math.sin(ang), cy + dx * Math.sin(ang) + dy * Math.cos(ang)]); } } let fields = ''; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const poly = [grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]]; // clockwise const pts = poly.map(([x, y]) => `${n1(x)},${n1(y)}`).join(' '); const [mx, my] = poly.reduce(([sx, sy], [x, y]) => [sx + x / 4, sy + y / 4], [0, 0]); if (i === REST[0] && j === REST[1]) { // fallow: grass, and poppies gone to seed fields += `<polygon points="${pts}" fill="${mix(palette.olive, palette.paper, 0.62)}"/>`; for (let f = 0; f < 420; f++) { const p = [mx + (rand() - 0.5) * 60, my + (rand() - 0.5) * 60]; const row = clipLine(poly, p, [1, 0]); // the field's width at this height if (!row || p[0] < row[0][0] || p[0] > row[1][0]) continue; const poppy = rand() < 0.22; fields += `<circle cx="${n1(p[0])}" cy="${n1(p[1])}" r="${poppy ? 0.5 : 0.32}" ` + `fill="${poppy ? palette.terracotta : mix(palette.olive, palette.paper, 0.25)}"/>`; } continue; } const [fill, furrow] = crops[kinds[Math.floor(rand() * kinds.length)]]; fields += `<polygon points="${pts}" fill="${fill}"/>`; const a = ang + (rand() < 0.5 ? 0 : Math.PI / 2) + (rand() - 0.5) * 0.25; const [d, nrm] = [[Math.cos(a), Math.sin(a)], [-Math.sin(a), Math.cos(a)]]; for (let s = -40; s <= 40; s += 2.4) { // furrows, 2.4 mm apart const seg = clipLine(poly, [mx + nrm[0] * s, my + nrm[1] * s], d); if (seg) { fields += `<path d="M${n1(seg[0][0])} ${n1(seg[0][1])}L${n1(seg[1][0])} ` + `${n1(seg[1][1])}" stroke="${furrow}" stroke-width="0.3"/>`; } } } } let hedges = ''; // the field edges, and trees along them with their shadows let trees = ''; for (let j = 0; j <= rows; j++) { for (let i = 0; i <= cols; i++) { for (const [ni, nj] of [[i + 1, j], [i, j + 1]]) { if (ni > cols || nj > rows) continue; const [a, b] = [grid[j][i], grid[nj][ni]]; hedges += `<path d="M${n1(a[0])} ${n1(a[1])}L${n1(b[0])} ${n1(b[1])}" stroke="${hedge}" ` + 'stroke-width="0.7" stroke-linecap="round"/>'; for (let tr = rand() < 0.45 ? 2 + Math.floor(rand() * 6) : 0; tr > 0; tr--) { const u = rand(); const [x, y] = [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]; const r = 0.9 + rand() * 1.3; trees += `<circle cx="${n1(x + r * 0.55)}" cy="${n1(y + r * 0.45)}" r="${n1(r)}" ` + `fill="${palette.ink}" fill-opacity="0.22"/><circle cx="${n1(x)}" cy="${n1(y)}" ` + `r="${n1(r)}" fill="${hedge}"/>`; } } } } const road = `<path d="M-5 ${H * 0.86} C ${W * 0.3} ${H * 0.66}, ${W * 0.46} ${H * 0.82}, ` + `${W * 0.6} ${H * 0.5} S ${W * 0.86} ${H * 0.14}, ${W + 5} ${H * 0.18}" fill="none" ` + `stroke="${palette.paper}" stroke-width="1.6"/>`; const farm = [[0, 0, 8, 5], [8.4, 1.2, 4.2, 6.5], [1.5, 5.6, 5.5, 3.6]].map(([x, y, w, h]) => { const [fx, fy] = [W * 0.61 + x, H * 0.4 + y]; return `<rect x="${n1(fx + 0.7)}" y="${n1(fy + 0.7)}" width="${w}" height="${h}" ` + `fill="${palette.ink}" fill-opacity="0.28"/><rect x="${n1(fx)}" y="${n1(fy)}" ` + `width="${w}" height="${h}" fill="${palette.terracotta}"/><path d="M${n1(fx)} ` + `${n1(fy + h / 2)}h${w}" stroke="${mix(palette.terracotta, palette.ink, 0.35)}" ` + 'stroke-width="0.35"/>'; }).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}">${fields}${hedges}${road}${farm}${trees}</svg>`; } function barcodeArt() { // 32 × 14 mm on paper: bars and no digits, so it reads as decoration const rand = mulberry32(14); let [x, bars] = [2, '']; while (x < 30) { const w = 0.25 + Math.floor(rand() * 3) * 0.25; if (rand() < 0.55) { bars += `<rect x="${n1(x)}" y="1.5" width="${w}" height="11" fill="${palette.ink}"/>`; } x += w + 0.25; } return '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="140" viewBox="0 0 32 14">' + `<rect width="32" height="14" fill="${palette.paper}"/>${bars}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Spectral: ['400', '400i', '700'], 'Bodoni Moda': ['400', '400i', '600', '700', '900'], Jost: ['400', '600'] }; // Spectral 700: the contributors' names; Jost 400: the captions // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const photos = [...new Set(resources.flatMap((r) => r.bitmap?.fileId ?? []))]; // each JPEG once await Promise.all([...photos.map((file) => loadImage(file, asset(file))), loadSvg('fields.svg', fieldsArt()), loadSvg('barcode.svg', barcodeArt())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine cover and sectioned contents', es: 'Portada de revista e índice por secciones' }) });
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

#Pon el nombre de la revista en el color de la casa

El nombre de la revista toma el color tierra del gran 14 del sumario.

-    textTransform: 'uppercase', align: 'center', color: col('ink') },
+    textTransform: 'uppercase', align: 'center', color: col('band') },

#Abre cada sección con una portadilla

Para partes con página propia, y un número de página en cada fila de parte del sumario, mira Partes en color con un solo atributo.

Errores frecuentes

Error frecuente

Las filas de parte sin portadilla no tienen número de página

Con parts.page: false no hay portadilla, así que la fila de parte del índice no tiene número de página que imprimir. En ese caso deja {pageNumber} fuera del diseño de la fila. Índice de contenidos →

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

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

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 '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

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

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

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

Error frecuente

El 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 →

  • Un elemento de imagen encaja la imagen en su caja y nunca la recorta. Recorta antes cada JPEG a su caja, 23 : 30 para la portada y 230 × 150 mm para las aperturas, o la imagen encoge y deja franjas blancas en los bordes.
  • En el texto, una parte cambia cualquier color cuyo hex coincida con el hex base de band, esté enlazado a band o no. Reserva ese hex para lo que deba cambiar con la sección.
  • Los artículos están ajustados a sus páginas en los dos idiomas: en castellano se acortaron algunas frases del Cuaderno de campo para que quepa en una página, y en inglés una frase del reportaje perdió tres palabras para que las dos columnas de la página 4 acaben a la misma altura. Si cambias el texto, comprueba que ningún artículo pase unas líneas a una página nueva y que todas las páginas, salvo la última de cada artículo, acaben igualadas.

Créditos

Texto
Texto original, CC BY 4.0
Imágenes
Fuentes
Spectral (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Jost (SIL OFL 1.1)