Em poucas palavras
Uma folha de atividades de ciências para crianças de oito anos, sobre as partes de uma planta. Cada pergunta tem embaixo um quadro para a resposta, e algumas frases têm lacunas para completar com uma lista de palavras.
O que você vai compor
A folha 3A de Seedlings Science, uma coleção inventada para crianças de oito anos, em quatro páginas de 200 × 260 mm impressas de um lado só. Uma faixa verde-folha traz a unidade numa pílula amarela, o título e campos brancos para nome e data; ao lado, um pé de ervilha em aquarela, dentro de um disco branco, tem as seis partes numeradas em amarelo. A aquarela foi gerada com modelos de difusão e já chega recortada no disco, em dois JPEGs que se encontram na borda da faixa: o de cima tem o verde da faixa nos cantos, e o de baixo, branco; os números e as linhas de chamada continuam em código, por cima da imagem. Cada atividade é um título numerado sobre um cartão verde-claro. Os cartões trazem caixas de resposta brancas com uma pequena indicação cinza e espaço para várias linhas, bancos de palavras em chips amarelos, lacunas contornadas dentro das frases, uma tabela de alimentos para ligar às partes da planta e uma lista de círculos para colorir. Cada caixa de resposta fica 2 mm abaixo da pergunta e 5 mm acima do que vem depois, e cada cartão termina 9 mm acima do que vier em seguida na página. O texto é composto em Andika, que a SIL desenhou para crianças em alfabetização.
Esta receita responde a
- Como aninho boxes, como uma ficha de exercícios com caixas de resposta, com espaçamento exato?
- Como monto uma ficha de exercícios: linhas para preencher, caixas de resposta, bancos de palavras, listas de verificação?
- Como faço chips em linha: teclas, etiquetas, bancos de palavras para exercícios?
- Como coloco imagens ou ícones dentro das células de uma tabela?
A resposta curta
// 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].
Ingredientes
- Funcionalidades
- Boxes aninhadosSair da grade de propósitoChips no textoBoxesColunas dentro de um boxeEspaço vertical explícitoEstilos de títuloTítulos numeradosCapítulos sem númeroAberturas desenhadasTextos, fios e caixas nos designs de páginaImagens nos designs de páginaAtributos de títuloQuebras de linha nos títulosEstilos de tabela com nomeImagens nas células de tabelaTipos de recurso personalizadosFiguras exatamente aquiListas com marcadores e de verificaçãoListas numeradas
- Também usa
- Equilíbrio de colunasFaixa de capítulo em largura totalEstilos de parágrafoFiguras e tabelas como recursos
- Tipografia
- Andika, Baloo 2, Fredoka (SIL OFL 1.1)
- Materiais
plant-1092.jpgplant-foot-1092.jpg- The pea plant on the opener, a watercolour (top of the disc) (Generated With Diffusion Models, original)
- The pea plant’s roots, the foot of the same watercolour below the band (Generated With Diffusion Models, original)
- The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette (Ignacio Ferro, MIT)
Preparo
#1 · Tirar também os títulos da grade
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 },
] },
Na resposta curta, as margens de cada caixa se fundem com as das vizinhas, e a maior prevalece. Os 5 mm de um banco de palavras vencem os 2 mm acima da lista que vem embaixo, e os 5 mm da última caixa de resposta caem dentro dos 5 mm de preenchimento inferior do cartão, em vez de se somarem a eles. Na grade, um título de nível 2 reserva entre 10,8 e 14,1 mm em vez dos 8,6 mm do seu disco, porque o que vem depois volta para a linha de 17 pt seguinte; headings.snapToGrid: false mantém a reserva em 8,6 mm. Pôr snapToGrid nos estilos aninhados não muda nada, porque só o cartão externo pode sair da grade (boxes aninhados). O balanceamento de colunas está desligado porque ele acrescenta espaço acima dos títulos para preencher a página; na página 2, poria 33 mm acima da atividade 3 em vez de 9.
#2 · Fazer de cada lacuna um 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) }),
];
Um chip é uma caixa inquebrável na linha, e o preenchimento dele é pintado para além da linha sem deixá-la mais alta. Um banco de palavras é uma fileira de chips num boxe aninhado bank. As lacunas, os pontos para ligar e os círculos para colorir também são chips, cada um contendo só um word joiner U+2060, porque no postext 1.4.1 um chip feito de espaços imprime a própria marcação (chips em linha). paddingX define a largura: uma lacuna tem 34 mm dentro de uma frase e 38 mm sozinha numa coluna, e um círculo ganha de cada lado metade da sua altura de 1,25 em. Na lista de verificação, o círculo fica onde estaria o marcador (bulletChar: ''). Com 19,6 pt, uma lacuna é mais alta que a linha de 17 pt, e por isso cada uma tem um item de lista só para ela, a 24 pt do seguinte. Na 1.4.1, o Sandbox ainda mostra o aviso “Chips encostam na linha vizinha” para blank, wide e tick, porque compara cada chip com o passo de 17 pt das linhas e deixa de fora os 7 pt entre os itens da lista.
#3 · Numerar as atividades com um desenho de título
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] } } };
Um título de nível 2 com advancedDesign mantém o seu lugar na coluna, e o design é desenhado no lugar do texto: um disco com {number}, que imprime o numberingTemplate do nível, e {titleText} à direita (span e design avançado). O disco e o título têm 8,6 mm de altura, e fora da grade o título reserva exatamente isso, então o design não precisa de minHeight. Como os números vêm do contador do nível 2, uma atividade inserida no Markdown renumera as que vêm depois. O título da autoavaliação usa um estilo com numbered: false, que o deixa fora da contagem, e uma estrela em SVG fica onde estaria o disco.
#4 · Pôr os desenhos nas células da tabela
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 desenha um recurso dentro da célula, aqui com 76 % da largura da célula, e a linha cresce para caber. verticalAlign: 'middle' centraliza em relação ao desenho o nome do alimento, os pontos e o chip. A coluna mais larga, 31 % da tabela, fica vazia para o aluno traçar a linha. O estilo nomeado arredonda a moldura da tabela e recorta nela o preenchimento branco do corpo. Os fios da grade também são brancos: o canvas deixa um fio claro entre células preenchidas, e um fio branco de 1 pt sobre o painel branco o cobre (estilos de tabela nomeados). O tipo de recurso dela tem captionPrefix vazio, e a tabela não tem texto de legenda, então nenhuma legenda sai embaixo. placement: { position: 'here' } põe a tabela onde está a sua linha ::resource, dentro do cartão.
A receita completa
// ═══ 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) · Plant: diffusion models · Icons: drawn in code // 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 plant's disc on the page const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet 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) }) }, ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]] .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })), ...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`---Amostra em Markdown · 119 linhas · 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: CC BY 4.0. Plant: diffusion models. 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 plant's leader lines, the six foods and the star, in the palette's colours // The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's // edge: the top one's corners carry the band's green, the foot's are white. One picture would // resample its green-to-white step and print a pale line along the band's edge. No words in // the drawings: 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 picture (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) => `<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)}</g>`; function leaders() { // from each dot to its part, in ink on a white halo that clears the soil // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds), // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot. const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4], 5: [38.8, 52], 6: [38.4, 64] }; const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join(''); return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35) + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink, ` stroke="${palette.paper}" stroke-width=".3"`)).join('')); } 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 = { leaders, 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' }), leaders: '', star: t({ en: 'A white star on a yellow disc', es: 'Una estrella blanca en un disco amarillo' }) }; // Each picture 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. Drawings 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, ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } })); // The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm. pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet', kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } }))); // #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())), ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]); 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: igual em todas as receitas · 271 linhas
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** 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. * 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; 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 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 = `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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** 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, '-'); } /** 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 v1 ── 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. */ 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 ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Devolver os cartões à grade
Com o padrão snapToGrid: true, o que vem depois de um cartão desce para a linha seguinte da grade de 17 pt, e os 9 mm sob cada cartão viram 14,5 mm sob a atividade 2, 13,2 mm sob a atividade 4 e 11,2 mm sob a atividade 6.
- snapToGrid: false,
+ snapToGrid: true,#Dar mais espaço a uma resposta
:::space conta em linhas de 17 pt da própria caixa de resposta, e cada linha a mais dá ao aluno 6 mm a mais para escrever.
Write here
-:::space{lines=4}
+:::space{lines=6}Erros comuns
Erro comum
:::space é descartado no topo de um boxe ou de uma coluna
:::space é descartado no topo de uma coluna, de um boxe ou de um grupo :::columns, mesmo quando é o único conteúdo do boxe, então um boxe de resposta feito só de espaço fica sem altura. Abra o boxe com um título ou uma linha de enunciado e acrescente o espaço depois. Espaço vertical explícito →
Erro comum
Um chip só com espaços imprime a marcação
Um chip cujo texto são só espaços, incluindo espaços não separáveis, imprime :chip[ ] literalmente. Para um chip de resposta em branco, coloque dentro dele um word joiner (U+2060). Chips no texto →
Erro comum
Um boxe aninhado ignora span, placement e snapToGrid
Um boxe aninhado em outro ignora seus span, placement, snapToGrid e floatBarrier: ele sempre é composto dentro do boxe que o contém, com a largura interna deste. Boxes aninhados →
Erro comum
Chips mais altos que a entrelinha encostam na linha seguinte
Um chip cuja caixa é mais alta que a entrelinha encosta nos chips da linha seguinte (o aviso chipOverlap). Reduza o preenchimento vertical, a borda ou o tamanho do chip, ou aumente a entrelinha. Chips no texto →
Erro comum
Uma tabela ou figura dentro de um boxe não ganha espaço em volta
No postext 1.4.1, uma tabela ou figura que ::resource coloca na posição 'here' dentro de um :::callout não recebe nada do espaço que mantém no texto corrido: encosta no parágrafo de cima e no de baixo. Ponha um :::space antes da linha ::resource, e outro depois quando houver texto em seguida; uma fração de linha, como lines=0.33, deixa um espaço pequeno. Figuras exatamente aqui →
Erro comum
As imagens de uma abertura nunca contam para a altura que ela reserva
No postext 1.4.1, um título com design avançado mede a altura que reserva sem as imagens: textos, fios e caixas contam, mesmo quando ancorados na página, mas uma imagem, como uma ilustração sangrada no alto da página, não reserva nada, então o texto pode começar por cima dela. Defina com minHeight onde o texto deve começar. Aberturas desenhadas →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
O texto dentro de um SVG <img> não pode usar fontes web
Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
Só 8 idiomas têm hifenização, com o código exato
A hifenização existe para en-us, es, fr, de, it, pt, ca e nl, com o código exato: 'es-ES' ou qualquer outro idioma passa sem aviso para o inglês americano. Hifenização e idioma do documento →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Verificação do Sandbox · chipOverlap
Chips encostam na linha seguinte
Por quê. A caixa de um estilo de chip é mais alta que a entrelinha, então os chips de linhas consecutivas se tocam.
Correção. Reduza o preenchimento vertical, o contorno ou o tamanho do chip, ou aumente a entrelinha. Docs →
- No postext 1.4.1, um boxe não consegue pautar as próprias linhas de escrita, porque os estilos de boxe não têm fundo pautado. As caixas de resposta ficam em branco e tiram a altura de
:::space, e as linhas curtas de completar são chips contornados. - Um estilo de título com
numbered: falsecontinua desenhando o design do seu nível, com{number}vazio. Sem um design próprio, a autoavaliação abriria com um disco verde vazio, entãoreviewcopia o design das atividades e põe a estrela onde ficava o disco.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imagens
- The pea plant on the opener, a watercolour (top of the disc) · Generated With Diffusion Models · original
- The pea plant’s roots, the foot of the same watercolour below the band · Generated With Diffusion Models · original
- The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette · Ignacio Ferro · MIT
- Fontes
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


