In short
Five pages of a children's science workbook about water. Each question has a coloured box with lines below it. A longer question makes its box taller and moves the rest down. A long project goes on overleaf.
What you'll build
Five pages of Water on the move, unit 3 of an invented Year 4 science activity book, on a 210 × 280 mm page. A flat drawing of the water cycle fills the cover to the bleed, over a 54 pt title and a line for the child's name. Each sheet is the same page template: a unit line, a blue title band, a short text beside a drawing, then three activity boxes in pale blue with a numbered tab. Ruled lines for the answers sit under the first and the third box. The boxes are as tall as their text, so the French sheet, a line longer here and there, pushes its lines further down the page than the English one, and no position in the template changes. The project, a rain gauge made from a bottle, has more steps than one page holds: its box stops at the foot with Continued and goes on at the head of an added page, followed by six lines for the child's notes. Every word, the tabs and the notes heading included, is in the Markdown.
This recipe answers
- How do I make the boxes of a page grow with their text and push the boxes below them down?
- How do I let an activity that does not fit go on on the next page, with a “Continued” mark?
- How do I build a worksheet page: fill-in lines, answer boxes, word banks, checklists?
The short answer
// An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty).
// Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part
// of the frame and move with it when a box above grows. minHeight too: a box that runs out of
// room may not take the lines away from the child.
const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines),
minHeight: mm(LINE * lines),
rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } });
// A box is as tall as its text, never less than drawn: a longer text makes it taller and
// pushes everything after it in the group down the page.
const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) });
const sheet = {
id: 'ficha',
decoration: { elements: [kicker] },
frames: [
...opening,
// The group starts where its first box is placed: under the text, and runs down from there.
{ ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) },
answer('lineas-1', 3),
box('act-2', 'actividad', 22),
// The last box goes on overleaf, with the answer space after it, when its text outgrows
// the page; the other two push it down instead.
{ ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } },
answer('lineas-3', 2),
],
groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5),
frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }],
};
Boxes in a stack grow with their text; empty ruled frames hold the answers
Ingredients
- Features
- Frames that grow in a groupFrames continued on the next pageComposed pagesExplicit vertical spaceInline chipsCallout boxesNumbered box tabsText, rules and boxes in page designsPictures in page designsHeading stylesBullet lists and checklistsNumbered listsMirrored marginsRunning heads and foliosSemantic colour palettePages on a canvasPDF export
- Also uses
- Column balancingDecoration anchored to framesHeading attributesFigures exactly hereFonts embedded in the PDFCustom resource typesFigures and tables as resourcesThreaded text frames
- Type
- Andika, Baloo 2, Lexend (SIL OFL 1.1)
- Assets
- None: every picture is drawn in code
Method
#1 · Pour each activity into its box by name
The template is the short answer above. In the Markdown, a sheet is one ::::page fence: the first heading goes to the frame that takes: 'h1', the paragraph to the flow frame, and each :::frame{slot="…"} to the frame of that name, with its tab words in label. The answer frames get nothing, which is why they are optional: true: an empty frame would otherwise raise slotEmpty. A drawing activity keeps its own blank room inside its box with :::space{lines=3}, since how much room a drawing needs is part of the question.
:::frame{slot="act-1" label="1 · Read and answer"}
Tom lives in Leeds. The water from his tap comes from a reservoir in the hills…
:::#2 · Let the stack move the lines with the boxes
Each box has a minHeight of 22 mm, the height it is drawn at, and an auto height above that. The group lays its five frames one under another with 4.5 mm between them, from where act-1 is placed under the text, so a taller box moves everything after it. The ruled lines belong to the empty frame: its rules draw one line at the foot of every 8 mm of its height, under its text, so the lines move with the frame and a longer answer space is a taller frame. Give the answer frames a minHeight equal to their height: when a group runs short of room, the reflow ladder takes space back from the frames of the group down to their minimum, and an empty frame's minimum is otherwise nothing, which would leave the lines lying on the next box. The last box of a sheet, act-3, goes on overleaf (overflow.continueIn: 'next-page') when its text outgrows the page, and the answer lines under it go with it.
#3 · Carry the project over the page
const project = {
id: 'proyecto',
overflowTemplate: 'proyecto-sigue',
decoration: { elements: [kicker] },
frames: [
...opening,
{ ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) },
// As tall as its text, never past the foot of the page, at full size: the rest ends
// with the language's mark (Continúa, Continued, À suivre) and goes on on an added page.
{ name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } },
],
groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }],
};
// The added page: the rest of the steps at the head, then lines for the child's notes,
// under a heading the page fence names (notas="…").
const projectGoesOn = {
id: 'proyecto-sigue',
decoration: { elements: [kicker,
{ kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY,
fontSize: pt(15), fontWeight: 700, color: col('sea'),
placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] },
frames: [
{ name: 'pasos', style: 'proyecto', width: mm(W),
placement: at('container', 'top-left', 0, 9) },
answer('notas', 6),
],
groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }],
};
The steps box has no fixed height: it is as tall as its text and stops at the foot of the area. A frame that can go on to another page goes on before its text is set smaller, so the box is cut between two lines at full size, ends with the language's mark (Continúa, Continued, À suivre, from locale) and goes on in the frame of the same name on a page of proyecto-sigue (when the text continues). The added page draws the notes lines under the rest of the box, with the heading that the fence names in notas="What I noticed", so the words stay in the Markdown and change with each edition.
#4 · Draw the cover to the bleed
const cover = {
id: 'portada', header: false, footer: false,
decoration: { elements: [
{ kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } },
{ kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1),
color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1),
size: { width: mm(W) } } },
] },
frames: [
{ name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) },
{ name: 'subtitulo', flow: true, width: mm(W) },
{ name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) },
{ name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') },
],
groups: [{ name: 'portada', kind: 'stack', gap: mm(4),
frames: ['titulo', 'subtitulo', 'nombre'] }],
};
The cover is a composed page too: its picture is decoration anchored to the bleed, and its title, series line and name field are a stack that starts 162 mm down the type area. The name field's rule is anchored to the bottom of its frame, so a two-line name label in another language would keep the line under the words. header: false and footer: false keep the folio off the cover.
The whole recipe
// ═══ Postext Cookbook · Nº 159 · Activity book with boxes that grow ══════════════ // https://postext.dev/en/cookbook/activity-book-growing-boxes // Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code // Fonts: Andika, Baloo 2, Lexend (SIL OFL 1.1) · Needs postext ≥ 1.38.0 import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es' | 'fr') const RECIPE = 'activity-book-growing-boxes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one ink, the water blue, the sun, and the pale fields of the boxes const palette = { ink: '#1f2d38', // text sea: '#1d659c', // titles, tabs, list numbers sky: '#e2f0f9', // activity boxes, the cover's sky sun: '#f0a531', // the project's box, the sun leaf: '#4c8f55', // the cover's hills sand: '#fcf0d8', // the materials box rule: '#93bcd8', // answer lines, blanks paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } }, ]; // #endregion const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Lexend']; // Andika: for early readers const W = 174; // mm: the width of the type area, and of every box const LINE = 8; // mm between two answer lines: room for a child's handwriting const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // The head every sheet shares: the unit over the title band, the text with its drawing. const kicker = { kind: 'text', id: 'tema', content: '{attr.tema}', fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 600, color: col('sea'), textTransform: 'uppercase', letterSpacing: pt(1.4), placement: at('container', 'top-left') }; const opening = [ { name: 'titulo', takes: 'h1', style: 'banda', width: mm(W), placement: at('container', 'top-left', 0, 6) }, { name: 'texto', flow: true, width: mm(W - 52), minHeight: mm(36), placement: at('#titulo', 'below', 0, 4) }, { name: 'dibujo', width: mm(44), placement: at('#titulo', 'below', W - 44, 4) }, ]; // #region answer: boxes in a stack grow with their text; empty ruled frames hold the answers // An empty frame is an answer space: the Markdown sends it nothing (optional: no slotEmpty). // Its `rules` draw a line at the foot of every LINE mm of its height, so the lines are part // of the frame and move with it when a box above grows. minHeight too: a box that runs out of // room may not take the lines away from the child. const answer = (name, lines) => ({ name, optional: true, width: mm(W), height: mm(LINE * lines), minHeight: mm(LINE * lines), rules: { every: mm(LINE), color: col('rule'), width: pt(0.75) } }); // A box is as tall as its text, never less than drawn: a longer text makes it taller and // pushes everything after it in the group down the page. const box = (name, style, minHeight) => ({ name, style, width: mm(W), minHeight: mm(minHeight) }); const sheet = { id: 'ficha', decoration: { elements: [kicker] }, frames: [ ...opening, // The group starts where its first box is placed: under the text, and runs down from there. { ...box('act-1', 'actividad', 22), placement: at('#texto', 'below', 0, 6) }, answer('lineas-1', 3), box('act-2', 'actividad', 22), // The last box goes on overleaf, with the answer space after it, when its text outgrows // the page; the other two push it down instead. { ...box('act-3', 'actividad', 22), overflow: { continueIn: 'next-page' } }, answer('lineas-3', 2), ], groups: [{ name: 'actividades', kind: 'stack', gap: mm(4.5), frames: ['act-1', 'lineas-1', 'act-2', 'act-3', 'lineas-3'] }], }; // #endregion // #region continue: the project's steps go on on a page of their own, after their mark const project = { id: 'proyecto', overflowTemplate: 'proyecto-sigue', decoration: { elements: [kicker] }, frames: [ ...opening, { ...box('material', 'material', 20), placement: at('#texto', 'below', 0, 9) }, // As tall as its text, never past the foot of the page, at full size: the rest ends // with the language's mark (Continúa, Continued, À suivre) and goes on on an added page. { name: 'pasos', style: 'proyecto', width: mm(W), overflow: { continueIn: 'next-page' } }, ], groups: [{ name: 'trabajo', kind: 'stack', gap: mm(9), frames: ['material', 'pasos'] }], }; // The added page: the rest of the steps at the head, then lines for the child's notes, // under a heading the page fence names (notas="…"). const projectGoesOn = { id: 'proyecto-sigue', decoration: { elements: [kicker, { kind: 'text', id: 'notas-titulo', content: '{attr.notas}', fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 700, color: col('sea'), placement: { ...at('#notas', 'top-left', 0, -3), size: { width: mm(W) } } }] }, frames: [ { name: 'pasos', style: 'proyecto', width: mm(W), placement: at('container', 'top-left', 0, 9) }, answer('notas', 6), ], groups: [{ name: 'trabajo', kind: 'stack', gap: mm(16), frames: ['pasos', 'notas'] }], }; // #endregion // #region cover: a sky drawn to the bleed, the title, and a line for the child's name const cover = { id: 'portada', header: false, footer: false, decoration: { elements: [ { kind: 'image', id: 'cielo', resourceId: 'portada', decorative: true, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(178) } } }, { kind: 'rule', id: 'nombre-linea', direction: 'horizontal', thickness: pt(1), color: col('sea'), placement: { ...at('#nombre', 'bottom-left', 0, 1), size: { width: mm(W) } } }, ] }, frames: [ { name: 'titulo', takes: 'h1', width: mm(W), placement: at('container', 'top-left', 0, 162) }, { name: 'subtitulo', flow: true, width: mm(W) }, { name: 'nombre', style: 'campo', width: mm(W), minHeight: mm(12) }, { name: 'colofon', style: 'colofon', width: mm(W), placement: at('container', 'bottom-left') }, ], groups: [{ name: 'portada', kind: 'stack', gap: mm(4), frames: ['titulo', 'subtitulo', 'nombre'] }], }; // #endregion // A box with a tab: the tab's words come from the Markdown (label="…"). const tabbed = (id, fill, tab, tabInk, extra = {}) => ({ id, backgroundEnabled: true, background: col(fill), borderRadius: mm(3), border: { enabled: false }, padding: { top: mm(3), right: mm(5), bottom: mm(3.5), left: mm(5) }, label: { position: 'top-left', background: col(tab), color: col(tabInk), fontFamily: LABEL, fontSize: pt(9), fontWeight: 600, height: mm(6), paddingX: mm(3), offset: mm(6), inset: mm(4), rule: { enabled: false, color: col(tab) } }, body: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), firstLineIndent: pt(0) }, ...extra, }); const plain = (id, size, lead, color, extra = {}) => ({ id, backgroundEnabled: false, border: { enabled: false }, padding: { top: mm(0), right: mm(0), bottom: mm(0), left: mm(0) }, body: { fontFamily: LABEL, fontSize: pt(size), lineHeight: pt(lead), color: col(color), boldColor: col(color), italicColor: col(color), firstLineIndent: pt(0) }, ...extra }); const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity, fontFamily: DISPLAY, fontSize: pt(11), fontWeight: 800, color: col('paper'), align: 'center', box: { backgroundColor: col('sea'), borderRadius: mm(4) }, placement: { ...at('page', edge, x, -9), size: { width: mm(8), height: mm(8) } } }); const bookTitle = (id, parity, edge, x) => ({ kind: 'text', id, content: '{title} · {subtitle}', parity, fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 500, color: col('sea'), textTransform: 'uppercase', letterSpacing: pt(1.1), placement: at('page', edge, x, -11) }); const config = { locale: t({ es: 'es', en: 'en-gb', fr: 'fr' }), // hyphenation and the continuation mark resourceTypes: [{ id: 'dibujo', name: t({ es: 'Dibujo', en: 'Drawing', fr: 'Dessin' }), shortLabel: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal', captionPrefix: '', defaultPlacement: { position: 'here' } }], colorPalette, page: { sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(20), bottom: mm(22), left: mm(20), right: mm(16), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(11.5), lineHeight: pt(16), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: DISPLAY, color: col('sea'), fontWeight: 700, balancing: { enabled: false }, levels: [ // Every sheet is a composed page, which starts a page of its own. { level: 1, fontSize: pt(25), lineHeight: pt(30), fontWeight: 800, color: col('paper'), breakBefore: { enabled: false }, marginTop: pt(0), marginBottom: pt(0) }, { level: 3, fontSize: pt(13.5), lineHeight: pt(17), marginTop: pt(6), marginBottom: pt(2) }, ], }, headingStyles: [{ id: 'portada', fontSize: pt(54), lineHeight: pt(54), fontWeight: 800, color: col('sea'), marginTop: pt(0), marginBottom: pt(0) }], unorderedLists: { bulletChar: '•', color: col('sea'), fontWeight: 700, itemSpacing: pt(3) }, orderedLists: { numberFormat: 'arabic', fontFamily: DISPLAY, fontWeight: 800, color: col('sea'), itemSpacing: pt(4) }, chipStyles: [ // An empty chip, :chip[ ]{style="hueco"}, is a blank to write in: padding and an outline. { id: 'hueco', fontFamily: LABEL, background: col('paper'), borderColor: col('rule'), borderWidth: pt(0.9), borderRadius: mm(1.4), paddingX: mm(13), paddingY: em(0.1) }, { id: 'casilla', fontFamily: LABEL, background: col('paper'), borderColor: col('sea'), borderWidth: pt(0.9), borderRadius: mm(1), paddingX: em(0.55), paddingY: em(0.05), gap: em(0.5) }, ], calloutStyles: [ tabbed('actividad', 'sky', 'sea', 'paper'), tabbed('material', 'sand', 'sun', 'ink'), tabbed('proyecto', 'paper', 'sun', 'ink', { border: { enabled: true, color: col('sun'), width: pt(1.5) } }), { ...plain('banda', 25, 30, 'paper'), backgroundEnabled: true, background: col('sea'), borderRadius: mm(3), padding: { top: mm(2.5), right: mm(5), bottom: mm(3), left: mm(5) } }, plain('campo', 10, 14, 'sea', { padding: { top: mm(5), right: mm(0), bottom: mm(0), left: mm(0) } }), plain('colofon', 7.5, 10, 'ink'), ], header: { elements: [] }, footer: { elements: [ folio('folio-par', 'even', 'bottom-left', 20), bookTitle('titulo-par', 'even', 'bottom-left', 32), folio('folio-impar', 'odd', 'bottom-right', -20), bookTitle('titulo-impar', 'odd', 'bottom-right', -32), ] }, pageTemplates: [cover, sheet, project, projectGoesOn], }; // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 100 lines · content.en.md
title: "Water on the move" subtitle: "Activity book" --- ::::page{template="portada"} # Water on the move {style="portada"} Science activity book · Year 4 · Unit 3 :::frame{slot="nombre"} Name ::: :::frame{slot="colofon"} Set in Andika, Baloo 2 and Lexend (SIL OFL) · Text and drawings: original, CC BY 4.0 ::: :::: ::::page{template="ficha" tema="Unit 3 · Sheet 1"} # Where is the water? Almost three quarters of the Earth is covered by water. Most of it is **salt water**, in the seas and oceans. **Fresh water**, the kind we drink, is only a small part: it runs in rivers, rests in lakes, hides under the ground or lies frozen at the poles. ::resource{id="planeta" slot="dibujo"} :::frame{slot="act-1" label="1 · Read and answer"} Tom lives in Leeds. The water from his tap comes from a reservoir in the hills, goes through a treatment works and travels through pipes to his house. Where does the water from your tap come from? Why do you think it has to be cleaned before we drink it? ::: :::frame{slot="act-2" label="2 · Fill in the gaps"} - The water in the seas and oceans is :chip[ ]{style="hueco"} and we cannot drink it. - Rivers, lakes and the ground below us hold :chip[ ]{style="hueco"} water. - At the poles, nearly all the water is frozen into :chip[ ]{style="hueco"}. ::: :::frame{slot="act-3" label="3 · Draw and write"} Draw a place near your home where there is water: a fountain, a river, a reservoir or the beach. Then write its name on the lines and what people use it for. :::space{lines=3} ::: :::: ::::page{template="ficha" tema="Unit 3 · Sheet 2"} # Solid, liquid and gas Water changes state when it gets warmer or colder. Below zero degrees it turns into **ice**, which is a solid. When ice warms up it melts and becomes a **liquid** again. When water boils it turns into **steam**, a gas we cannot see. ::resource{id="estados" slot="dibujo"} :::frame{slot="act-1" label="1 · Observe"} Put an ice cube on a plate and leave it on the table. Look at it every ten minutes for half an hour. What happens to the ice cube? What is on the plate at the end? Write down why you think this happens. ::: :::frame{slot="act-2" label="2 · Tick the true sentences"} - :chip[ ]{style="casilla"} Ice is water in its solid state. - :chip[ ]{style="casilla"} You can pick up steam in your hand. - :chip[ ]{style="casilla"} A puddle dries up because the sun makes the water evaporate. - :chip[ ]{style="casilla"} Water in the freezer turns into steam. ::: :::frame{slot="act-3" label="3 · Explain"} When you cook pasta, steam rises from the pan. If you hold a cold lid over it, the lid fills with tiny drops. Where do those drops come from? ::: :::: ::::page{template="proyecto" tema="Unit 3 · Project" notas="What I noticed"} # Make a rain gauge A rain gauge measures how much rain falls in one place. Weather stations use very precise instruments, but you can make one from a bottle and keep a record of the rain for a whole week. ::resource{id="pluviometro" slot="dibujo"} :::frame{slot="material" label="You will need"} - A clean, empty two-litre plastic bottle. - Scissors, a 20-centimetre ruler and sticky tape. - A permanent marker and a handful of small stones. ::: :::frame{slot="pasos" label="Step by step"} 1. Ask an adult to cut the bottle where it starts to get narrower. Keep the top part: it will be your funnel. 2. Put the stones in the bottom. They stop the bottle blowing over in the wind. 3. Pour in water until the stones are covered and mark that level with the marker. That line is the zero of your rain gauge. 4. Tape the ruler to the outside of the bottle, so that the zero on the ruler sits exactly on the line you marked. 5. Turn the top part upside down and push it into the bottle, neck first. The funnel collects the rain and slows down evaporation. 6. Find an open spot in the playground or the garden, away from trees and roofs, which keep the rain off. 7. Every day at the same time, read how many millimetres the water has risen above the zero and write it in your record. Then pour the water out down to the zero line. ### My record for the week - Monday: :chip[ ]{style="hueco"} millimetres - Tuesday: :chip[ ]{style="hueco"} millimetres - Wednesday: :chip[ ]{style="hueco"} millimetres - Thursday: :chip[ ]{style="hueco"} millimetres - Friday: :chip[ ]{style="hueco"} millimetres ### Think Which day had the most rain? Add up the five days: how many millimetres fell in total? Compare your result with a classmate who put their rain gauge somewhere else. ::: ::::`; // content.<lang>.md, inlined by the Cookbook // #region art: the water cycle on the cover, the planet, the three states, the rain gauge const P = palette; const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" ` + `width="${w * 10}" height="${h * 10}">${body}</svg>`; const cloud = (x, y, s, fill = P.paper) => `<g transform="translate(${x} ${y}) scale(${s})" ` + `fill="${fill}">` + '<circle cx="0" cy="0" r="9"/><circle cx="11" cy="-5" r="11"/><circle cx="24" cy="0" r="9"/>' + '<rect x="-9" y="0" width="42" height="9" rx="4.5"/></g>'; const arrow = (x1, y1, x2, y2, color) => { const a = Math.atan2(y2 - y1, x2 - x1); const tip = (d, o) => `${(x2 - d * Math.cos(a + o)).toFixed(1)},` + `${(y2 - d * Math.sin(a + o)).toFixed(1)}`; return `<path d="M${x1} ${y1} L${x2} ${y2}" stroke="${color}" stroke-width="1.6" ` + `stroke-linecap="round" stroke-dasharray="3 3" fill="none"/>` + `<path d="M${x2} ${y2} L${tip(5, 0.45)} L${tip(5, -0.45)} Z" fill="${color}"/>`; }; const rain = (x, y) => [0, 6, 12, 18].map((dx, i) => `<path d="M${x + dx} ${y + (i % 2) * 3} ` + `l-2 7" stroke="${P.sea}" stroke-width="1.6" stroke-linecap="round"/>`).join(''); const ART = { portada: [210, 178, () => `<rect width="210" height="178" fill="${P.sky}"/>` + `<circle cx="168" cy="38" r="17" fill="${P.sun}"/>` + Array.from({ length: 12 }, (_, i) => { const a = (i * Math.PI) / 6; const p = (r) => `${(168 + r * Math.cos(a)).toFixed(1)} ${(38 + r * Math.sin(a)).toFixed(1)}`; return `<path d="M${p(22)} L${p(29)}" stroke="${P.sun}" stroke-width="2.4" ` + 'stroke-linecap="round"/>'; }).join('') + cloud(28, 40, 1.2) + cloud(86, 26, 0.9) + rain(30, 58) + `<path d="M0 132 L34 84 L52 104 L74 70 L112 132 Z" fill="${P.leaf}"/>` + `<path d="M74 70 L66 82 L72 80 L76 85 L81 80 L74 70 Z" fill="${P.paper}"/>` + `<path d="M58 132 C70 120 64 112 76 104" stroke="${P.sea}" stroke-width="3" fill="none"/>` + `<path d="M0 132 L210 132 L210 178 L0 178 Z" fill="${P.sea}"/>` + [140, 152, 164].map((y) => `<path d="M${y - 120} ${y} q8 -4 16 0 t16 0 t16 0" ` + `stroke="${P.sky}" stroke-width="1.4" fill="none"/>`).join('') + arrow(150, 126, 128, 66, P.paper) + arrow(176, 124, 160, 70, P.paper) + arrow(120, 30, 70, 34, P.paper)], planeta: [56, 46, () => `<circle cx="26" cy="23" r="20" fill="${P.sea}"/>` + `<path d="M14 10 q8 -2 10 6 q-2 7 -9 6 q-5 -4 -1 -12 Z" fill="${P.leaf}"/>` + `<path d="M30 26 q9 -3 11 4 q-1 8 -8 9 q-6 -5 -3 -13 Z" fill="${P.leaf}"/>` + `<path d="M20 5 q6 -3 12 0 q-6 3 -12 0 Z" fill="${P.sky}"/>` + `<path d="M48 18 q-5 8 0 12 q5 -4 0 -12 Z" fill="${P.sea}" stroke="${P.paper}" ` + 'stroke-width="1"/>'], estados: [56, 46, () => `<rect x="3" y="24" width="14" height="14" rx="2.5" fill="${P.sky}" ` + `stroke="${P.sea}" stroke-width="1.4"/>` + `<path d="M22 18 L24 42 L36 42 L38 18 Z" fill="${P.sky}" stroke="${P.sea}" ` + 'stroke-width="1.4"/>' + `<path d="M23 27 L37 27" stroke="${P.sea}" stroke-width="1"/>` + `<path d="M23.3 27 L24 42 L36 42 L36.7 27 Z" fill="${P.sea}"/>` + [44, 49, 54].map((x) => `<path d="M${x - 4} 40 q-3 -6 0 -12 t0 -12" stroke="${P.rule}" ` + `stroke-width="1.6" fill="none" stroke-linecap="round"/>`).join('')], pluviometro: [56, 46, () => `<path d="M18 10 L40 10 L40 42 Q40 44 38 44 L20 44 Q18 44 18 42 Z" ` + `fill="${P.sky}" stroke="${P.sea}" stroke-width="1.4"/>` + `<path d="M16 10 L42 10 L33 19 L25 19 Z" fill="${P.paper}" stroke="${P.sea}" ` + 'stroke-width="1.4"/>' + '<path d="M18.7 30 L39.3 30 L39.3 42 Q39.3 43.3 38 43.3 L20 43.3 Q18.7 43.3 18.7 42 Z" ' + `fill="${P.sea}"/>` + [22, 26, 30, 34, 38].map((y) => `<path d="M40 ${y} l5 0" stroke="${P.ink}" ` + 'stroke-width="1"/>').join('') + cloud(4, 4, 0.35, P.rule) + rain(6, 8).replaceAll('l-2 7', 'l-1 4')], }; const ALT = { portada: t({ es: 'El ciclo del agua: el sol calienta el mar, el vapor sube a las nubes y la lluvia cae ' + 'en la montaña y vuelve por el río', en: 'The water cycle: the sun warms the sea, vapour rises to the clouds, and rain falls on ' + 'the hills and runs back down the river', fr: 'Le cycle de l’eau : le soleil chauffe la mer, la vapeur monte aux nuages, la pluie ' + 'tombe sur la montagne et revient par la rivière' }), planeta: t({ es: 'La Tierra, casi toda azul, y una gota de agua', en: 'The Earth, mostly blue, and a drop of water', fr: 'La Terre, presque toute bleue, et une goutte d’eau' }), estados: t({ es: 'Un cubito de hielo, un vaso de agua y vapor', en: 'An ice cube, a glass of water and steam', fr: 'Un glaçon, un verre d’eau et de la vapeur' }), pluviometro: t({ es: 'Una botella cortada con una regla: el pluviómetro', en: 'A cut bottle with a ruler: the rain gauge', fr: 'Une bouteille coupée avec une règle : le pluviomètre' }), }; const resources = Object.entries(ART).map(([id, [w, h]]) => ({ id, typeId: 'dibujo', kind: 'svg', caption: '', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, })); for (const [id, [w, h, draw]] of Object.entries(ART)) { await loadSvg(`${id}.svg`, svgDoc(w, h, draw())); } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build Andika: ['400', '400i', '700'], 'Baloo 2': ['700', '800'], Lexend: ['500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown, resources }, config, kitFonts(FONTS)); showPages(doc, { title: t({ es: 'El agua, de viaje', en: 'Water on the move', fr: 'L’eau en voyage' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images: the same in every recipe · 315 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ↗ (opens in a new tab)
Variations
#Give an activity more lines
The number of lines is the answer frame's height: its rules draw as many lines as fit. Change it in the template, and every sheet that uses it gets the lines.
- answer('lineas-1', 3),
+ answer('lineas-1', 5),#Let the steps shrink before they continue
A frame that can go on to another page goes on before its text is set smaller. With fit.preferContinue: false the ladder sets the steps smaller first, down to fit.minScale (90 %), and continues only if that is not enough. For a child's book the type size matters more than one page saved, so the recipe keeps the default.
- overflow: { continueIn: 'next-page' } },
+ fit: { preferContinue: false }, overflow: { continueIn: 'next-page' } },Pitfalls
- A frame that receives nothing raises
slotEmptyunless it isoptional: true. Every answer frame here is one. - A box in a stack is placed by the group, not by its own
placement, except the first one: place the first box of the group, or the stack starts at the top of the area, over the title.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Fonts
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Lexend (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


