In breve
Un articolo di una rivista di natura che si apre con una grande foto in testa alla pagina. Mostra come impaginare il titolo, la breve introduzione, l’autore e il credito fotografico, poi riquadri laterali e un piccolo segno finale.
Che cosa comporrai
Le pagine dei servizi di Boreal, il numero invernale di una rivista di natura, su una pagina di 225 × 297 mm. L’articolo si apre sotto un lago di montagna al vivo sulla testa della pagina. Sotto l’immagine, un occhiello spaziato sovrasta un titolo di due righe, con un sommario in corsivo alla sua destra e la firma sotto; il credito del fotografo corre sotto la foto. Il titolo è il testo del titolo Markdown, e occhiello, sommario, firma e credito sono i suoi attributi. L’articolo prosegue su una doppia pagina in due colonne giustificate. La pagina verso ha una citazione in evidenza con le virgolette sporgenti e un riquadro di dati in testa alla colonna di destra; il recto, una fascia di bosco di pini in alto e un pannello scuro di cifre al piede. Un piccolo quadrato chiude l’articolo. Nella pagina seguente, una guida sul campo riusa l’apertura con un disegno e un occhiello color ruggine.
Questa ricetta risponde a
- Come aggiungo a un’apertura una riga d’autore, un sommario o un attacco con capolettera?
- Come do a ogni capitolo una foto, un colore o una variante di apertura propri?
- Come compongo un’epigrafe, una dedica, una firma o una citazione in evidenza con una grande virgoletta?
La risposta breve
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', box: air, // wraps: a heading design
lineHeight: 0.94, // tight: the two lines of the headline nearly touch
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',
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="…"}
Ingredienti
- Funzionalità
- Aperture progettateImmagini nei progetti di paginaAttributi dei titoliRiquadri flottantiColonne in un riquadro o nel testoChip in lineaCapitoli che si aprono su un rectoFascia di capitolo a tutta larghezzaTestatine e folioBilanciamento delle colonneCollocazione delle figureAncoraggio degli elementi del progettoIcone dei riquadri e badge d’angoloRiquadriFigure e tabelle come risorseStili di titoloTavolozza di colori semanticaTestatine secondo il ruolo della paginaRighe di fonte e creditiColori per parte
- Tipo
- Literata, Instrument Serif, Instrument Sans (SIL OFL 1.1)
- Risorse
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)
Preparazione
#1 · Costruisci l’apertura dalla riga del titolo
Il codice è la risposta breve qui sopra. Ogni {attr.<key>} stampa il valore <key>="…" sulla riga # dell’articolo e {titleText} stampa il titolo stesso (attributi dei titoli), così ogni articolo tiene le sue parole nel proprio Markdown e tutti i titoli di livello 1 condividono un unico progetto grafico. La foto è appesa al bordo superiore della pagina e il contenitore comincia 22 mm più in basso, quindi depth - TOP è il bordo inferiore della foto misurato dal contenitore; il credito sta 2 mm sotto quella linea in alto a destra e l’occhiello 9 mm sotto in alto a sinistra. Il titolo va below l’occhiello, con la giustezza HEAD di 118 mm. Il sommario va right-of il titolo senza una larghezza propria, quindi va a capo al bordo del contenitore, e la firma va below il sommario, così un sommario più lungo spinge in basso la firma invece di finirle addosso. Il livello non ha minHeight. Il titolo e la firma terminano ciascuno con un padding vuoto di 5 mm che conta per l’altezza dell’apertura, quindi l’articolo comincia sulla prima riga di griglia che sta almeno 5 mm sotto quello dei due che finisce più in basso, su quante righe vadano.
#2 · Dichiara le immagini una volta, con i loro pixel reali
// The pictures are not numbered: their own type, with an empty caption prefix and template,
// keeps a figure number off the pine wood's caption.
const photoType = { id: 'photo', name: t({ en: 'Photograph', es: 'Fotografía' }),
shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '',
resetOn: 'never', counterFormat: 'decimal' };
const PX = 10; // the drawing's pixels per mm
const resources = [
{ id: 'lake', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
// the opener fits it inside its box, so the JPEG is cropped to the box, 225 × 160 mm
bitmap: { fileId: 'lake-2000.jpg', format: 'jpeg', width: 2000, height: 1422 },
altText: t({ en: 'A still mountain lake mirroring clouds between autumn slopes.',
es: 'Un lago de montaña en calma que refleja las nubes entre laderas otoñales.' }) },
{ id: 'thaw', typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0,
// Pixels at the page's 150 dpi (gotcha: bitmap-print-size): 2000 px make 339 mm, so the
// band shrinks to the 195 mm measure. At 300 dpi it would print 169 mm wide.
bitmap: { fileId: 'thaw-2000.jpg', format: 'jpeg', width: 2000, height: 944 },
// A top float opens the page after its ::resource line (gotcha: top-float-next-page):
// the line sits on the verso, so the band heads the recto.
placement: { position: 'top', span: 'page' },
caption: t({ en: 'Early March in the pine wood above the shore: the snow goes first where '
+ 'the sun reaches the ground, weeks before the ice lets go of the lake.',
es: 'Principios de marzo en el pinar sobre la orilla: la nieve se retira primero donde el '
+ 'sol llega al suelo, semanas antes de que el hielo suelte el lago.' }),
note: t({ en: 'Photograph: Hannah Donze, CC0, via Wikimedia Commons',
es: 'Fotografía: Hannah Donze, CC0, vía Wikimedia Commons' }),
altText: t({ en: 'A walker on a snowy path between tall pines.',
es: 'Un caminante en un sendero nevado entre pinos altos.' }) },
{ id: 'ice-art', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
svg: { fileId: 'ice-art.svg', width: TRIM * PX, height: ART * PX },
altText: t({ en: 'A lake in section: snow, white ice, black ice and water under a low sun.',
es: 'Un lago en sección: nieve, hielo blanco, hielo negro y agua bajo un sol bajo.' }) },
];
Un elemento grafico indica la sua immagine con l’id della risorsa, quindi la fotografia dell’apertura si dichiara come qualsiasi figura ma non viene mai citata: solo l’apertura la disegna, fino al rifilo, dove nessun elemento flottante arriva. Le bitmap si misurano ai 150 dpi della pagina, quindi i 2000 px del bosco di pini fanno 339 mm e si riducono alla giustezza di 195 mm, circa 260 dpi in stampa. Le immagini hanno un tipo proprio, con un prefisso di didascalia e un modello di numerazione vuoti, così la didascalia del bosco di pini non porta un numero di figura. La fascia è un elemento flottante top a tutta pagina, e un elemento flottante in testa apre la pagina successiva a quella che lo cita. La sua riga ::resource sta sulla pagina verso, dopo il paragrafo del 1944, quindi la fascia apre il recto.
#3 · Fai sporgere le virgolette nel margine
// The glyph is centred in an icon square that the box keeps as a column, size + gap wide,
// left of the text. A negative gap pulls the text back over the square's empty right side,
// and a left padding of −(size + gap) moves that column out into the margin, so the text
// starts on the column's edge and the mark hangs outside it. The frame stays on the column
// (a background would stop short of the mark). « sits lower and runs wider than “, so the
// Spanish mark is set smaller.
const MARK = t({ en: { glyph: '“', size: 50, column: 34 }, // pt; the column is 12 mm
es: { glyph: '«', size: 28, column: 22.5 } });
// The paddings are optical: once the next paragraph snaps to the grid, the quote has
// the same air above and below it, in both languages.
const quote = { id: 'pullquote', backgroundEnabled: false, marginTop: pt(LEAD),
marginBottom: pt(0), padding: { top: pt(8), right: pt(0), bottom: pt(7),
left: pt(-MARK.column) }, // negative: the icon column starts out in the margin
icon: { kind: 'glyph', glyph: MARK.glyph, fontFamily: 'Instrument Serif',
size: pt(MARK.size), color: col('lake') },
titleStyle: { gap: pt(MARK.column - MARK.size) }, // negative too: size + gap = column
body: { fontFamily: 'Instrument Serif', fontSize: pt(19), lineHeight: pt(1.5 * LEAD),
textAlign: 'left', hyphenation: false, color: col('lake'), // display type: no hyphens
italicColor: col('lake'), firstLineIndent: pt(0) } };
Un’icona glifo occupa una colonna propria accanto al testo, il che farebbe rientrare la citazione. Lo spazio negativo fa scivolare di nuovo il testo sopra il lato destro vuoto del quadrato del glifo. Un padding sinistro negativo largo quanto ciò che resta di quella colonna (dimensione + spazio, 34 pt) sposta poi la colonna fuori nel margine, e il testo ricomincia dal bordo della colonna. La cornice del riquadro resta sulla colonna, quindi uno sfondo si fermerebbe prima del segno. Il quadrato comincia 12 mm fuori, e le virgolette, centrate al suo interno, sporgono di circa 6 mm nel margine esterno di 14 mm della pagina verso. L’edizione spagnola compone il suo « più piccolo, perché un caporale sta più in basso ed è più largo di una virgoletta inglese.
#4 · Fai flottare i riquadri in testa a una colonna e al piede della pagina
// 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 of one space, :chip[ ]{style="end"}: an empty box. 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, like the text it closes, in the story and in the guide.
const endMark = { id: 'end', background: col('ink'), borderWidth: pt(0), borderRadius: pt(0),
fontSize: em(0.62), paddingX: em(0.525), paddingY: em(0), gap: em(0.8) }; // 1.05 em square
Con placement: 'top' il riquadro di dati flotta: esce dal flusso e prende la prima testa di colonna libera, così il testo prosegue fino al piede della colonna invece di lasciare un buco dove il riquadro non entrava. Il pannello delle cifre flotta nell’altra direzione, al piede della pagina, e occupa entrambe le colonne; le sue tre cifre sono titoli di livello 4, un livello che l’articolo non usa mai, collocati da breaks="3,5", che conta i blocchi figli, non le righe. Il segno finale è un chip senza testo visibile. Il suo paddingX dà al quadrato la larghezza e il corpo del chip gli dà l’altezza, entrambe di 1,05 em al corpo del chip stesso.
#5 · Testatine che nominano il numero e l’articolo
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
Gli elementi dell’intestazione si dipingono sopra la pagina e non riservano spazio, quindi ogni pezzo è ancorato alla pagina nel margine superiore con scostamenti in millimetri. La pagina verso nomina la rivista e il numero dal frontmatter, il recto il titolo del servizio, e pages: 'body' li tiene entrambi fuori dalle aperture, che portano solo un folio, al piede. La linea di base di un testo grafico sta a 0,8 dell’altezza del suo riquadro di riga, che è 1,2 volte il suo corpo, quindi baseline() abbassa l’etichetta da 7,5 pt di 0,34 mm rispetto al folio da 8,5 pt e mette il quadrato sulla stessa riga. Se cambi uno dei due corpi, i tre pezzi condividono comunque una linea di base.
#6 · Riusa l’apertura per il pezzo successivo
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, the boxes and the chips.
const guide = { id: 'guide', advancedDesign: opener('ice-art', ART),
palette: { lake: palette.rust } };
L’apertura è una funzione, quindi la guida sul campo riceve lo stesso progetto grafico con la propria immagine e la propria profondità tramite uno stile di titolo, # Five Kinds of Ice {style="guide" kicker="Field guide" …}. Il disegno scende al vivo per 126 mm invece di 160; il credito e l’occhiello si misurano da depth - TOP e le altre parole sono appese all’occhiello, quindi salgono tutti di 34 mm e il testo della guida comincia più in alto. La tavolozza dello stile mappa lake sul ruggine in quelle pagine, così l’occhiello, e ogni titoletto o riquadro collegato a lake, diventa ruggine senza passare un colore a opener(); anche un chip collegato a lake diventerebbe ruggine, e il segno finale è nero in entrambi i pezzi.
La ricetta 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.25.0 // 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 { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @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', box: air, // wraps: a heading design lineHeight: 0.94, // tight: the two lines of the headline nearly touch 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', 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 of one space, :chip[ ]{style="end"}: an empty box. 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, like the text it closes, in the story and in the guide. 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, the boxes and the chips. const guide = { id: 'guide', advancedDesign: opener('ice-art', ART), palette: { lake: palette.rust } }; // #endregion const config = () => ({ locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns 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.78, // a space never shrinks below 78 % (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'), levels: [ // '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`---Esempio Markdown · 107 righe · content.en.md
title: "Boreal" subtitle: "Winter 2026" --- # The Lake That Keeps Time {kicker="Climate · Field report" standfirst="For a hundred and fifteen winters, one family has written down the day their lake froze and the day it let go. Their notebooks are now one of the longest climate records in the mountains." byline="Words by Ingrid Solberg" credit="Late October, before the freeze · Photograph: Ales Krivec, CC0"} The notebook lives in a biscuit tin on the kitchen dresser, between the box of matches and the parish calendar. It is the fourth of its kind. The first three are in the regional archive now, wrapped in acid-free paper, but Hanna Brenner still prefers the tin. “The archive asked for this one as well,” she says, lifting the lid. “I told them we are still using it.” Her great-grandfather ran the ferry across the lake from 1906, and he began the record for a practical reason: a man who rows passengers over water needs to know when the water will carry a sledge instead. On the ninth of December 1911 he wrote, in pencil, *Frozen. All of it.* On the fourth of April he noted that the ice had gone out overnight, with a noise “like a door slammed somewhere in the mountains”. Every winter since, someone in the family has kept up the record, one line in pencil for every year. One hundred and fifteen lines in pencil do not look like much. Printed out, they fit on two sheets of paper. But the nearest weather station, in the next valley, opened only in 1931, and few lakes anywhere have an ice record as long as this one. The oldest, on Lake Suwa in Japan, was begun by Shinto priests in 1443 and notes the day a ridge of ice forms across the lake. ## A thermometer with a lid “A lake is a thermometer that you read once a year,” says Vera Lind, a limnologist who has spent six winters on the ice here. “The air changes from hour to hour. The lake averages all of that. When it freezes and when it thaws tells you what the whole season was like.” The reason lies in an odd habit of water. Most liquids grow denser as they cool. Fresh water does too, but only down to about 4 °C; below that it becomes lighter again, which is why a lake freezes from the top down; ice, lighter still, floats on it. In autumn, as the surface chills, the cold water sinks and warmer water rises to take its place. The lake turns over, again and again, for weeks. Only when the whole column has reached 4 °C can the surface cool further without sinking, and only then, on a still, clear night, can it skin over. :::callout{type="pullquote"} *A person stood on the same jetty and looked at the same water.* ::: That is why the freeze comes late and all at once. Hanna remembers standing on the jetty as a girl and watching the last open water close in the middle of the lake “like a pupil shrinking in the light”. By morning her father was walking out with an axe to measure the thickness. Ten centimetres will hold a person, the family rule went; twenty will hold the sledge; thirty, a horse. ## What the ice is made of The first ice to form is black ice, grown straight down from the water beneath it and so clear that you can see stones on the bottom through a hand’s width of it. It is the strongest ice a lake makes. Snow brings a second kind. A heavy fall presses the sheet down until water seeps up through the cracks and soaks the snow into slush, which freezes into white ice, cloudy with trapped air and barely half as strong. Lind drills through both every week of the winter. The cores come up banded like a tree trunk, and she reads them the same way: a thick black layer means a cold, dry December; a stack of white bands means storm after storm. Under the ice the lake goes on living. The water at the bottom stays close to 4 °C all winter. The trout slow down but keep feeding. Light still passes through clear ice, and algae grow in the green gloom beneath it, which surprised the first scientists who went looking. :::callout{type="glance" title="At a glance"} **Altitude** 1,540 m above sea level **Area** 3.2 km^2^, deepest point 63 m **Record kept since** the winter of 1911–12 **Ice cover, 1911–1960** 118 days a year on average **Ice cover, 1991–2025** 87 days a year on average **Winters with no ice** 2007 and 2020 ::: ## Counting the days For the first fifty years of the record, the lake stayed frozen for an average of 118 days a winter. Since 1991 the average has been 87. The freeze now comes about a fortnight later than it did in the ferryman’s time, and the ice goes out more than two weeks earlier. The change has not been smooth. Some winters of the 1960s, and again the winter of 2010, were as long as any in the notebooks. But the open winters are new. In 2007 and again in 2020 the lake never froze across at all, and the family wrote a single word for the year: *open*. The winter of 1944 nearly went unrecorded. The ferryman’s son was away at the war, and the dates for that year are in another hand, small and upright, with a note in the margin: *kept by his mother*. She had written them on the back of a ration card and copied them in when he came home. Lind has checked them against the weather station in the next valley. They are, she says, as good as the rest. ::resource{id="thaw"} Lind is careful about what one lake can tell us. A single record is a local story: the valley has its own winds, and the lake its own depth and shape. But the notebooks agree with hundreds of other lakes across the northern hemisphere, from Finland to Japan, where the ice seasons have shortened by weeks over the past century. “I trust it because the method never changed,” she says. “A person stood on the same jetty and looked at the same water.” She has added instruments of her own. A chain of temperature loggers hangs from a buoy over the deepest part of the lake, reading every fifteen minutes from the surface to the floor, and a camera on the church tower photographs the ice at noon each day. But when she sets them beside the notebooks, the dates hold up: the family has never been more than a day or two from what the loggers record. ## Ice-out The ice rarely leaves quietly. Through March the sun works on it from above and warmer streams from below, and the black ice rots into long vertical crystals, candle ice, that chime against each other when the wind moves them. Then a warm rain or a south wind breaks the sheet, and within a day or two the surface is open. The old fishermen claimed they could hear it happen from the village. The date matters to far more than the ferry. The spring bloom of algae, which feeds all the other life in the lake, starts when the ice breaks up and sunlight pours into the water. Perch spawn in the shallows and midges hatch on a timetable that the ice sets. When ice-out moves earlier, those timetables can drift apart, and a young fish may hatch into water whose food has already come and gone. :::callout{type="numbers" title="In numbers"} :::columns{count=3 breaks="3,5"} #### 31 fewer days of ice each winter than in the first fifty years of the notebooks #### 115 winters recorded, in pencil, by five generations of one family #### 4 °C the temperature of the lake floor all winter, where water is densest ::: ::: There are human timetables too. The winter road across the lake, which once carried hay, timber and the doctor’s sleigh, has not been opened officially since 2014. The skating club moved its races to an artificial rink in the town. The ice fishermen still go out, but later in the season, and they carry ropes. Hanna’s grandfather remembered the other extreme. In the hard winter of 1963 the ice grew to sixty centimetres, and a baker from the next village drove his van straight across the lake to save the long road round. The entry for that year has a small drawing of the van in the margin, the only picture in all four notebooks. Hanna is not sentimental about any of this. She teaches mathematics at the valley school, and she has turned the notebooks into a lesson: every class plots the two dates of each winter and draws a line through the dots. “The children always find the trend by themselves,” she says. “I hand out graph paper and a ruler and say nothing about climate.” Some of her pupils have gone further. Two years ago a class set the notebooks beside the school’s own record of when the cherry trees in the yard came into flower, and found that both dates had moved by about the same number of days. Lind now shows their chart at conferences, with the children’s names in the corner. The record will go on. Hanna’s son, who is fourteen, has taken over the November walks to the jetty to watch for the morning when the last dark patch of water disappears. He keeps a spreadsheet now, and every night a copy goes to a server at the university, where Lind stores her logger readings. But on the day the lake freezes he does what his great-great-grandfather did in 1911 and writes the date in pencil, in the notebook in the tin. Last winter the lake froze on the twenty-first of December and opened again on the eighteenth of March: eighty-seven days, almost exactly the modern average. Hanna wrote *ordinary* beside the dates, then crossed the word out. “Ordinary for now,” she says. :chip[ ]{style="end"} # Five Kinds of Ice {style="guide" kicker="Field guide" standfirst="How to read a frozen lake before you trust it with your weight, from the clear black sheet of early winter to the rotten candles of March." byline="Text by the editors" credit="Illustration generated in code for Boreal"} **Black ice.** The first ice of the season grows straight down from calm water in long crystals that let the light through. It looks dark because you are seeing the lake beneath it. Ten centimetres of new black ice will bear a walker, and on a calm morning you can watch fish pass under your boots. **White ice.** When snow loads the sheet, water seeps up through the cracks, soaks the snow and freezes into a milky layer full of air. It is roughly half as strong as black ice, so count it at half its thickness when you judge a crossing. On a sunny afternoon it softens further. **Slush.** A heavy snowfall can push the ice below the waterline and leave a layer of wet snow on top, kept liquid under an insulating crust. It is heavy going on foot and treacherous on skis, and it is where most white ice begins. Grey patches on fresh snow are its warning sign. **Shore ice.** The ice along the edge is the first to form and the first to go. Springs, reeds and the warmth of the ground weaken it, and by March a strip of open water, the moat, often separates the sheet from the land. You can walk out on good ice and find that you cannot walk back. **Candle ice.** In spring the sun rots the black ice along the seams of its crystals. The sheet can still look solid while it has become a bundle of loose vertical rods that give way under a boot. When the surface turns grey and granular, and the rods chime in the wind, stay on the shore. Never judge ice by its colour alone. Measure it every few steps, carry a pair of ice picks round your neck, and ask the people who live by the shore. :chip[ ]{style="end"} :::paragraphs{style="colophon"} Set in Literata, Instrument Serif and Instrument Sans (SIL Open Font License) · Text: Postext Cookbook, CC BY 4.0 · Photographs: Ales Krivec and Hannah Donze, CC0, via Wikimedia Commons · The lake, the family, the scientists and the writer are fictional. :::`; // 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 Literata: ['400', '400i', '700'], 'Instrument Serif': ['400', '400i'], 'Instrument Sans': ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── 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 buildDocumentWithFonts({ markdown, resources, continuation }, config(), kitFonts(FONTS)); 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: uguale in ogni ricetta · 270 righe
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Apri ogni pezzo su un recto
Con 'odd', la guida sul campo passa a pagina 61, e la pagina 60 resta bianca.
- { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
+ { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' },#Metti il titolo su una fascia di colore
Per il capitolo di un manuale, dove una fascia di colore e un grande numero sostituiscono la fotografia, vedi Apertura di capitolo su una fascia al vivo.
Errori frequenti
Errore frequente
Valori degli attributi: niente { o }; virgolette semplici per un valore con "
Il valore di un attributo finisce alla graffa di chiusura, quindi non può contenere { o }. Un valore che contiene una virgoletta doppia va tra virgolette semplici; il simbolo del dollaro va bene. Attributi dei titoli →
Errore frequente
Le bitmap prendono la dimensione di stampa da una risoluzione: dichiarala, non pixel alla dimensione di stampa
Una bitmap senza risoluzione viene impaginata a un pixel per pixel di impaginazione ai dpi del documento, quindi una foto di 2400 px è larga 406 mm su una pagina a 150 dpi. Dichiara i pixel reali del file e dai all’immagine la sua risoluzione (bitmap.resolution: 300), o imposta layout.bitmapResolution per ogni bitmap: un valore in ppi, o 'file' per leggere quello indicato dal file. La colonna limita comunque l’immagine. Figure e tabelle come risorse →
Errore frequente
Un elemento flottante 'top' va alla pagina dopo la sua citazione, a meno che non possa aprire la pagina che lo cita
Per impostazione predefinita un elemento flottante non va mai sopra il proprio riferimento, quindi un flottante 'top' a tutta larghezza citato a pagina N apre la pagina N+1. Imposta placement.citingPage: true sulla risorsa o sul defaultPlacement del suo tipo (o layout.floatsAtCitingPage per tutto il libro, da postext 1.25) e aprirà invece la pagina N, con il testo sopra il riferimento che scende sotto di esso, quando entra entro layout.maxTopFraction e la riga che lo cita resta su quella pagina. Non apre mai la prima pagina di un capitolo né una pagina dopo un’interruzione forzata: lì apre ancora la pagina successiva, e 'auto' o 'bottom' possono prendere il piede della pagina che lo cita. Collocazione delle figure →
Errore frequente
Gli elementi di intestazione e piè di pagina dipingono sopra il testo
Gli elementi di intestazione e piè di pagina vengono dipinti sopra la pagina e l’area del testo non fa loro spazio. Tienili entro i margini, che sono ciò che ne riserva lo spazio. Testatine e folio →
Verifica della Sandbox · bitmapTooSmall
Immagine a bassa risoluzione
Perché. Una bitmap è disegnata più di 1,5 volte più larga dei suoi pixel, quindi in stampa apparirà sfocata.
Soluzione. Fornisci circa 300 dpi alla dimensione di stampa, e dichiara la larghezza e l’altezza reali della bitmap. Documentazione →
- Un elemento immagine adatta l’immagine dentro il riquadro e non la ritaglia mai. Ritaglia il JPEG nelle proporzioni del riquadro, qui 225 × 160 mm, altrimenti la fotografia si rimpicciolisce e lascia strisce bianche ai bordi della pagina.
- Le virgolette sporgenti hanno bisogno di un margine. Se una modifica sposta la citazione in evidenza in una colonna di destra, il segno riempie l’intercolonna di 6 mm e tocca il testo della colonna di sinistra: sposta la citazione un paragrafo prima o dopo.
- L’articolo è adattato per finire a pagina 59, sopra il pannello delle cifre. Qualche riga in più manda la sua coda su una pagina a sé, quindi dopo una modifica adatta di nuovo il testo in entrambe le lingue.
runtMinCharacters: 40impedisce le ultime righe di una sola parola. Quando la divisione in righe non può evitare un’ultima riga troppo corta, il motore compone il paragrafo con una riga in meno, con spazi tra le parole più stretti e, se non basta, fino a 0,01 em in meno tra le lettere. Dopo una modifica, cerca un paragrafo composto così stretto e togli o aggiungi invece qualche parola; il testo pubblicato non ha bisogno della correzione in nessuna delle due lingue.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Immagini
- 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
- Font
- Literata (SIL OFL 1.1) · Instrument Serif (SIL OFL 1.1) · Instrument Sans (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


