Vai al contenuto principale
Ricetta numero 60

Ricettario · Capitolo 2 · Caratteri e testo

Abbecedario con chip delle sillabe

Un’unità di abbecedario sulla M: i suoni di ogni parola come chip nel colore della sua vocale, e una griglia di parole illustrate con un acquerello per cella.

p. 37 · 1 di 3

  • Esempio in inglese: non c’è ancora un’edizione italiana
  • Formato 210 × 260 mm
  • 1 colonna
  • Andika 11/15
  • DynaPuff
  • Playpen Sans
  • 3 pagine
  • Livello
  • Postext 1.30.0
  • Impaginato in 73 ms
  • 180 righe di codice

In breve

Tre pagine di un libro di lettura per bambini piccoli, sulla lettera M. Ogni suono di una parola sta in una piccola etichetta colorata, e ogni parola di una griglia ha la sua immagine.

Che cosa comporrai

L’unità 12 di Letter by Letter, un abbecedario fonetico inglese inventato: tre pagine sul suono m. L’apertura compone una «Mm» in DynaPuff da 190 pt su una fascia color crema accanto a due cose il cui nome comincia con m, una scimmia (monkey) e una luna (moon). Ogni parola è divisa nei suoi suoni, e ogni suono è un chip nel colore della vocale della parola: a rossa, e arancione, i verde acqua, o blu, u viola. La m è piena e gli altri suoni sono a contorno, così un bambino vede dove cade il suono di questa unità in map e in gum. La pagina 38 compone sei parole illustrate in una griglia arrotondata, con un acquerello in ogni cella, poi tre righe da ricalcare. Gli acquerelli sono stati generati con modelli di diffusione; la fascia, le icone e le righe di ricalco sono disegnate nel codice. La pagina 39 ha quattro frasi a 24 pt, un riquadro di parole da colorare e una nota per genitori ed educatori.

L’edizione spagnola è un’altra unità, non una traduzione: l’unità 7 di Letra a letra, sulla stessa lettera. Lo spagnolo si insegna per sillabe aperte (ma, me, mi, mo, mu), quindi i suoi chip contengono sillabe, {mi·mo·sa}. Quelle sillabe non sono parole inglesi, e gli abbecedari inglesi lavorano suono per suono, quindi l’unità inglese fonde i suoni di parole brevi, {m·a·p}, e le raggruppa in famiglie che fanno rima (-at, -an, -ug, -op). Entrambe le unità escono dallo stesso pen: gli stessi stili di chip, la stessa funzione syllables(), la stessa griglia, le stesse righe di ricalco e gli stessi riquadri.

Questa ricetta risponde a

  • 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?
  • Come aggiungo spazio verticale tra due blocchi, se le righe vuote non hanno effetto?
  • Come costruisco una pagina di esercizi: righe da compilare, riquadri per le risposte, banche di parole, liste di controllo?

La risposta breve

script.js · righe 27–48nel codice completo
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, v, end) => `:chip[${s}]{style="${v}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins. A piece with no
// vowel, the m and the p of the English {m·a·p}, takes the vowel of its word.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, vowel(s + word), before && '-frase')).join(''));

Ingredienti

Tipo
Andika, DynaPuff, Playpen Sans (SIL OFL 1.1)
Risorse
  • man-640.jpg
  • mano-640.jpg
  • manzana-480.jpg
  • mapa-640.jpg
  • mariposa-660.jpg
  • mat-640.jpg
  • mesa-640.jpg
  • mimosa-640.jpg
  • mono-640.jpg
  • moon-480.jpg
  • mop-640.jpg
  • mud-640.jpg
  • mug-640.jpg
  • muneca-640.jpg
  • A watercolour of the butterfly on the opener (Generated With Diffusion Models, originale)
  • A watercolour of the apple on the opener (Generated With Diffusion Models, originale)
  • A watercolour of the hand in the picture grid (Generated With Diffusion Models, originale)
  • A watercolour of the map in the picture grid of both units (Generated With Diffusion Models, originale)
  • A watercolour of the table in the picture grid (Generated With Diffusion Models, originale)
  • A watercolour of the mimosa sprig in the picture grid (Generated With Diffusion Models, originale)
  • A watercolour of the monkey: in the picture grid of the Spanish unit and on the opener of the English one (Generated With Diffusion Models, originale)
  • A watercolour of the doll in the picture grid (Generated With Diffusion Models, originale)
  • A watercolour of the moon on the opener of the English unit (Generated With Diffusion Models, originale)
  • A watercolour of the mat in the picture grid of the English unit (Generated With Diffusion Models, originale)
  • A watercolour of the man in the picture grid of the English unit (Generated With Diffusion Models, originale)
  • A watercolour of the mug in the picture grid of the English unit (Generated With Diffusion Models, originale)
  • A watercolour of the mop in the picture grid of the English unit (Generated With Diffusion Models, originale)
  • A watercolour of the mud in the picture grid of the English unit (Generated With Diffusion Models, originale)
  • The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Preparazione

#1 · Uno stile di chip per vocale e una funzione che scrive i chip

La risposta breve costruisce 21 stili di chip espandendo un unico oggetto syllable. C’è uno stile pieno per vocale per la m, uno a contorno per vocale per gli altri suoni, un gemello -frase di ciascuno di questi dieci, e pinta, il contorno a inchiostro che un bambino colora. syllables() trasforma {m·a·p} in tre :chip[…] senza nulla in mezzo, così i suoni di una parola si toccano e il bordo bianco disegna la giuntura. Un chip prende il colore della sua vocale, e un pezzo senza vocale, qui la m e la p, prende la vocale della sua parola, così map è rossa da cima a fondo. Attraverso uno spazio tra parole un chip conserva almeno il suo gap, e 0,5 em distanzia le parole di una fila a 24 pt di 4,2 mm, dove il solo spazio lascerebbe 2,3 mm. Dentro una frase quel distacco quasi raddoppierebbe lo spazio prima della parola nuova, quindi una parola che segue un’altra parola riceve i gemelli -frase, il cui gap di 0 lascia lo spazio di 2,3 mm così com’è. Nell’edizione spagnola i pezzi sono sillabe, {mi·mo·sa}, e ognuna ha una sua vocale.

#2 · Un’interlinea che lascia spazio ai chip

script.js · righe 144–154nel codice completo
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],

La spaziatura interna verticale e il bordo di un chip si dipingono fuori dalla sua riga e non aggiungono interlinea, quindi un chip più alto del passo delle righe invade i chip della riga sotto. Un chip a 24 pt è alto 10,4 mm; con un’interlinea di 30 pt (10,6 mm), i chip di due frasi starebbero a 0,1 mm di distanza. Le righe che un bambino legge ricevono 45 pt e le file a 34 pt della prima attività 60 pt, tre e quattro righe della griglia da 15 pt delle istruzioni.

#3 · Un’immagine in ogni cella

script.js · righe 96–109nel codice completo
const PICTURE_WORDS = t({ en: ['m·a·p', 'm·a·t', 'm·a·n', 'm·u·g', 'm·o·p', 'm·u·d'],
  es: ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'] });
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});

setCellImage mette un’immagine sopra il testo di una cella a una frazione della larghezza interna della cella, qui 0,72 (36,5 mm), e la riga cresce per contenerla. Ogni immagine è un JPEG dipinto su bianco e moltiplicato per il crema delle tessere, così sta sulla tessera senza riquadro. Sotto ogni immagine c’è la parola in chip di suoni. Una cella di tabella accetta il markup dei chip come qualunque paragrafo. Lo stile di tabella dibujos arrotonda la cornice e i suoi riempimenti a 5 mm e separa le tessere color crema con filetti bianchi da 4 pt.

#4 · Una riga di spazio prima del riquadro

script.js · righe 157–169nel codice completo
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],

Il Markdown mette :::space{lines=1} tra l’ultima frase e il riquadro da colorare, una riga da 15 pt (5,3 mm). Il marginTop del riquadro è 0, quindi quella riga è l’intero distacco; senza di essa il riquadro comincerebbe dove finisce la riga dell’ultima frase. Un :::space che apre un riquadro viene scartato, quindi il riquadro comincia con la sua riga di consegna e i chip della legenda dei colori, e il suo :::space viene dopo di essi, tra la legenda e l’attività da colorare.

#5 · L’apertura della lettera

script.js · righe 60–79nel codice completo
const THINGS = t({ en: ['monkey', 'moon'], es: ['mariposa', 'manzana'] }); // ids and labels
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// The opener reserves seventeen grid lines, the first grid line past the foot of the band's
// image, so the first activity title stands on the grid like the others: two lines under
// the band, its instruction right under it.
const opener = { enabled: true, minHeight: pt(17 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('first', THINGS[0], at(146, 12, { width: mm(40) })),
    picture('second', THINGS[1], at(151, 53, { width: mm(30) })),
    ...[45.5, 84.5].map((y, k) => words(THINGS[k], THINGS[k], LABEL, 10, 600, 'muted',
      at(146, y, { width: mm(40) }), { align: 'center' })), // its name under each picture
  ] } };

La «Mm» è il testo del titolo ({titleText}); il numero dell’unità, la collana e il nome sotto la lettera sono attributi sulla stessa riga del titolo. La fascia, la scimmia e la luna sono immagini. La fascia è alta 104 mm, e l’apertura riserva la pagina fino alla prima riga della griglia sotto il suo piede, così il titolo della prima attività comincia a 118,5 mm, sulla griglia e 16,6 mm sotto il punto più basso dell’onda (101,9 mm).

#6 · Icone accanto ai titoli delle attività

script.js · righe 83–92nel codice completo
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });

Ogni titolo di attività nomina il suo stile di titolo, come in ## Read the sentences {style="lee"}, e lo stile disegna un’icona con il titolo centrato accanto. I titoli restano titoli di livello 2, quindi è il livello H2 a spaziarli: due righe della griglia di lineHeight per il titolo e altre due di marginTop sopra di esso.

La ricetta completa

Sandbox
// ═══ Postext Cookbook · Nº 060 · Early reader with syllable chips ════════════════
// https://postext.dev/en/cookbook/reading-primer-syllables
// Code: MIT · Text: original (CC BY 4.0) · Pictures: diffusion models · Icons: drawn in code
// Fonts: Andika, DynaPuff, Playpen Sans (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage, setCellImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'reading-primer-syllables';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#242832', paper: '#ffffff', muted: '#5f6470', // text; instructions
  cream: '#f6f0e3', rule: '#d3c9b6', // the band, the tiles and the pictures' ground; guides
  // The vowel code: a syllable takes its vowel's colour. White on these fills measures
  // 3.2–4.9:1, above WCAG's 3:1 for large text: the vowel chips are bold, 20 pt or more.
  a: '#d9482b', e: '#dd740c', i: '#1f9a8f', o: '#3f6fd8', u: '#9152cf' };
// Every colour is linked to its palette entry by id.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults are linked to 'main-color': point it at the ink.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'DynaPuff', 'Playpen Sans']; // Andika: one-storey a
const [TOP, SIDE, GRID] = [18, 20, 15]; // mm, mm, pt: every activity starts on the 15 pt grid

// #region answer: one chip style per vowel, and {ma·má} in the text written out as chips
// A word's syllables are chips with nothing between them, so their boxes touch and the white
// border draws the seam. Across a word space a chip keeps at least `gap`: 0.5 em sets the
// words of a 24 pt row 4.2 mm apart, where the space alone leaves 2.3 mm. Inside a sentence
// the '-frase' twins keep no gap, so the word space alone parts a chip from the word before.
// A 24 pt chip, padding and border included, is 10.4 mm tall (gotcha: chip-overlap).
const syllable = { fontFamily: TEXT, bold: true, borderWidth: pt(1), borderColor: col('paper'),
  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
const chipStyles = [
  ...'aeiou'.split('').flatMap((v) => [ // one pair per vowel
    { ...syllable, id: v, background: col(v), color: col('paper') }, // a syllable with m
    { ...syllable, id: `${v}-borde`, background: col('paper'), color: col(v), borderColor: col(v) },
  ]).flatMap((style) => [style, { ...style, id: `${style.id}-frase`, gap: em(0) }]),
  { ...syllable, id: 'pinta', bold: false, background: col('paper'), color: col('ink'),
    borderColor: col('ink'), borderWidth: pt(0.8) }, // an outline for the child to colour in
];
const vowel = (s) => s.normalize('NFD').toLowerCase().match(/[aeiou]/)[0]; // 'mú' → 'u'
const chipOf = (s, v, end) => `:chip[${s}]{style="${v}${/m/i.test(s) ? '' : '-borde'}${end}"}`;
// {mi·mo·sa} → :chip[mi]{style="i"}:chip[mo]{style="o"}:chip[sa]{style="a-borde"}, and a word
// written after another word, as in 'me {mi·ma}.', takes the '-frase' twins. A piece with no
// vowel, the m and the p of the English {m·a·p}, takes the vowel of its word.
const syllables = (text) => text.replace(/(\p{L} )?\{([\p{L}·]+)\}/gu, (_, before = '', word) =>
  before + word.split('·').map((s) => chipOf(s, vowel(s + word), before && '-frase')).join(''));
// #endregion

const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, ...(size && { size }) });
const words = (id, content, family, size, weight, color, placement, extra) => ({ kind: 'text',
  id, content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col(color),
  align: 'left', overflow: 'wrap', placement, ...extra });
const picture = (id, resourceId, placement) => ({ kind: 'image', id, resourceId, placement });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.5) };

// #region opener: the letter's page: a cream band, a giant Mm and two things that start with m
const THINGS = t({ en: ['monkey', 'moon'], es: ['mariposa', 'manzana'] }); // ids and labels
const BAND = 104; // mm: the depth of the band's drawing; its wave dips to 101.9 mm
// The opener reserves seventeen grid lines, the first grid line past the foot of the band's
// image, so the first activity title stands on the grid like the others: two lines under
// the band, its instruction right under it.
const opener = { enabled: true, minHeight: pt(17 * GRID),
  slot: { elements: [
    picture('band', 'banda', at(0, 0, { width: mm(210), height: mm(BAND) })),
    words('unit', '{attr.unit}', LABEL, 9, 700, 'paper', at(SIDE, 12.5), { ...tag,
      box: { backgroundColor: col('ink'), borderRadius: mm(3),
        padding: { top: mm(1.2), right: mm(2.8), bottom: mm(1.2), left: mm(2.8) } } }),
    words('kicker', '{attr.kicker}', LABEL, 8.5, 600, 'muted', // level with the pill's text
      { anchor: { to: '#unit', edge: 'right-of' }, offset: { x: mm(4), y: mm(1.4) } }, tag),
    words('letter', '{titleText}', DISPLAY, 190, 700, 'ink', at(SIDE - 3, 19), { lineHeight: 1 }),
    words('name', '{attr.name}', LABEL, 20, 600, 'ink', at(SIDE + 1, 76)),
    picture('first', THINGS[0], at(146, 12, { width: mm(40) })),
    picture('second', THINGS[1], at(151, 53, { width: mm(30) })),
    ...[45.5, 84.5].map((y, k) => words(THINGS[k], THINGS[k], LABEL, 10, 600, 'muted',
      at(146, y, { width: mm(40) }), { align: 'center' })), // its name under each picture
  ] } };
// #endregion

// #region activities: each activity heading draws its icon beside the title
const ICON = 9; // mm
const H2 = { level: 2, marginBottom: pt(0), // two grid lines per title, and two above it
  lineHeight: pt(2 * GRID), marginTop: pt(2 * GRID) };
const activity = (id, icon) => ({ id, advancedDesign: { enabled: true, slot: { elements: [
  picture('icon', icon, { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(ICON), height: mm(ICON) } }),
  words('title', '{titleText}', LABEL, 15, 700, 'ink', { anchor: { to: '#icon',
    edge: 'right-of' }, offset: { x: mm(3) }, size: { width: mm(140), height: mm(ICON) } },
  { verticalAlign: 'middle' }), // centred on the icon
] } } });
// #endregion

// #region pictures: six picture words in a rounded grid, each picture inside its cell
const PICTURE_WORDS = t({ en: ['m·a·p', 'm·a·t', 'm·a·n', 'm·u·g', 'm·o·p', 'm·u·d'],
  es: ['ma·no', 'ma·pa', 'me·sa', 'mi·mo·sa', 'mo·no', 'mu·ñe·ca'] });
const tableStyles = [{ id: 'dibujos', borderRadius: mm(5), // the frame and its fills, rounded
  headerBackgroundEnabled: false, // a header row added later would print grey (#f0f0f0)
  bodyBackgroundEnabled: true, bodyBackground: col('cream'), // cream tiles…
  rules: 'grid', borderColor: col('paper'), borderWidth: pt(4), // …parted by white rules
  bodyFontSize: pt(20), cellPadding: mm(3) }]; // the words' chips are 20 pt
const cells = (row) => PICTURE_WORDS.slice(row * 3, row * 3 + 3)
  .map((word) => ({ content: syllables(`{${word}}`), align: 'center' }));
let grid = { columnWidths: [1, 1, 1], rows: [cells(0), cells(1)] };
PICTURE_WORDS.forEach((word, k) => { // the picture above the word, 0.72 of the cell's width
  const resourceId = word.replaceAll('·', '').replace('ñ', 'n');
  grid = setCellImage(grid, { row: Math.floor(k / 3), col: k % 3 }, { resourceId, width: 0.72 });
});
// #endregion

// The type of every picture and the table: its empty captionPrefix prints no caption line.
const sheet = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: '',
  captionPrefix: '', numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' };
const here = (id, rest) => ({ id, typeId: 'lamina', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, ...rest });

const FOLIO = 9; // mm: the folio's disc
const folio = (parity, edge, x, textEdge, textX) => [
  words(`n-${parity}`, '{pageNumber}', DISPLAY, 12, 700, 'paper',
    at(x, -10, { width: mm(FOLIO), height: mm(FOLIO) }, edge),
    { align: 'center', verticalAlign: 'middle', parity,
      box: { backgroundColor: col('ink'), borderRadius: mm(FOLIO / 2) } }),
  words(`s-${parity}`, '{title} · {attr.unit}: {attr.name}', LABEL, 8, 600, 'muted',
    at(textX, -10, { width: mm(100), height: mm(FOLIO) }, textEdge),
    { parity, verticalAlign: 'middle', align: textEdge.endsWith('left') ? 'left' : 'right' }),
];

const config = () => ({
  colorPalette, chipStyles, tableStyles, resourceTypes: [sheet],
  page: { width: mm(210), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } }, // equal sides: nothing to mirror
  locale: t({ en: 'en-gb', es: 'es' }), layout: { layoutType: 'single' },
  bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(GRID), color: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0) }, // ragged, so no word is hyphenated
  headings: { fontFamily: LABEL, // the titles print through designs, but are measured in it
    levels: [
      // A unit opens on a recto; its band is anchored to the page and paints up to the trim.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' }, advancedDesign: opener },
      H2] },
  headingStyles: [activity('oye', 'oreja'), activity('lee', 'libro'), activity('mira', 'ojo'),
    activity('repasa', 'lapiz')],
  // #region reading: the child's lines on multiples of the 15 pt grid, the instructions at 10 pt
  // A 24 pt chip is 10.4 mm tall: at 30 pt (10.6 mm) leading the chips of two sentences would
  // stand 0.1 mm apart, so every line the child reads has 45 pt, and the syllables 60 pt.
  paragraphStyles: [
    { id: 'consigna', fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID),
      color: col('muted') }, // the instruction under each activity
    { id: 'fila', fontSize: pt(34), lineHeight: pt(4 * GRID), textAlign: 'center' },
    { id: 'palabras', fontSize: pt(24), lineHeight: pt(3 * GRID), textAlign: 'center' },
    { id: 'lectura', fontSize: pt(24), lineHeight: pt(3 * GRID) },
    { id: 'colofon', fontSize: pt(7.5), lineHeight: pt(10), // Andika: Playpen has no italic
      color: col('muted'), marginTop: pt(GRID) },
  ],
  // #endregion
  // #region boxes: an activity on cream, the note for the family under a rule
  calloutStyles: [
    { id: 'colorea', background: col('cream'), borderRadius: mm(4),
      marginTop: pt(0), // the :::space before the box is the whole gap above it
      padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(15), fontWeight: 700, color: col('ink') },
      body: { fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(GRID), color: col('muted') } },
    { id: 'familia', backgroundEnabled: false,
      stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('rule') },
      padding: { top: mm(3), right: mm(0), bottom: mm(0), left: mm(0) },
      titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 700, color: col('ink'),
        ...tag },
      body: { fontSize: pt(10.5), lineHeight: pt(GRID) } },
  ],
  // #endregion
  header: { elements: [] },
  footer: { elements: [ // the folio on the outer edge, the series beside it
    ...folio('odd', 'bottom-right', -SIDE, 'bottom-right', -(SIDE + FOLIO + 3)),
    ...folio('even', 'bottom-left', SIDE, 'bottom-left', SIDE + FOLIO + 3)] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Esempio Markdown · 90 righe · content.en.mdtitle: "Letter by Letter" --- # Mm {kicker="Letter by Letter · Phonics book 1" unit="Unit 12" name="the sound m"} ## Listen and say {style="oye"} :::paragraphs{style="consigna"} Say each sound, then blend the sounds into a word. In the second row, the m comes last. ::: :::paragraphs{style="fila"} {m·a·p} {m·e·n} {m·o·p} {S·a·m} {d·i·m} {g·u·m} ::: ## Read the words {style="lee"} :::paragraphs{style="consigna"} A word takes the colour of its vowel; the m is filled. The word below it rhymes with it. ::: :::paragraphs{style="palabras"} {m·a·t} {m·a·n} {m·u·g} {m·o·p} {s·a·t} {p·a·n} {t·u·g} {t·o·p} ::: ## Look and read {style="mira"} :::paragraphs{style="consigna"} Say the name of each picture, then read it sound by sound. ::: ::resource{id="dibujos"} ## Trace {style="repasa"} :::paragraphs{style="consigna"} Start at the dot and follow the arrow. Then trace the words. ::: ::resource{id="trazos"} ## Read the sentences {style="lee"} :::paragraphs{style="consigna"} Read slowly. The first time a new word comes up, it is split into its sounds. ::: :::paragraphs{style="lectura"} Sam and {T·o·m} sat on a mat. Sam and Tom got mud on it. Tom got {M·u·m}. Mum got a mop. ::: :::space{lines=1} :::callout{type="colorea" title="Read and colour"} These are the colours of the vowels: :::paragraphs{style="palabras"} :chip[a]{style="a"} :chip[e]{style="e"} :chip[i]{style="i"} :chip[o]{style="o"} :chip[u]{style="u"} ::: :::space{lines=1} Colour each word with the colour of its vowel. :::paragraphs{style="palabras"} :chip[m]{style="pinta"}:chip[o]{style="pinta"}:chip[p]{style="pinta"} :chip[m]{style="pinta"}:chip[a]{style="pinta"}:chip[t]{style="pinta"} :chip[m]{style="pinta"}:chip[u]{style="pinta"}:chip[g]{style="pinta"} :chip[m]{style="pinta"}:chip[e]{style="pinta"}:chip[n]{style="pinta"} :chip[d]{style="pinta"}:chip[i]{style="pinta"}:chip[m]{style="pinta"} :chip[g]{style="pinta"}:chip[u]{style="pinta"}:chip[m]{style="pinta"} :chip[m]{style="pinta"}:chip[a]{style="pinta"}:chip[p]{style="pinta"} :chip[m]{style="pinta"}:chip[a]{style="pinta"}:chip[n]{style="pinta"} ::: ::: :::callout{type="familia" title="For parents and carers"} Read the words on page 37: your child says each sound, then blends them. Say *mmm* with closed lips, not *muh*. If your child mixes up *map* and *mop*, do not say the word: point to the middle sound and ask its colour. Ten minutes a day is enough; stop while your child still wants more. ::: :::paragraphs{style="colofon"} *Letter by Letter* is a primer invented for the Postext Cookbook. Text: CC BY 4.0. Watercolours: diffusion models. Set in Andika, DynaPuff and Playpen Sans (SIL Open Font License). :::
`; // content.<lang>.md, inlined by the Cookbook // #region art: the band, the icons and the tracing rows, drawn in the palette's colours // The picture words are watercolours, JPEGs in assets/ painted on white and multiplied by the // cream, so they sit on the band and the tiles without a box. The rest is drawn here, with no // words in it: the tracing letters are strokes and every label is set by the page. const P = palette; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const shape = (d, fill) => `<path d="${d}" fill="${fill}"/>`; const line = (d, color, w, extra = '') => `<path d="${d}" fill="none" stroke="${color}" ` + `stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const dot = (x, y, r, fill) => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" fill="${fill}"/>`; const drawings = { banda: () => svgDoc(210, BAND, shape(`M0 0H210V${BAND - 6}C190 ${BAND - 1} 172 ${BAND - 9} 150 ` + `${BAND - 6}S102 ${BAND + 1} 76 ${BAND - 5} 28 ${BAND - 12} 0 ${BAND - 6}Z`, P.cream)), }; // The activity icons: a white glyph on an ink disc. const icon = (glyph) => svgDoc(20, 20, dot(10, 10, 10, P.ink) + glyph); Object.assign(drawings, { oreja: () => icon(line('M7.5 8.5C7.5 5 10 3.5 12.5 3.5C15.5 3.5 16.5 6 16.5 8C16.5 11 13.5 ' + '11.5 13 14C12.5 16.5 10 17 8.5 15.5M10 8.5C10 7 11 6 12.5 6C13.8 6 14.3 7.2 14 8.3', P.paper, 1.5)), ojo: () => icon(line('M3.5 10Q10 3.5 16.5 10Q10 16.5 3.5 10Z', P.paper, 1.5) + dot(10, 10, 2.4, P.paper)), libro: () => icon(line('M10 6.5C7.5 5 5.5 5 4 5.8V14.8C5.5 14 7.5 14 10 15.5C12.5 14 14.5 14 ' + '16 14.8V5.8C14.5 5 12.5 5 10 6.5ZM10 6.5V15.5', P.paper, 1.3)), lapiz: () => icon(line('M5.5 14.5L6.5 11L13.5 4L16 6.5L9 13.5ZM12 5.5L14.5 8', P.paper, 1.3)), }); // Tracing: rows on writing guides, a model letter in ink, then dashed ones to go over. const TRACE = { w: 170, first: 26, pitch: 20.5 }; // mm const guides = (b) => line(`M0 ${b - 16}H${TRACE.w}M0 ${b}H${TRACE.w}`, P.rule, 0.4) + line(`M0 ${b - 8}H${TRACE.w}`, P.rule, 0.35, ' stroke-dasharray="1 1"'); // Letters as strokes: x-height 8, capitals 16, from the baseline b; [path, width]. const glyphs = { m: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} ` + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}M${x + 5.3} ${b - 5.4}` + `C${x + 5.3} ${b - 7.4} ${x + 6.7} ${b - 8} ${x + 8} ${b - 8}S${x + 10.6} ${b - 7.2} ` + `${x + 10.6} ${b - 5.4}V${b}`, 10.6], M: (x, b) => [`M${x} ${b}V${b - 16}L${x + 6} ${b - 6}L${x + 12} ${b - 16}V${b}`, 12], a: (x, b) => [`M${x + 6.4} ${b - 4}A3.2 4 0 0 0 ${x} ${b - 4}A3.2 4 0 0 0 ${x + 6.4} ${b - 4}` + `M${x + 6.4} ${b - 8}V${b}`, 6.4], e: (x, b) => [`M${x + 0.2} ${b - 4}H${x + 6.8}C${x + 6.8} ${b - 6.4} ${x + 5.2} ${b - 8} ` + `${x + 3.5} ${b - 8}S${x + 0.2} ${b - 6.2} ${x + 0.2} ${b - 4}S${x + 1.8} ${b} ${x + 3.7} ` + `${b}C${x + 5} ${b} ${x + 6} ${b - 0.5} ${x + 6.7} ${b - 1.3}`, 6.8], i: (x, b) => [`M${x + 0.6} ${b - 8}V${b}M${x + 0.6} ${b - 11.4}V${b - 11}`, 1.2], o: (x, b) => [`M${x + 6.8} ${b - 4}A3.4 4 0 0 0 ${x} ${b - 4}A3.4 4 0 0 0 ${x + 6.8} ${b - 4}`, 6.8], u: (x, b) => [`M${x} ${b - 8}V${b - 2.8}C${x} ${b - 1} ${x + 1.3} ${b} ${x + 3} ${b}S${x + 6.2} ` + `${b - 1} ${x + 6.2} ${b - 2.8}M${x + 6.2} ${b - 8}V${b}`, 6.2], n: (x, b) => [`M${x} ${b - 8}V${b}M${x} ${b - 5.4}C${x} ${b - 7.4} ${x + 1.4} ${b - 8} ` + `${x + 2.7} ${b - 8}S${x + 5.3} ${b - 7.2} ${x + 5.3} ${b - 5.4}V${b}`, 5.3], t: (x, b) => [`M${x + 1.6} ${b - 12}V${b - 2}C${x + 1.6} ${b - 0.7} ${x + 2.4} ${b} ${x + 3.8} ` + `${b}M${x} ${b - 8}H${x + 3.8}`, 3.8], }; const KERN = 1.8; // mm between letters const write = (word, x, b) => word.split('').reduce(([d, at], ch) => { const [path, w] = glyphs[ch](at, b); return [d + path, at + w + KERN]; }, ['', x]); // [path, x after the last letter + KERN] const dashed = ([d]) => line(d, P.muted, 1, ' stroke-dasharray="1.1 1"'); // The model: ink strokes, a teal dot where the pencil starts and an arrow beside the stem // pointing the way the first stroke goes (dir 1 down, −1 up). const model = (letter, b, [x, y], dir) => line(write(letter, 4, b)[0], P.ink, 1.3) + dot(x, y, 1.2, P.i) + line(`M${x - 2.4} ${y + dir * 1.5}V${y + dir * 6}`, P.i, 0.6) + shape(`M${x - 3.5} ${y + dir * 5.2}H${x - 1.3}L${x - 2.4} ${y + dir * 7.2}Z`, P.i); const repeat = (letter, b) => Array.from({ length: 7 }, (_, k) => dashed(write(letter, TRACE.first + k * TRACE.pitch, b))).join(''); // The last row: the five syllables, or three words of the English unit, each centred in // its share of the row. const TRACED = t({ en: ['man', 'mat', 'men'], es: ['ma', 'me', 'mi', 'mo', 'mu'] }); const syllableRow = (b) => TRACED.map((syll, k) => { const w = write(syll, 0, b)[1] - KERN; return dashed(write(syll, (k + 0.5) * (TRACE.w / TRACED.length) - w / 2, b)); }).join(''); drawings.trazos = () => svgDoc(TRACE.w, 67, guides(17) + model('m', 17, [4, 9], 1) + repeat('m', 17) + guides(41) + model('M', 41, [4, 41], -1) + repeat('M', 41) + guides(65) + syllableRow(65)); // The watercolours of each edition, by resource id: [file, width, height] in px. The map // and the monkey serve both. const painted = (ids) => Object.fromEntries(ids.map((id) => [id, [`${id}-640.jpg`, 640, 480]])); const PAINTINGS = t({ en: { monkey: ['mono-640.jpg', 640, 480], moon: ['moon-480.jpg', 480, 480], map: ['mapa-640.jpg', 640, 480], ...painted(['mat', 'man', 'mug', 'mop', 'mud']) }, es: { mariposa: ['mariposa-660.jpg', 660, 540], manzana: ['manzana-480.jpg', 480, 480], ...painted(['mano', 'mapa', 'mesa', 'mimosa', 'mono', 'muneca']) } }); const ALT = t({ en: { banda: 'A cream band with a wavy lower edge', monkey: 'A monkey sitting', moon: 'A yellow crescent moon with two stars', map: 'A treasure map with a river and a path to a cross', mat: 'A striped doormat', man: 'A man waving', mug: 'A blue mug with white dots', mop: 'A string mop', mud: 'A puddle of mud', oreja: 'An ear', ojo: 'An eye', libro: 'An open book', lapiz: 'A pencil', trazos: 'Three rows of writing guides: the small m and the capital M, each with a model and ' + 'seven dotted letters to trace, and the dotted words man, mat and men' }, es: { banda: 'Franja de color crema con el borde ondulado', manzana: 'Una manzana roja con una hoja', mariposa: 'Una mariposa morada con manchas amarillas', mano: 'Una mano abierta', mapa: 'Un mapa del tesoro con un río y un camino hasta una cruz', mesa: 'Una mesa con una manzana y una taza', mono: 'Un mono sentado', mimosa: 'Una rama de mimosa con flores amarillas y hojas plumosas', muneca: 'Una muñeca de trapo con coletas y vestido morado', oreja: 'Una oreja', ojo: 'Un ojo', libro: 'Un libro abierto', lapiz: 'Un lápiz', trazos: 'Tres pautas: la eme minúscula y la mayúscula, cada una con un modelo y siete de ' + 'puntos para repasar, y las sílabas ma, me, mi, mo y mu de puntos' } }); // Each picture is a resource that the opener, a heading style, a table cell or the text names // by id. Drawings in px, 10 to the millimetre (the viewBox's own). const size = (svg) => svg.match(/width="(\d+)" height="(\d+)"/).slice(1).map(Number); const common = (id) => ({ id, typeId: 'lamina', altText: ALT[id], createdAt: 0, updatedAt: 0 }); const artwork = [...Object.entries(drawings).map(([id, draw]) => { const [width, height] = size(draw()); return { ...common(id), kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, ...(id === 'trazos' && here(id)) }; }), ...Object.entries(PAINTINGS).map(([id, [fileId, w, h]]) => ({ ...common(id), kind: 'bitmap', bitmap: { fileId, format: 'jpeg', width: w, height: h } }))]; // #endregion const resources = [...artwork, here('dibujos', { kind: 'table', table: { styleId: 'dibujos', model: grid } })]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], DynaPuff: ['700'], 'Playpen Sans': ['400', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const text = syllables(markdown); await Promise.all([prepareFonts(text, config(), kitFonts(FONTS)), // every face before the build ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())), ...Object.values(PAINTINGS).map(([file]) => loadImage(file, asset(file)))]); const continuation = { pageIndexOffset: 36, pageNumbering: { startAt: 37 } }; // 37: a recto const doc = await buildDocumentWithFonts({ markdown: text, resources, continuation }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Letter by Letter · The m', es: 'Letra a letra · La eme' }) });
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

#Arrotonda i chip in pillole

Un raggio maggiore di metà dell’altezza del chip viene limitato a quella metà, quindi un valore grande disegna estremità tonde. Un chip di una sola lettera diventa quasi un cerchio, e le giunture dentro una parola diventano tacche.

-  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5) };
+  paddingX: em(0.26), paddingY: em(0.05), gap: em(0.5), borderRadius: em(1) };

Errori frequenti

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 →

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 →

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 →

Crediti

Testo
Testo originale, CC BY 4.0
Immagini
  • A watercolour of the butterfly on the opener · Generated With Diffusion Models · originale
  • A watercolour of the apple on the opener · Generated With Diffusion Models · originale
  • A watercolour of the hand in the picture grid · Generated With Diffusion Models · originale
  • A watercolour of the map in the picture grid of both units · Generated With Diffusion Models · originale
  • A watercolour of the table in the picture grid · Generated With Diffusion Models · originale
  • A watercolour of the mimosa sprig in the picture grid · Generated With Diffusion Models · originale
  • A watercolour of the monkey: in the picture grid of the Spanish unit and on the opener of the English one · Generated With Diffusion Models · originale
  • A watercolour of the doll in the picture grid · Generated With Diffusion Models · originale
  • A watercolour of the moon on the opener of the English unit · Generated With Diffusion Models · originale
  • A watercolour of the mat in the picture grid of the English unit · Generated With Diffusion Models · originale
  • A watercolour of the man in the picture grid of the English unit · Generated With Diffusion Models · originale
  • A watercolour of the mug in the picture grid of the English unit · Generated With Diffusion Models · originale
  • A watercolour of the mop in the picture grid of the English unit · Generated With Diffusion Models · originale
  • A watercolour of the mud in the picture grid of the English unit · Generated With Diffusion Models · originale
  • The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette · Ignacio Ferro · MIT
Font
Andika (SIL OFL 1.1) · DynaPuff (SIL OFL 1.1) · Playpen Sans (SIL OFL 1.1)
Sandbox