成品一览
虚构的山谷周报《The Elverdale Courier》的头版和第2页,纸张尺寸246 × 328 mm。哥特体报头夹在天气报眼和价格报眼之间,下方是一粗一细两条线和日期行。头条新闻以横跨两栏的两行大标题开头,字体为56 pt的Libre Franklin Black,接着是斜体副题和一条222 mm宽的照片图带,然后正文分两栏两端对齐,中间一条细栏线。页脚处,四条简讯共用一条浅色底带,每栏一条,各以一个红色行内标签开头。第2页以一面红色栏目旗标开始,包含配有代码绘制插画的大桥报道、右栏顶部的五日天气预报、第二个通页宽标题、读者来信,以及一张每行带条形的选区投票表。
这道食谱解答
- Postext能排三栏或更多栏,或者让文字绕图排吗?
- 怎样在页面中间排一个横跨两栏的框,比如三格并列的“数字一览”面板?
- 怎样控制图的位置:页面顶部、横跨两栏、就放在这里,还是放在边栏里?
- 怎样让章标题横跨页面,下面的正文分两栏排?
简短回答
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the most a body can have
columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } };
// Three or more columns live only inside a box: a :::columns group in a :::callout. Its fence
// spans the page and floats the box to the foot, so the story fills the columns above it:
// :::callout{type="briefs" span="page" placement="bottom" title="In brief"}
// :::columns{count=4 breaks="2,3,4"} ← each brief opens a column (child 2, 3 and 4)
// …four paragraphs…
// :::
// ::: (gotcha: callout-columns)
// The style could carry span and placement too; on the fence they stay in sight in the text.
// hook-up: config() takes layout as it is and lists briefs in calloutStyles.
const briefs = { id: 'briefs',
background: col('tint'), // one device: a tint, no stripe or border
padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
columnGap: mm(GUTTER), // the inner columns keep the page's gutter
titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) },
body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left',
firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns
用料
- 功能
- 横跨页面的框框内分栏浮动框设计过的章首页通栏章首色带标题样式标题属性锚定设计元素页面设计中的文字、线条和框书眉与页码栏间线标注框行内标签段落样式图的放置图就放在这里来源与出处行命名表格样式自定义资源类型表格单元格中的图片
- 类型
- Grenze Gotisch, PT Serif, Libre Franklin(SIL OFL 1.1)
- 素材
ridge-2400.jpg- Wind turbines on a snowy ridge (the front-page photograph, cropped to a band) (Jason Blackeye, CC0 1.0)
- The bridge drawing and the bars in the ward table, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)
做法
#1 · 新闻纸、油墨和一种红
const palette = {
ink: '#111315', // text and the heavy rules: a cold near-black
paper: '#f8f5ee', // newsprint, and type reversed out of ink or red
flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars
tint: '#ebe6db', // the In brief strip
rule: '#9a978f', // hairlines: the column rule, table rules
muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint
};
// The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
{ id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
页面是新闻纸色(#f8f5ee),文字是近黑的油墨色(#111315)。唯一的红色#a6192e用于栏目旗标、眉题、行内标签和“赞成”条形。每种颜色都同时写出paletteId和十六进制值,因为1.4.1按十六进制值给设计元素上色。main-color指向油墨色,所以斜体和配置中所有保留默认值的地方都印成油墨色。bodyText.boldColor也设为油墨色。引用颜色默认取它,每个框的粗体文字也复制它,而调色板对两者都不重新着色。不设这一项,第2页的两处引用和简讯的粗体导语会印成默认的蓝色#295AA3。
#2 · 报头就是头版的H1
const NAME = 52; // pt: the blackletter nameplate
const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side
const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline
const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts
const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content,
{ ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP)
: at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } }));
const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'),
lineHeight: 1.25 }; // a multiple of the size (gotcha: design-lineheight-multiple)
const nameplate = { enabled: true, slot: { elements: [
depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot
{ kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'),
placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } },
...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map(
([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align },
{ ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })),
rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule
text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME),
fontWeight: 700, lineHeight: 1, align: 'center' },
{ ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears
...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')],
['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]),
...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')],
['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]),
] } };
// hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements:
// [] } }, H1 already spans the page, and the Markdown opens with
// # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…"
// day="…" price="…"}
# The Elverdale Courier使用front标题样式,这个样式用标题文字和它的七个属性(issue、area、outlook、temps、since、day和price)填充报头;日期取自frontmatter中的publishDate。一个七条网格线深(31.1 mm)的空框决定首页设计的高度。线条、日期行和报名都挂在它的底边上,所以首页设计结束在一条网格线上。把框设成6.6行深,1.4.1就会把栏线一直画穿报名和日期行。
#3 · 横跨两栏的标题是一个框
// :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2
const banner = { id: 'banner', backgroundEnabled: false,
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title
marginTop: pt(0), // flush under the nameplate or the section flag
marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line
body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } };
// A story that starts mid-page: the same box under a rule. The columns above it are cut level.
const story = { ...banner, id: 'story', marginTop: pt(LEAD),
stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device
padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } };
// Inside the box the headline is an H2 in a heading style: one size for each rank of story.
const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02),
marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below
标题只有作为首页设计时才能横跨两栏,而首页设计会开新页。如果把span: 'page'放在选区报道的H2上而不是它的框上,这篇报道会移到第三页。无边框、带span="page"的框可以从页面中间开始。在第2页,各栏在它上方齐平截断,在它下方继续。框里放三种标题样式之一(56、30或22 pt)的H2、斜体副题,头版上还有照片。它的marginBottom为半行,使各栏从下一条网格线开始,在第2页即副题下方3.4 mm。用默认外边距时间隔为7.8 mm,读者来信会排到第三页。
#4 · 两栏以上要放在框里
这一步的代码见上面的简短回答。页面正文最多两栏(版式类型),所以报纸的四栏或五栏要放在框里,这里是一个通页宽的框,内含一组:::columns。breaks按组内的块计数,一条简讯一个块,所以第2、3、4条简讯分别开始第2、3、4栏。每栏宽49 mm,栏与栏之间是页面的6 mm栏间距。
:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
:::columns{count=4 breaks="2,3,4"}
:chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus …
:chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane …
…
:::
:::placement="bottom"把框浮动到第1页底部,占36.9 mm,报道填满它上方83 mm高的各栏。1.4.1也不支持文字绕排图片。浮动体占据所在栏或整页的一整条横带,所以头条照片是标题框里横跨两栏的图带,大桥插画占满第1栏的宽度。要把文字排在图片旁边,用同样的框:在:::columns{count=2 breaks="2"}中,::resource行开始第一栏,文字开始第二栏。
#5 · 图片内嵌,表格浮动
// A caption label prints only when captionPrefix has text: '' leaves no "Figure 1".
const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '',
numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle });
const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture
unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above
const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body,
createdAt: 0, updatedAt: 0, ...extra });
const resources = [
// Set inline in the page-span banner box: a 'top' float never lands above the line that
// cites it (gotcha: top-float-next-page). A :::space gives it air (gotcha: box-embed-no-gap).
// 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size).
resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400,
height: 800 }, { placement: { position: 'here' },
altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.',
caption: '**File picture:** turbines on an upland ridge. Each of the three planned for '
+ 'Harrow Ridge would generate 2.3 megawatts.',
note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }),
// Drawn in code (the art region) and set inline, where ::resource puts it in column 1.
resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 },
{ placement: { position: 'here' },
altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its '
+ 'central pier, a red concrete footing.',
caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the '
+ 'riverbed. Both arches were relaid with their own stones.',
note: 'Drawing: The Courier' }),
// A page-span 'bottom' float sits under both columns of the page that cites it. On the last
// page it follows the balanced columns, so the copy there is fitted to bring it to the foot.
resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() },
{ placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**',
note: 'Red: yes. Grey: no. The black line marks half the vote. '
+ 'Source: Elverdale Town Council.' }),
// A column 'top' float waits for the head of the next column with room.
resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() },
{ placement: { position: 'top', span: 'column' }, caption: '**The next five days**',
note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }),
];
照片内嵌在标题框里,因为通页宽的top浮动体从不排在引用它的那一行上方;作为浮动体,照片会排到第2页开头(见“常见问题”)。大桥插画也是内嵌的,在关于桥墩的那一段之后。天气预报浮动到栏顶,落在右栏顶部。选区表浮动到两栏下方、栏线停止的地方。在最后一页,它紧贴在齐底的各栏下方,所以上方的文字经过字数调整,把它推到页脚。两种资源类型都设了captionPrefix: '',而1.4.1只在前缀有文字时才印题注标签,所以不会有题注以“Figure 1”开头。
#6 · 内页以栏目旗标开头
const section = { enabled: true, slot: { elements: [
depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot
{ kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } },
text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'),
padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } },
at('#bar', 'above')),
] } };
const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower
const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto
text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side },
at('page', `top-${side}`, s * MARGIN.side, FOLIO)),
text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 600, 'muted'), align: side },
at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1),
{ kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'),
placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4), size: { width: mm(TRIM.width
- 2 * MARGIN.side) } } }] };
# Town & Valley是一个H1,其breakBefore的奇偶设置为'any',所以这个栏目直接从第2页开始,前面没有空白页。它的设计是3 pt横条上的一面红色旗标,挂在一个两条网格线深的空框上。页码行是锚定在页面顶边下方6 mm处的页眉,偶数页和奇数页各有一个版本。头版的样式清空页眉,所以报头上方不印页码行。
完整食谱
// ═══ Postext Cookbook · Nº 027 · Newspaper front page ═══════════════════════════ // https://postext.dev/en/cookbook/newspaper-front-page // Code: MIT · Text: original (CC BY 4.0) · Photo: Jason Blackeye (CC0) // Fonts: Grenze Gotisch, PT Serif, Libre Franklin (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 = 'newspaper-front-page'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: newsprint, ink and one red for the section flags const palette = { ink: '#111315', // text and the heavy rules: a cold near-black paper: '#f8f5ee', // newsprint, and type reversed out of ink or red flag: '#a6192e', // the one accent: section flags, kickers, the Yes bars tint: '#ebe6db', // the In brief strip rule: '#9a978f', // hairlines: the column rule, table rules muted: '#5d5a55', // bylines, the folio line's title and date, credit notes, the imprint }; // The hex rides along: 1.4.1 designs read it, not the link (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion const TRIM = { width: 246, height: 328 }; // mm: a 3 : 4 compact, smaller than a tabloid const MARGIN = { top: 14, bottom: 15, side: 12 }; // newspaper margins: narrow, not mirrored const GUTTER = 6; // mm between the two body columns, and between the In brief columns const LEAD = 12.6; // body leading in pt: the baseline grid const franklin = (size, weight, look = {}) => ({ fontFamily: 'Libre Franklin', fontSize: pt(size), fontWeight: weight, color: col('ink'), ...look }); const caps = (size, weight, colour = 'ink') => franklin(size, weight, { color: col(colour), textTransform: 'uppercase', letterSpacing: pt(size * 0.16) }); // capitals tracked 0.16 em 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, align: 'left', overflow: 'wrap', color: col('ink'), ...look, placement }); // design text defaults to centred, // black and ellipsized (gotcha: overflow-ellipsis-default) // A rule across the slot whose bottom sits `gap` mm above the element it names. const rule = (id, under, weight, gap = 0) => ({ kind: 'rule', id, thickness: pt(weight), color: col('ink'), placement: { ...at(under, 'above', 0, -gap), size: { width: 'fill' } } }); // An empty box exactly `lines` grid lines deep sets an opener's depth, and the rest hangs from // its foot. Ending on a grid line leaves no sliver of column beside the opener, which 1.4.1 // would rule down through the title (gotcha: column-rule-through-opener). const depth = (lines) => ({ kind: 'box', id: 'depth', style: {}, placement: { ...at('container', 'top-left'), size: { width: 'fill', height: pt(lines * LEAD) } } }); // #region nameplate: the front page's H1 is the paper's name, between two ears const NAME = 52; // pt: the blackletter nameplate const EAR = 31; // mm: each ear, in the room the nameplate leaves at either side const EAR_DROP = 1.8; // mm: measured so the ears' big figures sit on the nameplate's baseline const AIR = 2; // mm between the lower rule and the masthead's foot, where the banner box starts const ear = (side, lines) => lines.map(([id, content, look], i) => text(id, content, { ...look, align: side }, { ...(i === 0 ? at('container', `top-${side}`, 0, EAR_DROP) : at(`#${lines[i - 1][0]}`, 'below', 0, 1)), size: { width: mm(EAR) } })); const serif = { fontFamily: 'PT Serif', fontSize: pt(9), italic: true, color: col('ink'), lineHeight: 1.25 }; // a multiple of the size (gotcha: design-lineheight-multiple) const nameplate = { enabled: true, slot: { elements: [ depth(7), // the masthead is seven grid lines deep; the rules and dateline hang from its foot { kind: 'rule', id: 'foot', thickness: pt(0.5), color: col('ink'), placement: { ...at('#depth', 'align-bottom', 0, -AIR), size: { width: 'fill' } } }, ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.area}']].map( ([align, content]) => text(`date-${align}`, content, { ...caps(7.5, 600), align }, { ...at('#foot', 'above', 0, -1.4), size: { width: 'fill' } })), rule('thin', '#date-left', 0.5, 1.6), rule('heavy', '#thin', 2.5, 0.6), // the Oxford rule text('name', '{titleText}', { fontFamily: 'Grenze Gotisch', fontSize: pt(NAME), fontWeight: 700, lineHeight: 1, align: 'center' }, { ...at('#heavy', 'above', 0, -1.2), size: { width: 'fill' } }), // centred between the ears ...ear('left', [['w-kicker', 'Weather', caps(7, 700, 'flag')], ['w-outlook', '{attr.outlook}', serif], ['w-temps', '{attr.temps}', franklin(15, 800)]]), ...ear('right', [['p-kicker', '{attr.since}', caps(7, 700, 'flag')], ['p-day', '{attr.day}', serif], ['p-price', '{attr.price}', franklin(15, 800)]]), ] } }; // hook-up: headingStyles gets { id: 'front', advancedDesign: nameplate, header: { elements: // [] } }, H1 already spans the page, and the Markdown opens with // # The Elverdale Courier {style="front" issue="…" area="…" outlook="…" temps="…" since="…" // day="…" price="…"} // #endregion // #region answer: two body columns at most, so the four-up strip is a box with columns const layout = { layoutType: 'double', gutterWidth: mm(GUTTER), // the most a body can have columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.5) } }; // Three or more columns live only inside a box: a :::columns group in a :::callout. Its fence // spans the page and floats the box to the foot, so the story fills the columns above it: // :::callout{type="briefs" span="page" placement="bottom" title="In brief"} // :::columns{count=4 breaks="2,3,4"} ← each brief opens a column (child 2, 3 and 4) // …four paragraphs… // ::: // ::: (gotcha: callout-columns) // The style could carry span and placement too; on the fence they stay in sight in the text. // hook-up: config() takes layout as it is and lists briefs in calloutStyles. const briefs = { id: 'briefs', background: col('tint'), // one device: a tint, no stripe or border padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(GUTTER), // the inner columns keep the page's gutter titleStyle: { ...caps(9, 800, 'flag'), gap: mm(2) }, body: { fontFamily: 'PT Serif', fontSize: pt(8.6), lineHeight: pt(11.4), textAlign: 'left', firstLineIndent: pt(0) } }; // ragged and unindented, unlike the columns // #endregion // #region banner: a headline across both columns is a frameless page-span box // :::callout{type="banner" span="page" title="Transport"} ← an optional kicker, then the H2 const banner = { id: 'banner', backgroundEnabled: false, padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, titleStyle: { ...caps(8, 800, 'flag'), gap: mm(1.2) }, // a kicker, when the fence has a title marginTop: pt(0), // flush under the nameplate or the section flag marginBottom: pt(LEAD / 2), // half a line, then the columns start on the next grid line body: { fontFamily: 'PT Serif', fontSize: pt(13), lineHeight: pt(16.5), textAlign: 'left' } }; // A story that starts mid-page: the same box under a rule. The columns above it are cut level. const story = { ...banner, id: 'story', marginTop: pt(LEAD), stripe: { enabled: true, side: 'top', width: pt(1), color: col('ink') }, // its one device padding: { top: mm(2.5), right: pt(0), bottom: pt(0), left: pt(0) } }; // Inside the box the headline is an H2 in a heading style: one size for each rank of story. const headline = (id, size, look = {}) => ({ id, fontSize: pt(size), lineHeight: pt(size * 1.02), marginBottom: pt(size * 0.2), ...look }); // tight leading, a fifth of it below // #endregion // #region inside: an inside page opens with a section flag and carries a folio line const section = { enabled: true, slot: { elements: [ depth(2), // two grid lines: the flag sits on a 3 pt bar, 1.6 mm above the foot { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'), placement: { ...at('#depth', 'align-bottom', 0, -1.6), size: { width: 'fill' } } }, text('flag', '{titleText}', { ...caps(11, 800, 'paper'), box: { backgroundColor: col('flag'), padding: { top: mm(1.3), right: mm(3), bottom: mm(1.1), left: mm(3) } } }, at('#bar', 'above')), ] } }; const FOLIO = 6; // mm from the top edge to the folio line; its rule 4 mm lower const folio = (parity, side, s) => [ // s: +1 on a verso (folio on the left), −1 on a recto text(`n-${parity}`, '{pageNumber}', { ...franklin(9, 800), align: side }, at('page', `top-${side}`, s * MARGIN.side, FOLIO)), text(`t-${parity}`, '{title} · {publishDate}', { ...caps(7, 600, 'muted'), align: side }, at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.9)), ].map((element) => ({ ...element, parity })); const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1), { kind: 'rule', id: 'folio-rule', thickness: pt(0.5), color: col('ink'), placement: { ...at('page', 'top-left', MARGIN.side, FOLIO + 4), size: { width: mm(TRIM.width - 2 * MARGIN.side) } } }] }; // #endregion const label = { fontFamily: 'Libre Franklin', fontSize: pt(8), firstLineIndent: pt(0) }; const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, resourceTypes, 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.side), right: mm(MARGIN.side) } }, layout, // Hyphenation stays at its defaults: on, in 'en-us'. bodyText: { fontFamily: 'PT Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), // boldColor is restated: :ref labels take it, because 1.4.1 never points referenceColor // at main-color (gotcha: palette-skips-designs), and bold in a box copies it before the // palette applies. Italics do follow main-color. boldColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: false, minWordSpacing: 0.75, maxWordSpacing: 1.6, // tighter than the 0.6–2 defaults maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted) headings: { fontFamily: 'Libre Franklin', fontWeight: 800, // in ink, through main-color marginBottom: pt(0), levels: [ // Restated (gotcha: headings-drop-h1-break); 'any': a section opens the next page. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: section }, { level: 2, fontSize: pt(17), lineHeight: pt(19) }, // Letters; banners restyle it { level: 3, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 700, marginTop: pt(LEAD) }, ] }, headingStyles: [ { id: 'front', advancedDesign: nameplate, header: { elements: [] } }, // no folio line headline('lead', 56, { fontWeight: 900 }), headline('wide', 30), headline('second', 22), ], calloutStyles: [banner, story, briefs], chipStyles: [{ id: 'flag', background: col('flag'), color: col('paper'), borderWidth: pt(0), borderRadius: pt(0), fontFamily: 'Libre Franklin', fontSize: em(0.875), bold: true, paddingX: em(0.4), paddingY: em(0.15) }], paragraphStyles: [{ ...label, id: 'byline', textAlign: 'left', color: col('muted') }, { id: 'flush', firstLineIndent: pt(0) }, // a story's first paragraph, the second letter { ...label, id: 'jump', textAlign: 'right' }, // "…: page 2", where a story turns { id: 'sign', textAlign: 'right', firstLineIndent: pt(0) }, // a letter's signature { id: 'imprint', fontFamily: 'Libre Franklin', fontSize: pt(6.8), lineHeight: pt(9), textAlign: 'left', firstLineIndent: pt(0), color: col('muted'), marginTop: pt(LEAD) }], captionStyle: { fontFamily: 'Libre Franklin', fontSize: pt(8), gap: mm(1.6), note: { fontSize: pt(6.8), color: col('muted') } }, // the credit line tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Libre Franklin', headerFontSize: pt(7.5), bodyFontFamily: 'Libre Franklin', bodyFontSize: pt(8.5), cellPadding: mm(1.3) }, tableStyles: [{ id: 'results', cellPadding: mm(1) }, // tighter rows: the bars read as one chart { id: 'forecast', headerBackground: col('flag'), bodyFontSize: pt(8), // the weather box cellPadding: mm(1.2) }], header, footer: { elements: [] }, // newspapers put the folio at the head, if anywhere }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // #region art: the two tables as data, and the bridge and the ward bars drawn in code const WARDS = [ // Tuesday's parish poll: yes, no (they add up to 3,904 and 2,393) ['Market', 942, 418], ['Riverside', 861, 402], ['St Oswald’s', 896, 471], ['Brook Lane', 736, 591], ['Harrow', 469, 511]]; const cell = (content, extra = {}) => ({ content, ...extra }); const head = (content, align = 'left') => cell(content, { isHeader: true, align }); const num = (n) => n.toLocaleString('en-GB'); const share = (yes, no) => `${Math.round((100 * yes) / (yes + no))}%`; function barSvg(yes, no) { // 1000 × 34 units: yes in red from the left, no in grey, 50% marked const split = (1000 * yes) / (yes + no); return '<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="34" viewBox="0 0 1000 34">' + `<rect width="${split.toFixed(1)}" height="34" fill="${palette.flag}"/>` + `<rect x="${(split + 4).toFixed(1)}" width="${(996 - split).toFixed(1)}" height="34" ` + `fill="${palette.rule}"/><rect x="499" y="-1" width="2" height="36" fill="${palette.ink}"/>` + '</svg>'; } const TOWN = WARDS.reduce(([, y, n], [, yes, no]) => ['Whole town', y + yes, n + no], ['', 0, 0]); const bars = [...WARDS, TOWN].map(([ward, yes, no], i) => ({ ward, yes, no, id: i < WARDS.length ? `bar-${i + 1}` : 'bar-town', svg: barSvg(yes, no) })); // one per row function mulberry32(seed) { // a seeded PRNG: the same stones on every run 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 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 function bridgeSvg() { // 119 × 46 mm, 1 unit = 1 mm, about 2.6 mm to the metre const rand = mulberry32(1791); // the year the bridge was built, as far as the Courier knows const f = (v) => v.toFixed(2); const [W, H, TOP, DECK, WATER, BED] = [119, 46, 4.5, 8, 25, 30]; // heights from the top, mm const stone = mix(palette.tint, palette.muted, 0.35); const joint = mix(palette.tint, palette.ink, 0.55); const arches = [[16, 57], [62, 103]]; // two spans; the central pier stands between them const [SPRING, CROWN] = [WATER - 7, 13]; // the arches spring 7 mm above the water const CTRL = 2 * CROWN - SPRING; // the Bézier control point that puts the top at CROWN // The banks: road level at each end, down under the outer half of each arch to the water's // edge, 24 mm in, and on at the same slope to the bed. const EDGE = 24; const run = ((BED - WATER) * (EDGE - 10)) / (WATER - DECK); const bank = [[0, DECK], [10, DECK], [EDGE, WATER], [EDGE + run, BED]]; const profile = [...bank, ...bank.map(([x, y]) => [W - x, y]).reverse()]; const groundAt = (x) => { // the height of the ground at x, between two profile points const i = profile.findIndex(([px]) => px >= x); if (i <= 0) return profile[Math.max(i, 0)][1]; const [[x0, y0], [x1, y1]] = [profile[i - 1], profile[i]]; return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0); }; const water = mix(palette.paper, palette.ink, 0.14); let out = `<rect x="0" y="${WATER}" width="${W}" height="${BED - WATER}" fill="${water}"/>`; out += `<path d="M${EDGE} ${WATER}H${W - EDGE}" stroke="${palette.muted}" stroke-width="0.3"/>`; for (let i = 0; i < 9; i++) { // ripples on the river, between the banks const [x, y] = [EDGE + 3 + rand() * (W - 2 * EDGE - 12), WATER + 1.2 + rand() * (BED - WATER - 2.4)]; out += `<path d="M${f(x)} ${f(y)}h${f(3 + rand() * 5)}" stroke="${palette.paper}" ` + 'stroke-width="0.4"/>'; } // The masonry: parapet to riverbed, less the two arch openings (even-odd fill). The banks // are drawn over it, so only the stone above the ground shows. const opening = ([a, b]) => `M${a} ${BED}L${a} ${SPRING}Q${(a + b) / 2} ${CTRL} ` + `${b} ${SPRING}L${b} ${BED}Z`; const outline = `M4 ${BED}L10 ${DECK}V${TOP}H${W - 10}V${DECK}L${W - 4} ${BED}`; out += `<path fill-rule="evenodd" fill="${stone}" ` + `d="${outline}Z${arches.map(opening).join('')}"/>`; // Where the masonry is at height y: inside the sloping ends, outside the arch openings. const solid = (x, y) => { const end = y < DECK ? 10 : 10 - (6 * (y - DECK)) / (BED - DECK); if (x < end || x > W - end) return false; return !arches.some(([a, b]) => { // the opening's half width at y (a parabola) const c = (SPRING - y) / (2 * (SPRING - CTRL)); // t(1 − t) at height y const half = c <= 0 ? 0.5 : c >= 0.25 ? -1 : Math.sqrt(0.25 - c); return Math.abs(x - (a + b) / 2) <= half * (b - a); }); }; for (let y = TOP + 2.2; y < BED; y += 2.2) { // courses of stone, joints staggered for (let x = 4; x < W - 4; x += 0.5) { if (solid(x, y) && solid(x + 0.5, y)) { out += `<path d="M${f(x)} ${f(y)}h0.5" stroke="${joint}" stroke-width="0.2"/>`; } } for (let x = 4 + rand() * 4; x < W - 6; x += 3 + rand() * 4) { if (solid(x, y) && solid(x, y - 2.2)) { out += `<path d="M${f(x)} ${f(y - 2.2)}v2.2" stroke="${joint}" stroke-width="0.2"/>`; } } } for (const [a, b] of arches) { // the voussoirs: a ring of stones round each opening const cx = (a + b) / 2; for (let t = 0; t <= 1.0001; t += 1 / 14) { const x = a + (b - a) * t; const y = SPRING - 2 * t * (1 - t) * (SPRING - CTRL); const [dx, dy] = [x - cx, y - (WATER + 2)]; const k = 3 / Math.hypot(dx, dy); out += `<path d="M${f(x)} ${f(y)}l${f(dx * k)} ${f(dy * k)}" stroke="${joint}" ` + 'stroke-width="0.25"/>'; } out += `<path d="${opening([a, b]).replace(/Z$/, '')}" fill="none" stroke="${palette.ink}" ` + 'stroke-width="0.35"/>'; } out += `<path d="${outline}" fill="none" stroke="${palette.ink}" stroke-width="0.4"/>`; // The ground in section: the banks and the riverbed, hatched below the profile. const edge = profile.map(([x, y]) => `${f(x)} ${f(y)}`).join('L'); out += `<path d="M${edge}L${W} ${H}L0 ${H}Z" fill="${palette.paper}"/>`; for (let x0 = -H; x0 < W; x0 += 1.6) { // 45° hatching, cut where it leaves the ground let from = null; for (let t = 0; t <= H + 0.05; t += 0.1) { const [x, y] = [x0 + t, H - t]; const inside = x >= 0 && x <= W && y > groundAt(x); if (inside && !from) from = [x, y]; if (from && (!inside || t + 0.1 > H + 0.05)) { out += `<path d="M${f(from[0])} ${f(from[1])}L${f(x - 0.1)} ${f(y + 0.1)}" ` + `stroke="${palette.rule}" stroke-width="0.25"/>`; from = null; } } } out += `<path d="M${edge}" fill="none" stroke="${palette.muted}" stroke-width="0.3"/>` + `<rect x="55.5" y="${BED}" width="8" height="15.6" fill="${palette.flag}"/>` // 6 m deep + `<path d="M9.4 ${TOP}H${W - 9.4}" stroke="${palette.ink}" stroke-width="1.2"/>`; // coping return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}">${out}</svg>`; } function resultsTable() { const row = (name, yes, no, bar, bold = '') => [cell(`${bold}${name}${bold}`), cell('', { image: { resourceId: bar } }), cell(`${bold}${num(yes)}${bold}`, { align: 'right' }), cell(`${bold}${num(no)}${bold}`, { align: 'right' }), cell(`${bold}${share(yes, no)}${bold}`, { align: 'right' })]; return { headerRowCount: 1, columnWidths: [22, 110, 14, 14, 14], rows: [ [head('Ward'), head('Share of the vote'), head('Yes', 'right'), head('No', 'right'), head('Yes share', 'right')], ...bars.map((b) => row(b.ward, b.yes, b.no, b.id, b.id === 'bar-town' ? '**' : ''))] }; } function forecastTable() { const days = [['Thu', 'Bright spells', 16, 8, 'W 18'], ['Fri', 'Showers', 14, 9, 'W 22'], ['Sat', 'Heavy rain', 12, 9, 'SW 30'], ['Sun', 'Clearing', 13, 6, 'NW 20'], ['Mon', 'Sunny, cold start', 15, 4, 'N 8']]; return { headerRowCount: 1, columnWidths: [10, 40, 12, 12, 16], rows: [ [head('Day'), head('Outlook'), head('High', 'right'), head('Low', 'right'), head('Wind mph', 'right')], ...days.map(([day, sky, hi, lo, wind]) => [cell(`**${day}**`), cell(sky), cell(`${hi}°`, { align: 'right' }), cell(`${lo}°`, { align: 'right' }), cell(wind, { align: 'right' })])] }; } // #endregion // #region floats: the photo sits in its box, the results float to a foot, the forecast to a head // A caption label prints only when captionPrefix has text: '' leaves no "Figure 1". const unnumbered = (id, name, captionStyle) => ({ id, name, shortLabel: name, captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionStyle }); const resourceTypes = [unnumbered('picture', 'Picture'), // captions under the picture unnumbered('panel', 'Panel', { position: 'above', fontSize: pt(9.5) })]; // tables: titled above const resource = (id, typeId, kind, body, extra) => ({ id, typeId, kind, [kind]: body, createdAt: 0, updatedAt: 0, ...extra }); const resources = [ // Set inline in the page-span banner box: a 'top' float never lands above the line that // cites it (gotcha: top-float-next-page). A :::space gives it air (gotcha: box-embed-no-gap). // 2400 px at 150 dpi is 406 mm: the photo shrinks to the box (gotcha: bitmap-print-size). resource('ridge', 'picture', 'bitmap', { fileId: 'ridge-2400.jpg', format: 'jpeg', width: 2400, height: 800 }, { placement: { position: 'here' }, altText: 'Wind turbines on a snow-covered ridge under a bright, cloudy sky.', caption: '**File picture:** turbines on an upland ridge. Each of the three planned for ' + 'Harrow Ridge would generate 2.3 megawatts.', note: 'Photograph: Jason Blackeye, CC0, via Wikimedia Commons' }), // Drawn in code (the art region) and set inline, where ::resource puts it in column 1. resource('bridge', 'picture', 'svg', { fileId: 'bridge.svg', width: 1190, height: 460 }, { placement: { position: 'here' }, altText: 'Drawing of a two-arch stone bridge between sloping river banks; under its ' + 'central pier, a red concrete footing.', caption: '**The rebuilt pier.** Its new concrete footing (red) goes six metres below the ' + 'riverbed. Both arches were relaid with their own stones.', note: 'Drawing: The Courier' }), // A page-span 'bottom' float sits under both columns of the page that cites it. On the last // page it follows the balanced columns, so the copy there is fitted to bring it to the foot. resource('wards', 'panel', 'table', { styleId: 'results', model: resultsTable() }, { placement: { position: 'bottom', span: 'page' }, caption: '**How the wards voted**', note: 'Red: yes. Grey: no. The black line marks half the vote. ' + 'Source: Elverdale Town Council.' }), // A column 'top' float waits for the head of the next column with room. resource('forecast', 'panel', 'table', { styleId: 'forecast', model: forecastTable() }, { placement: { position: 'top', span: 'column' }, caption: '**The next five days**', note: 'Sunrise 6.57, sunset 19.03 on Thursday. Forecast: Wend Valley Weather Station.' }), ]; // #endregion resources.push(...bars.map((b) => resource(b.id, 'panel', 'svg', // the bars the table cells draw { fileId: `${b.id}.svg`, width: 1000, height: 34 }, { altText: `${b.ward}: ${num(b.yes)} yes, ${num(b.no)} no` }))); const markdown = String.raw`---Markdown样例 · 124行 · content.en.md
title: "The Elverdale Courier" author: "The Elverdale Courier" publishDate: "Thursday 24 September 2026" --- # The Elverdale Courier {style="front" issue="Vol. 116 · No. 39" area="Elverdale, Harrow and the Upper Wend" outlook="Bright spells, breezy" temps="16° · 8°" since="Est. 1911" day="Every Thursday" price="90p"} :::callout{type="banner" span="page"} ## Harrow Ridge turbines win the town’s vote {style="lead"} *Elverdale backs its own wind farm by 62% on a 71% turnout. Now the cooperative must raise £9.8 million* :::space{lines=0.5} ::resource{id="ridge"} ::: :::callout{type="briefs" span="page" placement="bottom" title="In brief"} :::columns{count=4 breaks="2,3,4"} :chip[TRANSPORT]{style="flag"} **Bus 44 back on Sundays.** The Elverdale to Kirkby bus will run every two hours on Sundays from 4 October, paid for by the county for a year. The first leaves the Market Place at 8.10. :chip[SCHOOLS]{style="flag"} **An orchard for Brook Lane.** Pupils at Brook Lane Primary planted 24 apple and pear trees on Friday, all of them varieties grown in the valley before 1900. :chip[ARTS]{style="flag"} **Book Weekend full.** Every ticket for the fourteen events of the Book Weekend, 9 to 11 October, has gone. The Saturday talks will be screened free in the Corn Exchange. :chip[COUNCIL]{style="flag"} **Bin day moves.** Recycling north of the river goes out on Tuesdays instead of Mondays, starting in the week of 5 October. New calendars come with next week’s Courier. ::: ::: :::paragraphs{style="byline"} By **Martha Quayle** · Local Democracy Reporter ::: :::paragraphs{style="flush"} Elverdale will build a wind farm of its own. In the first town-wide poll since the 1974 vote to save the Corn Exchange, 3,904 residents backed plans for three turbines on Harrow Ridge, and 2,393 voted against. ::: The count at the Corn Exchange finished shortly before midnight on Tuesday. Turnout was 71%, the highest the town clerk, Anjali Rees, can remember at any election. “People queued in the rain outside St Oswald’s,” she said. “We ran out of pencils at eleven.” The last ballot box, from Harrow, came in at twenty to ten. The scheme belongs to Wend Valley Energy, a cooperative that residents formed in 2023 so that the wind on the ridge would pay the valley rather than a distant landlord. Its three turbines of 2.3 megawatts each would supply about 6,500 homes in an average year, far more than the 4,700 in the parish. ### Who pays and who gains The cooperative must now raise £9.8 million. A share offer opens next Thursday, with a minimum stake of £50. It hopes to find £2.4 million in the valley and to borrow the rest from a community energy lender. Members would earn up to 4% a year, and £120,000 a year would go to a town fund, spent by a panel that residents elect. “Two thousand households putting in £1,200 each would do it,” said Tom Ashby, who chairs the cooperative and farms at Low Harrow. “We had 312 pledges by Wednesday lunchtime.” ### Objectors turn to the planners The Harrow Ridge Action Group, which campaigned against the scheme, says the turbines, 125 metres to the tip of the blade, will dominate the view from the Wend Way and disturb the curlews that nest on the moor. “A poll is not a planning permission,” said its secretary, Clare Whitlock. “We will make our case to the district council, and we expect to be heard.” The planning application is due in the spring, after a year of bird and wind surveys. If it succeeds, work could start in 2028, and the turbines would be turning by the autumn of 2029. The cooperative’s 640 founding members have paid £180,000 for the surveys. :::paragraphs{style="jump"} How each ward voted, and what happens next: **page 2** ::: # Town & Valley :::callout{type="banner" span="page" title="Transport"} ## Old Mill Bridge reopens after 14 months {style="wide"} *Cars, buses and the Saturday market cross the Wend again. Lorries must wait for a load test next spring* ::: :::paragraphs{style="byline"} By **Owen Pryce** ::: :::paragraphs{style="flush"} The Old Mill Bridge reopened to traffic at seven o’clock on Monday morning, fourteen months after the flood of July 2025 scoured out the footing of its central pier and cracked both of its arches. ::: The repair cost £1.9 million, most of it from the county council’s flood recovery fund. Engineers rebuilt the pier on a new concrete footing sunk six metres into the riverbed, and relaid the arches with 3,100 of their own stones, each one numbered as it came down and set back in the same place. ::resource{id="bridge"} It reopened five days before the first storm of the autumn. Heavy rain is forecast for Saturday :ref{id="forecast" text="(Weather, right)"}, and the county’s engineers will be on the bridge to watch the river rise. The first vehicle across was Keith Barlow’s dairy van, which has spent more than a year on the eleven-mile detour through Kirkby. “Forty minutes each way, six days a week,” he said. “I’d rather not add it up.” The bridge is open to cars, vans and buses, and closed to goods vehicles over 7.5 tonnes. The county says heavy traffic had weakened the old structure long before the flood, and it will not let lorries back until the new pier has passed a load test next spring. That has disappointed some traders. “It’s half a bridge for us,” said Priya Chandra, who runs the builders’ merchant on Mill Lane. “Our timber still comes round by Kirkby, eleven miles each way.” For the market, it came just in time. Stallholders from the south bank have carried their stock over the footbridge for a year, and Saturday’s market will be the first in more than a year with every pitch taken. :::callout{type="story" span="page" title="The wind farm vote"} ## Harrow was the one ward to say no {style="second"} ::: :::paragraphs{style="flush"} Every ward but one voted for the wind farm in Tuesday’s poll. Market gave it the biggest majority, with 69% voting yes, and Brook Lane the smallest, 736 votes to 591 :ref{id="wards" text="(Results, below)"}. ::: Harrow, the ward beneath the ridge, voted against by 511 votes to 469, on a turnout of 76%, the highest of the five wards. “Most of the people who voted no can see the ridge from their front doors,” said its councillor, Ruth Okafor, who has asked the cooperative to meet Harrow residents before the application goes in. The first meeting is on 8 October. Tom Ashby, the cooperative’s chair, said it had expected Harrow to be the hardest ward to win. He has offered to move the nearest turbine 200 metres further from the village, which would cost about 4% of its output, and will put both sites to Harrow residents at the October meeting. The poll does not bind the district council, which expects to decide the planning application in the summer of 2027. Anyone may comment on it for 21 days after it is published. :::columnbreak ## Letters **Tea at the count.** I was one of 22 volunteers who sorted 6,297 ballots in the Corn Exchange on Tuesday night. When the urn ran dry at ten, the cricket club lent us theirs, and we finished at a quarter to twelve. Thank you to the Rotary Club for the sandwiches, and to whoever left a tin of flapjacks on the returning officer’s table. :::paragraphs{style="sign"} *Margaret Oyelaran, Brook Lane* ::: :::paragraphs{style="flush"} **The view belongs to all of us.** Those of us who walk the Wend Way every week were outvoted by people who have never climbed the ridge. I counted eleven pairs of curlews up there in May. I hope the district council will remember that the moor is not the town’s to sell, whatever the count says. ::: :::paragraphs{style="sign"} *David Hurst, Low Harrow* ::: :::paragraphs{style="imprint"} An imaginary weekly. Text CC BY 4.0. Set in Grenze Gotisch, PT Serif and Libre Franklin (SIL OFL). :::`; // content.en.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'PT Serif': ['400', '400i', '700'], 'Grenze Gotisch': ['700'], 'Libre Franklin': ['400', '600', '700', '800', '900'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([loadImage('ridge-2400.jpg', asset('ridge-2400.jpg')), loadSvg('bridge.svg', bridgeSvg()), ...bars.map((b) => loadSvg(`${b.id}.svg`, b.svg))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'Newspaper front page' });工具包 · 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上的食谱文件夹 ↗
变化
#让简讯留在照片下方
在content.en.md中去掉placement="bottom"后,简讯条留在其围栏所在的位置,紧接照片下方,头条报道在它下面一直排到页脚。
-:::callout{type="briefs" span="page" placement="bottom" title="In brief"}
+:::callout{type="briefs" span="page" title="In brief"}常见问题
易错点
栏间线可能穿过通栏章首页
在postext 1.4.1中,一个区段的栏间线从其最高一栏的顶端开始。通栏标题仍属于第一栏,所以当它的章首页没有正好结束在网格行上时,旁边剩下的一小条第二栏会让栏间线从版心顶部开始,穿过章首页。让章首页的深度为整数个网格行:设计较矮时,加一个等高的空框或设minHeight,它就结束在网格行上,不留小条。 栏间线 →
易错点
框内的表或图四周没有间距
在postext 1.4.1中,::resource在:::callout里以位置'here'排入的表或图,得不到它在正文中享有的间距:上下紧贴前后两段。在::resource这一行之前放一个:::space,后面还有文字时再在其后放一个;取一行的几分之一(如lines=0.33)就能留出一道小间隙。 图就放在这里 →
易错点
'top'浮动体不会出现在引用它的那一页
浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →
易错点
位图按文档dpi以px排版:声明印刷尺寸
位图资源的尺寸取自它声明的宽和高,单位是文档dpi下的像素,而不是取自文件本身。声明印刷尺寸对应的像素数(按印刷宽度约300 dpi),图才会以正确的大小出现并保持清晰。 作为资源的图和表 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的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()。 排版前加载字体 →
- 给照片设
placement: { position: 'top', span: 'page' },它就会离开第1页,单独排在第2页。第1页的报道随之在简讯条上方92 mm处结束,栏目移到第3页。
致谢
- 文本
- 原创文字, CC BY 4.0
- 图片
- Wind turbines on a snowy ridge (the front-page photograph, cropped to a band) · Jason Blackeye · CC0 1.0
- The bridge drawing and the bars in the ward table, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- 字体
- Grenze Gotisch (SIL OFL 1.1) · PT Serif (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)


