What you'll build
Worksheet 3A of Seedlings Science, an invented series for eight-year-olds, on four 200 × 260 mm pages printed on one side. A leaf-green band carries a yellow unit pill, the title and white fields for name and date; beside it, a pea plant in a white disc has its six parts numbered in yellow. Every activity is a numbered heading over a pale green card. The cards hold white answer boxes with a small grey prompt and room for several lines, word banks of yellow chips, outlined blanks inside sentences, a table of foods to match with plant parts and a checklist of circles to colour. Each answer box sits 2 mm under its question and 5 mm above what follows, and every card ends 9 mm above whatever comes next on its page. The text is set in Andika, which SIL designed for children learning to read.
This recipe answers
- How do I nest boxes, such as a worksheet card holding answer boxes, with exact spacing?
- How do I build a worksheet page: fill-in lines, answer boxes, word banks, checklists?
- How do I make inline chips: keyboard keys, tags, word banks for exercises?
- How do I put pictures or icons inside table cells?
The short answer
// 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].
Cards that hold white answer boxes, stacked exactly, off the grid
Ingredients
- Features
- Nested boxesLeaving the grid on purposeInline chipsCallout boxesColumns inside a boxExplicit vertical spaceHeading stylesNumbered headingsUnnumbered chaptersDesigned openersText, rules and boxes in page designsPictures in page designsHeading attributesLine breaks in titlesNamed table stylesPictures in table cellsCustom resource typesFigures exactly hereBullet lists and checklistsNumbered lists
- Type
- Andika, Baloo 2, Fredoka (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Take the headings off the grid as well
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 },
] },
In the short answer, each box's margins collapse with its neighbours' and the larger one wins. A word bank's 5 mm beat the 2 mm above the list under it, and the last answer box's 5 mm fall inside the card's 5 mm bottom padding instead of adding to it. Left on the grid, a level-2 heading reserves between 10.8 and 14.1 mm instead of its disc's 8.6 mm, because what follows it snaps back to the next 17 pt line; headings.snapToGrid: false keeps it at 8.6 mm. Setting snapToGrid on the nested styles changes nothing, because only the outer card can leave the grid (nested boxes). Column balancing is off because it adds space above headings to fill out a page; on page 2 it would put 33 mm above activity 3 instead of 9.
#2 · Make every blank a chip
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) }),
];
A chip is an unbreakable box in the line, and its padding is painted beyond the line without making the line taller. A word bank is a row of chips in a nested bank box. The blanks, the dots to join and the circles to colour are chips too, each holding only a U+2060 word joiner, because in postext 1.4.1 a chip of spaces prints its markup (inline chips). paddingX sets the width: a blank is 34 mm wide in a sentence and 38 mm alone in a column, and a circle gets half its 1.25 em height on each side. In the checklist the circle stands where the bullet would be (bulletChar: ''). At 19.6 pt, a blank is taller than the 17 pt line, which is why each one has a list item to itself, 24 pt from the next. In 1.4.1 the Sandbox still shows the warning “Chips touch the next line” for blank, wide and tick, because it compares each chip with the 17 pt line pitch and leaves out the 7 pt between list items.
#3 · Number the activities with a heading design
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] } } };
A level-2 heading with an advancedDesign keeps its place in the column, and the design is drawn instead of its text: a disc holding {number}, which prints the level's numberingTemplate, and {titleText} to its right (span and advanced design). The disc and the title are both 8.6 mm tall, and off the grid the heading reserves exactly that, so the design needs no minHeight. Because the numbers come from the level-2 counter, an activity inserted in the Markdown renumbers the ones after it. The self-check heading uses a style with numbered: false, which leaves it out of the count, and an SVG star sits where the disc would be.
#4 · Put the pictures in the table's cells
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 draws a resource inside its cell, here at 76 % of the cell's width, and the row grows to fit it. verticalAlign: 'middle' centres the food's name, the dots and the chip on the picture. The widest column, 31 % of the table, stays empty for the pupil's line. The named style rounds the table's frame and clips the white body fill to it. Its grid rules are white as well: the canvas leaves a faint hairline between filled cells, and a white 1 pt rule on the white panel covers it (named table styles). Its resource type has an empty captionPrefix and the table has no caption text, so no caption prints under it. placement: { position: 'here' } sets the table where its ::resource line stands, inside the card.
The whole recipe
// ═══ 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 sample · 119 lines · 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' }) });Kit · core, fonts, viewer, images: the same in every recipe · 270 lines
// ─── 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 ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗
Variations
#Put the cards back on the grid
With the default snapToGrid: true, whatever follows a card drops to the next 17 pt grid line, and the 9 mm under each card become 14.5 mm under activity 2, 13.2 mm under activity 4 and 11.2 mm under activity 6.
- snapToGrid: false,
+ snapToGrid: true,#Give an answer more room
:::space counts in the answer box's own 17 pt lines, and each line added gives the pupil 6 mm more to write in.
Write here
-:::space{lines=4}
+:::space{lines=6}Pitfalls
Pitfall
:::space is dropped at the top of a box or column
:::space is dropped at the top of a column, a box or a :::columns group, even when it is the box's only content, so an answer box made of space collapses. Open the box with a title or a prompt line, then add the space. Explicit vertical space →
Pitfall
A whitespace-only chip prints its markup
A chip whose text is only spaces, no-break spaces included, prints :chip[ ] literally. For a blank answer chip put a word joiner (U+2060) inside it. Inline chips →
Pitfall
A nested box ignores span, placement and snapToGrid
A callout nested in another ignores its span, placement, snapToGrid and floatBarrier: it always flows inside its parent, at the parent's inner width. Nested boxes →
Pitfall
Chips taller than the line pitch touch the next line
A chip whose box is taller than the line pitch touches the chips on the next line (the chipOverlap warning). Reduce its vertical padding, border or size, or open the leading. Inline chips →
Pitfall
A table or figure inside a box gets no space around it
In postext 1.4.1 a table or figure that ::resource sets at position 'here' inside a :::callout gets none of the space it keeps in the running text: it touches the paragraph above it and the one below. Put a :::space before the ::resource line, and another after it when text follows; a fraction of a line, such as lines=0.33, gives a small gap. Figures exactly here →
Pitfall
An opener's images never count towards the height it reserves
In postext 1.4.1 an advanced-design heading measures the height it reserves without its images: its texts, rules and boxes count, even when anchored to the page, but an image, such as a picture bled across the head of the page, reserves nothing, so the text can start on top of it. Set minHeight to where the text should begin. Designed openers →
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
Text inside an SVG <img> cannot use web fonts
An SVG is drawn as an image, and an image has no access to the page's web fonts, so its labels fall back to a system face. Outline the text, embed an @font-face subset in the SVG, or move the labels to the caption. Figures and tables as resources →
Pitfall
A swapped palette misses design elements and the reference colour
postext 1.4.1 reads colorPalette into the text styles (body, headings, lists, captions, tables, boxes) but not into the elements of headers, footers, openers and part pages, nor into bodyText.referenceColor: they keep the hex written beside their paletteId. When you swap the palette, for a dark screen edition or a retint, rewrite every linked colour from colorPalette before the build. Semantic colour palette →
Pitfall
Only 8 locales hyphenate, by exact code
Hyphenation ships for en-us, es, fr, de, it, pt, ca and nl, matched exactly: 'es-ES' or any other language silently falls back to American English. Hyphenation and document language →
Pitfall
A config is cached by identity: build a fresh object
The engine caches resolved configs by object identity, so changing a config in place and building again reuses the old result. Build a fresh object for every build, which is why a recipe's config is a factory: config(). Pages on a canvas →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Sandbox check · chipOverlap
Chips touch the next line
Why. A chip style's box is taller than the line pitch, so chips on consecutive lines touch.
Fix. Reduce the chip's vertical padding, outline or size, or increase the leading. Docs →
- In postext 1.4.1 a box cannot rule its own writing lines, because callout styles have no ruled background. The answer boxes stay blank and take their height from
:::space, and the short fill-in lines are outlined chips. - A heading style with
numbered: falsestill draws its level's design, with{number}left empty. Without a design of its own, the self-check would open with an empty green disc, soreviewcopies the activity design and puts the star where the disc was.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)


