成品一览
Seedlings Science的练习单3A,这是一套虚构的面向八岁儿童的系列教材,共四页,200 × 260 mm,单面印刷。叶绿色色带上有黄色的单元标签、标题,以及填写姓名和日期的白色栏位;旁边一个白色圆盘里是一株豌豆,六个部位用黄色编号标出。每个活动都是一个带编号的标题,下面是一张浅绿色卡片。卡片里有:带灰色小提示、可写好几行的白色答题框,由黄色行内标签组成的词库,句子中描边的空,一张把食物和植物部位对应起来的表格,以及一份用来涂色的圆圈检查清单。每个答题框位于题目下方2 mm,距后面的内容5 mm;每张卡片结束处距本页后面的任何内容都是9 mm。正文字体是Andika,这是SIL为学习阅读的儿童设计的字体。
这道食谱解答
- 怎样嵌套框,比如装着答题框的练习卡片,并精确控制间距?
- 怎样做练习页:填空横线、答题框、词库、清单?
- 怎样做行内标签:键盘按键、标签、练习用的词库?
- 怎样在表格单元格里放图片或图标?
简短回答
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
// :::callout{type="card"}
// What is inside the pea pods?
// :::callout{type="answer"}
// Write here ← a prompt first: a box of :::space alone collapses
// :::space{lines=3} (gotcha: space-dropped-box-top)
// :::
// :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
id: 'card', background: col('tint'), borderRadius: mm(4),
padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
// Off the grid, or what follows a card drops to the next grid line. Only the outer card can
// set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
snapToGrid: false,
body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
// A small grey prompt on a full 17 pt line; :::space counts in these body lines.
body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
用料
做法
#1 · 标题也脱离网格
headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
balancing: { enabled: false }, // no extra space above headings to fill out a page
levels: [
// Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
{ level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
{ level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
advancedDesign: activity },
] },
在简短回答中,每个框的外边距与相邻框的外边距折叠,取较大的一个。词库的5 mm压过了下方列表上面的2 mm,最后一个答题框的5 mm落在卡片5 mm的底部内边距之内,而不是叠加上去。如果留在网格上,二级标题会占用10.8到14.1 mm,而不是其圆盘的8.6 mm,因为标题后面的内容会回到下一条17 pt的行上;headings.snapToGrid: false让它保持8.6 mm。在嵌套样式上设置snapToGrid没有作用,因为只有外层卡片可以脱离网格(嵌套框)。各栏齐底已关闭,因为它会在标题上方加空来填满页面;在第2页,它会在活动3上方留出33 mm而不是9 mm。
#2 · 每个空都用行内标签
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha:
// empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle.
const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
行内标签是行内一个不可断开的框,它的内边距画在行外,不会让行变高。词库是嵌套的bank框中的一排行内标签。填空、待连线的点和待涂色的圆圈也都是行内标签,每个只包含一个U+2060词连接符,因为在postext 1.4.1中,由空格组成的行内标签会把标记原样印出来(行内标签)。宽度由paddingX设定:句中的空宽34 mm,单独占一栏时宽38 mm;圆圈两侧各留其1.25 em高度的一半。在检查清单中,圆圈占据项目符号的位置(bulletChar: '')。空高19.6 pt,比17 pt的行高,所以每个空都独占一个列表项,与下一项相距24 pt。在1.4.1中,沙盒仍会对blank、wide和tick给出警告“行内标签碰到相邻行”,因为它拿每个标签与17 pt的行距比较,没有算上列表项之间的7 pt。
#3 · 用标题的版面设计给活动编号
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
placement: { anchor: { to: 'container', edge: 'top-left' },
size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
activityTitle] } } };
带advancedDesign的二级标题在栏中保留自己的位置,画出来的是版面设计而不是标题文字:一个圆盘装着{number}(印出该级的numberingTemplate),右边是{titleText}(通栏与高级版面设计)。圆盘和标题都是8.6 mm高,脱离网格后标题正好占这么多,所以版面设计不需要minHeight。编号来自二级标题的计数器,所以在Markdown中插入一个活动,后面的活动会自动重新编号。自我检查的标题使用numbered: false的样式,不参与计数,圆盘的位置放一颗SVG星星。
#4 · 把图片放进表格单元格
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
// White rules on the white panel: unseen, but they hide the canvas's seams between cells.
rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }];
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
placement: { position: 'here' }, table: { styleId: 'match', model: {
columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
TableCell.image在单元格内画出一个资源,这里占单元格宽度的76 %,行高会随之增加。verticalAlign: 'middle'让食物名称、圆点和标签与图片垂直居中。最宽的一栏占表格的31 %,留空给学生画线。命名样式把表格的边框做成圆角,并把白色的表体填充裁到边框内。它的网格线也是白色的:Canvas会在有填充的单元格之间留下一条淡淡的细线,白色面板上一条1 pt的白线正好把它盖住(命名表格样式)。它的资源类型captionPrefix为空,表格也没有题注文字,所以下方不印题注。placement: { position: 'here' }把表格排在其::resource行所在的位置,也就是卡片里面。
完整食谱
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════ // https://postext.dev/en/cookbook/worksheet-answer-boxes // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Andika, Baloo 2, Fredoka (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 = 'worksheet-answer-boxes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines // Design elements read the hex and ignore the palette (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror // #region answer: cards that hold white answer boxes, stacked exactly, off the grid // In the Markdown a box inside a box is a fence inside a fence (each ::: closes one): // :::callout{type="card"} // What is inside the pea pods? // :::callout{type="answer"} // Write here ← a prompt first: a box of :::space alone collapses // :::space{lines=3} (gotcha: space-dropped-box-top) // ::: // ::: const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card const card = { id: 'card', background: col('tint'), borderRadius: mm(4), padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) }, marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows // Off the grid, or what follows a card drops to the next grid line. Only the outer card can // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits). snapToGrid: false, body: { paragraphSpacing: false }, // the boxes' margins do all the spacing lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6), }; // A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the // last box's marginBottom falls inside the card's padding instead of adding to it. const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5), marginTop: mm(ASK), marginBottom: mm(AFTER), ...look }); const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) }, padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) }, // A small grey prompt on a full 17 pt line; :::space counts in these body lines. body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } }); const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }), padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) }, titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'), textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } }); // config() plugs in all three: calloutStyles: [card, answer, bank]. // #endregion // #region chips: words to pick, blanks to fill, dots to join, circles to colour const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill), borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look }); // An empty chip holds a U+2060 word joiner, as one of spaces prints its markup (gotcha: // empty-chip). Its box is a band 1.25 em tall, so paddingX ROUND makes it a circle. const [EMPTY, ROUND, OUTLINE] = ['\u2060', em(0.625), pt(0.9)]; // A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so // the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap). const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4), paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) }); const chipStyles = [ chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }), blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }), chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }), ]; // #endregion // #region activity: an in-column H2 design: the automatic number in a green disc const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}' fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'), box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: mm(DISC_H2), height: mm(DISC_H2) } } }; const activityTitle = { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } }; const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } }; // ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc. const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: { elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement }, activityTitle] } } }; // #endregion const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size const DISC = { x: 108, y: 5, d: 78 }; // mm: the drawing's disc on the page const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) }, size }); const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'), align: 'left', overflow: 'wrap', placement, ...extra }); const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) }; const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2, DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center', box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag), { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) }, placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field // Images reserve no height in an opener (gotcha: opener-image-no-reserve), so minHeight does. const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') }, placement: at(0, 0, { height: mm(BAND) }) }, { kind: 'image', id: 'plant', resourceId: 'plant', placement: at(DISC.x, DISC.y, { width: mm(DISC.d), height: mm(DISC.d) }) }, ...LABELS.map(dot), words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'), box: { backgroundColor: col('sun'), borderRadius: mm(3), padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }), words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }), { lineHeight: 0.98 }), // two lines: the headings break with \\ words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)), ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58), ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22), ] } }); // #region tables: a rounded panel with a picture in each row and chips to join const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'), bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4), // White rules on the white panel: unseen, but they hide the canvas's seams between cells. rules: 'grid', borderColor: col('paper'), borderWidth: pt(1) }]; const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra }); const dotCell = () => cell(`:chip[${EMPTY}]{style="dot"}`, { align: 'center' }); const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 }, align: 'center' }), cell(food), dotCell(), cell(''), dotCell(), cell(`:chip[${part}]{style="word"}`)]; const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number // Set 'here' in its card, after a :::space (gotcha: box-embed-no-gap); built once FOODS exist. const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'here' }, table: { styleId: 'match', model: { columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } }); // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] }, footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL, 7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] }, page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20), left: mm(SIDE), right: mm(SIDE) } }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // #region grid: the headings leave the grid too, and nothing stretches the gaps headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid balancing: { enabled: false }, // no extra space above headings to fill out a page levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() }, { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0), advancedDesign: activity }, ] }, // #endregion orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high headingStyles: [review], calloutStyles: [card, answer, bank], paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 119行 · content.en.md
title: "Seedlings Science" subtitle: "Year 4" --- # Plants and \\ their parts {unit="Unit 3" sheet="Worksheet 3A"} :::paragraphs{style="lead"} Every part of a plant has a job to do. In this worksheet you will name the parts of a pea plant and find out what each one does and which ones we eat. You will need a pencil and some coloured pencils. ::: ## Look closely :::callout{type="card"} What is inside the pea pods on the plant at the top of the page? :::callout{type="answer"} Write here :::space{lines=4} ::: Which part of the plant grows under the ground? Why does the plant need it? :::callout{type="answer"} Write here :::space{lines=6} ::: ::: ## Name the parts :::callout{type="card"} Six parts of the pea plant on page 1 have a number. Choose a word from the word bank and write it next to the same number. :::callout{type="bank"} :chip[stem]{style="word"} :chip[seeds]{style="word"} :chip[flower]{style="word"} :chip[roots]{style="word"} :chip[leaf]{style="word"} :chip[fruit]{style="word"} ::: :::columns{count=3} 1. :chip[]{style="wide"} 2. :chip[]{style="wide"} 3. :chip[]{style="wide"} 4. :chip[]{style="wide"} 5. :chip[]{style="wide"} 6. :chip[]{style="wide"} ::: ::: ## What does each part do? :::callout{type="card"} Finish each sentence with a word from the word bank. :::callout{type="bank"} :chip[food]{style="word"} :chip[insects]{style="word"} :chip[water]{style="word"} :chip[plant]{style="word"} :chip[upright]{style="word"} :chip[seeds]{style="word"} ::: 1. The **roots** hold the plant in the soil and take in :chip[]{style="blank"}. 2. The **stem** keeps the plant :chip[]{style="blank"} and carries water to the leaves. 3. The **leaves** use sunlight to make :chip[]{style="blank"} for the plant. 4. The **flower** has bright petals that attract :chip[]{style="blank"} such as bees. 5. The **fruit** grows around the :chip[]{style="blank"} and keeps them safe. 6. A **seed** can grow into a new :chip[]{style="blank"}. ::: **Did you know?** A pea plant cannot stand up on its own. It climbs by wrapping thin, curly tendrils around anything it touches. ## Parts we eat :::callout{type="card"} We eat many different plant parts. Draw a line from each food to the part of the plant it comes from. :::space{lines=0.33} ::resource{id="foods"} ::: ## Draw and label :::callout{type="card"} Draw a plant you have seen at home or at school. Label at least four of its parts. :::callout{type="answer"} My plant is called :::space{lines=7} ::: ::: ## Try it at home :::callout{type="card"} Put a dried pea on some wet cotton wool in a clear jar. Keep the cotton wool damp and look at the pea every day for a week. What do you think will come out of the pea first: a root or a shoot? :::callout{type="answer"} I think :::space{lines=3} ::: After a week, draw or write what you saw. :::callout{type="answer"} I saw :::space{lines=6} ::: ::: ## How did I do? {style="review"} :::callout{type="card"} Colour a circle for each thing you can do. - :chip[]{style="tick"} I can name the six parts of a plant. - :chip[]{style="tick"} I can say what each part does. - :chip[]{style="tick"} I can tell which part of a plant a food comes from. ::: :::paragraphs{style="colophon"} Seedlings Science is an invented series made for the Postext Cookbook. Text and drawings: CC BY 4.0. Set in Andika, Baloo 2 and Fredoka (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // The matching table's words. The parts are shuffled so that no food faces its own part. const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans', es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|'); const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower', es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|'); // #region art: the pea plant, the six foods and the star, in the palette's colours // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts); // the plant's numbers are design elements set in Baloo 2 over the drawing (see the band). // [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts. const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]]; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" ` + 'stroke-linecap="round" stroke-linejoin="round"'); const leafShape = (x, y, len, wid, turn, fill, vein) => `<g transform="translate(${n(x)} ` + `${n(y)}) rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ` + `${n(-wid)} ${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}` + `${vein ? stroke(`M${n(len * 0.12)} 0L${n(len * 0.8)} 0`, vein, 0.35) : ''}</g>`; function plant() { // a pea plant in a white disc, its roots in a slice of soil const [c, r, ground] = [39, 39, 58]; const dx = Math.sqrt(r * r - (ground - c) ** 2); let out = circle(c, c, r, palette.paper); out += path(`M${n(c - dx)} ${ground}Q20 ${ground - 2.2} 39 ${ground}T${n(c + dx)} ${ground}` + `A${r} ${r} 0 0 1 ${n(c - dx)} ${ground}Z`, palette.soil); for (const [x, y, rr] of [[22, 70, 0.9], [55, 66, 0.7], [47, 73, 1.1], [26, 62, 0.6]]) { out += circle(x, y, rr, palette.paper, ' fill-opacity=".25"'); } out += stroke('M39 58C38 63 36 66 33 70M39 58C40 64 43 67 46 71M38 62C35 63 31 63 27 65' + 'M40 63C44 63 48 62 52 63M36 66C35 69 34 72 34 75M44 68C45 71 46 73 45 76', palette.paper, 0.7); // roots out += stroke('M39 58C37 50 42 44 39 36S37 22 41 11', palette.leaf, 1.5); // the stem for (const [y, s] of [[49, 1], [39, -1], [28, 1], [19, -1]]) { // leaflets in pairs out += leafShape(39, y, 12, 3.6, s > 0 ? -28 : -152, palette.leaf, palette.tint); out += leafShape(39, y + 2, 10, 3.2, s > 0 ? -160 : -20, palette.leaf, palette.tint); } out += stroke('M41 12c2.5-1 4.6.4 4.2 2.6s-3 2.2-3.4.2 1.8-1.8 2.4-.4M39 27c-2.6-1.4-5.4-.6-5.2 ' + '1.6s2.8 2.4 3.4.6-1.4-2-2.2-.8M40 38c2.8-.8 5.2.6 4.6 2.6', palette.leaf, 0.45); // tendrils // A closed pod (the fruit) on the right, its peas showing through; an open one (the seeds). const calyx = (x, y, turn) => `<g transform="translate(${x} ${y}) rotate(${turn})">` + path('M0 0L1.6-1.2 1.2.4 2.4 1.2.6 1.4Z', palette.leaf) + '</g>'; out += stroke('M40 31.5Q42 31 43.4 32', palette.leaf, 0.5) + path('M43 31.6C48 30.8 54 32.4 57.6 37.8C53 38.6 47.4 36.8 43 31.6Z', palette.rule, ` stroke="${palette.leaf}" stroke-width=".5"`) + [[46.4, 33.4], [49.6, 34.8], [52.8, 36.1]].map(([x, y]) => circle(x, y, 1.15, palette.leaf, ' fill-opacity=".35"')).join('') + calyx(42.6, 31.8, -10); out += stroke('M38 36Q37 36 36.2 36.4', palette.leaf, 0.5) + path('M36 36C30 37 23 40 20 45C25 47 33 43 36 36Z', palette.tint, ` stroke="${palette.leaf}" stroke-width=".5"`) + stroke('M35.4 36.6C31 39.6 26 42.6 20.6 44.8', palette.leaf, 0.3) + calyx(36.4, 36.4, 160); for (const [x, y] of [[32.6, 39.4], [29.8, 41], [27, 42.5], [24.2, 43.9]]) { out += circle(x, y, 1.25, palette.leaf); } // Pea flowers, seen from the front: white, as a garden pea's are, outlined in green. for (const [x, y, turn] of [[42, 9, -18], [48, 20.5, 24]]) { const edge = ` stroke="${palette.leaf}" stroke-width=".4"`; out += `<g transform="translate(${x} ${y}) rotate(${turn})">` + stroke('M0 2.6L0 4.6', palette.leaf, 0.5) + path('M-1.1 1.1L0 3 1.1 1.1Z', palette.leaf) + path('M0 0C-4.2-.6-5.2-6.4-1.6-7.2C-.8-7.4-.2-6.8 0-6.2C.2-6.8.8-7.4 1.6-7.2' + 'C5.2-6.4 4.2-.6 0 0Z', palette.paper, edge) // the standard petal + path('M-2.6-1.4C-3.4 1.2-1 2.6 0 1.4C1 2.6 3.4 1.2 2.6-1.4C1.4-.4-1.4-.4-2.6-1.4Z', palette.tint, edge) + stroke('M0-1.2L0-5', palette.rule, 0.3) + '</g>'; // the wings } // Where each leader ends: 2 on the pod's wall past its last pea (the fruit), 3 on a pea in // the open pod (the seeds), 4 in the middle of a leaf's blade, well clear of the stem. const targets = { 1: [43, 5.6], 2: [55.5, 36.6], 3: [27, 42.5], 4: [33.2, 15.9], 5: [39.4, 47], 6: [36.7, 64.4] }; for (const [num, x, y] of LABELS) { // leader lines from each dot to its part const [tx, ty] = targets[num]; out += stroke(`M${x} ${y}L${tx} ${ty}`, palette.ink, 0.35) + circle(tx, ty, 0.7, palette.ink); } return svgDoc(78, 78, out); } function star() { // the self-check's badge: a white star on a sun disc const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? 4.4 : 9.4; return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`; }); return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`, palette.paper), 10); } const food = { carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 ' + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2', palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a, palette.leaf)).join(''), asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19` + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} ` + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('') + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun), lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2' + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf) + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>` + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45) + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`; }, broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8], [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr, palette.leaf)).join(''), orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30, palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'), beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=` + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0' + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''), }; const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order const drawings = { plant, star, ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20, draw())])) }; const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])), plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6', es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las ' + 'raíces numerados del 1 al 6' }), star: t({ en: 'A white star on a yellow disc', es: 'Una estrella blanca en un disco amarillo' }) }; // Each drawing is a resource that the opener, a table cell or the review heading names by // id. None is cited, so none is placed as a figure. Sizes in px, 10 to the millimetre. const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 200, ...{ plant: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } })); // #endregion const resources = [...pictures, foods()]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], // (gotcha: fonts-first) Fredoka: ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });工具包 · 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上的食谱文件夹 ↗
变化
常见问题
易错点
:::space在框顶或栏顶会被丢掉
:::space出现在栏顶、框顶或:::columns组顶部时会被丢掉,即使它是框里唯一的内容,所以只由空白组成的答题框会塌掉。框以标题或一行提示开头,再加空白。 显式竖向间距 →
易错点
嵌套框忽略span、placement和snapToGrid
嵌套在另一个标注框中的标注框会忽略自己的span、placement、snapToGrid和floatBarrier:它总是在父框内部排,宽度为父框的内宽。 嵌套框 →
易错点
框内的表或图四周没有间距
在postext 1.4.1中,::resource在:::callout里以位置'here'排入的表或图,得不到它在正文中享有的间距:上下紧贴前后两段。在::resource这一行之前放一个:::space,后面还有文字时再在其后放一个;取一行的几分之一(如lines=0.33)就能留出一道小间隙。 图就放在这里 →
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 在postext 1.4.1中,框不能给自己画书写横线,因为标注框样式没有横线背景。答题框保持空白,高度由
:::space决定,短的填空线则用描边的行内标签。 - 带
numbered: false的标题样式仍会画出该级的版面设计,只是{number}为空。如果没有自己的版面设计,自我检查会以一个空的绿色圆盘开头,所以review复制了活动的版面设计,把星星放在原来圆盘的位置。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)


