In breve
Una scheda di scienze per bambini di otto anni sulle parti di una pianta. Ogni domanda ha sotto un riquadro per la risposta, e alcune frasi hanno spazi vuoti da riempire con un elenco di parole.
Che cosa comporrai
La scheda 3A di Seedlings Science, una collana inventata per bambini di otto anni, su quattro pagine di 200 × 260 mm stampate su un solo lato. Una fascia verde foglia porta una pillola gialla con l’unità, il titolo e campi bianchi per nome e data; accanto, una pianta di pisello ad acquerello in un disco bianco ha le sue sei parti numerate in giallo. L’acquerello è stato generato con modelli di diffusione e arriva già composto nel suo disco, come due JPEG che si incontrano al bordo della fascia: quello superiore ha il verde della fascia negli angoli, quello al piede il bianco; i numeri e le linee di richiamo restano nel codice, sopra l’immagine. Ogni attività è un titolo numerato sopra una scheda verde chiaro. Le schede contengono riquadri bianchi per le risposte con una piccola consegna grigia e spazio per più righe, banche di parole fatte di chip gialli, spazi vuoti a contorno dentro le frasi, una tabella di alimenti da abbinare alle parti della pianta e una lista di cerchi da colorare. Ogni riquadro di risposta sta 2 mm sotto la sua domanda e 5 mm sopra ciò che segue, e ogni scheda finisce 9 mm sopra quello che viene dopo nella sua pagina. Il testo è composto in Andika, che SIL ha disegnato per i bambini che imparano a leggere.
Questa ricetta risponde a
- Come annido i riquadri, per esempio una scheda di esercizi che contiene riquadri per le risposte, con spaziature esatte?
- Come costruisco una pagina di esercizi: righe da compilare, riquadri per le risposte, banche di parole, liste di controllo?
- Come creo chip in linea: tasti della tastiera, etichette, banche di parole per esercizi?
- Come metto immagini o icone dentro le celle di una tabella?
La risposta breve
// 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 ← the prompt first: a :::space that opens a box is dropped
// :::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].
Ingredienti
- Funzionalità
- Riquadri annidatiUscire dalla griglia di propositoChip in lineaRiquadriColonne in un riquadro o nel testoSpazio verticale esplicitoStili di titoloTitoli numeratiCapitoli non numeratiAperture progettateTesto, filetti e riquadri nei progetti di paginaImmagini nei progetti di paginaAttributi dei titoliA capo nei titoliStili di tabella con nomeImmagini nelle celle delle tabelleTipi di risorsa personalizzatiFigure proprio quiElenchi puntati e liste di controlloElenchi numerati
- Usa anche
- Bilanciamento delle colonneFascia di capitolo a tutta larghezzaStili di paragrafoFigure e tabelle come risorse
- Tipo
- Andika, Baloo 2, Fredoka (SIL OFL 1.1)
- Risorse
plant-1092.jpgplant-foot-1092.jpg- The pea plant on the opener, a watercolour (top of the disc) (Generated With Diffusion Models, originale)
- The pea plant’s roots, the foot of the same watercolour below the band (Generated With Diffusion Models, originale)
- The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette (Ignacio Ferro, MIT)
Preparazione
#1 · Togli dalla griglia anche i titoli
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: [
// parity 'any': a sheet opens the next page, on either side.
{ 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 },
] },
Nella risposta breve, i margini di ogni riquadro si fondono con quelli dei vicini e vince il maggiore. I 5 mm di una banca di parole prevalgono sui 2 mm sopra l’elenco sottostante, e i 5 mm dell’ultimo riquadro di risposta cadono dentro i 5 mm di spaziatura inferiore della scheda invece di sommarsi. Lasciato sulla griglia, un titolo di livello 2 riserva tra 10,8 e 14,1 mm invece degli 8,6 mm del suo disco, perché ciò che lo segue torna alla successiva riga da 17 pt; headings.snapToGrid: false lo tiene a 8,6 mm. Impostare snapToGrid sugli stili annidati non cambia nulla, perché solo la scheda esterna può lasciare la griglia (riquadri annidati). Il bilanciamento delle colonne è disattivato perché aggiunge spazio sopra i titoli per riempire una pagina; a pagina 2 metterebbe 33 mm sopra l’attività 3 invece di 9.
#2 · Fai di ogni spazio vuoto un 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, :chip[ ], is a box of padding and outline alone. It is a band 1.25 em tall,
// so paddingX ROUND makes it a circle.
const [ROUND, OUTLINE] = [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) }),
];
Un chip è un riquadro indivisibile nella riga, e la sua spaziatura interna si dipinge oltre la riga senza renderla più alta. Una banca di parole è una fila di chip in un riquadro annidato bank. Anche gli spazi vuoti, i punti da unire e i cerchi da colorare sono chip, ciascuno scritto :chip[ ]: un chip di uno spazio è un riquadro vuoto fatto di spaziatura e contorno (chip in linea). paddingX imposta la larghezza: uno spazio vuoto è largo 34 mm in una frase e 38 mm da solo in una colonna, e un cerchio riceve metà della sua altezza di 1,25 em su ciascun lato. Nella lista di controllo il cerchio sta dove sarebbe il punto elenco (bulletChar: ''). A 19,6 pt, uno spazio vuoto è più alto della riga da 17 pt, ed è per questo che ognuno ha una voce d’elenco tutta sua, a 24 pt dalla successiva.
#3 · Numera le attività con il design di un titolo
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] } } };
Un titolo di livello 2 con un advancedDesign conserva il suo posto nella colonna, e al posto del suo testo viene disegnato il design: un disco che contiene {number}, che stampa il numberingTemplate del livello, e {titleText} alla sua destra (span e design avanzato). Il disco e il titolo sono entrambi alti 8,6 mm, e fuori griglia il titolo riserva esattamente quella misura, quindi il design non ha bisogno di minHeight. Poiché i numeri vengono dal contatore di livello 2, un’attività inserita nel Markdown rinumera quelle che seguono. Il titolo dell’autovalutazione usa uno stile con numbered: false, che lo esclude dal conteggio, e una stella SVG sta dove sarebbe il disco.
#4 · Metti le immagini nelle celle della tabella
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),
rules: 'none' }]; // the white panel is the body fill alone
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(':chip[ ]{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, built once FOODS exist. A box keeps a line of its text, 6 mm, over
// an embedded table; with that off (see layout), a :::space{lines=0.33} sets ASK instead.
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 disegna una risorsa dentro la sua cella, qui al 76 % della larghezza della cella, e la riga cresce per contenerla. verticalAlign: 'middle' centra sull’immagine il nome dell’alimento, i punti e il chip. La colonna più larga, il 31 % della tabella, resta vuota per la linea dell’alunno. Lo stile con nome arrotonda la cornice della tabella e vi ritaglia il riempimento bianco del corpo. Non disegna filetti, quindi il pannello è solo il riempimento bianco (stili di tabella con nome). Il suo tipo di risorsa ha un captionPrefix vuoto e la tabella non ha testo di didascalia, quindi sotto non si stampa alcuna didascalia. placement: { position: 'here' } mette la tabella dove si trova la sua riga ::resource, dentro la scheda. Un riquadro tiene una riga del suo testo sopra una tabella di questo tipo, qui 6 mm; layout.inlineResourceGapInBoxes: false lo disattiva, e un :::space{lines=0.33} prima della riga ::resource imposta i 2 mm che i riquadri di risposta tengono sotto le loro domande.
La ricetta 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.25.0 import { buildDocumentWithFonts, prepareFonts, renderPageToCanvas, 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 // Each colour names its palette entry and carries its hex. 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 ← the prompt first: a :::space that opens a box is dropped // :::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, :chip[ ], is a box of padding and outline alone. It is a band 1.25 em tall, // so paddingX ROUND makes it a circle. const [ROUND, OUTLINE] = [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 // The opener reserves down to the disc's foot; minHeight adds AIR, 5 mm, under it. 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), rules: 'none' }]; // the white panel is the body fill alone const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra }); const dotCell = () => cell(':chip[ ]{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, built once FOODS exist. A box keeps a line of its text, 6 mm, over // an embedded table; with that off (see layout), a :::space{lines=0.33} sets ASK instead. 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 = () => ({ locale: t({ en: 'en-us', es: 'es' }), // the hyphenation patterns of the edition 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', inlineResourceGapInBoxes: false }, // ASK over the table 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: [ // parity 'any': a sheet opens the next page, on either side. { 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`---Esempio Markdown · 119 righe · 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: 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'], Fredoka: ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), ...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 buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });Kit · core, fonts, viewer, images: uguale in ogni ricetta · 270 righe
// ─── 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 · 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 ───────────────────────────────────────────────────────────────────────
Lo script.js composto funziona così com’è: incollalo nello script modulo di qualsiasi pagina, oppure apri la ricetta su CodePen. Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)
Varianti
#Rimetti le schede sulla griglia
Con il valore predefinito snapToGrid: true, ciò che segue una scheda scende alla successiva riga della griglia da 17 pt, e i 9 mm sotto ogni scheda diventano 14,5 mm sotto l’attività 2, 13,2 mm sotto l’attività 4 e 11,2 mm sotto l’attività 6.
- snapToGrid: false,
+ snapToGrid: true,#Dai più spazio a una risposta
:::space conta nelle righe da 17 pt del riquadro di risposta stesso, e ogni riga aggiunta dà all’alunno 6 mm in più per scrivere.
Write here
-:::space{lines=4}
+:::space{lines=6}Errori frequenti
Errore frequente
:::space viene eliminato in cima a un riquadro o a una colonna
:::space viene eliminato in cima a una colonna, a un gruppo :::columns, a un riquadro senza titolo che ha altro contenuto e alla continuazione di un riquadro diviso. Da postext 1.5 un riquadro fatto solo di :::space è alto quanto chiede lo spazio (una casella per la risposta, spazio per scrivere), e lo spazio subito sotto il titolo di un riquadro fa cominciare il corpo altrettanto più in basso. Per aprire con dello spazio un riquadro senza titolo, dagli un titolo o una prima riga e aggiungi lo spazio dopo. Spazio verticale esplicito →
Errore frequente
Un riquadro annidato ignora span, placement e snapToGrid
Un riquadro annidato in un altro ignora i suoi span, placement, snapToGrid e floatBarrier: scorre sempre dentro il suo genitore, alla larghezza interna del genitore. Riquadri annidati →
Errore frequente
I chip più alti del passo di riga toccano la riga successiva
Un chip il cui riquadro è più alto del passo di riga tocca i chip della riga successiva (l’avviso chipOverlap). Riduci la sua spaziatura interna verticale, il bordo o il corpo, o aumenta l’interlinea. Chip in linea →
Verifica della Sandbox · chipOverlap
Chip che toccano la riga successiva
Perché. Il riquadro di uno stile di chip è più alto del passo delle righe, quindi i chip su righe consecutive si toccano.
Soluzione. Riduci il padding verticale, il contorno o le dimensioni del chip, oppure aumenta l’interlinea. Documentazione →
- Un riquadro non può tracciare le proprie righe di scrittura, perché gli stili dei riquadri non hanno uno sfondo rigato. I riquadri di risposta restano vuoti e prendono l’altezza da
:::space, e le brevi righe da completare sono chip a contorno. - Uno stile di titolo con
numbered: falsedisegna comunque il design del suo livello, con{number}lasciato vuoto. Senza un design proprio, l’autovalutazione si aprirebbe con un disco verde vuoto, quindireviewcopia il design delle attività e mette la stella dove c’era il disco.
Crediti
- Ricetta
- Ignacio Ferro
- Testo
- Testo originale, CC BY 4.0
- Immagini
- The pea plant on the opener, a watercolour (top of the disc) · Generated With Diffusion Models · originale
- The pea plant’s roots, the foot of the same watercolour below the band · Generated With Diffusion Models · originale
- The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette · Ignacio Ferro · MIT
- Font
- Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
- Codice
- MIT, come Postext
Modifica questo testo ↗ (si apre in una nuova scheda)Cartella della ricetta su GitHub ↗ (si apre in una nuova scheda)


