Saltar al contingut principal
Recepta número 23

Receptari · Capítol 9 · Publicacions completes

Portada de revista i sumari per seccions

Una portada amb les crides penjades del nom de la revista i un sumari generat amb una fila de color per secció. Cada secció és una part sense portadella.

  • Mostra en castellà: encara no hi ha edició en català
  • Format 230 × 300 mm
  • 2 columnes, espai entre columnes de 6 mm
  • Spectral 9,5/13,5
  • Bodoni Moda
  • Jost
  • 6 pàgines
  • Nivell
  • Postext 1.9.2
  • Compost en 63 ms
  • 250 línies de codi

En poques paraules

La portada i les primeres pàgines d'una revista de cuina i camp, amb foto a la portada i un sumari. Ensenya a donar a cada secció el seu color, que es manté a totes les seves pàgines.

Què compondràs

Sis pàgines de 230 × 300 mm d'un número de FALLOW, revista trimestral imaginària de cuina i terra. La portada és una vall toscana a sang sota FALLOW en Bodoni de 118 pt, amb tres crides sobre un plafó de paper, cada avantítol del color de la seva secció, i el preu i el codi de barres a la cantonada. :::toc compon el sumari de la pàgina 2: sota la pestanya de color de cada secció, el títol, la pàgina i l'entradeta de l'article; a la dreta, la carta de l'editora, els col·laboradors i el colofó. Segueixen tres seccions, en teula, oliva i granat, amb articles que s'obren amb una imatge a sang al capdamunt de la pàgina i un plafó del seu color muntat a sobre; folis, vinyetes i requadres porten aquest color. El posa la tanca :::part que precedeix cada article, sense obrir portadella.

Aquesta recepta respon a

  • Com faig una coberta, una portadella, una portada i un colofó?
  • Com afegeixo un índex que s'actualitzi sol (línies de punts, números de pàgina, autors, files de part)?
  • Com divideixo un llibre en parts o seccions, cadascuna amb el seu color i la seva portadella?
  • Com dono a cada capítol la seva pròpia foto, el seu color o una variant d'obertura?

La resposta curta

script.js · línies 39–74al codi complet
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="…"}

Ingredients

Tipografia
Spectral, Bodoni Moda, Jost (SIL OFL 1.1)
Recursos
  • castellina-cover-1150.jpg
  • castellina-farm-1530.jpg
  • fields-1610.jpg
  • flasks-1920.jpg
  • harvest-1840.jpg
  • Castellina in Chianti (the cover and its copy in the contents) (Rowan Heuvel, CC0 1.0)
  • Castellina in Chianti, a detail (the feature's photograph) (Rowan Heuvel, CC0 1.0)
  • Plants in beakers (the Field notes opener) (chuttersnap, CC0 1.0)
  • Harvesting the wheat crop (the Kitchen opener) (meriç tuna, CC0 1.0)
  • Fields from above near Castellina in Chianti (the feature's opener) (Generated With Diffusion Models, original)
  • The cover's barcode, drawn in code (Ignacio Ferro, MIT)

Elaboració

#1 · Penja la portada del seu nom

El codi és la resposta curta de més amunt. Només la foto, les dues línies de capçalera i el preu s'ancoren a la pàgina. Tota la resta s'ancora a un altre element (posicionament d'elements): el nom de la revista, sota la línia superior; el primer avantítol, 8 mm per sota del nom, i a partir d'aquí cada crida sota el seu avantítol i cada avantítol sota la crida anterior. Així, si una crida ocupa més línies o el nom creix, empeny el que té a sota en comptes de trepitjar-ho. Cada avantítol i cada crida porten la seva pròpia caixa de paper de 84 mm d'amplada, i les sis caixes apilades formen el plafó, que creix amb el text. Cada caixa, tret de la primera, se solapa 0,3 mm (SEAM) amb la de sobre, igual que el codi de barres amb el preu, perquè al canvas dos fons que només es toquen deixen veure entre ells una ratlla finíssima de la foto. Una revista no porta l'avantportada ni la portada d'un llibre; el nom va a la portada, i el número, la data i el colofó, al sumari. Els preliminars d'un llibre són a Preliminars amb folis romans i després la pàgina 1.

#2 · Fes de cada secció una part sense pàgina

script.js · línies 78–87al codi complet
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'),
];

Amb parts.page: false, una tanca com :::part{title="Reportajes" palette="band=#5d6a2b"} no obre portadella i només fixa el títol i la paleta de la secció des del bloc següent (el contenidor :::part). L'H1 de cada article fa servir un estil de títol amb numbered: false, de manera que el sumari el recull sense número. L'estil tria la imatge de l'obertura, i el color el posa la part.

#3 · Enllaça tots els colors de secció a una entrada

script.js · línies 14–27al codi complet
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 part redefineix entrades de la paleta pel seu id, així que tot el que canvia amb la secció s'enllaça a band: el plafó de l'obertura, la pestanya del foli, les vinyetes i els números de les llistes, la cita destacada, i el filet i el títol del requadre de dades. En el text, la redefinició va per valor: qualsevol color amb un hex que coincideixi amb l'hex base de band (#8b6a3e) pren el de la secció, i per això cap altra entrada no fa servir aquest hex. En els elements de disseny és al revés. Postext 1.4.1 pinta l'hex escrit al costat del seu paletteId i no consulta colorPalette, però la redefinició d'una part els arriba pel paletteId; per això col() escriu tots dos.

#4 · Deixa que el sumari s'agrupi sol

script.js · línies 91–124al codi complet
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 recull els articles i dona a cada :::part una fila composta amb toc.parts.design i la paleta d'aquella part (índex de continguts), de manera que un sol disseny de fila imprimeix tres pestanyes de tres colors. El filet i la pestanya pengen de mid, el centre de la fila pujat 1 mm perquè la pestanya no fregui el títol de sota. Sense línia de punts, entre cada títol i la seva pàgina (3, 4 i 6) només queda blanc, i subtitle.attr posa a sota, en cursiva, l'atribut standfirst de l'obertura. El colofó que tanca la columna dreta és un estil de paràgraf en Jost de 7 pt i color muted que el Markdown aplica amb :::paragraphs{style="colophon"}.

#5 · Dona a tots els articles la mateixa obertura

script.js · línies 128–152al codi complet
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 imatge, a sang, ocupa els 150 mm de dalt de la pàgina. Un plafó en band munta 34 mm sobre el seu peu: són tres caixes de text de 136 mm, cadascuna ancorada below (a sota) de l'anterior, amb el nom de la secció que dona {partTitle}, el títol i l'entradeta. La imatge no reserva alçada, així que el text comença sota l'element més baix que sí que en reserva, la signatura, amb un farciment inferior buit de 5 mm que el manté com a mínim a aquesta distància.

#6 · Anomena la secció al foli

script.js · línies 156–171al codi complet
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 quadrat de band de 6,5 mm, al peu i pel costat exterior. El nom de la secció s'ancora al seu costat, cap al marge interior, i el títol de l'article al costat del nom; les tres caixes fan 6,5 mm d'alçada i porten el text centrat verticalment, així que comparteixen eix. Les revistes també folien les pàgines d'obertura, per això el peu no porta filtre pages. L'estil de la portada deixa el peu buit, i el del sumari hi posa el nom de la revista i la data del número ({title} i {publishDate}) on van la secció i l'article.

La recepta completa

Sandbox
// ═══ 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), diffusion
// 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-1610.jpg', [1610, 1050], { en: 'Fields from above, one '
    + 'unsown, and a farmhouse.', es: 'Campos desde arriba, uno sin sembrar, y una casa.' }),
  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`---
Mostra en Markdown · 138 línies · 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, imagen generada, y el código de barras, dibujado 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="Fotografía: generada 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: 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); 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('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 a totes les receptes · 270 línies// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

Variants

#Posa el nom de la revista en el color de la casa

El nom de la revista pren el color terra del gran 14 del sumari.

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

#Obre cada secció amb una portadella

Per a parts amb pàgina pròpia, i un número de pàgina a cada fila de part del sumari, mira Parts en color amb un sol atribut.

Errors freqüents

Error freqüent

Les files de part sense portadella no tenen número de pàgina

Amb parts.page: false no hi ha portadella, així que la fila de part de l'índex no té número de pàgina per imprimir. En aquest cas deixa {pageNumber} fora del disseny de la fila. Índex de continguts →

Error freqüent

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

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

Error freqüent

Les imatges d'una obertura no compten per a l'alçada que reserva

A postext 1.4.1, un títol amb disseny avançat mesura l'alçada que reserva sense comptar-ne les imatges: els seus textos, filets i caixes compten, encara que estiguin ancorats a la pàgina, però una imatge, com un dibuix a sang al capdamunt de la pàgina, no reserva res, de manera que el text pot començar a sobre. Fixa amb minHeight on ha de començar el text. Obertures dissenyades →

Error freqüent

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

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

Error freqüent

Un flotant 'top' no cau mai a la pàgina que el cita

Un flotant no va mai per sobre de la seva pròpia referència, així que un flotant 'top' a tota l'amplada citat a la pàgina N obre la pàgina N+1. Cita'l abans, o fes servir la posició 'auto' o 'bottom', que poden ocupar el peu de la pàgina que el cita. Col·locació de figures →

Error freqüent

Els mapes de bits es mesuren en píxels a la resolució del document: declara la mida d'impressió

Un recurs de mapa de bits pren la mida de l'amplada i l'alçada que declara, en píxels a la resolució del document, no del fitxer. Declara els píxels de la mida d'impressió (uns 300 ppp a l'amplada impresa) perquè la figura surti a la seva mida i nítida. Figures i taules com a recursos →

Error freqüent

El lineHeight d'un text de disseny és un múltiple, mai una mesura

En una ranura de disseny, el lineHeight d'un element de text multiplica el seu cos (lineHeight: 1.05). A postext 1.4.1 una mesura com pt(15) no dona error: l'alçada de l'obertura surt NaN, l'espai que reserva, minHeight inclòs, es perd sense avís i el text se superposa al títol. Textos, filets i caixes als dissenys de pàgina →

Error freqüent

La correcció de les línies curtes pot estrènyer un espaiat entre lletres que no es pinta mai

A postext 1.4.1, quan un paràgraf acaba en una línia curta, el motor el compon amb una línia menys: primer estreny l'espai entre paraules i després aplica fins a maxRuntTracking mil·lèsimes d'em d'espaiat negatiu entre lletres. Els renderitzadors de canvas i PDF només pinten l'espaiat entre lletres més gran que zero, així que el paràgraf s'imprimeix sense aquest espaiat: les seves línies justificades perden aquesta diferència en els espais entre paraules, que surten aixafats, i l'última línia pot passar-se de la mesura i quedar tallada a la vora de la columna. Posa bodyText.maxRuntTracking: 0, que conserva la correcció amb l'espai entre paraules, i reescriu els paràgrafs que tornin a acabar en una línia curta. Vídues, òrfenes i línies curtes →

Error freqüent

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

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

Error freqüent

Carrega totes les fonts abans de compondre

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

  • Un element d'imatge encaixa la imatge a la seva caixa i no la retalla mai. Retalla abans cada JPEG a la seva caixa, 23 : 30 per a la portada i 230 × 150 mm per a les obertures, o la imatge s'encongeix i deixa franges blanques a les vores.
  • En el text, una part canvia qualsevol color amb un hex que coincideixi amb l'hex base de band, tant si està enllaçat a band com si no. Reserva aquest hex per al que hagi de canviar amb la secció.
  • Els articles estan ajustats a les seves pàgines en les dues llengües: en castellà es van escurçar algunes frases del Quadern de camp perquè càpiga en una pàgina, i en anglès una frase del reportatge va perdre tres paraules perquè les dues columnes de la pàgina 4 acabin a la mateixa alçada. Si canvies el text, comprova que cap article no passi unes línies a una pàgina nova i que totes les pàgines, tret de l'última de cada article, acabin igualades.

Crèdits

Text
Text original, CC BY 4.0
Imatges
  • Castellina in Chianti (the cover and its copy in the contents) · Rowan Heuvel · CC0 1.0
  • Castellina in Chianti, a detail (the feature's photograph) · Rowan Heuvel · CC0 1.0
  • Plants in beakers (the Field notes opener) · chuttersnap · CC0 1.0
  • Harvesting the wheat crop (the Kitchen opener) · meriç tuna · CC0 1.0
  • Fields from above near Castellina in Chianti (the feature's opener) · Generated With Diffusion Models · original
  • The cover's barcode, drawn in code · Ignacio Ferro · MIT
Fonts
Spectral (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Jost (SIL OFL 1.1)
Sandbox