En bref
La couverture et les premières pages d’un magazine de cuisine et d’agriculture, avec une couverture photo et une page de sommaire. On y voit comment donner à chaque rubrique sa couleur, qui la suit dans ses pages.
Ce que vous allez composer
Six pages d’un numéro de FALLOW, un trimestriel imaginaire consacré à la cuisine et à la terre, sur une page de 230 × 300 mm. Une vallée toscane s’étend à fond perdu sur la couverture sous un titre en Bodoni de 118 pt, et trois accroches reposent sur un panneau couleur papier, chaque surtitre dans la couleur de sa rubrique, avec le prix et le code-barres dans le coin. La page 2 porte le sommaire généré : un onglet coloré par rubrique, avec en dessous le titre, le numéro de page et le chapeau de chaque article. La colonne de droite reçoit l’édito, les contributeurs et l’ours. Les rubriques qui suivent sont terre cuite, olive et lie-de-vin. Chaque article s’ouvre sur une image à fond perdu en tête de page, dans laquelle monte un panneau de la couleur de la rubrique, et ses onglets de folio, ses puces et ses encadrés prennent cette couleur. La couleur vient du bloc :::part placé au-dessus de chaque article, qui n’ouvre aucune page de partie.
Cette recette répond à
- Comment faire une couverture, un faux-titre, une page de titre et un colophon ?
- Comment ajouter une table des matières qui se met à jour seule (points de conduite, numéros de page, auteurs, lignes de partie) ?
- Comment diviser un livre en parties ou en sections, chacune avec sa couleur et sa page de partie ?
- Comment donner à chaque chapitre sa propre photo, sa propre couleur ou sa propre variante d’ouverture ?
La réponse courte
const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block
const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph
const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows
// A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one
// width. Chained with 'below' they read as one panel, and a longer line pushes the rest down.
const sheet = (top, bottom) => ({ backgroundColor: col('paper'),
padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } });
const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') };
const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [
text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour),
box: sheet(n === 1 ? 5 : 4, 1.5) },
{ ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }),
text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15),
lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel
{ ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }),
];
const cover = { enabled: true, slot: { elements: [
{ kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto
text('issue', '{attr.issue}', { ...small, align: 'right' },
at('page', 'top-right', -MARGIN.outer, 12)),
text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118),
lineHeight: 0.86, // set tight: the masthead is capitals, with no descenders
letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') },
{ ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }),
...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'),
// The price and a made-up barcode sit in the corner, anchored to the page, not the words.
text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) },
{ ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }),
{ kind: 'image', id: 'barcode', resourceId: 'barcode',
placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } },
] } };
// hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
// footer: { elements: [] } }, with no folio; the Markdown's first line is
// # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"}
Ingrédients
- Fonctionnalités
- Ancrer les éléments de maquetteTable des matièresCouleurs par partieTitres courants par sectionOuvertures composéesStyles de titreChapitres non numérotésImages dans les maquettes de pageTexte, filets et boîtes dans les maquettes de pageAttributs de titrePalette de couleurs sémantiqueBandeau de chapitre sur toute la largeurPlacement des figuresEncadrés flottantsEncadrésStyles de paragrapheTypes de ressource personnalisésFigures et tableaux comme ressourcesPages sur un canvas
- Utilise aussi
- Des figures exactement iciCouleur du papierSauts de page et de colonnePages de partieEspace vertical explicite
- Typographie
- Spectral, Bodoni Moda, Jost (SIL OFL 1.1)
- Ressources
castellina-cover-1150.jpgcastellina-farm-1530.jpgfields-1610.jpgflasks-1920.jpgharvest-1840.jpg- Castellina in Chianti (the cover and its copy in the contents) (Rowan Heuvel, CC0 1.0)
- Castellina in Chianti, a detail (the feature's photograph) (Rowan Heuvel, CC0 1.0)
- Plants in beakers (the Field notes opener) (chuttersnap, CC0 1.0)
- Harvesting the wheat crop (the Kitchen opener) (meriç tuna, CC0 1.0)
- Fields from above near Castellina in Chianti (the feature's opener) (Generated With Diffusion Models, original)
- The cover's barcode, drawn in code (Ignacio Ferro, MIT)
Préparation
#1 · Suspendre la couverture à son titre
Le code se trouve dans la réponse courte plus haut. Seuls la photographie, les deux lignes de tête et le prix sont ancrés à la page. Tout le reste est ancré à un autre élément (placement des éléments) : le titre sous la ligne de tête, le premier surtitre 8 mm sous le titre, puis chaque ligne sous son surtitre et chaque surtitre sous la ligne précédente ; une accroche plus longue ou un titre plus grand repousse donc vers le bas ce qui se trouve dessous au lieu de s’imprimer par-dessus. Chaque surtitre et chaque ligne porte sa propre boîte couleur papier de 84 mm de large, et les six boîtes empilées forment le panneau, qui grandit avec le texte. Chaque boîte après la première chevauche celle du dessus de 0,3 mm (SEAM), et le code-barres chevauche le prix de la même façon, car sur le canvas deux aplats qui se touchent à peine laissent entre eux un filet de photographie. Un magazine n’a ni faux-titre ni page de titre : la couverture porte son nom, et la page de sommaire porte le numéro, la saison et l’ours. Pour les pages liminaires d’un livre, voir Pages liminaires en folios romains, puis page 1.
#2 · Faire de chaque rubrique une partie sans page
const parts = { page: false }; // a :::part now only sets the section's title and palette
const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) });
const headingStyles = () => [
{ id: 'cover', numbered: false, toc: false, advancedDesign: cover,
footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere)
{ id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener,
footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead
// One opener for every story; the style names its picture, the part its colour.
story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'),
];
Avec parts.page: false, un bloc comme :::part{title="Features" palette="band=#5d6a2b"} n’ouvre aucune page de partie et se contente de fixer le titre et la palette de la rubrique à partir du bloc suivant (le conteneur :::part). Le H1 de chaque article prend un style de titre avec numbered: false, si bien que le sommaire le liste sans numéro. Le style choisit l’image d’ouverture, et la partie fournit la couleur.
#3 · Relier chaque couleur de rubrique à une seule entrée
const palette = {
ink: '#1c1a16', // text: a warm near-black
paper: '#fbf8f1', // the page, and type set on colour
band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band'
muted: '#6b6358', // credits and the standfirsts in the contents
olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both
};
// A part overrides by id: col() writes the id beside the hex, and the designs follow it.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
{ id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
Une partie remplace les entrées de la palette par leur id : tout ce qui change avec la rubrique se relie donc à band : le panneau d’ouverture, l’onglet de folio, les puces et les numéros de liste, la citation en exergue, ainsi que le filet et le titre de l’encadré. Dans le texte, le remplacement se fait par valeur : toute couleur dont l’hexadécimal égale celui de base de band (#8b6a3e) prend celle de la rubrique, et c’est pourquoi aucune autre entrée n’utilise cet hexadécimal. Les éléments de maquette fonctionnent à l’inverse : le remplacement d’une partie les atteint par le paletteId, et col() écrit donc l’id à côté de l’hexadécimal.
#4 · Laisser le sommaire se regrouper tout seul
const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title
const contents = { // passed to the config as `toc`
levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600,
marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading
pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni
leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic
fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') },
// A part row is a design as wide as the column; its 'band' takes the part's palette. No
// {pageNumber} in it: each story under the tab prints its own page.
parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (the default: 2 em)
{ kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab
placement: { ...mid, size: { width: 'fill' } } },
text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
color: col('paper'), box: { backgroundColor: col('band'),
padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } },
mid),
] } },
};
// The colophon closes the contents page; the Markdown calls it with :::paragraphs.
const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10),
color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) };
const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower
const contentsOpener = { enabled: true, slot: { elements: [
{ kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'),
placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
color: col('band') }, at('#rule', 'below', 0, 5)),
text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05,
color: col('ink') }, at('#kicker', 'below', 0, 2)),
text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11),
color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)),
text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1,
align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)),
] } };
:::toc liste les articles et donne à chaque :::part une ligne mise en page d’après toc.parts.design avec la palette de cette partie (table des matières) : un seul dessin de ligne imprime ainsi trois onglets de trois couleurs. Le filet et l’onglet sont tous deux suspendus à mid, le milieu de la ligne relevé de 1 mm, ce qui écarte l’onglet du titre en dessous. Sans points de conduite, rien ne relie un titre à son numéro de page (3, 4 et 6), et subtitle.attr imprime l’attribut standfirst de l’ouverture sous le titre, en italique. L’ours qui termine la colonne de droite est un style de paragraphe, Jost 7 pt en muted, que le Markdown applique avec :::paragraphs{style="colophon"}.
#5 · Donner à chaque article la même ouverture
const PHOTO = 150; // mm from the top edge to the foot of the picture
const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin
const LIFT = 34; // mm: how far the panel rises into the picture
const WIDE = 136; // mm: the panel's width
const panel = (top, bottom) => ({ backgroundColor: col('band'),
padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } });
const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } });
const opener = (resourceId) => ({ enabled: true, slot: { elements: [
{ kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'),
letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)),
text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7),
color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT),
size: { width: mm(WIDE) } }),
text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02,
color: col('paper'), box: panel(0, 3) }, below('section')),
text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true,
fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left',
color: col('paper'), box: panel(0, 6) }, below('title')),
// The panel and the byline reach below the picture's foot, so they set where the story
// starts; the byline's empty padding keeps the first line 5 mm under it.
text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3),
box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')),
] } });
L’image part à fond perdu du haut de la page et descend sur 150 mm. Un panneau en band monte de 34 mm dans son pied. Le panneau est fait de trois boîtes de texte de 136 mm de large, chacune enchaînée below la précédente, qui portent le nom de la rubrique tiré de {partTitle}, le titre et le chapeau. Le panneau et la signature descendent sous le pied de l’image : l’article commence donc sous l’élément le plus bas, la signature, dont les 5 mm de marge intérieure basse, vides, maintiennent la première ligne au moins à cette distance.
#6 · Nommer la rubrique dans le folio
const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it
const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]]
.flatMap(([parity, side, s]) => {
const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter
const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side },
{ ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically
return [
text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'),
align: 'center', box: { backgroundColor: col('band') } },
{ ...at('page', `bottom-${side}`, s * MARGIN.outer, -11),
size: { width: mm(TAB), height: mm(TAB) } }),
beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3),
beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true,
fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5),
].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too
}) });
Le numéro de page se loge dans un carré de 6,5 mm en band, au pied extérieur. Le nom de la rubrique est ancré à côté, vers le petit fond, et le titre de l’article à côté du nom ; les trois boîtes mesurent 6,5 mm de haut avec leur texte centré verticalement, et partagent donc la même ligne médiane. Les magazines impriment aussi le folio sur une page d’ouverture : le pied de page n’a donc pas de filtre pages. Le style de titre de la couverture vide le pied de page, et celui du sommaire met le nom du magazine et la saison du numéro ({title} et {publishDate}) à la place de la rubrique et de l’article.
La recette complète
// ═══ Postext Cookbook · Nº 023 · Magazine cover and sectioned contents ═══════════════ // https://postext.dev/en/cookbook/magazine-cover-and-contents // Code: MIT · Text: original (CC BY 4.0) · Photos: R. Heuvel, chuttersnap, m. tuna (CC0), diffusion // Fonts: Spectral, Bodoni Moda, Jost (SIL OFL 1.1) · Needs postext ≥ 1.25.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'magazine-cover-and-contents'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one house colour, 'band', that every section overrides const palette = { ink: '#1c1a16', // text: a warm near-black paper: '#fbf8f1', // the page, and type set on colour band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band' muted: '#6b6358', // credits and the standfirsts in the contents olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both }; // A part overrides by id: col() writes the id beside the hex, and the designs follow it. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion const TRIM = { width: 230, height: 300 }; // an independent-magazine trim, in mm const MARGIN = { top: 22, bottom: 20, inner: 16, outer: 14 }; // mirrored const LEAD = 13.5; // body leading in pt: the baseline grid const caps = { fontFamily: 'Jost', fontWeight: 600, textTransform: 'uppercase' }; // the label face const label = { ...caps, align: 'left' }; // design text is centred by default const bodoni = { fontFamily: 'Bodoni Moda', overflow: 'wrap', align: 'left' }; const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const text = (id, content, look, placement) => ({ kind: 'text', id, content, placement, ...look }); // #region answer: the cover, one design slot whose words hang from each other const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows // A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one // width. Chained with 'below' they read as one panel, and a longer line pushes the rest down. const sheet = (top, bottom) => ({ backgroundColor: col('paper'), padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } }); const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') }; const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [ text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour), box: sheet(n === 1 ? 5 : 4, 1.5) }, { ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }), text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15), lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel { ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }), ]; const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30 placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } }, text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto text('issue', '{attr.issue}', { ...small, align: 'right' }, at('page', 'top-right', -MARGIN.outer, 12)), text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118), lineHeight: 0.86, // set tight: the masthead is capitals, with no descenders letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') }, { ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }), ...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'), // The price and a made-up barcode sit in the corner, anchored to the page, not the words. text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) }, { ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }), { kind: 'image', id: 'barcode', resourceId: 'barcode', placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } }, ] } }; // hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover, // footer: { elements: [] } }, with no folio; the Markdown's first line is // # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"} // #endregion // #region sections: parts with no divider page, and a heading style for each kind of page const parts = { page: false }; // a :::part now only sets the section's title and palette const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) }); const headingStyles = () => [ { id: 'cover', numbered: false, toc: false, advancedDesign: cover, footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere) { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener, footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead // One opener for every story; the style names its picture, the part its colour. story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'), ]; // #endregion // #region contents: generated from the headings, with a row in each section's colour const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title const contents = { // passed to the config as `toc` levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600, marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') }, // A part row is a design as wide as the column; its 'band' takes the part's palette. No // {pageNumber} in it: each story under the tab prints its own page. parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (the default: 2 em) { kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab placement: { ...mid, size: { width: 'fill' } } }, text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('paper'), box: { backgroundColor: col('band'), padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } }, mid), ] } }, }; // The colophon closes the contents page; the Markdown calls it with :::paragraphs. const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }; const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower const contentsOpener = { enabled: true, slot: { elements: [ { kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'), placement: { ...at('container', 'top-left'), size: { width: 'fill' } } }, text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8), color: col('band') }, at('#rule', 'below', 0, 5)), text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05, color: col('ink') }, at('#kicker', 'below', 0, 2)), text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11), color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)), text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1, align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)), ] } }; // #endregion // #region opener: one story opener; its colour comes from the part, not from the design const PHOTO = 150; // mm from the top edge to the foot of the picture const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin const LIFT = 34; // mm: how far the panel rises into the picture const WIDE = 136; // mm: the panel's width const panel = (top, bottom) => ({ backgroundColor: col('band'), padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } }); const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } }); const opener = (resourceId) => ({ enabled: true, slot: { elements: [ { kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150 placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } }, text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'), letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)), text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7), color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT), size: { width: mm(WIDE) } }), text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02, color: col('paper'), box: panel(0, 3) }, below('section')), text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left', color: col('paper'), box: panel(0, 6) }, below('title')), // The panel and the byline reach below the picture's foot, so they set where the story // starts; the byline's empty padding keeps the first line 5 mm under it. text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3), box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')), ] } }); // #endregion // #region folios: a folio tab in the section's colour, then the section and the story const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]] .flatMap(([parity, side, s]) => { const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side }, { ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically return [ text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } }, { ...at('page', `bottom-${side}`, s * MARGIN.outer, -11), size: { width: mm(TAB), height: mm(TAB) } }), beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3), beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true, fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5), ].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too }) }); // #endregion const flush = { marginTop: pt(0), marginBottom: pt(0) }; // lists sit on the grid const marker = { fontFamily: 'Jost', fontWeight: 600, color: col('band'), ...flush }; // list marks const boxText = { textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) }; const boxStyle = { backgroundEnabled: false, ...flush, // one device: a stripe in 'band' stripe: { enabled: true, side: 'top', width: pt(2), color: col('band') }, padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) } }; const config = () => ({ locale: t({ en: 'en-us', es: 'es' }), // each edition's hyphenation patterns colorPalette, resourceTypes: [photoType], page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner layout: { gutterWidth: mm(6) }, // two columns, the default bodyText: { fontFamily: 'Spectral', fontSize: pt(9.5), lineHeight: pt(LEAD), // justified color: col('ink'), boldColor: col('band'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(3.5), indentAfterHeading: false, minWordSpacing: 0.7, maxWordSpacing: 1.7, // tighter than the 0.6–2 defaults runtMinCharacters: 40 }, // counted in word spaces: last lines under about 20 letters cost headings: { fontFamily: 'Bodoni Moda', marginBottom: pt(0), levels: [ // 'any': a story opens on the very next page, left or right. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), fontWeight: 400, italic: true, color: col('band'), marginTop: pt(LEAD) }, ] }, headingStyles: headingStyles(), parts, toc: contents, unorderedLists: { ...marker, bulletChar: '–' }, orderedLists: marker, // in the label face // The quote keeps a line above and a line under it; the floated box has neither. calloutStyles: [{ id: 'quote', ...boxStyle, marginTop: pt(LEAD), marginBottom: pt(LEAD), body: { fontFamily: 'Bodoni Moda', fontSize: pt(16), lineHeight: pt(1.5 * LEAD), italicColor: col('band'), ...boxText } }, { id: 'facts', ...boxStyle, placement: 'top', // floats to the next column head titleStyle: { ...caps, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('band') }, body: { fontFamily: 'Jost', fontSize: pt(8.5), lineHeight: pt(12), ...boxText } }], captionStyle: { fontFamily: 'Jost', fontSize: pt(7.5), gap: mm(2), note: { fontSize: pt(6.5), color: col('muted') } }, paragraphStyles: [{ id: 'bios', firstLineIndent: pt(0), spaceBetween: pt(LEAD / 2) }, colophon], header: { elements: [] }, footer: folioLine('{partTitle}', '{chapterTitle}'), // at the foot }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const photoType = { id: 'photo', // no figure number: an empty caption prefix and template name: t({ en: 'Photograph', es: 'Fotografía' }), shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const picture = (id, fileId, [width, height], alt, extra = {}) => { // alt: { en, es } const kind = fileId.endsWith('.svg') ? 'svg' : 'bitmap'; return { id, kind, typeId: 'photo', createdAt: 0, updatedAt: 0, altText: t(alt), ...extra, [kind]: { fileId, width, height, ...(kind === 'bitmap' && { format: 'jpeg' }) } }; }; const resources = [ // alt texts and captions in both languages; t() picks the edition's picture('cover', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'Vines, cypresses and a ' + 'farmhouse below hazy hills.', es: 'Viñas, cipreses y una casa bajo colinas brumosas.' }), picture('thumb', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'The cover of this issue.', es: 'La portada de este número.' }, { placement: { position: 'here', width: 0.45 }, caption: t({ en: 'On the cover: the hills below Castellina in Chianti, where our feature ' + 'is set.', es: 'En la portada: las colinas de Castellina in Chianti, escenario del ' + 'reportaje.' }), note: t({ en: 'Photograph: Rowan Heuvel, CC0', es: 'Fotografía: Rowan Heuvel, CC0' }) }), picture('flasks', 'flasks-1920.jpg', [1920, 1252], { en: 'Glass flasks holding green shoots ' + 'on a shelf.', es: 'Matraces de vidrio con brotes verdes en una balda.' }), picture('fields', 'fields-1610.jpg', [1610, 1050], { en: 'Fields from above, one ' + 'unsown, and a farmhouse.', es: 'Campos desde arriba, uno sin sembrar, y una casa.' }), picture('harvest', 'harvest-1840.jpg', [1840, 1200], { en: 'Ripe wheat, a combine harvester ' + 'blurred behind it.', es: 'Trigo maduro y, desenfocada detrás, una cosechadora.' }), picture('barcode', 'barcode.svg', [320, 140], { en: 'A barcode.', es: 'Un código de barras.' }), // 1530 px at 150 dpi would print 259 mm: it shrinks to the measure (gotcha: bitmap-print-size). // A top float opens the page after its ::resource line (gotcha: top-float-next-page). picture('vines', 'castellina-farm-1530.jpg', [1530, 900], { en: 'Rows of vines, cypresses and ' + 'a farmhouse on a Tuscan slope.', es: 'Hileras de viñas, cipreses y una casa de campo en ' + 'una ladera toscana.' }, { placement: { position: 'top', span: 'page' }, caption: t({ en: 'Vines and cypresses by a farmhouse near Castellina in Chianti, in summer.', es: 'Viñas y cipreses junto a una casa de campo de Castellina in Chianti, en verano.' }), note: t({ en: 'Photograph: Rowan Heuvel, CC0, via Wikimedia Commons', es: 'Fotografía: Rowan Heuvel, CC0, vía Wikimedia Commons' }) }), ]; const markdown = String.raw`---Exemple Markdown · 132 lignes · content.en.md
title: "Fallow" subtitle: "A quarterly of food and land" publishDate: "Autumn 2026" --- # Fallow {style="cover" issue="No. 14 · Autumn 2026" price="£12 · No. 14" kicker1="Features" line1="The year of rest: on a Chianti farm, every field gets a year off" kicker2="Field notes" line2="The grain archive: two hundred old wheats, kept alive in a village school" kicker3="Kitchen" line3="Threshing-day bread, and a loaf for the last sheaf"} # Contents {style="contents" number="14" issue="Autumn 2026"} :::toc ::resource{id="thumb"} :::columnbreak ## From the editor A field left unsown for a year has an image problem. To a passing driver it looks abandoned; to an accountant it is a line with nothing in it. The farmers in this issue use another word for it. They say the field is resting, the word a baker uses for a dough left under a cloth to rise. Our feature follows one farm above Castellina in Chianti through its seven-year rotation, including the year in which a field grows only weeds and the farmer gets a letter from the council about the thistles. In Field notes, a seed library in a former village school keeps two hundred old wheats alive by sowing a few of them every autumn. And in the Kitchen, the loaf baked on the day the last sheaf comes in, with a little of that old grain in the dough. We called this magazine *Fallow* fourteen issues ago, half as a joke. A quarterly is slow to make, and there were seasons when the name read more like a warning. We kept it because the pieces we are proudest of took a year or more, and for most of that year nothing seemed to happen. *Clara Ashdown, editor* ## Contributors :::paragraphs{style="bios"} **Tomás Arrieta** writes about seeds and the people who save them. He lives in Poggibonsi, where he keeps bees, badly. **Lucia Fenn** grew up on a dairy farm in Somerset and has reported on Italian farming for twelve years. **Nell Harrow** bakes and teaches in Siena. Her book on country breads comes out next spring. ::: :::paragraphs{style="colophon"} FALLOW is an imaginary quarterly of food and land, made for the Postext Cookbook. Set in Spectral, Bodoni Moda and Jost (SIL Open Font License). Text: CC BY 4.0. Photographs: Rowan Heuvel, chuttersnap and meriç tuna, CC0, via Wikimedia Commons; the fields are a generated image and the barcode is drawn in code. ::: :::part{title="Field notes" palette="band=#a3472a"} ::: # The Grain Archive {style="grain" standfirst="In a former village school in the Val d’Elsa, a seed library keeps two hundred old wheats alive by sowing a few of them every autumn and putting the new grain back in the jars." byline="Words by Tomás Arrieta" credit="Photograph: chuttersnap, CC0"} The classroom still has its blackboard. On it, someone has chalked the week’s sowing list: Verna, Gentil Rosso, Frassineto and a wheat with no name at all, only a village and a year. Behind the desks, where the coats used to hang, stand the shelves: two hundred jars of grain, each sealed with wax and labelled by hand. The library began in 2009, when a retired teacher found a sack of seed in her late uncle’s barn and could not bring herself to feed it to the hens. Today it has forty members with borrowing cards, and a rule that whoever takes a jar in autumn brings back twice as much grain the following summer. On the Saturday we visited, the borrowers came in one by one with their sacks. A baker from Colle took two jars of Verna for a field he rents behind the bypass. A woman who grows beans for the market wanted a wheat short enough to stand up to the wind on her hilltop, and left with one from the Casentino that nobody had sown since 1987. Each loan went into a ledger in pencil: the jar, the name, the field, and a line left blank for the harvest. The rule is there because seed ages. In a cool, dry jar a wheat keeps its vigour for a few years; after that, fewer and fewer grains sprout, until one spring none do. National gene banks slow the clock by drying their seed and storing it at eighteen degrees below zero. A village library has no freezer that size, so it sows its wheats, a few rows at a time, and puts fresh seed back on the shelf. In the back room, under a bank of lamps, a row of glass flasks holds the hardest cases: grains so old that the librarians no longer trust them to a field. Most will not sprout. The few that grow are planted out in March in the test plot behind the school, where children from the new school down the road come in June to count the ears. Every jar has a card saying who grew the wheat, on which slope, and what it was good for. Some of the notes are older than the library. One, in a spidery hand on a ration book, says only: good for straw, poor for bread, Grandfather swore by it. Nobody here claims that the old wheats are better. They grow tall, so a storm flattens them, and they yield less than modern varieties. But some stand up to a dry spring or a late frost, and last year a plant breeder from Florence borrowed three of them for her drought trials. :::part{title="Features" palette="band=#5d6a2b"} ::: # The Year of Rest {style="rest" standfirst="On a farm above Castellina in Chianti, seven fields take turns at wheat, beans, sulla and barley. Every year one of them is left unsown, and the wheat that follows needs little fertiliser." byline="Words by Lucia Fenn" credit="Photograph: generated for FALLOW"} Marco Rinaldi keeps a map of his farm on the kitchen wall, drawn on the back of a feed merchant’s calendar. It shows seven fields, numbered in pencil, and beside each a column of crops running back to 1998, when he took over from his father. Read across, the columns make a staircase: wheat, field beans, wheat again, two years of sulla for the sheep, a year of barley, and then a year in which the square is left blank. “That one,” he says, tapping this year’s blank, “is the field everybody asks about.” It is field four, twelve hectares of clay on the slope below the house. In May it was ploughed once, shallowly, and since then nobody has touched it. By late August, when we walked it, it had grown a shin-high crop of whatever wanted to grow: wild oats, poppies gone to seed, chicory with its blue flowers shut against the heat, and the thistles that make the neighbours shake their heads. ::resource{id="vines"} Leaving land unsown is one of the oldest practices in farming, and before artificial fertiliser most farmers could not do without it. Medieval villages across northern Europe worked their open fields in three: one sown in autumn, one in spring, one left to rest. Here the old word for it is *maggese*, from *maggio*, because the resting field was ploughed in May, and in the hills around Siena farmers kept the habit long after the tractors came. The rest works slowly, and most of what it does happens underground. A year without a crop breaks the life cycle of the pests and diseases that follow wheat from field to field. The weeds that come up are grazed or ploughed in before they set seed, and their roots open the clay. The beans and the sulla of the years before have already stored nitrogen in the soil, and the fallow gives it time to become something the next wheat can use. It also costs money, which is why most farms gave it up. A field that grows nothing earns nothing, and a farm that must pay for its tractor every month finds it hard to leave a seventh of its land alone. Rinaldi did the sums as everyone does. “On paper I lose a seventh of the farm every year,” he says. “On paper.” :::callout{type="facts" title="Seven fields, seven years"} Every autumn each field moves one step down the list, so one of the seven is always resting. 1. Wheat 2. Field beans 3. Wheat 4. Sulla, grazed by sheep 5. Sulla, grazed by sheep 6. Barley 7. Fallow ::: The paper leaves out the next wheat. On the fields that have rested, his yields in the following year come close to those of neighbours who spread fertiliser every spring, and his bill for fertiliser and sprays is a fraction of theirs. In the wet autumn of 2023, when the valley’s wheat went in late and patchy, field six, the year after its rest, came up so evenly that a neighbour stopped to look at it. A fallow field does look untidy, and in a landscape sold on postcards, untidiness is noticed. Rinaldi has had a letter from the council about his thistles and a visit from a man selling weedkiller who thought that the farm had been abandoned. “I tell them it is on holiday,” he says. “They don’t laugh.” A young couple farming below Radda have begun a five-field rotation of their own, and the seed library in the Val d’Elsa now sends its members a sheet on how to rest a plot. In June, the resting field was the only one on the slope where skylarks nested, and the barn owls from the ruin up the hill hunt it every evening, because the voles have moved in. :::callout{type="quote"} *“On paper I lose a seventh of the farm every year. On paper.”* ::: Towards the end of our visit, Rinaldi took us back into field four. Near the hedge the soil had cracked in the heat, and he knelt and broke off a clod. Inside, it was dark and crumbly and threaded with roots, and a worm withdrew from the light. “Look at that,” he said, the way another farmer might show you a prize heifer. “That is a year’s work.” In October he will plough the field again and sow it with wheat, and next summer the square on the calendar will have a crop in it. Another field will take its turn to rest. By then, he says, the neighbours will have found something else to talk about. :::part{title="Kitchen" palette="band=#7a2c3a"} ::: # Threshing-Day Bread {style="bread" standfirst="When the last load of grain came in, farm kitchens baked with the new flour. This loaf keeps a fifth of an old wheat in the dough." byline="Recipe by Nell Harrow" credit="Photograph: meriç tuna, CC0"} All over Europe, the end of the harvest had its rites. The last sheaf was plaited into a doll, drenched with water or carried home on the last cart, and the kitchen baked with grain from the new crop. This loaf takes its cue from those, with a fifth of the flour milled from an old Tuscan wheat such as Verna. The old flour is weaker, so the dough is wetter than it looks; fold it instead of adding flour. It makes two loaves, one to keep and one to give away. ## Ingredients - 800 g strong white bread flour - 200 g stoneground flour from an old wheat - 700 g water, lukewarm - 200 g active sourdough starter - 20 g fine sea salt :::columnbreak ## Method 1. Mix both flours with 650 g of the water and leave for an hour. 2. Add the starter, the salt and the rest of the water, and squeeze them in with a wet hand until the dough is smooth. 3. For the next two hours, fold the dough over itself every half hour. Then leave it covered until it has risen by half, three to four hours in a warm kitchen. 4. Divide the dough, shape each half into a tight round and set it seam up in a floured basket. Cover and chill overnight. 5. Heat the oven to 250 °C with a lidded cast-iron pot inside. Tip in one loaf, slash the top and bake for 20 minutes with the lid on, then 20 to 25 minutes more without it at 230 °C, until deep brown. Bake the second loaf the same way. 6. Leave to cool for an hour before cutting. On threshing day, the first slice goes to whoever brought in the last sheaf. :::space If you have no old wheat, use any stoneground wholemeal flour. The loaf comes out a shade darker, with a closer crumb.`; // content.<lang>.md, inlined by the Cookbook // #region art: a made-up barcode, drawn in code with a seeded PRNG function mulberry32(seed) { return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } const n1 = (v) => v.toFixed(2); function barcodeArt() { // 32 × 14 mm on paper: bars and no digits, so it reads as decoration const rand = mulberry32(14); let [x, bars] = [2, '']; while (x < 30) { const w = 0.25 + Math.floor(rand() * 3) * 0.25; if (rand() < 0.55) { bars += `<rect x="${n1(x)}" y="1.5" width="${w}" height="11" fill="${palette.ink}"/>`; } x += w + 0.25; } return '<svg xmlns="http://www.w3.org/2000/svg" width="320" height="140" viewBox="0 0 32 14">' + `<rect width="32" height="14" fill="${palette.paper}"/>${bars}</svg>`; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build Spectral: ['400', '400i', '700'], 'Bodoni Moda': ['400', '400i', '600', '700', '900'], Jost: ['400', '600'] }; // Spectral 700: the contributors' names; Jost 400: the captions // ─── 4 · Build & show ─────────────────────────────────────────────────────── const photos = [...new Set(resources.flatMap((r) => r.bitmap?.fileId ?? []))]; // each JPEG once await Promise.all([...photos.map((file) => loadImage(file, asset(file))), loadSvg('barcode.svg', barcodeArt())]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Magazine cover and sectioned contents', es: 'Portada de revista e índice por secciones' }) });Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes
// ─── 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Composer le titre dans la couleur maison
Le titre prend le brun terre du grand « 14 » de la page de sommaire.
- textTransform: 'uppercase', align: 'center', color: col('ink') },
+ textTransform: 'uppercase', align: 'center', color: col('band') },#Ouvrir chaque rubrique sur une page de partie
Pour des parties avec leur propre page, et un numéro de page dans chaque ligne de partie du sommaire, voir Des parties en couleur à partir d’un seul attribut.
Erreurs fréquentes
Erreur fréquente
Un flottant 'top' va à la page qui suit sa citation, sauf s’il peut ouvrir la page qui le cite
Par défaut, un flottant ne passe jamais au-dessus de son propre renvoi : un flottant 'top' sur toute la largeur cité en page N ouvre donc la page N+1. Réglez placement.citingPage: true sur la ressource ou sur le defaultPlacement de son type (ou layout.floatsAtCitingPage pour tout le livre, depuis postext 1.25) et il ouvre plutôt la page N, le texte au-dessus du renvoi descendant sous lui, quand il tient dans layout.maxTopFraction et que la ligne qui le cite reste sur cette page. Il n’ouvre jamais la première page d’un chapitre ni une page qui suit un saut forcé : là, il ouvre toujours la page suivante, et 'auto' ou 'bottom' peut prendre le pied de la page qui le cite. Placement des figures →
Erreur fréquente
Les images matricielles tirent leur taille d’impression d’une résolution : déclarez-la, pas des pixels à la taille d’impression
Une image matricielle sans résolution est mise en page à raison d’un pixel par pixel de mise en page à la résolution du document : une photo de 2 400 px fait donc 406 mm de large sur une page à 150 dpi. Déclarez les pixels réels du fichier et donnez à l’image sa résolution (bitmap.resolution: 300), ou réglez layout.bitmapResolution pour toutes les images matricielles : une valeur en ppi, ou 'file' pour lire celle qu’indique le fichier. La colonne limite toujours la taille de l’image. Figures et tableaux comme ressources →
- Un élément image fait tenir son image dans sa boîte et ne la recadre jamais. Découpez d’abord chaque JPEG aux proportions de sa boîte, 23 : 30 pour la couverture et 230 × 150 mm pour les ouvertures, sinon l’image rétrécit et laisse des bandes blanches sur les bords.
- Dans le texte, une partie recolore toute couleur dont l’hexadécimal égale celui de base de
band, qu’elle soit reliée àbandou non. Réservez cet hexadécimal à ce qui doit changer avec la rubrique. - Les articles sont calibrés dans les deux langues : quelques phrases de « Field notes » en espagnol ont été raccourcies pour tenir sur une page, et une phrase du grand article en anglais a perdu trois mots pour que les deux colonnes de la page 4 finissent à la même hauteur. Après une modification, vérifiez qu’aucun article ne déborde de quelques lignes sur une nouvelle page et que chaque page, sauf la dernière d’un article, finit à hauteur égale.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Images
- Castellina in Chianti (the cover and its copy in the contents) · Rowan Heuvel · CC0 1.0
- Castellina in Chianti, a detail (the feature's photograph) · Rowan Heuvel · CC0 1.0
- Plants in beakers (the Field notes opener) · chuttersnap · CC0 1.0
- Harvesting the wheat crop (the Kitchen opener) · meriç tuna · CC0 1.0
- Fields from above near Castellina in Chianti (the feature's opener) · Generated With Diffusion Models · original
- The cover's barcode, drawn in code · Ignacio Ferro · MIT
- Polices
- Spectral (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Jost (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


