Em poucas palavras
A capa e as primeiras páginas de uma revista de comida e campo, com foto na capa e um sumário. Mostra como dar a cada seção uma cor própria, que se mantém em todas as suas páginas.
O que você vai compor
Seis páginas de 230 × 300 mm de uma edição de FALLOW, revista trimestral imaginária de comida e terra. Na capa, um vale toscano sangrado sob o nome da revista em Bodoni de 118 pt, e três chamadas sobre um painel de papel, cada chapéu na cor da sua seção, com o preço e o código de barras no canto. A página 2 traz o sumário gerado: uma aba colorida por seção e, sob ela, o título, a página e o olho de cada matéria. A coluna da direita leva a carta da editora, os colaboradores e o expediente. Vêm depois três seções, em terracota, oliva e vinho. Cada matéria abre com uma imagem sangrada no alto da página e um painel na cor da seção subindo sobre ela, e as abas de fólio, os marcadores e os boxes usam essa cor. Quem define a cor é a cerca :::part acima de cada matéria, que não abre página divisória.
Esta receita responde a
- Como faço uma capa, uma falsa folha de rosto, uma folha de rosto e um colofão?
- Como acrescento um sumário que se atualiza sozinho (pontilhados, números de página, autores, linhas de parte)?
- Como divido um livro em partes ou seções, cada uma com sua cor e sua página divisória?
- Como dou a cada capítulo sua própria foto, cor ou variante de abertura?
A resposta curta
const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block
const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph
const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows
// A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one
// width. Chained with 'below' they read as one panel, and a longer line pushes the rest down.
const sheet = (top, bottom) => ({ backgroundColor: col('paper'),
padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } });
const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') };
const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [
text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour),
box: sheet(n === 1 ? 5 : 4, 1.5) },
{ ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }),
text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15),
lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel
{ ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }),
];
const cover = { enabled: true, slot: { elements: [
{ kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto
text('issue', '{attr.issue}', { ...small, align: 'right' },
at('page', 'top-right', -MARGIN.outer, 12)),
text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118),
lineHeight: 0.86, // a multiple of the size (gotcha: design-lineheight-multiple)
letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') },
{ ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }),
...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'),
// The price and a made-up barcode sit in the corner, anchored to the page, not the words.
text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) },
{ ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }),
{ kind: 'image', id: 'barcode', resourceId: 'barcode',
placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } },
] } };
// hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover,
// footer: { elements: [] } }, with no folio; the Markdown's first line is
// # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"}
Ingredientes
- Funcionalidades
- Ancoragem de elementos de designSumárioCores por parteCabeços por seçãoAberturas desenhadasEstilos de títuloCapítulos sem númeroImagens nos designs de páginaTextos, fios e caixas nos designs de páginaAtributos de títuloPaleta de cores semânticaFaixa de capítulo em largura totalPosicionamento de figurasBoxes flutuantesBoxesEstilos de parágrafoTipos de recurso personalizadosFiguras e tabelas como recursosPáginas em um canvas
- Também usa
- Figuras exatamente aquiCor do papelQuebras de página e de colunaPáginas de abertura de parteEspaço vertical explícito
- Tipografia
- Spectral, Bodoni Moda, Jost (SIL OFL 1.1)
- Materiais
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)
Preparo
#1 · Pendurar a capa no nome da revista
O código é a resposta curta acima. Só a foto, as duas linhas do alto e o preço são ancorados à página. Todo o resto se ancora a outro elemento (posicionamento de elementos): o nome da revista sob a linha superior, o primeiro chapéu 8 mm abaixo do nome e, daí em diante, cada chamada sob o seu chapéu e cada chapéu sob a chamada anterior. Assim, uma chamada mais longa ou um nome maior empurra para baixo o que está sob ele em vez de imprimir por cima. Cada chapéu e cada chamada têm a sua própria caixa de papel, de 84 mm de largura, e as seis caixas empilhadas formam o painel, que cresce com o texto. Toda caixa depois da primeira se sobrepõe 0,3 mm (SEAM) à de cima, e o código de barras ao preço da mesma forma, porque no canvas dois fundos que apenas se tocam deixam aparecer entre eles um fio finíssimo da foto. Uma revista não tem falsa folha de rosto nem folha de rosto: o nome vai na capa, e o número, a estação e o expediente vão no sumário. Para os elementos pré-textuais de um livro, veja Páginas iniciais com fólios romanos e depois a página 1.
#2 · Fazer de cada seção uma parte sem página
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'),
];
Com parts.page: false, uma cerca como :::part{title="Features" palette="band=#5d6a2b"} não abre página divisória e só define o título e a paleta da seção a partir do bloco seguinte (o contêiner :::part). O H1 de cada matéria usa um estilo de título com numbered: false, e por isso o sumário o lista sem número. O estilo escolhe a imagem da abertura, e a cor vem da parte.
#3 · Ligar todas as cores de seção a uma só entrada
const palette = {
ink: '#1c1a16', // text: a warm near-black
paper: '#fbf8f1', // the page, and type set on colour
band: '#8b6a3e', // the house earth; each :::part fence brings its own 'band'
muted: '#6b6358', // credits and the standfirsts in the contents
olive: '#5d6a2b', terracotta: '#a3472a', wine: '#7a2c3a', // as in the :::part fences: edit both
};
// A part overrides by id; designs paint the hex in 1.4.1 (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue.
{ id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
Uma parte substitui entradas da paleta pelo id, então tudo o que muda com a seção fica ligado a band: o painel da abertura, a aba do fólio, os marcadores e os números das listas, o olho de citação, e o fio e o título do boxe de dados. No texto, a substituição é feita pelo valor: qualquer cor cujo hex seja igual ao hex base de band (#8b6a3e) assume a da seção, e por isso nenhuma outra entrada usa esse hex. Nos elementos de design é o contrário. O Postext 1.4.1 pinta o hex escrito ao lado do paletteId e ignora colorPalette, mas a substituição feita por uma parte chega a eles pelo paletteId; por isso col() escreve os dois.
#4 · Deixar o sumário se agrupar sozinho
const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title
const contents = { // passed to the config as `toc`
levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600,
marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading
pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni
leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic
fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') },
// A part row is a design as wide as the column; its 'band' takes the part's palette. No
// {pageNumber}: a part with no divider page has no page (gotcha: toc-part-rows-no-label).
parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (1.4.1's default: 2 em)
{ kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab
placement: { ...mid, size: { width: 'fill' } } },
text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6),
color: col('paper'), box: { backgroundColor: col('band'),
padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } },
mid),
] } },
};
// The colophon closes the contents page; the Markdown calls it with :::paragraphs.
const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10),
color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) };
const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower
const contentsOpener = { enabled: true, slot: { elements: [
{ kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'),
placement: { ...at('container', 'top-left'), size: { width: 'fill' } } },
text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8),
color: col('band') }, at('#rule', 'below', 0, 5)),
text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05,
color: col('ink') }, at('#kicker', 'below', 0, 2)),
text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11),
color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)),
text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1,
align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)),
] } };
:::toc lista as matérias e dá a cada :::part uma faixa diagramada com toc.parts.design e a paleta dessa parte (sumário), de modo que um único desenho de faixa imprime três abas em três cores. O fio e a aba pendem de mid, o meio da faixa erguido 1 mm, o que mantém a aba longe do título de baixo. Sem linha de pontos, entre cada título e a sua página (3, 4 e 6) fica só o branco, e subtitle.attr põe embaixo, em itálico, o atributo standfirst da abertura. O expediente que fecha a coluna da direita é um estilo de parágrafo, Jost de 7 pt em muted, que o Markdown aplica com :::paragraphs{style="colophon"}.
#5 · Dar a todas as matérias a mesma abertura
const PHOTO = 150; // mm from the top edge to the foot of the picture
const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin
const LIFT = 34; // mm: how far the panel rises into the picture
const WIDE = 136; // mm: the panel's width
const panel = (top, bottom) => ({ backgroundColor: col('band'),
padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } });
const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } });
const opener = (resourceId) => ({ enabled: true, slot: { elements: [
{ kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'),
letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)),
text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7),
color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT),
size: { width: mm(WIDE) } }),
text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02,
color: col('paper'), box: panel(0, 3) }, below('section')),
text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true,
fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left',
color: col('paper'), box: panel(0, 6) }, below('title')),
// The picture reserves nothing (gotcha: opener-image-no-reserve); the panel and the byline
// reach below it, and the byline's empty padding keeps the story 5 mm under it.
text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3),
box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')),
] } });
A imagem sangra pelo alto da página e desce 150 mm. Um painel em band sobe 34 mm sobre o pé dela. O painel são três caixas de texto de 136 mm de largura, cada uma encadeada below (abaixo) da anterior, com o nome da seção vindo de {partTitle}, o título e o olho. A imagem não reserva altura, então a matéria começa sob o elemento mais baixo que reserva, a assinatura, cujo preenchimento inferior vazio de 5 mm mantém a primeira linha pelo menos a essa distância.
#6 · Pôr o nome da seção no fólio
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
}) });
O número da página fica num quadrado de band de 6,5 mm, no pé e do lado externo. O nome da seção se ancora ao lado dele, em direção à medianiz, e o título da matéria ao lado do nome; as três caixas têm 6,5 mm de altura e o texto centralizado na vertical, e assim compartilham o mesmo eixo. Revistas também numeram as páginas de abertura, por isso o rodapé não tem filtro pages. O estilo de título da capa esvazia o rodapé, e o do sumário põe o nome da revista e a estação da edição ({title} e {publishDate}) onde iriam a seção e a matéria.
A receita completa
// ═══ Postext Cookbook · Nº 023 · Magazine cover and sectioned contents ═══════════════ // https://postext.dev/en/cookbook/magazine-cover-and-contents // Code: MIT · Text: original (CC BY 4.0) · Photos: R. Heuvel, chuttersnap, m. tuna (CC0), diffusion // Fonts: Spectral, Bodoni Moda, Jost (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = '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; designs paint the hex in 1.4.1 (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the ink, so nothing prints blue. { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion const TRIM = { width: 230, height: 300 }; // an independent-magazine trim, in mm const MARGIN = { top: 22, bottom: 20, inner: 16, outer: 14 }; // mirrored const LEAD = 13.5; // body leading in pt: the baseline grid const caps = { fontFamily: 'Jost', fontWeight: 600, textTransform: 'uppercase' }; // the label face const label = { ...caps, align: 'left' }; // design text is centred by default const bodoni = { fontFamily: 'Bodoni Moda', overflow: 'wrap', align: 'left' }; const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const text = (id, content, look, placement) => ({ kind: 'text', id, content, placement, ...look }); // #region answer: the cover, one design slot whose words hang from each other const MASTHEAD = TRIM.width - MARGIN.inner - MARGIN.outer; // the word spans the text block const PANEL = 84; // mm: the cover lines' panel, a column of paper over the photograph const SEAM = 0.3; // mm: each box overlaps the one above, so no hairline of photo shows // A cover line: a kicker in its section's colour over a line in Bodoni, on paper boxes of one // width. Chained with 'below' they read as one panel, and a longer line pushes the rest down. const sheet = (top, bottom) => ({ backgroundColor: col('paper'), padding: { top: mm(top), right: mm(5), bottom: mm(bottom), left: mm(5) } }); const small = { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('ink') }; const coverLine = (n, colour, above = n === 1 ? '#masthead' : `#line${n - 1}`) => [ text(`kicker${n}`, `{attr.kicker${n}}`, { ...small, color: col(colour), box: sheet(n === 1 ? 5 : 4, 1.5) }, { ...at(above, 'below', 0, n === 1 ? 8 : -SEAM), size: { width: mm(PANEL) } }), text(`line${n}`, `{attr.line${n}}`, { ...bodoni, italic: true, fontSize: pt(15), lineHeight: 1.12, color: col('ink'), box: sheet(0, n === 3 ? 5 : 0) }, // line 3 ends the panel { ...at(`#kicker${n}`, 'below', 0, -SEAM), size: { width: mm(PANEL) } }), ]; const cover = { enabled: true, slot: { elements: [ { kind: 'image', id: 'photo', resourceId: 'cover', // the JPEG is cropped to the trim, 23 : 30 placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } }, text('strap', '{subtitle}', small, at('page', 'top-left', MARGIN.inner, 12)), // a recto text('issue', '{attr.issue}', { ...small, align: 'right' }, at('page', 'top-right', -MARGIN.outer, 12)), text('masthead', '{titleText}', { ...bodoni, fontWeight: 900, fontSize: pt(118), lineHeight: 0.86, // a multiple of the size (gotcha: design-lineheight-multiple) letterSpacing: pt(1), textTransform: 'uppercase', align: 'center', color: col('ink') }, { ...at('#strap', 'below', 0, 6), size: { width: mm(MASTHEAD) } }), ...coverLine(1, 'olive'), ...coverLine(2, 'terracotta'), ...coverLine(3, 'wine'), // The price and a made-up barcode sit in the corner, anchored to the page, not the words. text('price', '{attr.price}', { ...small, fontSize: pt(7), box: sheet(1, 1.5) }, { ...at('page', 'bottom-right', -MARGIN.outer, -12), size: { width: mm(32) } }), { kind: 'image', id: 'barcode', resourceId: 'barcode', placement: { ...at('#price', 'above', 0, SEAM), size: { width: mm(32) } } }, ] } }; // hook-up: headingStyles gets { id: 'cover', numbered: false, toc: false, advancedDesign: cover, // footer: { elements: [] } }, with no folio; the Markdown's first line is // # Fallow {style="cover" issue="…" price="…" kicker1="…" line1="…" … kicker3="…" line3="…"} // #endregion // #region sections: parts with no divider page, and a heading style for each kind of page const parts = { page: false }; // a :::part now only sets the section's title and palette const story = (id, picture) => ({ id, numbered: false, advancedDesign: opener(picture) }); const headingStyles = () => [ { id: 'cover', numbered: false, toc: false, advancedDesign: cover, footer: { elements: [] } }, // no folio on the cover (the header is empty everywhere) { id: 'contents', numbered: false, toc: false, advancedDesign: contentsOpener, footer: folioLine('{title}', '{publishDate}') }, // no section yet: the issue instead // One opener for every story; the style names its picture, the part its colour. story('grain', 'flasks'), story('rest', 'fields'), story('bread', 'harvest'), ]; // #endregion // #region contents: generated from the headings, with a row in each section's colour const mid = at('container', 'left', 0, -1); // the row's middle, 1 mm up: air above the title const contents = { // passed to the config as `toc` levels: [{ level: 1, fontFamily: 'Bodoni Moda', fontSize: pt(17), fontWeight: 600, marginBottom: pt(2 * LEAD) }], // in ink, on the body's 13.5 pt leading pageNumber: { fontSize: pt(26), width: mm(14) }, // in the levels' Bodoni leader: { enabled: false }, subtitle: { enabled: true, attr: 'standfirst', // in italic fontFamily: 'Spectral', fontSize: pt(10), color: col('muted') }, // A part row is a design as wide as the column; its 'band' takes the part's palette. No // {pageNumber}: a part with no divider page has no page (gotcha: toc-part-rows-no-label). parts: { height: pt(2 * LEAD), design: { elements: [ // two grid lines (1.4.1's default: 2 em) { kind: 'rule', id: 'line', thickness: pt(0.75), color: col('band'), // behind the tab placement: { ...mid, size: { width: 'fill' } } }, text('tab', '{titleText}', { ...label, fontSize: pt(8), letterSpacing: pt(1.6), color: col('paper'), box: { backgroundColor: col('band'), padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } } }, mid), ] } }, }; // The colophon closes the contents page; the Markdown calls it with :::paragraphs. const colophon = { id: 'colophon', fontFamily: 'Jost', fontSize: pt(7), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }; const air = { padding: { bottom: mm(9) } }; // empty padding counts: the columns start lower const contentsOpener = { enabled: true, slot: { elements: [ { kind: 'rule', id: 'rule', thickness: pt(1), color: col('ink'), placement: { ...at('container', 'top-left'), size: { width: 'fill' } } }, text('kicker', '{titleText}', { ...label, fontSize: pt(9), letterSpacing: pt(1.8), color: col('band') }, at('#rule', 'below', 0, 5)), text('issue', '{attr.issue}', { ...bodoni, italic: true, fontSize: pt(44), lineHeight: 1.05, color: col('ink') }, at('#kicker', 'below', 0, 2)), text('strap', '{subtitle}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11), color: col('muted'), align: 'left' }, at('#issue', 'below', 0, 1.5)), text('number', '{attr.number}', { ...bodoni, fontWeight: 900, fontSize: pt(160), lineHeight: 1, align: 'right', color: col('band'), box: air }, at('container', 'top-right', 0, 4)), ] } }; // #endregion // #region opener: one story opener; its colour comes from the part, not from the design const PHOTO = 150; // mm from the top edge to the foot of the picture const FOOT = PHOTO - MARGIN.top; // the same foot from the container, which starts at the margin const LIFT = 34; // mm: how far the panel rises into the picture const WIDE = 136; // mm: the panel's width const panel = (top, bottom) => ({ backgroundColor: col('band'), padding: { top: mm(top), right: mm(6), bottom: mm(bottom), left: mm(6) } }); const below = (id) => ({ ...at(`#${id}`, 'below', 0, -SEAM), size: { width: mm(WIDE) } }); const opener = (resourceId) => ({ enabled: true, slot: { elements: [ { kind: 'image', id: 'picture', resourceId, // fitted, never cropped: each file is 230 : 150 placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } }, text('credit', '{attr.credit}', { ...small, fontSize: pt(6.5), color: col('muted'), letterSpacing: pt(0.6), align: 'right' }, at('container', 'top-right', 0, FOOT + 2)), text('section', '{partTitle}', { ...label, fontSize: pt(8.5), letterSpacing: pt(1.7), color: col('paper'), box: panel(6, 2) }, { ...at('container', 'top-left', 0, FOOT - LIFT), size: { width: mm(WIDE) } }), text('title', '{titleText}', { ...bodoni, fontWeight: 700, fontSize: pt(32), lineHeight: 1.02, color: col('paper'), box: panel(0, 3) }, below('section')), text('standfirst', '{attr.standfirst}', { fontFamily: 'Spectral', italic: true, fontSize: pt(11.5), lineHeight: 1.35, overflow: 'wrap', align: 'left', color: col('paper'), box: panel(0, 6) }, below('title')), // The picture reserves nothing (gotcha: opener-image-no-reserve); the panel and the byline // reach below it, and the byline's empty padding keeps the story 5 mm under it. text('byline', '{attr.byline}', { ...small, fontSize: pt(7.5), letterSpacing: pt(1.3), box: { padding: { top: mm(4), bottom: mm(5) } } }, at('#standfirst', 'below')), ] } }); // #endregion // #region folios: a folio tab in the section's colour, then the section and the story const TAB = 6.5; // mm: the tab's side; the words beside it take its height and centre on it const folioLine = (first, second) => ({ elements: [['even', 'left', 1], ['odd', 'right', -1]] .flatMap(([parity, side, s]) => { const inward = s > 0 ? 'right-of' : 'left-of'; // from the outer edge towards the gutter const beside = (id, content, style, to, gap) => text(id, content, { ...style, align: side }, { ...at(to, inward, s * gap), size: { height: mm(TAB) } }); // text centres vertically return [ text(`tab-${parity}`, '{pageNumber}', { ...label, fontSize: pt(8), color: col('paper'), align: 'center', box: { backgroundColor: col('band') } }, { ...at('page', `bottom-${side}`, s * MARGIN.outer, -11), size: { width: mm(TAB), height: mm(TAB) } }), beside(`first-${parity}`, first, { ...small, color: col('band') }, `#tab-${parity}`, 3), beside(`second-${parity}`, second, { fontFamily: 'Spectral', italic: true, fontSize: pt(8), color: col('muted') }, `#first-${parity}`, 2.5), ].map((element) => ({ ...element, parity })); // no pages filter: openers get folios too }) }); // #endregion const flush = { marginTop: pt(0), marginBottom: pt(0) }; // lists sit on the grid const marker = { fontFamily: 'Jost', fontWeight: 600, color: col('band'), ...flush }; // list marks const boxText = { textAlign: 'left', hyphenation: false, firstLineIndent: pt(0) }; const boxStyle = { backgroundEnabled: false, ...flush, // one device: a stripe in 'band' stripe: { enabled: true, side: 'top', width: pt(2), color: col('band') }, padding: { top: mm(3), right: pt(0), bottom: pt(0), left: pt(0) } }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, resourceTypes: [photoType], page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left = inner layout: { gutterWidth: mm(6) }, // two columns, the default bodyText: { fontFamily: 'Spectral', fontSize: pt(9.5), lineHeight: pt(LEAD), // justified color: col('ink'), boldColor: col('band'), italicColor: col('ink'), referenceColor: col('ink'), firstLineIndent: mm(3.5), indentAfterHeading: false, minWordSpacing: 0.7, maxWordSpacing: 1.7, // tighter than the 0.6–2 defaults runtMinCharacters: 40, // counted in word spaces: last lines under about 20 letters cost maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted) headings: { fontFamily: 'Bodoni Moda', marginBottom: pt(0), levels: [ // Restated (gotcha: headings-drop-h1-break); 'any': a story opens on the very next page. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(15), lineHeight: pt(LEAD), fontWeight: 400, italic: true, color: col('band'), marginTop: pt(LEAD) }, ] }, headingStyles: headingStyles(), parts, toc: contents, unorderedLists: { ...marker, bulletChar: '–' }, orderedLists: marker, // in the label face calloutStyles: [{ id: 'quote', ...boxStyle, marginTop: pt(LEAD), // the floated box has none body: { fontFamily: 'Bodoni Moda', fontSize: pt(16), lineHeight: pt(1.5 * LEAD), italicColor: col('band'), ...boxText } }, { id: 'facts', ...boxStyle, placement: 'top', // floats to the next column head titleStyle: { ...caps, fontSize: pt(7.5), letterSpacing: pt(1.5), color: col('band') }, body: { fontFamily: 'Jost', fontSize: pt(8.5), lineHeight: pt(12), ...boxText } }], captionStyle: { fontFamily: 'Jost', fontSize: pt(7.5), gap: mm(2), note: { fontSize: pt(6.5), color: col('muted') } }, paragraphStyles: [{ id: 'bios', firstLineIndent: pt(0), spaceBetween: pt(LEAD / 2) }, colophon], header: { elements: [] }, footer: folioLine('{partTitle}', '{chapterTitle}'), // at the foot }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const photoType = { id: 'photo', // no figure number: an empty caption prefix and template name: t({ en: 'Photograph', es: 'Fotografía' }), shortLabel: t({ en: 'photo', es: 'foto' }), captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const picture = (id, fileId, [width, height], alt, extra = {}) => { // alt: { en, es } const kind = fileId.endsWith('.svg') ? 'svg' : 'bitmap'; return { id, kind, typeId: 'photo', createdAt: 0, updatedAt: 0, altText: t(alt), ...extra, [kind]: { fileId, width, height, ...(kind === 'bitmap' && { format: 'jpeg' }) } }; }; const resources = [ // alt texts and captions in both languages; t() picks the edition's picture('cover', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'Vines, cypresses and a ' + 'farmhouse below hazy hills.', es: 'Viñas, cipreses y una casa bajo colinas brumosas.' }), picture('thumb', 'castellina-cover-1150.jpg', [1150, 1500], { en: 'The cover of this issue.', es: 'La portada de este número.' }, { placement: { position: 'here', width: 0.45 }, caption: t({ en: 'On the cover: the hills below Castellina in Chianti, where our feature ' + 'is set.', es: 'En la portada: las colinas de Castellina in Chianti, escenario del ' + 'reportaje.' }), note: t({ en: 'Photograph: Rowan Heuvel, CC0', es: 'Fotografía: Rowan Heuvel, CC0' }) }), picture('flasks', 'flasks-1920.jpg', [1920, 1252], { en: 'Glass flasks holding green shoots ' + 'on a shelf.', es: 'Matraces de vidrio con brotes verdes en una balda.' }), picture('fields', 'fields-1610.jpg', [1610, 1050], { en: 'Fields from above, one ' + 'unsown, and a farmhouse.', es: 'Campos desde arriba, uno sin sembrar, y una casa.' }), picture('harvest', 'harvest-1840.jpg', [1840, 1200], { en: 'Ripe wheat, a combine harvester ' + 'blurred behind it.', es: 'Trigo maduro y, desenfocada detrás, una cosechadora.' }), picture('barcode', 'barcode.svg', [320, 140], { en: 'A barcode.', es: 'Un código de barras.' }), // 1530 px at 150 dpi would print 259 mm: it shrinks to the measure (gotcha: bitmap-print-size). // A top float opens the page after its ::resource line (gotcha: top-float-next-page). picture('vines', 'castellina-farm-1530.jpg', [1530, 900], { en: 'Rows of vines, cypresses and ' + 'a farmhouse on a Tuscan slope.', es: 'Hileras de viñas, cipreses y una casa de campo en ' + 'una ladera toscana.' }, { placement: { position: 'top', span: 'page' }, caption: t({ en: 'Vines and cypresses by a farmhouse near Castellina in Chianti, in summer.', es: 'Viñas y cipreses junto a una casa de campo de Castellina in Chianti, en verano.' }), note: t({ en: 'Photograph: Rowan Heuvel, CC0, via Wikimedia Commons', es: 'Fotografía: Rowan Heuvel, CC0, vía Wikimedia Commons' }) }), ]; const markdown = String.raw`---Amostra em Markdown · 132 linhas · 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 (gotcha: fonts-first) Spectral: ['400', '400i', '700'], 'Bodoni Moda': ['400', '400i', '600', '700', '900'], Jost: ['400', '600'] }; // Spectral 700: the contributors' names; Jost 400: the captions // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const photos = [...new Set(resources.flatMap((r) => r.bitmap?.fileId ?? []))]; // each JPEG once await Promise.all([...photos.map((file) => loadImage(file, asset(file))), loadSvg('barcode.svg', barcodeArt())]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Magazine cover and sectioned contents', es: 'Portada de revista e índice por secciones' }) });Kit · core, fonts, viewer, images: igual em todas as receitas · 271 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** 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, '-'); } /** 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 v1 ── 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. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Compor o nome da revista na cor da casa
O nome da revista assume o marrom-terra do grande 14 do sumário.
- textTransform: 'uppercase', align: 'center', color: col('ink') },
+ textTransform: 'uppercase', align: 'center', color: col('band') },#Abrir cada seção numa página divisória
Para partes com página própria, e um número de página em cada faixa de parte do sumário, veja Partes em cor a partir de um só atributo.
Erros comuns
Erro comum
Linhas de parte sem página divisória não têm número de página
Com parts.page: false não há página divisória, então a linha de parte do sumário não tem número de página para imprimir. Nesse caso, deixe {pageNumber} fora do design da linha de parte. Sumário →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
As imagens de uma abertura nunca contam para a altura que ela reserva
No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Um flutuante 'top' nunca cai na página que o cita
Um flutuante nunca fica acima da própria referência, então um flutuante 'top' na largura da página citado na página N abre a página N+1. Cite-o antes, ou use a posição 'auto' ou 'bottom', que podem ocupar o pé da página que o cita. Posicionamento de figuras →
Erro comum
Bitmaps são diagramados em px na resolução do documento: declare o tamanho de impressão
Um recurso bitmap é dimensionado pela largura e pela altura que declara, em pixels na resolução (dpi) do documento, não pelo arquivo. Declare os pixels do tamanho de impressão (cerca de 300 dpi na largura impressa) para que a figura saia no tamanho certo e nítida. Figuras e tabelas como recursos →
Erro comum
O lineHeight de um texto de design é um múltiplo, nunca uma medida
Num slot de design, o lineHeight de um elemento de texto multiplica o tamanho da fonte (lineHeight: 1.05). No postext 1.4.1, uma medida como pt(15) não é rejeitada: a altura da abertura dá NaN, o espaço que ela reserva, minHeight incluído, se perde sem aviso e o texto passa por baixo do título. Textos, fios e caixas nos designs de página →
Erro comum
O ajuste de linhas curtas pode apertar um tracking que nunca é pintado
No postext 1.4.1, quando um parágrafo termina numa linha curta, a diagramação o compõe com uma linha a menos: primeiro aperta o espaçamento entre palavras, depois aplica até maxRuntTracking milésimos de em de tracking negativo. Os renderizadores de canvas e PDF só pintam tracking acima de zero, então o parágrafo sai impresso sem ele: as linhas justificadas perdem essa diferença nos espaços entre palavras, que ficam esmagados, e a última linha pode passar da medida e ser cortada na borda da coluna. Defina bodyText.maxRuntTracking: 0, que mantém o ajuste pelo espaçamento entre palavras, e reescreva os parágrafos que voltarem a terminar numa linha curta. Viúvas, órfãs e linhas curtas →
Erro comum
Só 8 idiomas têm hifenização, com o código exato
A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
- Um elemento de imagem encaixa a imagem dentro da caixa e nunca a corta. Corte antes cada JPEG no formato da caixa, 23 : 30 para a capa e 230 × 150 mm para as aberturas, ou a imagem encolhe e deixa faixas brancas nas bordas.
- No texto, uma parte muda toda cor cujo hex seja igual ao hex base de
band, esteja ela ligada abandou não. Reserve esse hex para o que deve mudar com a seção. - As matérias estão ajustadas às páginas nos dois idiomas: algumas frases do Caderno de campo em espanhol foram encurtadas para que ele caiba numa página, e uma frase da reportagem em inglês perdeu três palavras para que as duas colunas da página 4 terminem na mesma altura. Depois de editar, confira que nenhuma matéria passe umas linhas para uma página nova e que todas as páginas, exceto a última de cada matéria, terminem niveladas.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imagens
- 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
- Fontes
- Spectral (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Jost (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


