成品一览
虚构的饮食与土地季刊《FALLOW》一期中的六页,页面尺寸230 × 300 mm。封面上一幅托斯卡纳山谷出血排到页边,上方是118 pt的Bodoni刊名;三条封面标题放在一块纸色底板上,每条的眉题取其栏目的颜色,角落里是价格和条形码。第2页是自动生成的目录:每个栏目一条彩色色标,下面是文章标题、页码和导语。右栏是编者按、撰稿人和版权栏。后面的栏目依次为赤陶色、橄榄绿和酒红色。每篇文章都以一幅横贯页面上部的出血图片开篇,一块栏目颜色的底板升入图片中;文章的页码色标、项目符号和框也取这种颜色。颜色由每篇文章上方的:::part围栏设定,这个围栏不开分隔页。
这道食谱解答
- 怎样制作封面、简扉页、扉页和版权页?
- 怎样添加自动更新的目录(前导符、页码、作者、篇名行)?
- 怎样把书分成若干篇或部分,每篇有自己的颜色和隔页?
- 怎样给每章不同的照片、颜色或章首页样式?
简短回答
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="…"}
用料
- 功能
- 锚定设计元素目录按篇配色分节书眉设计过的章首页标题样式不编号的章页面设计中的图片页面设计中的文字、线条和框标题属性语义调色板通栏章首色带图的放置浮动框标注框段落样式自定义资源类型作为资源的图和表在Canvas上绘制页面
- 类型
- Spectral, Bodoni Moda, Jost(SIL OFL 1.1)
- 素材
castellina-cover-1150.jpgcastellina-farm-1530.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)
做法
#1 · 让封面从刊名往下挂
代码见上面的简短回答。只有照片、页头的两行文字和价格锚定在页面上。其余元素都锚定在另一个元素上(元素定位):刊名在标语之下,第一条眉题在刊名下方8 mm,之后每条标题在其眉题之下、每条眉题在前一条标题之下。这样,封面标题变长或刊名变大时,会把下面的内容往下推,而不是压在上面。每条眉题和每条标题各有一个84 mm宽的纸色框,六个框叠在一起就是底板,随文字多少而伸缩。第一个框之后的每个框都与上一个重叠0.3 mm(SEAM),条形码与价格框也同样重叠,因为在Canvas上两块刚好相接的填充色之间会漏出一条细细的照片。杂志没有半书名页和书名页:封面承载刊名,目录页承载期号、季节和版权栏。图书的前置部分见前置部分用罗马数字页码,正文从第1页起。
#2 · 把每个栏目设为不开新页的篇
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'),
];
设置parts.page: false后,:::part{title="Features" palette="band=#5d6a2b"}这样的围栏不开分隔页,只从下一个块起设定栏目的标题和调色板(:::part容器)。每篇文章的H1使用带numbered: false的标题样式,所以目录列出它时不带编号。样式决定首页的图片,篇提供颜色。
#3 · 把所有栏目颜色链接到同一个条目
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' } },
];
篇按id覆盖调色板条目,因此随栏目变化的一切都链接到band:首页的底板、页码色标、项目符号和列表编号、引文框,以及资料框的色条和标题。在正文中,覆盖按值进行:凡是十六进制值等于band基准值(#8b6a3e)的颜色,都换成栏目的颜色,所以其他条目都不用这个值。设计元素则相反。Postext 1.4.1按写在其paletteId旁边的十六进制值上色,不理会colorPalette;但篇的覆盖会通过paletteId作用到它们,所以col()两者都写。
#4 · 让目录自己分组
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列出各篇文章,并为每个:::part排一行,版式取自toc.parts.design,颜色用该篇的调色板(目录),所以一种行设计印出三种颜色的三个色标。细线和色标都挂在mid上,即这一行的中线再上移1 mm,使色标不碰到下方的标题。关闭前导符后,标题和页码(3、4、6)之间不连任何东西;subtitle.attr把首页标题的standfirst属性用斜体印在标题下方。右栏末尾的版权栏是一个段落样式,Jost 7 pt、颜色muted,Markdown用:::paragraphs{style="colophon"}套用它。
#5 · 给每篇文章同样的首页
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')),
] } });
图片从页面顶边出血,向下延伸150 mm。一块band颜色的底板升入图片底部34 mm。底板由三个136 mm宽的文本框组成,每个都用below接在前一个之下,分别放{partTitle}给出的栏目名、标题和导语。图片不占高度,所以正文从占高度的元素中最低的那个下方开始,也就是署名;署名底部5 mm的空白内边距让第一行正文至少离它这么远。
#6 · 在页码处写出栏目名
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
}) });
页码放在外侧页脚一个6.5 mm见方的band色块里。栏目名锚定在它旁边、朝向订口一侧,文章标题又在栏目名旁边;三个框都高6.5 mm,文字垂直居中,所以共用一条中线。杂志在首页上也印页码,因此页脚不设pages筛选。封面的标题样式清空页脚;目录的样式在原来放栏目和文章的位置印出杂志名和本期季节({title}和{publishDate})。
完整食谱
// ═══ 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) // 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.svg', [TRIM.width * 10, PHOTO * 10], { en: 'Fields from above, one ' + 'unsown and in flower.', es: 'Campos vistos desde arriba; uno, sin sembrar y en flor.' }), 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`---Markdown样例 · 132行 · 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 and the barcode are 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="Drawing: made in code 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: the fields from above and 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); const channel = (hex, i) => parseInt(hex.slice(i, i + 2), 16); const mix = (a, b, k) => `#${[1, 3, 5].map((i) => Math.round(channel(a, i) * (1 - k) + channel(b, i) * k).toString(16).padStart(2, '0')).join('')}`; // a towards b by k // The part of the line p + t·d inside a convex polygon (Cyrus–Beck), or null. function clipLine(poly, p, d) { let lo = -1e9; let hi = 1e9; for (let i = 0; i < poly.length; i++) { const [a, b] = [poly[i], poly[(i + 1) % poly.length]]; const nx = a[1] - b[1]; const ny = b[0] - a[0]; // the inward normal of a clockwise polygon (y points down) const den = nx * d[0] + ny * d[1]; const num = nx * (p[0] - a[0]) + ny * (p[1] - a[1]); if (Math.abs(den) < 1e-9) { if (num < 0) return null; continue; } const tt = -num / den; if (den > 0) lo = Math.max(lo, tt); else hi = Math.min(hi, tt); } if (lo >= hi) return null; return [[p[0] + lo * d[0], p[1] + lo * d[1]], [p[0] + hi * d[0], p[1] + hi * d[1]]]; } function fieldsArt() { // TRIM.width × PHOTO mm: the valley from above, one field resting const rand = mulberry32(1998); const [W, H] = [TRIM.width, PHOTO]; const hedge = mix(palette.olive, palette.ink, 0.5); const crops = { // [fill, furrow] per crop, all mixed from the page's palette wheat: [mix(palette.band, '#f0c95a', 0.6), mix(palette.band, '#f0c95a', 0.25)], barley: [mix(palette.band, palette.paper, 0.55), mix(palette.band, palette.paper, 0.3)], beans: [mix(palette.olive, palette.paper, 0.2), mix(palette.olive, palette.ink, 0.25)], sulla: [mix(palette.wine, palette.paper, 0.5), mix(palette.wine, palette.paper, 0.25)], earth: [mix(palette.band, palette.paper, 0.1), mix(palette.band, palette.ink, 0.3)], }; const kinds = Object.keys(crops); const [cols, rows, REST] = [7, 5, [4, 1]]; // REST: the column and row of the resting field const [cx, cy, ang] = [W / 2, H / 2, (-14 * Math.PI) / 180]; const grid = []; // a jittered lattice over a larger area, turned 14° for (let j = 0; j <= rows; j++) { grid.push([]); for (let i = 0; i <= cols; i++) { const edge = i === 0 || j === 0 || i === cols || j === rows; const x = -30 + (i * (W + 60)) / cols + (edge ? 0 : (rand() - 0.5) * 14); const y = -40 + (j * (H + 80)) / rows + (edge ? 0 : (rand() - 0.5) * 12); const [dx, dy] = [x - cx, y - cy]; grid[j].push([cx + dx * Math.cos(ang) - dy * Math.sin(ang), cy + dx * Math.sin(ang) + dy * Math.cos(ang)]); } } let fields = ''; for (let j = 0; j < rows; j++) { for (let i = 0; i < cols; i++) { const poly = [grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]]; // clockwise const pts = poly.map(([x, y]) => `${n1(x)},${n1(y)}`).join(' '); const [mx, my] = poly.reduce(([sx, sy], [x, y]) => [sx + x / 4, sy + y / 4], [0, 0]); if (i === REST[0] && j === REST[1]) { // fallow: grass, and poppies gone to seed fields += `<polygon points="${pts}" fill="${mix(palette.olive, palette.paper, 0.62)}"/>`; for (let f = 0; f < 420; f++) { const p = [mx + (rand() - 0.5) * 60, my + (rand() - 0.5) * 60]; const row = clipLine(poly, p, [1, 0]); // the field's width at this height if (!row || p[0] < row[0][0] || p[0] > row[1][0]) continue; const poppy = rand() < 0.22; fields += `<circle cx="${n1(p[0])}" cy="${n1(p[1])}" r="${poppy ? 0.5 : 0.32}" ` + `fill="${poppy ? palette.terracotta : mix(palette.olive, palette.paper, 0.25)}"/>`; } continue; } const [fill, furrow] = crops[kinds[Math.floor(rand() * kinds.length)]]; fields += `<polygon points="${pts}" fill="${fill}"/>`; const a = ang + (rand() < 0.5 ? 0 : Math.PI / 2) + (rand() - 0.5) * 0.25; const [d, nrm] = [[Math.cos(a), Math.sin(a)], [-Math.sin(a), Math.cos(a)]]; for (let s = -40; s <= 40; s += 2.4) { // furrows, 2.4 mm apart const seg = clipLine(poly, [mx + nrm[0] * s, my + nrm[1] * s], d); if (seg) { fields += `<path d="M${n1(seg[0][0])} ${n1(seg[0][1])}L${n1(seg[1][0])} ` + `${n1(seg[1][1])}" stroke="${furrow}" stroke-width="0.3"/>`; } } } } let hedges = ''; // the field edges, and trees along them with their shadows let trees = ''; for (let j = 0; j <= rows; j++) { for (let i = 0; i <= cols; i++) { for (const [ni, nj] of [[i + 1, j], [i, j + 1]]) { if (ni > cols || nj > rows) continue; const [a, b] = [grid[j][i], grid[nj][ni]]; hedges += `<path d="M${n1(a[0])} ${n1(a[1])}L${n1(b[0])} ${n1(b[1])}" stroke="${hedge}" ` + 'stroke-width="0.7" stroke-linecap="round"/>'; for (let tr = rand() < 0.45 ? 2 + Math.floor(rand() * 6) : 0; tr > 0; tr--) { const u = rand(); const [x, y] = [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u]; const r = 0.9 + rand() * 1.3; trees += `<circle cx="${n1(x + r * 0.55)}" cy="${n1(y + r * 0.45)}" r="${n1(r)}" ` + `fill="${palette.ink}" fill-opacity="0.22"/><circle cx="${n1(x)}" cy="${n1(y)}" ` + `r="${n1(r)}" fill="${hedge}"/>`; } } } } const road = `<path d="M-5 ${H * 0.86} C ${W * 0.3} ${H * 0.66}, ${W * 0.46} ${H * 0.82}, ` + `${W * 0.6} ${H * 0.5} S ${W * 0.86} ${H * 0.14}, ${W + 5} ${H * 0.18}" fill="none" ` + `stroke="${palette.paper}" stroke-width="1.6"/>`; const farm = [[0, 0, 8, 5], [8.4, 1.2, 4.2, 6.5], [1.5, 5.6, 5.5, 3.6]].map(([x, y, w, h]) => { const [fx, fy] = [W * 0.61 + x, H * 0.4 + y]; return `<rect x="${n1(fx + 0.7)}" y="${n1(fy + 0.7)}" width="${w}" height="${h}" ` + `fill="${palette.ink}" fill-opacity="0.28"/><rect x="${n1(fx)}" y="${n1(fy)}" ` + `width="${w}" height="${h}" fill="${palette.terracotta}"/><path d="M${n1(fx)} ` + `${n1(fy + h / 2)}h${w}" stroke="${mix(palette.terracotta, palette.ink, 0.35)}" ` + 'stroke-width="0.35"/>'; }).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}">${fields}${hedges}${road}${farm}${trees}</svg>`; } 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('fields.svg', fieldsArt()), 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' }) });工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#刊名用杂志的主色
刊名改用目录页上大号14的土棕色。
- textTransform: 'uppercase', align: 'center', color: col('ink') },
+ textTransform: 'uppercase', align: 'center', color: col('band') },#每个栏目以分隔页开篇
如果要让每个篇有自己的页面,并在目录的每个篇行中印出页码,见用一个属性给各篇配色。
常见问题
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
'top'浮动体不会出现在引用它的那一页
浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →
易错点
位图按文档dpi以px排版:声明印刷尺寸
位图资源的尺寸取自它声明的宽和高,单位是文档dpi下的像素,而不是取自文件本身。声明印刷尺寸对应的像素数(按印刷宽度约300 dpi),图才会以正确的大小出现并保持清晰。 作为资源的图和表 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
消除孤字时收紧的字距可能根本不绘制
在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 图片元素把图片缩放到框内,从不裁切。先把每张JPEG裁成框的比例,封面为23 : 30,文章首页为230 × 150 mm,否则图片会缩小,边上留出白条。
- 在正文中,篇会给所有十六进制值等于
band基准值的颜色换色,不管它是否链接到band。这个值只留给应随栏目变化的内容。 - 两种语言的文章都做过字数调整:西班牙语版Field notes删短了几句,以便排在一页之内;英文专题有一句删了三个词,使第4页两栏齐底。修改之后,检查没有哪篇文章多出几行溢到新的一页,并且除每篇文章的最后一页外,每页各栏都齐底。
致谢
- 文本
- 原创文字, CC BY 4.0
- 图片
- 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
- 字体
- Spectral (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Jost (SIL OFL 1.1)


