En poques paraules
Un reportatge d'una revista de natura que s'obre amb una gran foto a dalt de la pàgina. Ensenya a col·locar el títol, la presentació, la signatura i el crèdit de la foto, i després requadres i una petita marca final.
Què compondràs
El reportatge del número d'hivern de Boreal, revista de natura, en pàgina de 225 × 297 mm. Un llac de muntanya a sang ocupa la part alta de la primera pàgina. A sota, un avantítol espaiat encapçala un titular en dues línies, amb l'entradeta en cursiva a la dreta i la signatura a sota; el crèdit del fotògraf va al peu de la foto. El titular és el text del títol de Markdown, i l'avantítol, l'entradeta, la signatura i el crèdit, els seus atributs. La doble pàgina següent va a dues columnes justificades. A la parella, una cita amb les cometes penjades al marge i un requadre de dades al capdamunt de la columna dreta; a la senar, una pineda nevada a dalt i un plafó fosc de xifres al peu. Un quadradet negre tanca el reportatge. Després, una guia de camp repeteix l'obertura amb un dibuix i l'avantítol en teula.
Aquesta recepta respon a
- Com afegeixo a una obertura una línia d'autor, una entradeta o un primer paràgraf amb caplletra?
- Com dono a cada capítol la seva pròpia foto, el seu color o una variant d'obertura?
- Com componc un epígraf, una dedicatòria, una signatura o una cita destacada amb una cometa gran?
La resposta curta
const TOP = 22; // top margin in mm: an opener's container starts here
const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' };
const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line
// Empty padding paints nothing but counts: the story starts on the first grid line at least
// 5 mm under the lower of the headline and the byline, however many lines each one runs to.
const air = { padding: { bottom: mm(5) } };
const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge },
offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds
enabled: true, // no minHeight: the story starts under the headline and byline (see air)
slot: {
elements: [ // the photo is an element, not a float: no float reaches the trim
{ kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed',
edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } },
// The photo hangs from the page's top edge, the words from the container, TOP mm lower:
// depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm.
{ kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
placement: at('container', 'top-right', 0, depth - TOP + 2) },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
letterSpacing: pt(1.7), color: col('lake'), align: 'left',
placement: at('container', 'top-left', 0, depth - TOP + 9) },
{ kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif',
fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air,
lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple)
placement: at('#kicker', 'below', 0, 2.5, HEAD) },
{ kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true,
fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple
color: col('ink'), align: 'left',
overflow: 'wrap', // gotcha: overflow-ellipsis-default
placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge
{ kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
placement: at('#standfirst', 'below', 0, 3.5) },
],
},
}); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign:
// opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…":
// # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"}
Ingredients
- Funcions
- Obertures dissenyadesImatges als dissenys de pàginaAtributs de títolRequadres flotantsColumnes dins d'un requadreChips en líniaCapítols que obren en pàgina senarBanda de capítol d'amplada completaCapçaleres i folisEquilibri de columnesCol·locació de figuresAncoratge d'elements de dissenyIcones i distintius de cantonadaRequadresFigures i taules com a recursosEstils de títolPaleta de color semànticaCapçaleres segons el tipus de pàginaLínies de font i crèditColors per part
- Tipografia
- Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1)
- Recursos
lake-2000.jpgthaw-2000.jpg- Clouds mirrored in a mountain lake (the opener photograph) (Ales Krivec, CC0 1.0)
- Walk in a thawing forest (the photo band) (Hannah Donze, CC0 1.0)
Elaboració
#1 · Munta l'obertura amb la línia del títol
El codi és la resposta curta de més amunt. Cada {attr.<clave>} imprimeix el valor <clave>="…" de la línia # del reportatge, i {titleText}, el títol mateix (atributs d'encapçalament), de manera que cada peça porta els seus textos al seu propi Markdown i tots els títols de nivell 1 comparteixen disseny. La foto penja de la vora superior de la pàgina i el contenidor comença 22 mm més avall, així que depth - TOP és la vora inferior de la foto mesurada des del contenidor; el crèdit va 2 mm per sota, a la cantonada superior dreta, i l'avantítol 9 mm per sota, a l'esquerra. El titular va below (a sota) de l'avantítol, amb els 118 mm de HEAD com a mesura. L'entradeta va right-of (a la dreta) del titular, sense amplada pròpia, així que les seves línies arriben fins a la vora del contenidor; la signatura va below l'entradeta, de manera que una entradeta més llarga l'empeny cap avall en comptes de muntar-s'hi a sobre. El nivell no fixa minHeight. El titular i la signatura acaben en un farciment buit de 5 mm que compta en l'alçada de l'obertura, així que el text arrenca a la primera línia de la retícula com a mínim 5 mm per sota del que acabi més avall, ocupi cadascun les línies que ocupi.
#2 · Declara les imatges una vegada, amb els seus píxels reals
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
{ id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
// the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
{ id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
// Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
// band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
// A top float opens the page after its ::resource line (gotcha: top-float-next-page):
// the line sits on the verso, so the band heads the recto.
placement: { position: 'top', span: 'page' },
caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
+ 'the sun reaches the ground, weeks before the ice lets go of the lake.',
es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
+ 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
altText: t({ en: 'A walker on a snowy path between tall pines.',
es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
{ id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];
Un element de disseny anomena la seva imatge per l'id del recurs, així que la foto de l'obertura es declara com qualsevol figura però no es cita mai: només l'obertura la dibuixa, fins al tall, on no arriba cap flotant. Els mapes de bits es mesuren als 150 dpi de la pàgina, així que els 2000 px de la pineda donen 339 mm i es redueixen a l'amplada de la caixa de text, 195 mm, uns 260 ppp a la impressió. Les imatges tenen un tipus propi, amb el prefix del peu i la plantilla de numeració buits, així que el peu de la pineda no porta número de figura. La banda és un flotant top a tota l'amplada, i un flotant top obre la pàgina següent a la que el cita. La seva línia ::resource queda a la pàgina parella, després del paràgraf de 1944, així que la banda encapçala la senar.
#3 · Penja les cometes al marge
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
size: pt(MARK.size), color: col('lake') },
titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
italicColor: col('lake'), firstLineIndent: pt(0) } };
Una icona de glif ocupa una columna pròpia al costat del text, i això sagnaria la cita. La separació (gap) negativa torna el text sobre la part dreta del quadrat del glif, que és buida. Un farciment esquerre negatiu, igual al que queda d'aquesta columna (mida + separació), la treu després al marge, i el text torna a començar a la vora de la columna. El marc de la caixa es queda a la columna, així que un fons no arribaria fins a les cometes. Les cometes llatines s'assenten més avall i són més amples, així que aquesta edició compon la seva « a 28 pt, en un quadrat que el farciment treu 22,5 pt; la “ anglesa va a 50 pt i en surt 34 pt. La « treu el cap uns 4,5 mm dins dels 14 mm del marge exterior de la pàgina parella, i la “, uns 6.
#4 · Porta els requadres al capdamunt d'una columna i al peu de pàgina
// Floated boxes keep one body line from the text, so they need no margins of their own.
const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole
backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') },
padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
color: col('lake'), gap: mm(2) },
body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2),
textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText
const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot
background: col('ink'), columnGap: mm(8),
padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) },
titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5),
color: col('ice'), gap: mm(1) },
body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5),
textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } };
// The panel's figures are level-4 headings (#### 31), a level the story never uses.
const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'),
marginBottom: pt(4) };
// The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces
// prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes
// the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink,
// not lake: the guide's palette would leave a lake chip teal on its rust page.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square
Amb placement: 'top' el requadre de dades flota: surt del flux i pren el següent capdamunt de columna lliure, així que el text continua fins al peu de la columna en comptes de deixar un blanc on el requadre no hi cabia. El plafó de xifres flota cap al costat contrari, al peu de la pàgina, i ocupa les dues columnes; les seves tres xifres són títols de nivell 4, un nivell que el reportatge no fa servir, repartits amb breaks="3,5", que compta blocs, no línies. El signe final és un xip sense text visible. El seu paddingX dona l'amplada al quadrat, i el cos del xip, l'alçada; totes dues fan 1,05 em del mateix xip.
#5 · Capçaleres que anomenen el número i el reportatge
const FOLIO_PT = 8.5; // the folio's size in pt
const LABEL_PT = 7.5; // the label's size in pt
const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height
const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT),
color: col('ink') };
// A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight).
const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt
const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin
const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge
const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge },
offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text)
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
{ kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
placement: pin(`top-${edge}`, s * OUTER) },
{ kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') },
placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line
size: { width: mm(SQUARE), height: mm(SQUARE) } } },
// The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its
// box goes that much lower: folio, square and label share one baseline at any size.
{ kind: 'text', id: `head-${parity}`, ...label,
content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot
Els elements de capçalera es pinten sobre la pàgina i no reserven lloc, així que cada peça s'ancora a la pàgina, al marge superior, amb distàncies en mil·límetres. La pàgina parella anomena la revista i el número des del frontmatter; la senar, el títol del reportatge, i pages: 'body' les aparta de les obertures, que només porten el foli, al peu. En un text de disseny, la línia de base cau a 0,8 de l'alçada de la seva caixa de línia, que fa 1,2 vegades el cos. Amb aquest càlcul, baseline() col·loca l'etiqueta de 7,5 pt 0,34 mm més avall que el foli de 8,5 pt i recolza el quadrat a la mateixa línia. Si canvies qualsevol dels dos cossos, les tres peces continuen en una sola línia de base.
#6 · Reutilitza l'obertura a la peça següent
const ART = 126; // mm: the drawing bleeds less far down the page than the photograph
// On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
palette: { lake: palette.rust } };
L'obertura és una funció, així que la guia de camp rep el mateix disseny amb la seva pròpia imatge i la seva pròpia alçada mitjançant un estil de títol, # Cinco clases \\ de hielo {style="guide" …}; la \\ parteix el titular per on vol la redacció. El dibuix baixa 126 mm per la pàgina en comptes de 160; el crèdit i l'avantítol es mesuren des de depth - TOP i els altres textos pengen de l'avantítol, així que tots pugen 34 mm i el text de la guia comença més amunt. La paleta de l'estil canvia lake pel teula en aquestes pàgines, així que l'avantítol, i qualsevol intertítol o requadre enllaçat a lake, es torna teula sense afegir un argument de color a opener(); els xips no segueixen la paleta, i per això el signe final és negre.
La recepta completa
// ═══ Postext Cookbook · Nº 004 · Magazine feature: photo opener to end mark ═══════ // https://postext.dev/en/cookbook/magazine-feature-opener // Code: MIT · Text: original (CC BY 4.0) · Photos: Ales Krivec, Hannah Donze (CC0) // Fonts: Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A nature feature from a winter issue. The level-1 heading carries its kicker, standfirst, // byline and photo credit as attributes, and one opener design lays them out under a bleed // photograph; the story runs on with a pull quote, a fact box, a photo band, a numbers panel // and an end mark, and the next item reuses the opener with a drawing in place of the photo. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'magazine-feature-opener'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // Every colour below is linked to this palette by id. const palette = { ink: '#15191c', // text: a blue-black lake: '#2d6a7d', // the accent: kickers, crossheads, the quote, the fact box's stripe ember: '#c8773d', // the panel's figures: 5.2:1 on ink (only 3.4:1 on paper) rust: '#9a5a2e', // the field guide's accent, swapped in for 'lake' by its heading style ice: '#dbe8ec', // the type on the dark panel and the drawing's sky rule: '#c7cdd1', // the drawing's far ridge and air bubbles muted: '#66707a', // running heads, credits, the colophon paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'lake (defaults)', value: { hex: palette.lake, model: 'hex' } }, ]; const TRIM = 225; // page width in mm, shared with the drawing const INNER = 16; // inner margin in mm const OUTER = 14; // outer margin in mm: folios and running heads align to it const LEAD = 13.4; // body leading in pt: the baseline grid // #region answer: a bleed photo, then the heading's kicker, headline, standfirst, byline and credit const TOP = 22; // top margin in mm: an opener's container starts here const sans = { fontFamily: 'Instrument Sans', fontWeight: 600, textTransform: 'uppercase' }; const HEAD = 118; // mm: the headline's measure; the standfirst takes the rest of the line // Empty padding paints nothing but counts: the story starts on the first grid line at least // 5 mm under the lower of the headline and the byline, however many lines each one runs to. const air = { padding: { bottom: mm(5) } }; const at = (id, edge, x, y, width) => ({ anchor: { to: id, edge }, offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); const opener = (resourceId, depth) => ({ // depth: how far down the page the picture bleeds enabled: true, // no minHeight: the story starts under the headline and byline (see air) slot: { elements: [ // the photo is an element, not a float: no float reaches the trim { kind: 'image', id: 'photo', resourceId, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(depth) } } }, // The photo hangs from the page's top edge, the words from the container, TOP mm lower: // depth − TOP is the photo's foot, so the credit sits 2 mm under it and the kicker 9 mm. { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500, fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right', placement: at('container', 'top-right', 0, depth - TOP + 2) }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5), letterSpacing: pt(1.7), color: col('lake'), align: 'left', placement: at('container', 'top-left', 0, depth - TOP + 9) }, { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Instrument Serif', fontSize: pt(58), color: col('ink'), align: 'left', overflow: 'wrap', box: air, lineHeight: 0.94, // a multiple of the size (gotcha: design-lineheight-multiple) placement: at('#kicker', 'below', 0, 2.5, HEAD) }, { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', italic: true, fontFamily: 'Instrument Serif', fontSize: pt(13.5), lineHeight: 1.22, // a multiple color: col('ink'), align: 'left', overflow: 'wrap', // gotcha: overflow-ellipsis-default placement: at('#headline', 'right-of', 7, 3.2) }, // wraps at the container's edge { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air, placement: at('#standfirst', 'below', 0, 3.5) }, ], }, }); // hook-up: headings.levels[0] = { level: 1, span: 'page', breakBefore, advancedDesign: // opener('lake', 160) }. {titleText} prints the heading's text, {attr.<key>} its <key>="…": // # The Lake That Keeps Time {kicker="…" standfirst="…" byline="…" credit="…"} // #endregion // #region heads: magazine and issue on the verso, the story on the recto, a lake square const FOLIO_PT = 8.5; // the folio's size in pt const LABEL_PT = 7.5; // the label's size in pt const SQUARE = 2.1; // mm: the lake square's side, the folio's cap height const label = { ...sans, fontSize: pt(LABEL_PT), letterSpacing: pt(1.3), color: col('muted') }; const folio = { fontFamily: 'Instrument Sans', fontWeight: 700, fontSize: pt(FOLIO_PT), color: col('ink') }; // A design text's baseline sits 0.8 down its line box, 1.2 × its size (the default lineHeight). const baseline = (size) => size * 1.2 * 0.8 * 25.4 / 72; // mm from its box's top, size in pt const HEAD_Y = 12; // mm from the top edge to the folio's box, inside the 22 mm top margin const LINE = HEAD_Y + baseline(FOLIO_PT); // the heads' one baseline, from the top edge const pin = (edge, x, y = HEAD_Y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }); // in the margin (gotcha: header-paints-over-text) const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left const header = { elements: sides.flatMap(([parity, edge, s]) => [ { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio, placement: pin(`top-${edge}`, s * OUTER) }, { kind: 'box', id: `square-${parity}`, style: { backgroundColor: col('lake') }, placement: { ...pin(`top-${edge}`, s * (OUTER + 7.5), LINE - SQUARE), // on the line size: { width: mm(SQUARE), height: mm(SQUARE) } } }, // The smaller label's baseline sits higher in its box (0.34 mm at 7.5 and 8.5 pt), so its // box goes that much lower: folio, square and label share one baseline at any size. { kind: 'text', id: `head-${parity}`, ...label, content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}', placement: pin(`top-${edge}`, s * (OUTER + 11.5), LINE - baseline(LABEL_PT)) }, ].map((element) => ({ ...element, parity, pages: 'body' }))) }; // no running heads on openers const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity, id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge, placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // an opener's only folio, at the foot // #endregion // #region quote: a pull quote whose mark hangs in the margin, outside the text's edge // The glyph is centred in an icon square that the box keeps as a column, size + gap wide, // left of the text. A negative gap pulls the text back over the square's empty right side, // and a left padding of −(size + gap) moves that column out into the margin, so the text // starts on the column's edge and the mark hangs outside it. The frame stays on the column // (a background would stop short of the mark). « sits lower and runs wider than “, so the // Spanish mark is set smaller. const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm es: { glyph: '«', size: 28, column: 22.5 } }); // The paddings are optical: once the next paragraph snaps to the grid, the quote has // the same air above and below it, in both languages. const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD), marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7), left: pt(-MARK.column) }, // negative: the icon column starts out in the margin icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif', size: pt(MARK.size), color: col('lake') }, titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens italicColor: col('lake'), firstLineIndent: pt(0) } }; // #endregion // #region boxes: a fact box at a column head, a dark panel at the page foot, the end mark // Floated boxes keep one body line from the text, so they need no margins of their own. const glance = { id: 'glance', placement: 'top', // floats to the next column head: no hole backgroundEnabled: false, // one device, the stripe; the text keeps the column's edges stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('lake') }, padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('lake'), gap: mm(2) }, body: { fontFamily: 'Instrument Sans', fontSize: pt(8.6), lineHeight: pt(12.2), textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) } }; // ink from bodyText const numbers = { id: 'numbers', span: 'page', placement: 'bottom', // floats to a page foot background: col('ink'), columnGap: mm(8), padding: { top: mm(5), right: mm(6), bottom: mm(5.5), left: mm(6) }, titleStyle: { ...sans, fontWeight: 700, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('ice'), gap: mm(1) }, body: { fontFamily: 'Instrument Sans', fontSize: pt(9), lineHeight: pt(12.5), textAlign: 'left', hyphenation: false, color: col('ice'), firstLineIndent: pt(0) } }; // The panel's figures are level-4 headings (#### 31), a level the story never uses. const figures = { level: 4, fontSize: pt(40), lineHeight: pt(40), color: col('ember'), marginBottom: pt(4) }; // The end mark is a chip with no visible text: a U+2060 inside, because a chip of spaces // prints its markup (gotcha: empty-chip). Its lengths are in its own ems: paddingX makes // the width, and the height is its font size's band (0.8 ascent + 0.25 descent). It is ink, // not lake: the guide's palette would leave a lake chip teal on its rust page. const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0), fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square // #endregion // #region guide: the next item reuses the opener with its own picture, depth and accent const ART = 126; // mm: the drawing bleeds less far down the page than the photograph // On its pages, 'lake' turns rust in the opener, the headings and the boxes, but not in chips. const guide = { id: 'guide', advancedDesign: opener('ice-art', ART), palette: { lake: palette.rust } }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) resourceTypes: [photoType], // one unnumbered type for every picture (see the resources) colorPalette, page: { width: mm(TRIM), height: mm(297), margins: { top: mm(TOP), bottom: mm(20), left: mm(INNER), right: mm(OUTER), mirror: true }, // a magazine trim; left is the inner side dpi: 150 }, // the layout's pixels per inch, which bitmaps are measured in (see 'thaw') layout: { layoutType: 'double', gutterWidth: mm(6) }, bodyText: { fontFamily: 'Literata', fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false, minWordSpacing: 0.65, // a space never shrinks below 65 % (the default allows 60 %) runtMinCharacters: 40 }, // 40 spaces' width, about 20 letters: no one-word last lines // Hyphenation, optimal line breaking and widow control are on by default. headings: { fontFamily: 'Instrument Serif', fontWeight: 400, color: col('ink'), // Under a top photo band on a closing page, this lever can drop the shorter column a line, // out of line with the other (gotcha: float-stretch-closing-page). The switch covers every // page, not only closing ones; the shipped copy does not trip it, edited copy might. balancing: { stretchAfterFloats: false }, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break); // 'any' lets the next item open on the following page, recto or verso. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), advancedDesign: opener('lake', 160) }, { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), italic: true, color: col('lake'), marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above figures, ], }, headingStyles: [guide], calloutStyles: [quote, glance, numbers], chipStyles: [endMark], captionStyle: { fontFamily: 'Instrument Sans', fontSize: pt(7.6), gap: mm(2), // ink: bodyText's note: { fontSize: pt(6.5), color: col('muted'), gap: mm(0.6) } }, paragraphStyles: [{ id: 'colophon', fontFamily: 'Instrument Sans', fontSize: pt(6.6), lineHeight: pt(9.4), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(2 * LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // #region resources: two photographs and a drawing, declared once, by their real pixels // The pictures are not numbered: their own type, with an empty caption prefix and template, // keeps a figure number off the pine wood's caption. const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }), shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const PX = 10; // the drawing's pixels per mm const resources = [ { id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited: // the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 }, altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.', es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) }, { id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the // band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide. bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 }, // A top float opens the page after its ::resource line (gotcha: top-float-next-page): // the line sits on the verso, so the band heads the recto. placement: { position: 'top', span: 'page' }, caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where ' + 'the sun reaches the ground, weeks before the ice lets go of the lake.', es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el ' + 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }), note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons', es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }), altText: t({ en: 'A walker on a snowy path between tall pines.', es: 'Un caminante en un sendero nevado entre pinos altos.' }) }, { id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX }, altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.', es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) }, ]; // #endregion const markdown = String.raw`---Mostra en Markdown · 107 línies · content.es.md
title: "Boreal" subtitle: "Invierno 2026" --- # El lago que lleva la cuenta {kicker="Clima · Reportaje" standfirst="Durante ciento quince inviernos, una familia ha anotado el día en que su lago se helaba y el día en que se deshelaba. Sus cuadernos son hoy uno de los registros climáticos más largos de la montaña." byline="Texto de Ingrid Solberg" credit="Finales de octubre, antes de la helada · Fotografía: Ales Krivec, CC0"} El cuaderno está en una lata de galletas sobre el aparador de la cocina, entre las cerillas y el calendario de la parroquia. Es el cuarto de la serie. Los tres primeros están ahora en el archivo comarcal, envueltos en papel sin ácido, pero Hanna Brenner sigue prefiriendo la lata. «El archivo también quería este», dice mientras levanta la tapa. «Les dije que todavía lo usamos». Su bisabuelo llevaba la barca que cruzaba el lago desde 1906, y empezó el registro por un motivo práctico: quien pasa viajeros de una orilla a otra necesita saber cuándo el agua aguantará un trineo en vez de una barca. El 9 de diciembre de 1911 escribió a lápiz: *Helado. Entero.* El 4 de abril anotó que el hielo se había ido durante la noche, con un ruido «como de una puerta que se cierra de golpe en algún lugar de las montañas». Desde entonces, cada invierno, alguien de la familia ha seguido llevando la cuenta. Ciento quince líneas a lápiz, una por invierno, no parecen gran cosa. Impresas, caben en dos hojas de papel. Pero la estación meteorológica del valle vecino no se abrió hasta 1931, y pocos lagos del mundo tienen un registro de hielo tan largo. El del lago Suwa, en Japón, el más antiguo, lo empezaron unos sacerdotes sintoístas en 1443. ## Un termómetro con tapa «Un lago es un termómetro que se lee una vez al año», dice Vera Lind, limnóloga, que ha pasado seis inviernos sobre este hielo. «El aire cambia de una hora a otra. El lago lo promedia todo. Cuándo se hiela y cuándo se deshiela te dice cómo ha sido la estación entera». La razón está en una rareza del agua. Casi todos los líquidos se vuelven más densos al enfriarse. El agua dulce también, pero solo hasta unos 4 °C; por debajo vuelve a aligerarse, y por eso los lagos se hielan de arriba abajo; el hielo, más ligero aún, flota encima. En otoño, a medida que la superficie se enfría, el agua fría se hunde y la más templada sube a ocupar su lugar. El lago se mezcla una y otra vez durante semanas. Solo cuando toda la columna ha llegado a 4 °C puede la superficie seguir enfriándose sin hundirse, y solo entonces, en una noche quieta y despejada, se cubre de una piel de hielo. :::callout{type="pullquote"} *Alguien miraba la misma agua desde el mismo embarcadero.* ::: Eso explica que la helada llegue tarde y de golpe. Hanna recuerda ver de niña, desde el embarcadero, cómo se cerraba el último trozo de agua abierta en el centro del lago, «como una pupila que se encoge con la luz». A la mañana siguiente su padre salía con un hacha a medir el grosor. Diez centímetros aguantan a una persona, decía la regla de la familia; veinte, el trineo; treinta, un caballo. ## De qué está hecho el hielo El primer hielo que se forma es el hielo negro, que crece hacia abajo desde el agua y es tan transparente que a través de un palmo de él se ven las piedras del fondo. Es el hielo más resistente que da un lago. Con la nieve se forma un segundo hielo. Una nevada fuerte hunde la lámina hasta que el agua sube por las grietas y empapa la nieve, que se congela en hielo blanco, turbio de aire atrapado y con la mitad de resistencia. Lind perfora los dos cada semana del invierno. Los testigos salen con bandas, como el tronco de un árbol, y ella los lee igual: una capa negra gruesa significa un diciembre frío y seco; un montón de bandas blancas, temporal tras temporal. Bajo el hielo el lago sigue vivo. El agua del fondo se mantiene cerca de 4 °C todo el invierno. Las truchas se vuelven lentas, pero siguen comiendo. La luz aún atraviesa el hielo claro, y bajo él crecen algas en la penumbra verde, algo que sorprendió a los primeros científicos que fueron a buscarlas. :::callout{type="glance" title="De un vistazo"} **Altitud** 1540 m sobre el nivel del mar **Superficie** 3,2 km²; 63 m en el punto más hondo **Registro desde** el invierno de 1911-1912 **Hielo, 1911-1960** 118 días al año de media **Hielo, 1991-2025** 87 días al año de media **Inviernos sin hielo** 2007 y 2020 ::: ## Contar los días Durante los primeros cincuenta años del registro, el lago estuvo helado una media de 118 días por invierno. Desde 1991, la media es de 87. La helada llega ahora unas dos semanas más tarde que en tiempos del barquero, y el hielo se va más de dos semanas antes. El cambio no ha sido regular: algunos inviernos de los sesenta, y el de 2010, fueron tan largos como el que más. Pero los inviernos sin hielo son nuevos. En 2007 y de nuevo en 2020 el lago no llegó a helarse de orilla a orilla, y la familia escribió una sola palabra para ese año: *abierto*. El invierno de 1944 estuvo a punto de quedar en blanco. El hijo del barquero estaba en la guerra, y las fechas de ese año aparecen con otra letra, pequeña y derecha, con una nota al margen: *las tomó su madre*. Las había apuntado en el dorso de una cartilla de racionamiento. Lind las ha contrastado con la estación meteorológica del valle vecino. Son, dice, tan buenas como las demás. ::resource{id="thaw"} Lind es prudente con lo que dice un solo lago. Un registro aislado es una historia local: el valle tiene sus vientos, y el lago, su hondura y su forma. Pero los cuadernos coinciden con cientos de lagos del hemisferio norte, de Finlandia a Japón, donde la temporada de hielo se ha acortado semanas en el último siglo. «Me fío de este registro porque el método nunca cambió», dice. «Alguien miraba la misma agua desde el mismo embarcadero». Ha instalado instrumentos propios. Una cadena de sensores de temperatura cuelga de una boya sobre la parte más honda del lago y mide cada quince minutos de la superficie al fondo, y una cámara en el campanario fotografía el hielo cada mediodía. Pero cuando pone sus lecturas junto a los cuadernos, las fechas cuadran: la familia nunca se ha desviado más de uno o dos días de lo que marcan los sensores. ## El deshielo El hielo rara vez se va en silencio. Durante marzo el sol lo trabaja desde arriba y los arroyos más templados desde abajo, y el hielo negro se pudre en largos cristales verticales, el hielo de velas, que tintinean unos contra otros cuando el viento los mueve. Luego una lluvia templada o un viento del sur rompe la lámina, y en uno o dos días la superficie queda abierta. Los pescadores viejos aseguraban que se oía desde el pueblo. La fecha importa a algo más que a la barca. La floración de algas de primavera, que alimenta todo el lago, empieza cuando se va el hielo y entra la luz. El hielo marca el calendario de las percas, que desovan en los bajíos, y el de los insectos. Cuando el deshielo se adelanta, esos calendarios pueden desacompasarse, y un alevín puede nacer en un agua cuyo alimento ya vino y se fue. :::callout{type="numbers" title="En cifras"} :::columns{count=3 breaks="3,5"} #### 31 días menos de hielo por invierno que en los primeros cincuenta años #### 115 inviernos anotados a lápiz por cinco generaciones de una misma familia #### 4 °C la temperatura del fondo todo el invierno: la del agua más densa ::: ::: También hay calendarios humanos. El camino de hielo que cruzaba el lago, por el que pasaban el heno, la madera y el trineo del médico, no se ha abierto oficialmente desde 2014. El club de patinaje trasladó sus carreras a una pista artificial de la ciudad. Los pescadores de hielo siguen saliendo, pero más tarde, y con cuerdas. El abuelo de Hanna recordaba el otro extremo. En el duro invierno de 1963 el hielo llegó a los sesenta centímetros, y un panadero del pueblo de al lado cruzó el lago en furgoneta para ahorrarse el largo rodeo. La entrada de ese año lleva al margen un dibujito de la furgoneta, el único de los cuatro cuadernos. Hanna no se pone sentimental. Da clase de matemáticas en la escuela del valle y ha convertido los cuadernos en una lección: cada grupo representa las dos fechas de cada invierno y traza una recta entre los puntos. «Los niños siempre encuentran la tendencia por su cuenta», dice. «Yo reparto papel milimetrado y una regla, y no digo nada del clima». Una clase fue más lejos: comparó los cuadernos con el registro escolar de la floración de los cerezos del patio y vio que las dos fechas se habían movido casi los mismos días. Lind enseña ahora su gráfico en los congresos, con los nombres de los niños en una esquina. El registro seguirá. El hijo de Hanna, que tiene catorce años, se ha hecho cargo de los paseos de noviembre al embarcadero, en busca de la mañana en que desaparece la última mancha de agua oscura. Ahora usa una hoja de cálculo con copia en la universidad. Pero el día en que el lago se hiela también escribe la fecha a lápiz, en el cuaderno de la lata, porque así lo hacía su tatarabuelo. El invierno pasado el lago se heló el 21 de diciembre y se abrió el 18 de marzo: ochenta y siete días, casi exactamente la media actual. Hanna escribió *normal* junto a las fechas y luego tachó la palabra. «Normal por ahora», dice. :chip[]{style="end"} # Cinco clases \\ de hielo {style="guide" kicker="Guía de campo" standfirst="Cómo leer un lago helado antes de confiarle tu peso, desde la lámina negra y clara de principios de invierno hasta las velas podridas de marzo." byline="Texto de la redacción" credit="Ilustración generada con código para Boreal"} **Hielo negro.** El primer hielo de la temporada crece hacia abajo desde el agua en calma, en cristales largos que dejan pasar la luz. Parece oscuro porque a través de él ves el lago. Diez centímetros de hielo negro nuevo aguantan a una persona a pie; es el hielo más resistente que forma un lago. **Hielo blanco.** Cuando la nieve carga la lámina, el agua sube por las grietas, empapa la nieve y se congela en una capa lechosa llena de aire. Resiste más o menos la mitad que el hielo negro, así que cuenta solo la mitad de su grosor cuando decidas si cruzar. **Nieve empapada.** Una nevada fuerte puede hundir el hielo por debajo del nivel del agua y dejar encima una capa de nieve mojada que sigue líquida bajo una costra aislante. Cuesta caminar por ella, es traicionera con esquís y es donde empieza casi todo el hielo blanco. **Hielo de orilla.** El hielo del borde es el primero en formarse y el primero en irse. Los manantiales, los juncos y el calor del suelo lo debilitan, y en marzo una franja de agua abierta, el foso, suele separar la lámina de la tierra. Puedes salir por buen hielo y descubrir que no puedes volver. **Hielo de velas.** En primavera el sol pudre el hielo negro por las juntas entre sus cristales. La lámina aún puede parecer sólida cuando ya se ha convertido en un haz de varillas verticales sueltas que ceden bajo una bota. Si la superficie se vuelve gris y granulosa, y las varillas tintinean con el viento, quédate en la orilla. No juzgues nunca el hielo solo por su color. Mídelo cada pocos pasos y pregunta a quienes viven junto a la orilla. :chip[]{style="end"} :::paragraphs{style="colophon"} Compuesto en Literata, Instrument Serif e Instrument Sans (SIL Open Font License) · Texto: Recetario de Postext, CC BY 4.0 · Fotografías: Ales Krivec y Hannah Donze, CC0, vía Wikimedia Commons · El lago, la familia, los científicos y la autora son ficticios. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the guide's picture, a lake in section, drawn in code with a seeded PRNG function mulberry32(seed) { return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } function iceArt() { // in mm, TRIM × ART: sky, shore, snow, white ice, black ice, water const rand = mulberry32(14); const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; const rect = (x, y, w, h, paint) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" ${paint}/>`; const ridge = (base, amp, step, paint) => { // a mountain line, closed down to the shore let d = `M0 ${base}`; for (let x = 0; x <= TRIM; x += step) d += `L${x} ${(base - rand() * amp).toFixed(1)}`; return `<path d="${d}L${TRIM} 60L0 60Z" ${paint}/>`; }; const pines = Array.from({ length: 46 }, (_, i) => { // the far shore, a row of spruces const x = i * 5 + rand() * 3; const h = 4 + rand() * 5; return `<path d="M${x.toFixed(1)} ${60 - h}l${h * 0.28} ${h}h${-h * 0.56}Z" ` + `${f('ink', 0.85)}/>`; }).join(''); const bubbles = Array.from({ length: 70 }, () => { // air trapped in the white ice const r = 0.25 + rand() * 0.6; const [cx, cy] = [(rand() * TRIM).toFixed(1), (72 + rand() * 6).toFixed(1)]; return `<circle cx="${cx}" cy="${cy}" r="${r}" ${f('paper')} stroke="${palette.rule}" ` + 'stroke-width="0.15"/>'; }).join(''); // Black ice: a solid sheet on the winter side (left) that rots into candles towards spring, // ten rods evenly spaced, each shorter and thinner than the last. const candles = Array.from({ length: 10 }, (_, i) => rect(150 + i * 7.5, 79, (2.6 - i * 0.1).toFixed(2), (12.5 - i * 0.55 - rand() * 1.5).toFixed(1), f('ink'))).join(''); const clouds = [[18, 15, 52], [98, 8, 38], [146, 21, 30]].map(([x, y, w]) => [[x, y, w], [x + w * 0.22, y - 2.4, w * 0.42]].map(([cx, cy, cw]) => `<rect x="${cx}" y="${cy}" ` + `width="${cw}" height="4.4" rx="2.2" ${f('paper', 0.7)}/>`).join('')).join(''); const fish = (x, y, s) => `<path d="M${x} ${y}c${3 * s} ${-2 * s} ${7 * s} ${-2 * s} ${9 * s} 0` + `c${-2 * s} ${2 * s} ${-6 * s} ${2 * s} ${-9 * s} 0Z` // the body, then the tail + `m0 0l${-2.5 * s} ${-1.6 * s}v${3.2 * s}Z" ${f('ink', 0.55)}/>`; return `<svg xmlns="http://www.w3.org/2000/svg" width="${TRIM * PX}" height="${ART * PX}" ` + `viewBox="0 0 ${TRIM} ${ART}">` + rect(0, 0, TRIM, ART, f('ice')) + clouds // winter sky + `<g transform="translate(0 ${ART - 112})">` // the lake keeps to the foot of the picture + `<circle cx="188" cy="24" r="9" ${f('ember', 0.9)}/>` // a low March sun + ridge(38, 16, 9, f('rule')) + ridge(48, 12, 6, f('muted', 0.55)) + pines + rect(0, 60, TRIM, 11, f('paper')) + rect(0, 60, TRIM, 11, f('ice', 0.3)) // snow + rect(0, 71, TRIM, 8, f('ice', 0.75)) + bubbles // white ice, cloudy with air + rect(0, 79, TRIM, 33, f('lake')) // the water, 4 °C at the floor + rect(0, 79, 146, 13, f('ink')) + candles + fish(60, 101, 1) + fish(128, 106, 0.8) + '</g></svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Literata: ['400', '400i', '700'], 'Instrument Serif': ['400', '400i'], 'Instrument Sans': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([loadImage('lake-2000.jpg', asset('lake-2000.jpg')), loadImage('thaw-2000.jpg', asset('thaw-2000.jpg')), loadSvg('ice-art.svg', iceArt())]); const continuation = { pageNumbering: { startAt: 57 } }; // pages 57–60 of the issue const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine feature: photo opener to end mark', es: 'Reportaje de revista: de la foto de apertura al signo final' }) });Kit · core, fonts, viewer, images: igual 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
#Obre cada peça en pàgina senar
Amb 'odd', la guia de camp passa a la pàgina 61 i la 60 es queda en blanc.
- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },#Posa el titular sobre una banda de color
Per a un capítol de llibre de text, on una banda de color i un número gran ocupen el lloc de la foto, mira Obertura de capítol sobre banda a sang.
Errors freqüents
Error freqüent
Valors d'atribut: sense { ni }; cometes simples si porten "
Un valor d'atribut acaba a la clau de tancament, així que no pot contenir { ni }. Un valor que porti cometes dobles va entre cometes simples; el signe de dòlar no dona problemes. Atributs de títol →
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
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
Un xip només amb espais imprimeix el marcatge
Un xip el text del qual són només espais, inclosos els de no separació, imprimeix :chip[ ] tal qual. Per a un xip de resposta en blanc, posa-hi dins un unidor de paraules (U+2060). Chips en línia →
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 →
Error freqüent
Els elements de capçalera i peu es pinten damunt del text
Els elements de capçalera i peu es pinten sobre la pàgina i l'àrea de text no els deixa lloc. Mantén-los dins dels marges, que són els que els reserven l'espai. Capçaleres i folis →
Error freqüent
El desbordament del text de disseny és 'ellipsis-end' per defecte
Un element de text de disseny que no cap en la seva amplada acaba en punts suspensius per defecte. Posa overflow: 'wrap' als títols que hagin de passar a més línies. Textos, filets i caixes als dissenys de pàgina →
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 superior pot fer baixar l'última columna en una pàgina de tancament
A postext 1.4.1, quan un capítol o un reportatge acaba en una pàgina que s'obre amb un flotant superior a tota l'amplada i les seves línies es reparteixen de manera desigual entre les columnes, l'ajust stretchAfterFloats afegeix una línia en blanc sota el flotant a la columna més curta en lloc de deixar que acabi abans, i les dues columnes ja no comencen a la mateixa línia. Posa headings.balancing.stretchAfterFloats a false o ajusta el text a un nombre parell de línies. Equilibri de columnes →
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
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
:::columns només funciona dins d'un requadre i no es parteix
:::columns s'ignora fora d'un requadre, i un requadre que es parteix mai no talla dins d'un grup de columnes. L'atribut breaks compta blocs fills, i un requadre imbricat compta com un. Columnes dins d'un requadre →
Comprovació del Sandbox · bitmapTooSmall
Imatge de baixa resolució
Per què. Un mapa de bits es dibuixa més d'1,5 vegades més ample que els seus píxels, de manera que es veurà borrós en imprimir-lo.
Solució. Proporciona uns 300 ppp a la mida d'impressió i declara l'amplada i l'alçada reals del mapa de bits. Documentació →
- Un element d'imatge encaixa la foto dins de la seva caixa i no la retalla mai. Retalla el JPEG a les proporcions de la caixa, aquí 225 × 160 mm, o la foto s'encongirà i deixarà franges blanques a les vores de la pàgina.
- Les cometes penjades necessiten marge. Si en editar la cita passa a una columna dreta, les cometes es fiquen als 6 mm de l'espai entre columnes, gairebé enganxades al text de la columna esquerra: avança-la o endarrereix-la un paràgraf.
- El text està ajustat per acabar a la pàgina 59, a sobre del plafó de xifres. Unes quantes línies més envien la cua a una pàgina pròpia, així que, després de cada canvi, torna a ajustar el text de les dues edicions.
runtMinCharacters: 40evita les últimes línies d'una sola paraula. Quan no hi ha manera d'evitar una última línia curta, el motor compon el paràgraf amb una línia menys: estreny els espais entre paraules i, si no n'hi ha prou, treu fins a 0,01 em entre lletres. Després d'editar, cerca els paràgrafs tan estrets i retalla-hi o afegeix-hi unes quantes paraules; el text publicat no necessita aquest ajust en cap de les dues llengües.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Text original, CC BY 4.0
- Imatges
- Clouds mirrored in a mountain lake (the opener photograph) · Ales Krivec · CC0 1.0
- Walk in a thawing forest (the photo band) · Hannah Donze · CC0 1.0
- Fonts
- Literata (SIL OFL 1.1) · Instrument Serif (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


