成品一览
Brindlewood Seed Co.(一家虚构的种子农场)的春季价目表,排成浅黄色纸上的四页小册子。封面用Alfa Slab One排Spring Seeds,下面是一株向日葵和一串番茄,再下面是两栏的致种植者信。第2、3页是价目表。每个品种的单元格顶部画着它的种子袋,名称排在袋下,蔬菜的类别沿合并的第一栏往下排。价格齐右,用窄体标签字体;红色和绿色的行内标签标出新品和有机种子。表格在煎炒甜椒之后断开,在对页同一条红色色带下接续,标有(continued)。封底是订货单,两个圆角表格,留有空行供填写。
这道食谱解答
- 怎样在表格单元格里放图片或图标?
- 怎样把长表跨页拆分,并重复表头、加上“续表”标记?
- 怎样在一份文档里给几张表设不同的样式(填色、斑马纹单元格、圆角边框)?
- 怎样做一个有表头行、合并单元格、栏宽和逐格对齐的表?
简短回答
const at = (row, c) => ({ row, col: c });
const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal'
function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row
let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm
for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column
for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right');
for (let r = 1; r < m.rows.length; r++) {
// The drawing is a resource of its own; the cell sets it at the top, centred, with the
// variety's name under it, and the row grows to hold both.
const variety = m.rows[r][1].content;
m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET });
m = setCellContent(m, at(r, 1), `**${variety}**`);
m = setAlignment(m, at(r, 1), 'center');
for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a
// bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash)
m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`);
m = setAlignment(m, at(r, c), 'right');
}
if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above
let end = r;
while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++;
// One cell down the rows of its kind; mergeCells leaves the covered cells in the grid,
// marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it.
m = mergeCells(m, { start: at(r, 0), end: at(end, 0) });
m = setAlignment(m, at(r, 0), 'center', 'middle');
m = setCellBackground(m, at(r, 0), col('cream'));
}
return m;
}
// The letter on page 1 cites the list, so it floats to the first free slot after the
// letter, page 2, and is cut between rows where the page ends; span 'page' gives it the
// full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split).
const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table',
caption: '**Vegetable Seeds**', placement: { span: 'page' },
note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.',
table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 });
用料
做法
#1 · 补上TSV没有的内容
代码见上文简短回答。TSV里只有文字和价格。setCellImage把每个品种的种子袋(一个以品种命名的资源)放在单元格顶部,宽度为内宽的55%,所以名称排在一幅20.3 × 27.1 mm的图下,每行增高到36 mm。mergeCells让每个类别沿第一栏向下延伸,并把被覆盖的单元格标为hiddenBy。表格的正文单元格共用一种字体,这里是Gelasio,所以每个价格都是一个不带填充、边框和左右内边距的行内标签,用粗体Cabin Condensed、1.12 em,以取得等高数字。
#2 · 引用价目表,让它跨两页排开
const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio
headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'),
rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5),
// 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit
// page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts.
overflow: 'split', continuedSuffix: '(continued)',
continuesMarker: 'Continued on the facing page' };
const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on
borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'),
bodyFontFamily: LABEL, bodyFontSize: pt(8.5),
bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }];
// The caption sits above the table on a tomato band and is repeated on every part. Its note
// and the marker take the caption's face, so the caption keeps the body's Gelasio, which has
// the italic the suffix and the marker are set in; Cabin Condensed has none.
const captionStyle = { fontSize: pt(12), color: col('cream'),
position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2),
gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } };
第1页的信用:ref{id="vegetables" text="the price list"}引用价目表,text用这几个词代替编号印出来。被引用的表会浮动到引用之后的第一个空位,这里是第2页顶部。表在那里放不下,所以引擎在Jimmy Nardello之后的行间把它断开,绝不在合并的类别中间断开。第3页重复表头行和带后缀的题注色带;标记放在第一部分下面,注释放在最后一部分下面(超过页面高度的表)。parseTSV不把任何单元格标为表头,所以要手动设置headerRowCount: 1,把第一行画成表头行,并在第3页重复。后缀和标记都是斜体,沿用题注的字体,所以色带保持Gelasio;Cabin Condensed没有斜体。
#3 · 每个种子袋一个资源
// Nothing cites the drawings, so none is numbered or placed: the cover design and the cells
// draw them by id, and their typeId is never looked up.
const picture = (id, [w, h], altText) => ({ id, typeId: 'figure', kind: 'svg', altText,
svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE }, createdAt: 0, updatedAt: 0 });
const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content);
const resources = [vegetableList(list), shipTo, orderForm,
picture('cover-art', [ART.w, ART.h], 'A sunflower and a truss of tomatoes in a ploughed field.'),
...varieties.map((name) => picture(slug(name), PACK, `Seed packet of ${name}.`))];
await loadFonts(FONTS, markdown + list);
await loadSvg('cover-art.svg', coverArt()); // the kit's loadSvg calls registerResourceImage
for (const name of varieties) await loadSvg(`${slug(name)}.svg`, packet(PACKETS[slug(name)]));
单元格图片按id指定资源,所以每个种子袋都是一个独立的SVG资源,工具包的loadSvg用registerResourceImage把它的图注册在fileId下。没有任何地方引用这些种子袋,所以它们都不编号,也不会被放到单元格外。作为图片绘制的SVG不能使用页面的网页字体,所以种子袋上不带文字,名称作为单元格文字排出。
#4 · 订货单用第二种表格样式
function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height
let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) =>
widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) };
for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) });
m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff
if (r === 0 || !cell.content || cell.hiddenBy) return;
m = setCellBackground(m, at(r, c), col('paper'));
if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels
}));
return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' },
table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 };
}
const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'],
['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]);
const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet
const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces',
'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'],
['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]),
'We guarantee every packet to grow. If a variety fails to come up in your garden, write to '
+ 'us before August 1 and we will send a new packet or refund what you paid for it.');
两个表格用具名样式form:网格线、外框2.5 mm的圆角,以及供填写的米色底。价目表保留文档的tableStyle(具名表格样式)。空单元格不需要占位内容,因为它所在的行仍占一行高度,这里是7.3 mm。每个总计行把四栏合并成一个齐右的标签。订货单的标题样式把第4页切换为单栏,所以两个表格都占满157 mm的整个行长。
#5 · 让信排在图的下方
const ART = { w: 150, h: 104, y: 60 }; // mm: the cover drawing, and its top on the page
const FRAME = 8; // mm: the cover's frame, in from the trim
const AIR = 10; // mm: at least this much between the drawing and the letter
const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines
const cover = { id: 'cover', span: 'page', footer: { elements: [] },
// A style's header replaces the document's on every page of its section, so it carries
// the running heads too. The frame is drawn there: in the design it would count as
// reserved height down to the page's foot, more than the column holds, and 1.4.1 then
// drops the reservation and sets the letter over the drawing (gotcha:
// opener-reserves-anchored).
header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener',
style: { borderColor: col('basil'), borderWidth: pt(1.2) },
placement: on('page', 'top-left', FRAME, FRAME,
{ width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] },
// The drawing is an image, and images do not count towards the height an opener
// reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it.
advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [
text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }),
text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY,
fontSize: pt(58), lineHeight: 1, color: col('tomato') }),
text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'),
box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1),
left: mm(4), right: mm(4) } } }),
{ kind: 'image', id: 'art', resourceId: 'cover-art',
placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) },
] } },
};
封面图是一个图片元素,而图片不增加章首页预留的高度。没有minHeight,信会从距页顶55 mm处开始,压在向日葵上。SINK在图下加10 mm空白,并把上页边距以下的深度向上取整为整数个13 pt行,共34行,所以信的两栏都从网格上开始,距页顶177.9 mm。
完整食谱
// ═══ Postext Cookbook · Nº 036 · Mail-order catalogue with pictures in cells ═══════ // https://postext.dev/en/cookbook/seed-catalogue // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Gelasio, Alfa Slab One, Cabin Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A fictional seed farm's spring list: a table read from TSV, a packet drawn in each row. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, parseTSV, mergeCells, setAlignment, setCellBackground, setCellContent, setCellImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'seed-catalogue'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // eight named colours; every colour in the config links to one of them ink: '#2d2620', paper: '#f0e2c4', // brown-black text on buff cream: '#fcf7ea', tomato: '#c23b22', // cells to write in; the accent: bands, titles, NEW basil: '#2f6b3b', sun: '#e8b53a', // rules, ORGANIC, the form's head; the cover's banner rule: '#c7ae86', muted: '#6d5f50', // hairlines; running heads and notes }; // 1.4.1 designs read the hex, not paletteId: col() sets both (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries(palette) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const TRIM = { w: 190, h: 250 }; // mm: a stapled mail-order booklet const MARGIN = { top: 22, bottom: 20, inner: 18, outer: 15 }; // mm, mirrored: 157 mm wide const LEAD = 13; // pt: the body leading, the grid the headings keep to const TEXT = 'Gelasio', DISPLAY = 'Alfa Slab One', LABEL = 'Cabin Condensed'; const PACKET = 0.55; // the packet drawing's share of its cell's inner width const caps = (size, fontWeight = 600) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight, letterSpacing: pt(size * 0.18), textTransform: 'uppercase' }); const on = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const text = (id, content, placement, style) => ({ kind: 'text', id, content, placement, overflow: 'wrap', ...style }); // not '…' (gotcha: overflow-ellipsis-default) const chip = (id, size, ink, box) => ({ id, fontFamily: LABEL, bold: true, fontSize: em(size), color: col(ink), borderRadius: pt(1.2), ...box }); const rule = (id, below, gap, thickness) => ({ kind: 'rule', id, direction: 'horizontal', thickness: pt(thickness), color: col('basil'), placement: { ...on(`#${below}`, 'below', 0, gap), size: { width: 'fill' } } }); // #region answer: a price list from TSV: a packet in each variety's cell, kinds merged down const at = (row, c) => ({ row, col: c }); const slug = (name) => name.toLowerCase().replace(/\W+/g, '-'); // 'Gold Medal' → 'gold-medal' function priceList(tsv) { // Kind · Variety · Description · Packet · Ounce, under one head row let m = { ...parseTSV(tsv), headerRowCount: 1, columnWidths: [20, 40, 63, 17, 17] }; // mm for (const c of [0, 1]) m = setAlignment(m, at(0, c), 'center'); // each head over its column for (const c of [3, 4]) m = setAlignment(m, at(0, c), 'right'); for (let r = 1; r < m.rows.length; r++) { // The drawing is a resource of its own; the cell sets it at the top, centred, with the // variety's name under it, and the row grows to hold both. const variety = m.rows[r][1].content; m = setCellImage(m, at(r, 1), { resourceId: slug(variety), width: PACKET }); m = setCellContent(m, at(r, 1), `**${variety}**`); m = setAlignment(m, at(r, 1), 'center'); for (const c of [3, 4]) { // prices in the label face: lining figures, flush right, and a // bare $, since a cell prints the backslash of \$ (gotcha: cell-dollar-backslash) m = setCellContent(m, at(r, c), `:chip[$${m.rows[r][c].content}]{style="price"}`); m = setAlignment(m, at(r, c), 'right'); } if (!m.rows[r][0].content) continue; // an empty Kind goes on with the one above let end = r; while (m.rows[end + 1] && !m.rows[end + 1][0].content) end++; // One cell down the rows of its kind; mergeCells leaves the covered cells in the grid, // marked hiddenBy (gotcha: merged-cells-hiddenby). A split never cuts through it. m = mergeCells(m, { start: at(r, 0), end: at(end, 0) }); m = setAlignment(m, at(r, 0), 'center', 'middle'); m = setCellBackground(m, at(r, 0), col('cream')); } return m; } // The letter on page 1 cites the list, so it floats to the first free slot after the // letter, page 2, and is cut between rows where the page ends; span 'page' gives it the // full 157 mm, not a 75 mm column. Only a float splits (gotcha: here-table-no-split). const vegetableList = (tsv) => ({ id: 'vegetables', typeId: 'list', kind: 'table', caption: '**Vegetable Seeds**', placement: { span: 'page' }, note: 'A packet holds about 30 tomato or pepper seeds, 40 beans or 12 squash seeds.', table: { model: priceList(tsv) }, createdAt: 0, updatedAt: 0 }); // #endregion // #region split: the list's style says what a continued part prints; the forms have their own const tableStyle = { bodyFontSize: pt(9), headerFontFamily: LABEL, // body cells in Gelasio headerFontSize: pt(7.5), headerColor: col('paper'), headerBackground: col('ink'), rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.5), // 'split' is the default, written out as the key to change: 'clip' keeps the rows that fit // page 2 and drops the rest, 'hide' drops the whole list. Suffix and marker label the parts. overflow: 'split', continuedSuffix: '(continued)', continuesMarker: 'Continued on the facing page' }; const tableStyles = [{ id: 'form', rules: 'grid', borderRadius: mm(2.5), // a card to write on borderColor: col('muted'), borderWidth: pt(0.6), headerBackground: col('basil'), bodyFontFamily: LABEL, bodyFontSize: pt(8.5), bodyBackgroundEnabled: true, bodyBackground: col('cream'), cellPadding: mm(1.6) }]; // The caption sits above the table on a tomato band and is repeated on every part. Its note // and the marker take the caption's face, so the caption keeps the body's Gelasio, which has // the italic the suffix and the marker are set in; Cabin Condensed has none. const captionStyle = { fontSize: pt(12), color: col('cream'), position: 'above', backgroundEnabled: true, background: col('tomato'), padding: mm(2), gap: mm(0), note: { fontSize: pt(7.8), color: col('muted') } }; // #endregion const run = (id, content, parity, edge, x, extra) => ({ kind: 'text', id, content, parity, pages: 'body', ...caps(7.5), color: col('muted'), placement: on('page', edge, x, 12.5), ...extra }); const folio = { fontWeight: 700, color: col('ink') }; const header = { elements: [ // folios outside, the booklet's name on the verso, its issue opposite run('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, folio), run('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 6), run('recto-title', '{subtitle}', 'odd', 'top-right', -(MARGIN.outer + 6)), run('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, folio), ] }; const footer = { elements: [{ ...run('drop-folio', '{pageNumber}', 'all', 'bottom', 0, folio), pages: 'opener', placement: on('page', 'bottom', 0, -11) }] }; // #region cover: the frontmatter and the heading set the type; the drawing fills the middle const ART = { w: 150, h: 104, y: 60 }; // mm: the cover drawing, and its top on the page const FRAME = 8; // mm: the cover's frame, in from the trim const AIR = 10; // mm: at least this much between the drawing and the letter const SINK = Math.ceil((ART.y + ART.h + AIR - MARGIN.top) / (LEAD * 25.4 / 72)); // lines const cover = { id: 'cover', span: 'page', footer: { elements: [] }, // A style's header replaces the document's on every page of its section, so it carries // the running heads too. The frame is drawn there: in the design it would count as // reserved height down to the page's foot, more than the column holds, and 1.4.1 then // drops the reservation and sets the letter over the drawing (gotcha: // opener-reserves-anchored). header: { elements: [...header.elements, { kind: 'box', id: 'frame', pages: 'opener', style: { borderColor: col('basil'), borderWidth: pt(1.2) }, placement: on('page', 'top-left', FRAME, FRAME, { width: mm(TRIM.w - 2 * FRAME), height: mm(TRIM.h - 2 * FRAME) }) }] }, // The drawing is an image, and images do not count towards the height an opener // reserves (gotcha: opener-image-no-reserve): minHeight holds the letter under it. advancedDesign: { enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [ text('publisher', '{title}', on('page', 'top', 0, 17), { ...caps(9.5), color: col('basil') }), text('title', '{titleText}', on('page', 'top', 0, 23), { fontFamily: DISPLAY, fontSize: pt(58), lineHeight: 1, color: col('tomato') }), text('issue', '{subtitle}', on('page', 'top', 0, 49), { ...caps(8.5, 700), color: col('ink'), box: { backgroundColor: col('sun'), padding: { top: mm(1.3), bottom: mm(1.1), left: mm(4), right: mm(4) } } }), { kind: 'image', id: 'art', resourceId: 'cover-art', placement: on('page', 'top', 0, ART.y, { width: mm(ART.w) }) }, ] } }, }; // #endregion // #region heads: slab-serif section heads over a basil double rule, and the order sheet const h2 = { level: 2, marginTop: pt(0), marginBottom: pt(0), advancedDesign: { // both open a enabled: true, minHeight: pt(2 * LEAD), slot: { elements: [ // column: no space above text('title', '{titleText}', on('container', 'top-left', 0, 0), { fontFamily: DISPLAY, fontSize: pt(14), lineHeight: 1.1, color: col('tomato') }), rule('thick', 'title', 1.4, 1.2), rule('thin', 'thick', 0.7, 0.4), ] } } }; const order = { id: 'order', span: 'page', layout: { layoutType: 'single' }, // one wide column advancedDesign: { enabled: true, slot: { elements: [ text('address', '{attr.address}', on('container', 'top-left', 0, 0), { ...caps(8), color: col('basil') }), text('title', '{titleText}', on('#address', 'below', 0, 1.5), { fontFamily: DISPLAY, fontSize: pt(34), lineHeight: 1, color: col('tomato') }), rule('thick', 'title', 2.2, 1.6), rule('thin', 'thick', 0.8, 0.5), text('how', '{attr.how}', { ...on('#thin', 'below', 0, 3), size: { width: mm(150) } }, { fontFamily: TEXT, italic: true, fontSize: pt(10.5), lineHeight: 1.35, align: 'left', color: col('ink') }), ] } } }; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) colorPalette, page: { width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, layout: { layoutType: 'double', gutterWidth: mm(7) }, // two 75 mm columns of letter text bodyText: { fontFamily: TEXT, fontSize: pt(9.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceBold: false, referenceItalic: true, firstLineIndent: mm(4), indentAfterHeading: false, // ~48 characters to a 75 mm column, so maxWordSpacing: 1.6 }, // a cap under the default 2; Knuth–Plass can exceed it, so reword to fit headings: { fontFamily: DISPLAY, fontWeight: 400, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0) }, h2] }, headingStyles: [cover, order], paragraphStyles: [ { id: 'signature', textAlign: 'right', firstLineIndent: pt(0) }, { id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'center', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], chipStyles: [ // a price is a bare chip: the label face, bold, a little larger than the text chip('price', 1.12, 'ink', { backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }), chip('new', 0.8, 'cream', { background: col('tomato'), borderWidth: pt(0) }), chip('organic', 0.8, 'basil', { backgroundEnabled: false, borderColor: col('basil'), borderWidth: pt(0.6) })], resourceTypes: [{ id: 'list', name: 'Price list', shortLabel: 'List', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], // no prefix: the // caption is the list's name alone, with no "Table 1". tableStyle, tableStyles, captionStyle, header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 35行 · content.en.md
title: "Brindlewood Seed Co." subtitle: "Catalog No. 12 · Spring 2027" --- # Spring Seeds {style="cover"} ## To Our Growers This is our twelfth spring catalog, and it lists only the vegetables we grew out ourselves last summer at Brindlewood Farm, nine of them, each tested for germination in the packing shed this January. You will find each of them, with a drawing of its packet, in :ref{id="vegetables" text="the price list"} on the next two pages. Tomatoes open the list, as they have done since our first catalog in 2016. Two varieties are new to it this spring and carry a red :chip[NEW]{style="new"} tag, while four were grown on the certified organic field behind the barn and are marked :chip[ORGANIC]{style="organic"}. The rest came from farms we have bought seed from for years, and each one was grown out beside our own before we listed it. Order early. Last spring the Brandywine and the Jimmy Nardello were sold out by March. We mail seed three days a week from February 1 to May 31. The order sheet is on the back page; tear it out, or copy it by hand and keep the catalog whole. :::paragraphs{style="signature"} *Ada Brindle and Tom Okafor* Brindlewood Farm, Marrow Creek ::: ## From Our Trial Garden Every variety on these pages spent last summer in our trial rows. We set out twenty plants of each and score them for how quickly they come up, how long they bear and whether the fruit comes true to type. A tomato that throws odd fruit on two of its twenty plants is dropped from the list. Each lot was tested in January: a hundred seeds on damp paper in a warm room, counted after one week and again after two, and the figure is printed on the packet. Stored cool and dry in a sealed jar, tomato and squash seed stays good for four years, bean seed for three and pepper seed for two. Onion and parsnip seed keeps for only one year, which is why this list carries neither. # Order Sheet {style="order" address="Mail to Brindlewood Seed Co. · Box 12 · Marrow Creek" how="Write one line for each variety: its number, its name and how many packets or ounces you want. Add the postage, and mail the sheet with a check or money order."} ::resource{id="ship-to"} ::resource{id="order"} :::paragraphs{style="colophon"} Set in Gelasio, Alfa Slab One and Cabin Condensed (SIL OFL) · Text and drawings: CC BY 4.0 · A fictional seed house :::`; // the letter, the trial notes and the order sheet const list = String.raw`KIND VARIETY DESCRIPTION PACKET OUNCEMarkdown样例 · 9行 · content.list.en.md
**Heirloom tomatoes** Brandywine *No. 101 · 90 days.* The pink beefsteak a Philadelphia seed house first listed in 1889. Fruit of a pound or more on tall, potato-leaved vines, sweet but with enough bite to taste of tomato. Stake the vines well. :chip[ORGANIC]{style="organic"} 3.95 42.00 Cherokee Purple *No. 102 · 80 days.* Dusky rose fruit of 10 to 12 ounces with green shoulders and brick-red flesh, kept by a Tennessee family long before it reached seed lists in the 1990s. Rich and smoky; slice it thick. 3.95 42.00 Gold Medal *No. 103 · 85 days.* A yellow beefsteak streaked red inside and out, so every slice comes out marbled. Mild and low in acid. Give it the longest season you have. :chip[NEW]{style="new"} 4.25 46.00 Amish Paste *No. 104 · 80 days.* Heart-shaped fruit of 8 to 12 ounces, meaty enough for sauce and juicy enough to eat off the vine. A paste tomato holds less water and fewer seeds, so it cooks down faster. 3.95 38.00 **Frying pepper** Jimmy Nardello *No. 201 · 80 days.* A long, thin-walled Italian frying pepper, sweet and red when ripe; fry it whole in olive oil. Brought from Basilicata to Connecticut in 1887 by the Nardello family. :chip[ORGANIC]{style="organic"} 4.25 48.00 **Beans** Kentucky Wonder *No. 301 · 65 days.* Pole bean. Meaty green pods 7 to 9 inches long, stringless if you pick them young, and in American gardens since the 1870s. Give it a pole or a fence 6 feet tall. :chip[ORGANIC]{style="organic"} 3.50 4.75 Dragon Tongue *No. 302 · 60 days.* A bush bean from the Netherlands. Flat, pale yellow pods streaked violet, tender up to 7 inches long. Cooking turns them plain yellow. :chip[NEW]{style="new"} 3.75 5.25 **Squash** Black Beauty *No. 401 · 50 days.* Zucchini. Glossy green-black fruit on an open bush that is easy to pick over. Cut them at 6 to 8 inches, every other day in July, and the plants keep bearing. On seed lists since the 1920s. 3.50 9.50 Waltham Butternut *No. 402 · 100 days.* Winter squash of 4 to 5 pounds with sweet orange flesh. Its hard, solid stems resist the vine borer that kills other squash, and it keeps until March in a cool room. :chip[ORGANIC]{style="organic"} 3.75 11.00`; // the price list: TSV, as a spreadsheet exports it // #region forms: the order sheet: empty rows to write in, labels on buff, totals merged function form(id, widths, rows, merges, note) { // an empty cell still takes a line's height let m = { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => widths.map((_, c) => ({ content: row[c] ?? '', isHeader: r === 0 }))) }; for (const [r, c0, c1] of merges) m = mergeCells(m, { start: at(r, c0), end: at(r, c1) }); m.rows.forEach((row, r) => row.forEach((cell, c) => { // a label takes the paper's buff if (r === 0 || !cell.content || cell.hiddenBy) return; m = setCellBackground(m, at(r, c), col('paper')); if (cell.colSpan > 1) m = setAlignment(m, at(r, c), 'right'); // the totals' labels })); return { id, typeId: 'list', kind: 'table', note, placement: { position: 'here' }, table: { model: m, styleId: 'form' }, createdAt: 0, updatedAt: 0 }; } const shipTo = form('ship-to', [24, 60, 24, 49], [['Ship to'], ['Name'], ['Street or box'], ['Town', '', 'State and ZIP']], [[0, 0, 3], [1, 1, 3], [2, 1, 3]]); const lines = Array.from({ length: 11 }, () => []); // eleven varieties to a sheet const orderForm = form('order', [16, 73, 20, 20, 28], [['No.', 'Variety', 'Packets', 'Ounces', 'Amount'], ...lines, ['Seeds total'], ['Postage: free on orders of $40 or more, otherwise $4.50'], ['**Total enclosed**']], [1, 2, 3].map((k) => [lines.length + k, 0, 3]), 'We guarantee every packet to grow. If a variety fails to come up in your garden, write to ' + 'us before August 1 and we will send a new packet or refund what you paid for it.'); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) Gelasio: ['400', '400i', '700'], // text, captions, notes and the list 'Alfa Slab One': ['400'], // display: the cover, section heads, the order sheet 'Cabin Condensed': ['400', '600', '700'], // labels: heads, prices, chips, the forms }; // #region art: packets and the cover in flat colour with an ink line, from a seeded PRNG const SCALE = 10; // px per unit of the drawings' boxes: the engine keeps only the ratio const PACK = [60, 80]; // a packet: 3 : 4 const f = (n) => +n.toFixed(2); const INK = `stroke="${palette.ink}" stroke-linejoin="round"`; const HUE = { leaf: '#5d8a2f', vein: '#3f6424', sky: '#dde6dc', soil: '#8e6c47', furrow: '#6f5234', purple: '#7a3845', gold: '#f0b43c', squash: '#d9a55a', dusk: '#e4d0a4', seed: '#4b2f18', zucchini: '#2e4a2a' }; function mulberry32(seed) { // the same speckles on every run: no Math.random() return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const svg = ([w, h], body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" ` + `height="${h * SCALE}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const g = (x, y, deg, body) => `<g transform="translate(${f(x)} ${f(y)}) rotate(${f(deg)})">` + `${body}</g>`; const shape = (d, fill, width) => `<path d="${d}" fill="${fill}" ${INK} ` + `stroke-width="${f(width)}"/>`; const stroke = (d, color, width) => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${f(width)}" stroke-linecap="round"/>`; function lobed(cx, cy, w, h, n, bulge) { // an ellipse whose edge swells between n points const at2 = (a, k = 1) => `${f(cx + Math.cos(a) * w * k)} ${f(cy + Math.sin(a) * h * k)}`; let d = `M${at2(-Math.PI / 2)}`; for (let i = 1; i <= n; i++) { const a = -Math.PI / 2 + (i / n) * 2 * Math.PI; d += `Q${at2(a - Math.PI / n, bulge)} ${at2(a)}`; } return `${d}Z`; } function outline(len, half, bend = 0) { // a closed shape round an axis that bends by `bend` const top = [], bottom = []; for (let i = 0; i <= 36; i++) { const t = i / 36, x = t * len, y = bend * t * t, h = half(t); top.push(`${f(x)} ${f(y - h)}`); bottom.unshift(`${f(x)} ${f(y + h)}`); } return `M${top.join('L')}L${bottom.join('L')}Z`; } function leaf(x, y, len, wid, deg, teeth = 0) { // from its stalk at (x, y); teeth serrate it const saw = (t) => (teeth ? 1 - 0.3 * ((t * teeth) % 1) : 1); // a serrated edge const half = (t) => wid * Math.sin(Math.PI * t) ** 0.8 * saw(t); return g(x, y, deg, shape(outline(len, half), HUE.leaf, wid * 0.1) + stroke(`M0 0H${f(len * 0.85)}`, HUE.vein, wid * 0.09)); } function sprig(x, y, len, deg, teeth = 3) { // a tomato leaf: leaflets in pairs, one at the tip const pair = (t, k) => [-1, 1].map((side) => leaf(t * len, 0, len * 0.3 * k, len * 0.1 * k, side * 58, teeth)).join(''); return g(x, y, deg, stroke(`M0 0H${f(len * 0.9)}`, HUE.vein, len * 0.03) + pair(0.28, 0.8) + pair(0.52, 0.95) + pair(0.74, 1) + leaf(len * 0.86, 0, len * 0.34, len * 0.12, 0, teeth)); } function squashLeaf(x, y, r) { // five rounded lobes and their veins const veins = [0, 1, 2, 3, 4].map((i) => { const a = -Math.PI / 2 + (i / 5) * 2 * Math.PI; const end = `${f(x + Math.cos(a) * r * 0.8)} ${f(y + Math.sin(a) * r * 0.7)}`; return stroke(`M${f(x)} ${f(y)}L${end}`, HUE.vein, r * 0.06); }).join(''); return shape(lobed(x, y, r, r * 0.86, 5, 1.32), HUE.leaf, r * 0.06) + veins; } function calyx(x, y, len) { // six sepals splayed over the shoulder, and the stalk const sepals = [168, 205, 250, 292, 335, 12].map((deg, i) => { const a = (deg * Math.PI) / 180, l = len * (i % 2 ? 0.8 : 1); const tx = x + Math.cos(a) * l, ty = y + Math.sin(a) * l * 0.55 + l * 0.12; const nx = -Math.sin(a) * l * 0.16, ny = Math.cos(a) * l * 0.16; const mx = (x + tx) / 2, my = (y + ty) / 2; return `M${f(x)} ${f(y)}Q${f(mx + nx)} ${f(my + ny)} ${f(tx)} ${f(ty)}` + `Q${f(mx - nx)} ${f(my - ny)} ${f(x)} ${f(y)}`; }); return shape(sepals.join(''), HUE.vein, len * 0.05) + stroke(`M${f(x)} ${f(y)}q${f(len * 0.1)} ${f(-len * 0.4)} ${f(len * 0.35)} ${f(-len * 0.6)}`, HUE.vein, len * 0.22); } function tomato(cx, cy, r, { fill = palette.tomato, rib = '#9e2c17', form = 'round', shoulder, streak } = {}) { const w = r * (form === 'beef' ? 1.22 : 1); const h = r * (form === 'beef' ? 0.86 : form === 'heart' ? 1.14 : 0.94); const body = form === 'heart' ? `M${f(cx)} ${f(cy - h * 0.92)}C${f(cx + w * 1.35)} ${f(cy - h * 1.12)} ${f(cx + w * 0.9)} ` + `${f(cy + h * 0.6)} ${f(cx)} ${f(cy + h)}C${f(cx - w * 0.9)} ${f(cy + h * 0.6)} ` + `${f(cx - w * 1.35)} ${f(cy - h * 1.12)} ${f(cx)} ${f(cy - h * 0.92)}Z` : lobed(cx, cy, w, h, form === 'beef' ? 7 : 6, form === 'beef' ? 1.09 : 1.04); let out = shape(body, fill, r * 0.07); if (shoulder) { out += `<path d="M${f(cx - w * 0.72)} ${f(cy - h * 0.5)}Q${f(cx)} ${f(cy - h * 1.05)} ` + `${f(cx + w * 0.72)} ${f(cy - h * 0.5)}Q${f(cx)} ${f(cy - h * 0.62)} ${f(cx - w * 0.72)} ` + `${f(cy - h * 0.5)}Z" fill="${shoulder}" opacity="0.8"/>`; } if (streak) { out += [-0.45, -0.1, 0.3, 0.6].map((k) => stroke(`M${f(cx + w * k)} ${f(cy + h * 0.8)}` + `Q${f(cx + w * k * 1.35)} ${f(cy + h * 0.1)} ${f(cx + w * k * 0.9)} ${f(cy - h * 0.45)}`, streak, r * 0.12)).join(''); } out += (form === 'beef' ? [-0.4, 0.4] : [-0.3, 0.3]).map((k) => stroke(`M${f(cx + w * k * 0.4)} ` + `${f(cy - h * 0.78)}Q${f(cx + w * k * 1.5)} ${f(cy)} ${f(cx + w * k)} ${f(cy + h * 0.8)}`, rib, r * 0.05)).join(''); out += `<ellipse cx="${f(cx - w * 0.45)}" cy="${f(cy - h * 0.25)}" rx="${f(w * 0.15)}" ` + `ry="${f(h * 0.08)}" transform="rotate(-40 ${f(cx - w * 0.45)} ${f(cy - h * 0.25)})" ` + 'fill="#ffffff" opacity="0.45"/>'; return out + calyx(cx, cy - h * (form === 'heart' ? 0.88 : 0.84), r * 0.55); } function pod(x, y, len, wid, deg, fill, streak) { // a bean hanging from (x, y), seeds swelling const half = (t) => (wid / 2) * Math.sin(Math.PI * Math.min(1, t * 1.04)) ** 0.3 * (1 + 0.1 * Math.cos(t * Math.PI * 10)); let body = shape(outline(len, half, len * 0.14), fill, wid * 0.13); if (streak) { // violet flecks run along the pod body += [[0.12, -0.18], [0.42, 0.15], [0.66, -0.12]].map(([t, k]) => stroke(`M${f(t * len)} ` + `${f(len * 0.14 * t * t + wid * k)}q${f(len * 0.1)} ${f(len * 0.03)} ${f(len * 0.2)} ` + `${f(len * 0.06)}`, streak, wid * 0.16)).join(''); } return g(x, y, deg, body + stroke(`M0 0h${f(-wid * 0.9)}`, HUE.vein, wid * 0.35)); } function beans(fill, streak, flat) { // a trifoliate leaf over three pods hanging from the vine const wid = flat ? 4.2 : 3; return stroke('M10 35Q20 28 30 31T50 31', HUE.vein, 1.1) + leaf(30, 31, 12, 5.2, -150) + leaf(30, 31, 12, 5.2, -30) + leaf(30, 31, 11, 5.5, -90) + pod(25, 33, 25, wid, 98, fill, streak) + pod(30, 33, 27, wid, 88, fill, streak) + pod(35, 33, 24, wid, 76, fill, streak); } function zucchini() { // a fruit lying under two leaves, its flower still on the end const rand = mulberry32(401); const dots = Array.from({ length: 30 }, () => `<circle cx="${f(-16 + rand() * 32)}" ` + `cy="${f(-3.5 + rand() * 7)}" r="0.45" fill="#6f8f4f"/>`).join(''); return squashLeaf(19, 38, 10) + squashLeaf(41, 37, 9) + g(28, 50, -10, `<rect x="-19" y="-5.5" width="38" height="11" rx="5.5" ` + `fill="${HUE.zucchini}" ${INK} stroke-width="0.6"/>${dots}` + stroke('M-14 -2.8H12', '#6d8a58', 1) + stroke('M-19 0h-3', HUE.vein, 2.4)) + shape(lobed(46, 46, 3.6, 3.6, 5, 1.6), palette.sun, 0.5) + '<circle cx="46" cy="46" r="1.3" fill="#d9831e"/>'; } function butternut() { // the bell of a winter squash, stem up const d = 'M26 24C26 35 20 37 20 47A10 9.5 0 0 0 40 47C40 37 34 35 34 24Q30 21 26 24Z'; return squashLeaf(15, 40, 8) + shape(d, HUE.squash, 0.7) + stroke('M28 26Q27 38 24 54M32 26Q33 38 36 54', '#b98a45', 0.5) + stroke('M30 23.5V19.5', '#6d5a2e', 2.2) + '<ellipse cx="30" cy="53.5" rx="2" ry="1.2" ' + 'fill="#b98a45"/>'; } function horns() { // two long frying peppers, their green caps together const half = (t) => 2.7 * (1 - t) ** 0.7 + 0.25; const horn = (x, y, deg) => g(x, y, deg, shape(outline(33, half, 6), palette.tomato, 0.6) + stroke('M3 -1.5Q16 -1.9 29 2', '#e8735a', 0.8) + shape('M-1.5 -2.4Q2 -2.8 2.5 0Q2 2.8 -1.5 2.4Z', HUE.vein, 0.4) + stroke('M-1.5 0h-3', HUE.vein, 1.6)); return leaf(29, 32, 12, 4.5, -60) + horn(18, 38, 8) + horn(22, 31, 26); } const TOMATO = { brandywine: { fill: '#d4574c', form: 'beef' }, // potato-leaved: smooth leaves 'cherokee-purple': { fill: HUE.purple, rib: '#4f2029', shoulder: '#6a7a38' }, 'gold-medal': { fill: HUE.gold, rib: '#c98a1f', form: 'beef', streak: palette.tomato }, 'amish-paste': { form: 'heart' } }; const PACKETS = { // each variety's packet: its band colour and what the window shows ...Object.fromEntries(Object.entries(TOMATO).map(([id, look]) => [id, { band: palette.tomato, art: sprig(26, 30, 15, -150, id === 'brandywine' ? 0 : 3) + sprig(34, 30, 15, -30, id === 'brandywine' ? 0 : 3) + tomato(30, 44, look.form === 'beef' ? 16 : 15, look) }])), 'jimmy-nardello': { band: palette.tomato, art: horns() }, 'kentucky-wonder': { band: palette.basil, art: beans('#6f9a36') }, 'dragon-tongue': { band: palette.basil, art: beans('#efd27a', HUE.purple, true) }, 'black-beauty': { band: palette.sun, art: zucchini() }, 'waltham-butternut': { band: palette.sun, art: butternut() }, }; // No lettering on the packet: an SVG drawn as an image cannot use the page's fonts (gotcha: // svg-no-webfonts), so the variety's name is set in the cell under it. function packet({ band, art }) { // cream paper, a coloured head and foot, a window on the crop return svg(PACK, `<rect x="1" y="1" width="58" height="78" rx="2" fill="${palette.cream}" ` + `${INK} stroke-width="1"/><rect x="4" y="4" width="52" height="9" fill="${band}"/>` + `<path d="M4 15.6H56" stroke="${band}" stroke-width="0.8"/><path d="M7 66V41A23 23 0 0 1 ` + `53 41V66Z" fill="${HUE.sky}" stroke="${band}" stroke-width="1.6"/><path d="M7.8 58Q30 ` + `53.5 52.2 58V65.2H7.8Z" fill="${HUE.soil}"/>${art}<rect x="4" y="69" width="52" ` + `height="7" fill="${band}"/>`); } function sunflower(cx, cy, r) { // two rings of petals round a disc of seeds in a spiral const rand = mulberry32(1931); const petals = (n, len, fill, turn) => Array.from({ length: n }, (_, i) => g(cx, cy, (360 * i) / n + turn, shape(`M${f(r * 0.5)} 0Q${f(r * 0.5 + len * 0.5)} ${f(-len * 0.22)} ` + `${f(r * 0.5 + len)} 0Q${f(r * 0.5 + len * 0.5)} ${f(len * 0.22)} ${f(r * 0.5)} 0Z`, fill, 0.35))).join(''); const seeds = Array.from({ length: 170 }, (_, i) => { // the golden angle, 137.5° const a = i * 2.39996, d = Math.sqrt(i) * r * 0.037 + rand() * 0.15; return `<circle cx="${f(cx + Math.cos(a) * d)}" cy="${f(cy + Math.sin(a) * d)}" r="0.42" ` + `fill="${i % 3 ? HUE.seed : '#7a5230'}"/>`; }).join(''); return petals(24, r * 0.72, '#d9982a', 7.5) + petals(24, r * 0.78, palette.sun, 0) + shape(lobed(cx, cy, r * 0.52, r * 0.52, 12, 1.01), HUE.seed, 0.5) + seeds; } function coverArt() { // a sunflower and a truss of tomatoes growing out of a ploughed field const [w, h] = [ART.w, ART.h]; const furrows = [0, 1, 2].map((i) => stroke(`M${28 + i * 6} ${h - 5 + i * 2.2}Q75 ` + `${h - 15 + i * 2.6} ${122 - i * 6} ${h - 5 + i * 2.2}`, HUE.furrow, 0.6)).join(''); return svg([w, h], `<circle cx="75" cy="50" r="47" fill="${HUE.dusk}"/>` + shape(`M6 ${h}Q75 ${h - 26} 144 ${h}Z`, HUE.soil, 0.6) + furrows + stroke(`M50 ${h - 10}Q44 66 51 40`, HUE.vein, 3.2) + leaf(47, 74, 24, 9, 200, 6) + leaf(49, 60, 22, 8, -25, 6) + leaf(48, 86, 18, 7, -15, 6) + sunflower(51, 37, 26) + stroke(`M111 ${h - 10}Q104 76 110 56T107 20`, HUE.vein, 2.6) + sprig(110, 86, 22, 200) + sprig(110, 72, 24, -22) + sprig(108, 42, 22, 205) + sprig(107, 28, 20, -40) + sprig(107, 21, 12, -95) + stroke('M110 52Q124 45 137 49M120 48.5V58M110 75Q104 73 101.5 77', HUE.vein, 1.2) + tomato(135, 57, 8) + tomato(100, 83, 6, { fill: '#9dbb52', rib: '#6f8f3a' }) + tomato(118, 70, 12, { form: 'beef', fill: '#d4574c' })); } // #endregion // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region pictures: every drawing is a resource of its own, registered under its fileId // Nothing cites the drawings, so none is numbered or placed: the cover design and the cells // draw them by id, and their typeId is never looked up. const picture = (id, [w, h], altText) => ({ id, typeId: 'figure', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE }, createdAt: 0, updatedAt: 0 }); const varieties = parseTSV(list).rows.slice(1).map((row) => row[1].content); const resources = [vegetableList(list), shipTo, orderForm, picture('cover-art', [ART.w, ART.h], 'A sunflower and a truss of tomatoes in a ploughed field.'), ...varieties.map((name) => picture(slug(name), PACK, `Seed packet of ${name}.`))]; await loadFonts(FONTS, markdown + list); await loadSvg('cover-art.svg', coverArt()); // the kit's loadSvg calls registerResourceImage for (const name of varieties) await loadSvg(`${slug(name)}.svg`, packet(PACKETS[slug(name)])); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown + list); showPages(doc, { title: 'Brindlewood Seed Co. · Spring 2027' });工具包 · 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上的食谱文件夹 ↗
变化
#只保留放得下的部分
用overflow: 'clip'时,价目表在第2页放得下的五个品种之后结束,注释排在它们下面,第3页只有试验园笔记,排在第一栏。
- overflow: 'split', continuedSuffix: '(continued)',
+ overflow: 'clip', continuedSuffix: '(continued)',#缩小种子袋
设为40%时,每行降到29 mm,但两种豆子共用一个合并单元格,合起来需要57 mm,超过第2页剩下的44 mm。试验园笔记上移填进这块空间,第3页只放价目表的其余部分,157 mm以下留白。
-const PACKET = 0.55; // 种子袋图占单元格内宽的比例
+const PACKET = 0.4; // 种子袋图占单元格内宽的比例常见问题
易错点
章首页预留的高度一直到最低的页面锚定元素
高级设计的章首页会预留到其最低元素为止的高度,标题下方锚定在页面或出血上的元素也算在内,所以页脚处的装饰会把正文推到下一页。把这类装饰放在标题上方,移到页眉或页脚槽位,或者用minHeight设定预留高度。 设计过的章首页 →
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
表格单元格会保留\$中的反斜杠
postext 1.4.1不在表格单元格中识别数学公式,也不对其中的\$去转义:单元格写\$4.50就印出\$4.50,反斜杠照印,而同一单元格里的\*却印成普通星号。段落里必须转义,因为单独的$在那里会开始数学公式。单元格内容里直接写$,段落里写\$;两处共用的字符串总会在其中一处印错。 由数据生成的表 →
- 没有被引用的资源永远不会被放置。把信里的
:ref去掉,目录就没有价目表了。 - 在75 mm宽的栏里,Knuth–Plass可能留下词间距超过
maxWordSpacing的两端对齐行。信和试验园笔记经过改写,直到最松的一行词间距为正常宽度的1.33倍。笔记还被调到正好14行:若是13行,末页的各栏齐底会在From Our Trial Garden上方加一条空网格线,标题就比另一栏的文字低一行开始。
致谢
- 文本
- 原创文字, CC BY 4.0
- 图片
- The cover's sunflower and tomatoes and the nine seed packets, drawn in code in the page's palette · Ignacio Ferro · CC BY 4.0
- 字体
- Gelasio (SIL OFL 1.1) · Alfa Slab One (SIL OFL 1.1) · Cabin Condensed (SIL OFL 1.1)


