跳到主要内容
食谱编号42

排版食谱 · 第1章 · 页面与网格

社区简报:头条与简讯

一份两页的菜园协会简报,采用一栏半版面:头条占满宽栏并转到第2页接续;分栏符把带标签的简讯送进窄栏。

本页内容
  • 英文样例:尚无中文版本
  • 成品尺寸210 × 297 mm
  • 一栏半, 栏间距8 mm
  • Work Sans 9.4/13.4
  • Courier Prime
  • Titan One
  • 2页
  • 难度
  • Postext 1.4.1
  • 排版用时7 ms
  • 180行代码

成品一览

The Allotment Gazette的春季号,这是一个虚构菜园协会的简报,印在一张A4纸的正反两面。一条麦秆色色带上用Titan One分两行排着刊名,底边一排幼苗,还有一个番茄色标签写着期号和季节。色带下方,页面分成一个113 mm的栏和一个57 mm的栏。头条讲新建的三个堆肥池,排满宽栏,上方是一幅用代码画的场地平面图,结束于一个“Continued on page 2”标签。窄栏排五条简讯,每条以胶囊形标签开头。第2页在转页标题下接续头条,并把一幅温度图表居中排在栏里。再往下,一个两栏的框把一幅苗床图和介绍它的段落并排放置。窄栏排满八条通知。

这道食谱解答

  • 怎样做一栏半版式,一个宽的正文栏加一个窄的侧栏?
  • Postext能排三栏或更多栏,或者让文字绕图排吗?
  • 怎样在框里分两栏(文字栏旁边是图栏)?
  • 怎样做行内标签:键盘按键、标签、练习用的词库?

简短回答

script.js · 第33–52行在完整代码中
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).

用料

类型
Work Sans, Titan One, Courier Prime(SIL OFL 1.1)
素材
  • The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette (Ignacio Ferro, CC BY 4.0)

做法

#1 · 让Markdown填满每一栏

版面设置见上文的简短回答。在oneAndHalf版面中,窄栏默认排正文,正文先排宽栏,再排窄栏,然后是下一页的宽栏(版面类型)。:::columnbreak在所在位置结束一栏(指令):头条的“Continued on page 2”标签之后,它开启窄栏排简讯;最后一条简讯之后,它位于该页的最后一栏,于是开启第2页。这一期第1页的两栏都调整为结束于同一条网格线,距上切边275 mm,所以删掉任一个分栏符,页面都不变。稿子一改,分栏符就起作用了。删掉头条的第二段,如果没有第一个分栏符,“In brief”会从宽栏往下247 mm处、那个标签下方开始,第一条简讯会延续到窄栏,后半段保持开始时113 mm的行长,被裁到窄栏的57 mm之内。

#2 · 用前置元数据搭出报头

script.js · 第56–80行在完整代码中
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
    // area on this page, behind the masthead, however deep the masthead is.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
    // (gotcha: quote-frontmatter): an unquoted date prints nothing here.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };

H1采用一个标题样式,其版面设计画出报头,span: 'page'让这个设计横跨两栏上方。60 pt的{titleText}在178 mm宽的框里折成两行。标签把写在H1行上的{attr.issue}和前置元数据里的{publishDate}拼在一起,后者必须是带引号的字符串(文本元素)。没有minHeight的话,报头会预留到色带底部,即距上切边62 mm处,正文会从下一条网格线,也就是62.5 mm处开始,紧贴色带。minHeight设为十条网格线,正文就从67 mm处开始。

#3 · 用行内标签标记简讯

script.js · 第84–94行在完整代码中
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];

声明chipStyles会替换内置的浅蓝色标签,不带style的标签采用第一个样式(标签样式)。圆角半径大于框高的一半时,画出的就是胶囊形。标签内部永远不断开,所以一行只会在标签前后的空格处断开,“Sat 11 April”这样的日期会留在同一行。标签在Markdown里用大写字母输入,因为标签样式没有大小写设置。垂直内边距画在行外,所以13.4 pt的网格保持不变。

#4 · 在框里把文字排在图旁

script.js · 第98–109行在完整代码中
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
//   :::callout{type="bed" title="Sixteen squares by the gate"}
//   :::columns{count=2 breaks="2"}
//   The demonstration bed by the gate is 1.2 metres square …   ← block 1
//   ::resource{id="bed"}                                        ← block 2
//   :::
//   :::                                   (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
  padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
  titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
  body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };

Postext不做图文绕排:一幅图占据所在栏的一整条区域。不过在标注框里,:::columns组可以把块并排放置(:::columns)。breaks="2"让第二个50 mm的栏从图开始,所以段落完整留在第一栏。这一期里段落(65.6 mm)比图及其题注(71.7 mm)短,删掉breaks="2"也没有变化。它是为更长的段落准备的:没有它,这一组会做齐底处理,一段二十行的文字会有最后两行跑到第二栏顶部、图的上方。

#5 · 把窄图表居中放在所在区域

script.js · 第113–125行在完整代码中
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];

width: 0.62让图表在113 mm的栏里宽70.1 mm,align: 'center'在两侧各留出21.5 mm空白(位置)。上下的正文保持完整行长,不会流进这片空白。这些图属于一个captionPrefix为空的资源类型,所以每条题注都以自己的粗体词语开头,前面没有“Figure 1”。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 042 · Community newsletter: lead story and briefs ═════
// https://postext.dev/en/cookbook/community-newsletter
// Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0)
// Fonts: Work Sans, Titan One, Courier Prime (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 = 'community-newsletter';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: garden colours, every one linked by id
// ink: text · leaf: nameplate and headlines · tomato, the accent: tags, tab, column heads ·
// straw: the band · marigold: second tag · tint: the box · rule: hairlines · muted: notes
const palette = { ink: '#1d211c', leaf: '#2f6b3a', tomato: '#c43f2a', straw: '#f2d492',
  marigold: '#e2b33c', tint: '#eef4e8', rule: '#cfd3c6', muted: '#5f6659', paper: '#fbfaf5' };
// 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 leaf, never blue
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'leaf (defaults)', value: { hex: palette.leaf, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Work Sans', 'Titan One', 'Courier Prime'];
const SIDE = 16; // mm: the side margins of one A4 sheet, printed on both sides
const LEAD = 13.4; // body leading in pt: the baseline grid
const caps = (size, colour) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
  textTransform: 'uppercase', letterSpacing: pt(size * 0.12), color: col(colour) });
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: a wide column for the lead, a narrow one for the briefs, one flow
const layout = {
  layoutType: 'oneAndHalf', // the narrow column sits on the right and takes text (the
  // defaults of sideColumnSide and sideColumnRole; 'floats' would keep text out of it)
  sideColumnPercent: 32, // 57 mm of the 178 mm measure
  gutterWidth: mm(8), // the wide column keeps the other 113 mm
  columnRule: { enabled: true, color: col('rule') }, // a 0.5 pt hairline in the gutter
};
// The text runs down the wide column, then the narrow one, then the next page's wide
// column. A :::columnbreak sends the next block on to the following column, so the
// Markdown decides what each column holds:
//   ## Shared compost bays open by the top gate      ← the lead, in the wide column
//   … :chip[Continued on page 2]{style="jump"}
//   :::columnbreak                                    ← the briefs open the narrow column
//   ## In brief {style="rail"}
//   …
//   :::columnbreak                                    ← from the last column: next page
//   :chip[Continued from page 1]{style="jump"}        ← the lead goes on in the wide column
// Fit the copy so that each column ends on a whole paragraph: in 1.4.1 a paragraph that
// runs over into the other column keeps the measure it started with (gotcha:
// split-paragraph-measure).
// #endregion

// #region masthead: the H1 is the nameplate; the issue tab reads the frontmatter
const BEARING = 0.6; // mm: Titan One's left side bearing at 60 pt (T 0.42, G 0.63, L/H 0.85)
const DRILL = { id: 'drill', typeId: 'drawing', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'drill.svg', width: 2100, height: 80 } }; // drawn by the design, not the text
const masthead = { enabled: true,
  minHeight: pt(10 * LEAD), // ten grid lines: the text starts one line clear of the band
  slot: { elements: [
    // The band, 62 mm deep, covers the column rule, which 1.4.1 starts at the top of the text
    // area on this page, behind the masthead, however deep the masthead is.
    { kind: 'box', id: 'band', style: { backgroundColor: col('straw') },
      placement: { ...at('page', 'top-left'), size: { width: mm(210), height: mm(62) } } },
    { kind: 'image', id: 'drill', resourceId: 'drill', // seedlings along the band's foot
      placement: { ...at('page', 'top-left', 0, 54), size: { width: mm(210), height: mm(8) } } },
    { kind: 'text', id: 'society', content: '{subtitle}', ...caps(8, 'leaf'), align: 'left',
      placement: { ...at('page', 'top-left', SIDE, 12), size: { width: mm(150) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(60),
      lineHeight: 0.9, // a multiple of the size (gotcha: design-lineheight-multiple)
      color: col('leaf'), align: 'left', overflow: 'wrap', // two lines in 178 mm
      placement: { ...at('page', 'top-left', SIDE - BEARING, 17), size: { width: mm(178) } } },
    // {attr.issue} comes from the H1 line, {publishDate} from the quoted frontmatter
    // (gotcha: quote-frontmatter): an unquoted date prints nothing here.
    { kind: 'text', id: 'tab', content: '{attr.issue} · {publishDate}', ...caps(9, 'paper'),
      align: 'right', box: { backgroundColor: col('tomato'),
        padding: { top: mm(1.8), right: mm(3.2), bottom: mm(1.6), left: mm(3.2) } },
      placement: at('page', 'top-right', -SIDE, 44) },
  ] } };
// #endregion

// #region tags: four chip styles replace the built-in pale-blue chip
const chip = (id, look) => ({ id, fontFamily: LABEL, bold: true,
  fontSize: pt(7.7), // in points: em(0.82) is 7.7 pt in the text, 6.6 pt on the 8 pt jump lines
  borderWidth: pt(0), borderRadius: em(1), // a radius past half the height draws a pill
  paddingX: em(0.5), paddingY: em(0.14), ...look }); // em: of the chip's own size
const chipStyles = [
  chip('tag', { background: col('tomato'), color: col('paper') }), // the first is the default
  chip('free', { background: col('marigold'), color: col('ink') }),
  chip('when', { backgroundEnabled: false, borderWidth: pt(0.8), borderColor: col('leaf'),
    color: col('leaf') }),
  chip('jump', { background: col('straw'), color: col('ink') }),
];
// #endregion

// #region box: two columns inside a box set a paragraph beside its drawing
// Text never runs round a picture in a column (text wrap is a gap), but a :::columns group
// inside a box sets blocks side by side; breaks="2" opens column two at the second block:
//   :::callout{type="bed" title="Sixteen squares by the gate"}
//   :::columns{count=2 breaks="2"}
//   The demonstration bed by the gate is 1.2 metres square …   ← block 1
//   ::resource{id="bed"}                                        ← block 2
//   :::
//   :::                                   (gotcha: callout-columns)
const bedBox = { id: 'bed', background: col('tint'), // one device: a tint, no stripe
  padding: { top: mm(3.5), right: mm(4), bottom: mm(4), left: mm(4) }, columnGap: mm(5),
  titleStyle: { ...caps(8.5, 'leaf'), gap: mm(2.4) },
  body: { fontSize: pt(8.8), lineHeight: pt(12.4), paragraphSpacing: false } };
// #endregion

// #region band: a narrower figure still takes the whole band of its column
const drawing = (id, w, h, placement = {}) => ({ id, typeId: 'drawing', kind: 'svg',
  svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, // mm × 10: fitted to the column
  placement: { position: 'here', ...placement }, // drawn where ::resource{id} stands
  caption: CAPTIONS[id][0], note: CAPTIONS[id][1], altText: CAPTIONS[id][2],
  createdAt: 0, updatedAt: 0 });
// width is a fraction of the column and align where the figure sits in it: the text above
// and below never moves into the white either side.
const HEAT = { width: 0.62, align: 'center' };
const resources = () => [drawing('plan', 114, 56.5), drawing('heat', 68, 40, HEAT),
  drawing('bed', 50, 50), DRILL];
// Newsletter drawings carry no "Figure 1": an empty prefix prints no label.
const resourceTypes = [{ id: 'drawing', name: 'Drawing', shortLabel: 'Drawing',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];
// #endregion

const head = (id, content, edge, x, y, look) => ({ kind: 'text', id, content, pages: 'body',
  align: edge.slice(4), placement: at('page', edge, x, y), ...look });
const folio = { elements: [ // page 2's head: title and date over a hairline, the folio right
  head('name', '{title} · {publishDate}', 'top-left', SIDE, 11, caps(7.5, 'leaf')),
  head('folio', '{pageNumber}', 'top-right', -SIDE, 9.6,
    { fontFamily: DISPLAY, fontSize: pt(12), color: col('tomato') }),
  { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'), pages: 'body',
    placement: { ...at('page', 'top-left', SIDE, 15.5), size: { width: mm(210 - 2 * SIDE) } } },
] };

const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), ...look });
const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes, chipStyles, layout, calloutStyles: [bedBox],
  page: { width: mm(210), height: mm(297), dpi: 150, backgroundColor: col('paper'),
    margins: { top: mm(20), bottom: mm(18), left: mm(SIDE), right: mm(SIDE) } },
  bodyText: { fontFamily: TEXT, fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
    referenceColor: col('leaf'), // for a :ref label: the palette reaches bold but not this
    // colour, which would stay the default blue (gotcha: palette-skips-designs)
    // Ragged: no hyphens, no runt check (gotchas: ragged-no-hyphenation, ragged-runts)
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('leaf'), levels: [
    // Restated (gotcha: headings-drop-h1-break); span: 'page' sets the masthead over both columns
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } },
    { level: 2, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginTop: pt(0),
      marginBottom: pt(LEAD / 2) },
    { level: 3, fontFamily: TEXT, fontWeight: 700, fontSize: pt(10.2), lineHeight: pt(LEAD),
      color: col('ink'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a brief's name
    { level: 4, ...caps(8.5, 'tomato'), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }, // a notice's name
  ] },
  headingStyles: [
    // marginBottom 0: the level's 0.5 em is added under minHeight and would drop the text a line
    { id: 'masthead', marginBottom: pt(0), advancedDesign: masthead },
    { id: 'rail', fontSize: pt(17), lineHeight: pt(2 * LEAD), color: col('tomato') },
    { id: 'jump', fontSize: pt(19), lineHeight: pt(1.5 * LEAD), marginTop: pt(LEAD / 2) },
  ],
  paragraphStyles: [
    { id: 'standfirst', fontSize: pt(12), lineHeight: pt(16) },
    { id: 'byline', ...label(8, { color: col('muted'), marginBottom: pt(LEAD / 2) }) },
    { id: 'jump', ...label(8, { textAlign: 'right' }) }, // 'Continued on page 2'
    { id: 'from', ...label(8) }, // 'Continued from page 1'
    { id: 'colophon', ...label(7, { lineHeight: pt(9.4), color: col('muted'),
      marginTop: pt(LEAD) }) },
  ],
  captionStyle: { fontFamily: TEXT, fontSize: pt(8), color: col('ink'), gap: mm(1.6),
    note: { fontFamily: LABEL, fontSize: pt(6.8), color: col('muted') } },
  header: folio,
  footer: { elements: [] }, // the folio is at the head of page 2; page 1 has the masthead
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 116行 · content.en.mdtitle: "The Allotment Gazette" subtitle: "Newsletter of the Wren Lane Allotment Society" publishDate: "Spring 2026" --- # The Allotment Gazette {style="masthead" issue="No. 47"} :chip[SITE NEWS]{style="tag"} ## Shared compost bays open by the top gate :::paragraphs{style="standfirst"} Sixty plots now feed three timber bays instead of a heap each, and bay one reached 63 °C in five days. Its compost goes back to the plots in September. ::: :::paragraphs{style="byline"} Maureen Adeyemi, site secretary ::: ::resource{id="plan"} On the first Saturday of March eleven of us put up three bays of reclaimed scaffold boards by the top gate. Each is a metre and a half square and a metre high, with loose boards at the front that lift out, so a heap can be forked into the next bay. Hallam’s yard on Station Road let us have the boards for the cost of the van, and Ron Ellis brought his drill. Until now each plot has kept its own heap, and most of those heaps never get warm. A heap needs about a cubic metre of mixed material before it holds its heat, and few of us fill that much in a season. In one bay, with the stable manure the riding school leaves at the gate, the same waste heats up within days. :::paragraphs{style="jump"} :chip[Continued on page 2]{style="jump"} ::: :::columnbreak ## In brief {style="rail"} ### Path day :chip[WORK PARTY]{style="tag"} :chip[Sun 12 April]{style="when"} The grass paths between the plots need edging, and the main path a fresh load of gravel. Andy Price brings his edging iron; there are spades and barrows. Meet at the hut at nine. ### Seed swap :chip[EVENT]{style="tag"} :chip[Sat 11 April]{style="when"} Bring spare seed in labelled envelopes to the hut between ten and twelve. Seed saved from open-pollinated plants is welcome; seed from F1 hybrids will not come true. Tea and flapjack, 50p. ### Stable manure :chip[FREE]{style="free"} Whitecross Riding School leaves a trailer-load at the top gate every other Tuesday. Take what your plot needs, but top up bay one first, and keep the gateway clear for the tractor. ### Jam jars and a gazebo :chip[WANTED]{style="tag"} Clean jars with lids for the jam stall at the summer show, and a gazebo to borrow on Saturday 18 July. Leave jars in the crate by the hut door; Sue Marsh collects them on Fridays. ### New neighbours :chip[WELCOME]{style="free"} Four plots changed hands this winter. Welcome to the Okafors on plot 9, Priya and Tom on 23a, Mr Hensley on 31, and the Year 5 class at St Anne’s, who share plot 40 with Mrs Dunn. :::columnbreak :::paragraphs{style="from"} :chip[Continued from page 1]{style="jump"} ::: ## What goes into the bays {style="jump"} Bay one was filled on 7 March. On the 12th the thermometer at its centre read 63 °C, and the heap stayed at 55 °C or more for six days, long enough to kill most weed seeds. The morning readings are chalked on the board by the gate. Fill one bay at a time, with green and brown in equal barrow-loads: grass cuttings, weeds without seed heads, spent crops and peelings with torn cardboard, straw and manure. Chop anything thicker than a thumb. Keep out clubroot-infected brassica roots, bindweed and couch grass, and cooked food, which brings rats. ::resource{id="heat"} Turning lets air into the middle of the heap, and the temperature climbs again: bay one, turned on 18 March, was back above 60 °C two days later. The rota on the hut door puts two plots in charge of the fork each week. In September every plot can take away two barrow-loads of finished compost, and whatever is left goes on the demonstration bed by the gate. :::callout{type="bed" title="Sixteen squares by the gate"} :::columns{count=2 breaks="2"} The demonstration bed by the gate is 1.2 metres square, edged with the same scaffold boards as the bays. This year it holds compost from the council depot, 15 centimetres deep; next spring it gets our own. Strings divide the bed into sixteen squares, 30 centimetres a side, each planted with one, four, nine or sixteen plants according to the size of the crop. Year 5 from St Anne’s planted it on 20 March and will keep its diary. ::resource{id="bed"} ::: ::: :::columnbreak ## Notices {style="rail"} #### Rents Rents for 2026 are £52 for a full plot and £28 for a half, due by the end of April. Pay Dev Sharma on plot 18, or by bank transfer: the details are on the hut door. #### Water The troughs are filled from 1 April to the end of October. Use a hose to fill cans only, and never leave one running on a bed. #### Bonfires None from April to September, and none at any time when the wind blows towards the houses on Wren Lane. #### Sheds A new shed over two metres tall needs the committee’s approval. Send a sketch with its size to the secretary before you buy. #### Waiting list Thirty-one names are on the list. We expect to offer eight plots this year, most of them half plots. #### Bees On sunny Saturdays in April, Jan inspects the two hives on plot 52. Keep to the main path while her flag is up. #### Lost A green ten-litre watering can with a brass rose. Please bring it back to plot 7. #### Next issue Please leave copy for the summer issue in the letterbox on the hut door by 15 June. :::paragraphs{style="colophon"} The Allotment Gazette is written and put together by members of the Wren Lane Allotment Society. Set in Work Sans, Titan One and Courier Prime (SIL Open Font License) · Text: original, CC BY 4.0. :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the site plan, the heap's temperature and the square-metre bed function mulberry32(seed) { // a seeded PRNG: the same plots 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 const f = (v) => +v.toFixed(2); const P = palette; const SOIL = mix(P.tomato, P.ink, 0.62); const EARTH = mix(SOIL, P.straw, 0.28); const GRASS = mix(P.tint, P.leaf, 0.3); const DEEP = mix(P.leaf, P.ink, 0.35); const svgOf = (w, h, body, style = '') => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * 10}" height="${h * 10}" viewBox="0 0 ${w} ${h}">${style}${body}</svg>`; const dot = (x, y, r, fill) => `<circle cx="${f(x)}" cy="${f(y)}" r="${f(r)}" fill="${fill}"/>`; const rect = (x, y, w, h, fill, extra = '') => `<rect x="${f(x)}" y="${f(y)}" width="${f(w)}" ` + `height="${f(h)}" fill="${fill}"${extra}/>`; const line = (x1, y1, x2, y2, stroke, width) => `<path d="M${f(x1)} ${f(y1)}L${f(x2)} ${f(y2)}" ` + `stroke="${stroke}" stroke-width="${width}" stroke-linecap="round"/>`; // One plot seen from above: rows of crops across it, sometimes a shed or a greenhouse. function plot(x, y, w, h, rand, extra) { let out = rect(x, y, w, h, EARTH); const strips = 2 + Math.floor(rand() * 3); let top = y + 0.8; for (let s = 0; s < strips; s++) { const room = y + h - 0.8 - top; if (room < 2) break; let sh = s === strips - 1 ? room : Math.max(4, (h - 1.6) * (0.2 + rand() * 0.3)); if (room - sh < 4) sh = room; // no strip thinner than 4 mm const kind = Math.floor(rand() * 6); const [x0, x1] = [x + 0.9, x + w - 0.9]; if (kind === 0) { // rows of lettuce and onions for (let yy = top + 1; yy < top + sh - 0.6; yy += 1.7) { for (let xx = x0 + 0.6; xx < x1; xx += 1.5) out += dot(xx, yy, 0.55, P.leaf); } } else if (kind === 1) { // peas and beans up canes for (let yy = top + 1.2; yy < top + sh - 0.8; yy += 2.6) out += line(x0, yy, x1, yy, DEEP, 1); } else if (kind === 2) { // brassicas under netting out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, mix(P.tint, P.paper, 0.4)); for (let yy = top + 1.6; yy < top + sh - 1.2; yy += 3) { for (let xx = x0 + 1.4; xx < x1 - 0.8; xx += 3) { out += dot(xx, yy, 1.1, mix(P.leaf, P.rule, 0.45)); } } } else if (kind === 3) { // rhubarb and squashes for (let xx = x0 + 2; xx < x1 - 1; xx += 4 + rand() * 2) { const yy = top + sh / 2 + (rand() - 0.5) * (sh - 3); out += dot(xx, yy, 1.8, P.leaf) + dot(xx + 0.6, yy - 0.5, 0.45, P.marigold); } } else if (kind === 4) { // dug over and raked for (let yy = top + 0.7; yy < top + sh - 0.3; yy += 0.9) { out += line(x0, yy, x1, yy, SOIL, 0.25); } } else { // straw round the strawberries out += rect(x0 - 0.3, top, x1 - x0 + 0.6, sh - 0.4, P.straw); for (let xx = x0 + 1; xx < x1; xx += 2.2) { out += dot(xx, top + sh / 2 - 0.2, 0.8, P.leaf) + dot(xx + 0.4, top + sh / 2 + 0.3, 0.3, P.tomato); } } top += sh; } if (extra === 'shed') { // a shed with its felt roof in tomato, and a water butt const sx = rand() < 0.5 ? x + 0.6 : x + w - 5.1; out += rect(sx, y + 0.6, 4.5, 3.4, P.tomato) + line(sx, y + 2.3, sx + 4.5, y + 2.3, SOIL, 0.3) + dot(sx + (sx > x + 1 ? -1.3 : 5.8), y + 1.6, 0.9, P.ink); } else if (extra === 'glass') { // a greenhouse: glass over the whole end of the plot out += rect(x + 0.8, y + h - 7.2, w - 1.6, 6.4, mix(P.paper, P.tint, 0.6)) + [0.25, 0.5, 0.75].map((k) => line(x + 0.8 + k * (w - 1.6), y + h - 7.2, x + 0.8 + k * (w - 1.6), y + h - 0.8, P.rule, 0.3)).join(''); } return out; } function planSvg(face) { // 114 × 56.5 mm: the top of the Wren Lane site, north up const rand = mulberry32(1921); // the year the society took the lease const [W, H, PATH] = [114, 56.5, 26.5]; const label = (lx, ly, s, fill, anchor = 'start') => `<text x="${lx}" y="${ly}" ` + `font-size="2.3" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(0, 0, W, H, GRASS) + rect(0, PATH, W, 4.5, P.straw); // the main path, gravel out += label(W - 2, PATH + 3, t({ en: 'MAIN PATH', es: 'CAMINO' }), SOIL, 'end'); // The top gate at the end of the path, and the corner behind it. out += rect(0, PATH - 0.8, 1.2, 1.2, P.ink) + rect(0, PATH + 4.1, 1.2, 1.2, P.ink) + label(2.2, PATH + 3, t({ en: 'TOP GATE', es: 'PUERTA DE ARRIBA' }), SOIL); out += rect(2, 2.4, 11, 8, P.tomato) + rect(2, 6.4, 11, 4, mix(P.tomato, P.ink, 0.22)) // the hut + label(7.5, 7.2, t({ en: 'HUT', es: 'CASETA' }), P.paper, 'middle'); out += rect(15.5, 3.2, 11.5, 3.6, mix(P.rule, P.ink, 0.3)) // the trough + rect(16.1, 3.8, 10.3, 2.4, mix(P.tint, P.rule, 0.4)); for (let i = 0; i < 3; i++) { // the three bays in fresh boards: full, half full, empty const bx = 2 + i * 8.6; out += rect(bx, 13.5, 7.8, 7.8, P.marigold) + rect(bx + 0.8, 14.3, 6.2, 6.2, SOIL); for (let k = 0; k < 16 - i * 7; k++) { out += dot(bx + 1.5 + rand() * 4.8, 15 + rand() * 4.8, 0.5, i === 0 ? DEEP : EARTH); } out += label(bx + 3.9, 24.6, i + 1, P.ink, 'middle'); } out += dot(25.5, 10.5, 3.6, DEEP); // the old pear // Plots: four above the path, five below; one split in halves, grass paths between. const row = (x0, x1, y, h, n, extras) => { const w = (x1 - x0 - (n - 1) * 1.4) / n; for (let i = 0; i < n; i++) { const px = x0 + i * (w + 1.4); if (extras[i] === 'halves') { const half = (h - 1.4) / 2; out += plot(px, y, w, half, rand) + plot(px, y + half + 1.4, w, half, rand, 'shed'); } else out += plot(px, y, w, h, rand, extras[i]); } }; row(31, W - 2, 2, PATH - 3.5, 4, ['shed', 'halves', '', 'shed']); row(2, W - 2, PATH + 6.5, H - PATH - 8.5, 5, ['', 'shed', 'glass', '', 'shed']); return svgOf(W, H, out, face); } // An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so the // chart carries its label face inline, as a data URL of the Fontsource file. async function inlineFace(family, weight) { const id = family.toLowerCase().replace(/\s+/g, '-'); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}` + '-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return `<style>@font-face{font-family:L;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:L;font-weight:700}</style>`; } const HEAT_LOG = [12, 24, 38, 49, 57, 63, 64, 62, 59, 55, 51, 47, 56, 61, 62, 60, 57, 54, 51, 48, 46, 44]; // °C at the heap's centre, 7 to 28 March; turned after the reading on the 18th function heatSvg(face) { // 68 × 40 mm; the plot runs 7–28 March and 0–70 °C const [W, H, X0, X1, Y0, Y1] = [68, 40, 9, 66, 3, 33]; const x = (day) => X0 + ((day - 7) / 21) * (X1 - X0); const y = (deg) => Y1 - (deg / 70) * (Y1 - Y0); const text = (tx, ty, s, fill, anchor = 'end', size = 2.5) => `<text x="${f(tx)}" y="${f(ty)}" ` + `font-size="${size}" fill="${fill}" text-anchor="${anchor}">${s}</text>`; let out = rect(X0, y(65), X1 - X0, y(55) - y(65), P.straw); // where weed seeds die for (const deg of [20, 40, 60]) { out += line(X0, y(deg), X1, y(deg), P.rule, 0.2) + text(X0 - 1.2, y(deg) + 0.9, deg, P.muted); } out += text(X0 - 1.2, y(70) + 0.9, '°C', P.muted); // the unit, over the scale for (const day of [7, 14, 21, 28]) { out += line(x(day), Y1, x(day), Y1 + 1, P.ink, 0.25) + text(x(day), Y1 + 3.8, day === 28 ? t({ en: '28 March', es: '28 marzo' }) : day, P.muted, day === 28 ? 'end' : 'middle'); } out += line(x(18.5), y(70), x(18.5), Y1, P.leaf, 0.35) + text(x(18.5) + 1, y(70) + 2.2, t({ en: 'turned', es: 'volteo' }), P.leaf, 'start'); const pts = HEAT_LOG.map((deg, i) => [x(7 + i), y(deg)]); out += `<path d="M${pts.map(([px, py]) => `${f(px)} ${f(py)}`).join('L')}" fill="none" ` + `stroke="${P.tomato}" stroke-width="0.55" stroke-linejoin="round"/>`; out += pts.map(([px, py]) => dot(px, py, 0.6, P.tomato)).join(''); out += line(X0, Y1, X1, Y1, P.ink, 0.3); return svgOf(W, H, out, face); } // The square-metre bed: sixteen squares of 30 cm, one to sixteen plants in each. const CROPS = { // plants per square, size and colour of each plant seen from above cabbage: [1, 4.2, mix(P.leaf, P.rule, 0.35)], lettuce: [4, 2.1, mix(P.leaf, P.straw, 0.35)], chard: [4, 1.9, mix(P.leaf, P.tomato, 0.3)], marigold: [4, 1.5, P.marigold], beetroot: [9, 1.1, mix(P.tomato, P.ink, 0.35)], beans: [9, 1.2, P.leaf], onion: [9, 0.8, mix(P.straw, P.paper, 0.3)], radish: [16, 0.6, P.tomato], carrot: [16, 0.55, mix(P.marigold, P.tomato, 0.35)], }; const BED = [['cabbage', 'lettuce', 'lettuce', 'marigold'], ['beetroot', 'radish', 'carrot', 'onion'], ['chard', 'beans', 'beetroot', 'radish'], ['marigold', 'onion', 'carrot', 'lettuce']]; function bedSvg() { // 50 × 50 mm: 1.2 m of bed inside its scaffold boards const [S, BOARD] = [50, 2.6]; const cell = (S - 2 * BOARD) / 4; let out = rect(0, 0, S, S, P.marigold) + rect(BOARD, BOARD, S - 2 * BOARD, S - 2 * BOARD, SOIL); BED.forEach((cropRow, r) => cropRow.forEach((crop, c) => { const [n, size, fill] = CROPS[crop]; const k = Math.sqrt(n); for (let i = 0; i < n; i++) { const cx = BOARD + c * cell + ((i % k) + 0.5) * (cell / k); const cy = BOARD + r * cell + (Math.floor(i / k) + 0.5) * (cell / k); out += dot(cx, cy, size, fill); if (crop === 'marigold') out += dot(cx, cy, size * 0.4, P.tomato); } })); for (let i = 1; i < 4; i++) { // the strings const at = BOARD + i * cell; out += line(at, BOARD, at, S - BOARD, P.paper, 0.25) + line(BOARD, at, S - BOARD, at, P.paper, 0.25); } return svgOf(S, S, out); } // A drill of seedlings along the foot of the masthead band, 210 × 8 mm: some show only their // seed leaves, the rest their first true leaves too. function leaf(x, y, len, wid, deg, fill) { // a leaf from its stalk end, deg from the vertical const a = (deg * Math.PI) / 180; const [dx, dy] = [Math.sin(a), -Math.cos(a)]; const [mx, my, px, py] = [x + (dx * len) / 2, y + (dy * len) / 2, -dy * wid, dx * wid]; return `<path d="M${f(x)} ${f(y)}Q${f(mx + px)} ${f(my + py)} ${f(x + dx * len)} ` + `${f(y + dy * len)}Q${f(mx - px)} ${f(my - py)} ${f(x)} ${f(y)}Z" fill="${fill}"/>`; } function drillSvg() { const rand = mulberry32(47); // the issue number const [W, H, RIDGE] = [210, 8, 1.3]; const SEED = mix(P.leaf, P.straw, 0.3); let out = rect(0, H - RIDGE, W, RIDGE, SOIL); for (let x = 2.6; x < W - 1; x += 4.6 + rand() * 1.2) { const ground = H - RIDGE; const grown = rand() < 0.62; const stem = grown ? 2.6 + rand() * 1.2 : 1.2 + rand() * 0.8; const lean = (rand() - 0.5) * 10; out += line(x, ground, x, ground - stem, P.leaf, 0.45); const top = ground - stem; if (grown) { out += leaf(x, ground - stem * 0.45, 1.6, 0.55, -68 + lean, SEED) + leaf(x, ground - stem * 0.45, 1.6, 0.55, 68 + lean, SEED) + leaf(x, top, 2.6 + rand() * 0.5, 1, -32 + lean, P.leaf) + leaf(x, top, 2.6 + rand() * 0.5, 1, 32 + lean, P.leaf); } else { out += leaf(x, top, 1.7, 0.6, -58 + lean, SEED) + leaf(x, top, 1.7, 0.6, 58 + lean, SEED); } } return svgOf(W, H, out); } // What the drawings say under them, in the sample's language: caption, credit note, alt text. const CAPTIONS = { plan: [t({ en: '**The top of the Wren Lane site, from the air.** The three new bays stand between ' + 'the hut and the top gate.', es: '**La parte alta del Soto, desde el aire.** Los tres cajones nuevos están entre la ' + 'caseta y la puerta de arriba.' }), t({ en: 'Drawing: The Gazette, from the society’s site plan', es: 'Dibujo: La Gaceta, a partir del plano de la asociación' }), t({ en: 'Plan of allotment plots seen from above, with three compost bays by the gate.', es: 'Plano de parcelas de huerto vistas desde arriba, con tres cajones de compost junto a la ' + 'puerta.' })], heat: [t({ en: '**Bay one in March.** Temperature at the centre of the heap, read at nine each ' + 'morning. In the shaded band, 55 to 65 °C, most weed seeds die.', es: '**El cajón uno en marzo.** Temperatura en el centro, a las nueve. En la franja, de ' + '55 a 65 °C, mueren casi todas las semillas de malas hierbas.' }), t({ en: 'Readings: the compost rota', es: 'Lecturas: el turno del compost' }), t({ en: 'Line chart: the heap rises from 12 °C to 64 °C in six days, cools to 47 °C, and ' + 'climbs back to 62 °C after it is turned on 18 March.', es: 'Gráfico de líneas: el montón sube de 12 °C a 64 °C en seis días, baja a 47 °C y vuelve ' + 'a 62 °C después del volteo del 18 de marzo.' })], bed: [t({ en: '**What goes where.** One cabbage to a square; four lettuces, chard or marigolds; ' + 'nine beetroot, beans or onions; sixteen radishes or carrots.', es: '**Cómo se planta el bancal.** Una col por cuadro; cuatro lechugas, acelgas o ' + 'caléndulas; nueve remolachas, judías o cebollas; dieciséis rábanos o zanahorias.' }), t({ en: 'Drawing: The Gazette', es: 'Dibujo: La Gaceta' }), t({ en: 'A square bed divided by strings into sixteen squares, each planted with one to ' + 'sixteen plants.', es: 'Un bancal cuadrado dividido con cuerdas en dieciséis cuadros, ' + 'cada uno con entre una y dieciséis plantas.' })], }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Text, display and label faces, loaded before the build (gotcha: fonts-first). const FONTS = { 'Work Sans': ['400', '700'], 'Titan One': ['400'], 'Courier Prime': ['400', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const face = await inlineFace(LABEL, 700); // the label face, for the drawings' own lettering await Promise.all([loadSvg('plan.svg', planSvg(face)), loadSvg('heat.svg', heatSvg(face)), loadSvg('bed.svg', bedSvg()), loadSvg('drill.svg', drillSvg())]); const doc = await buildWithFonts( () => buildDocument({ markdown, resources: resources() }, config()), markdown); showPages(doc, { title: t({ en: 'Community newsletter', es: 'Boletín vecinal' }) });
工具包 · 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上的食谱文件夹 ↗

变化

#简讯放在左边

宽栏仍然先读,所以同一份Markdown会把头条排在右边,简讯排在左边。

   layoutType: 'oneAndHalf',
+  sideColumnSide: 'left',

#简讯横排整页

正文最多排两栏。报纸头版在页面下部用一个带:::columns{count=4}的框横排四条简讯。

常见问题

易错点

段落保持它起始那一栏的行长

在postext 1.4.1中,段落只断一次行,按它起始那一栏的宽度。在两栏都有正文的一栏半页面中,从宽栏延续到窄栏的段落保留长行,这些行在窄栏边缘被裁掉;从窄栏延续到下一页宽栏的段落则保留短行。让每一栏在完整的段落处结束:调整文稿篇幅,并在最后一段之后放一个:::columnbreak,使下一个块从下一栏开始。 一栏半 →

易错点

:::columns只在框内起作用,且不会拆分

:::columns在标注框之外会被忽略,拆分的框也不会从columns组中间断开。breaks属性按子块计数,嵌套的框算一个。 框内分栏 →

易错点

frontmatter的每个值都加引号

YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →

易错点

齐左文字从不断词

断词只用于两端对齐的文字;左对齐不齐行的文字在词间断行,所以窄栏的齐左文字行尾会很参差。把这段改为两端对齐,或加大行长。 断词与文档语言 →

易错点

齐左文字从不检查孤字

optimalLineBreaking、avoidRunts、runtPenalty和runtMinCharacters作用于Knuth–Plass断行器,而postext 1.4.1只对两端对齐的文字运行它。齐左段落逐行断行,无论这些设置怎么写,都可能以一个短词结尾。检查齐左文字的末行,改写以孤字结尾的段落。 段末孤行、段首孤行与孤字 →

易错点

不换行空格仍然会断行

在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →

易错点

传入任何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)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

  • 在这种版面里,第1页的栏间线从正文区顶部、报头后面开始,无论报头预留了多少条网格线。麦秆色色带盖在它上面;如果报头没有填色的色带,就会看到栏间线穿过刊名。

致谢

文本
原创文字, CC BY 4.0
图片
  • The site plan, the temperature chart, the square-metre bed and the row of seedlings under the nameplate, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
字体
Work Sans (SIL OFL 1.1) · Titan One (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)
沙盒