Aller au contenu principal
Recette numéro 60

Recettes · Chapitre 2 · Caractères et texte

Livret de lecture avec pastilles de sons

Un livret de lecture sur la lettre M : chaque son en pastille de la couleur de sa voyelle, et une grille de mots illustrés, une aquarelle par case.

p. 37 · 1 sur 3

  • Exemple en anglais : pas encore d’édition française
  • Format 210 × 260 mm
  • 1 colonne
  • Andika 11/15
  • DynaPuff
  • Playpen Sans
  • 3 pages
  • Niveau
  • Postext 1.30.0
  • Mis en page en 73 ms
  • 180 lignes de code

En bref

Trois pages d’un livre de lecture pour jeunes enfants, sur la lettre M. Chaque son d’un mot est dans une petite étiquette de couleur, et chaque mot d’une grille a sa propre image.

Ce que vous allez composer

L’unité 12 de Letter by Letter, un livret de lecture anglais inventé fondé sur la méthode phonique : trois pages sur le son m. L’ouverture compose un Mm de 190 pt en DynaPuff sur un bandeau crème, à côté de deux choses dont le nom commence par m, un singe (monkey) et une lune (moon). Chaque mot est découpé en sons, et chaque son est une pastille de la couleur de la voyelle du mot : a rouge, e orange, i bleu canard, o bleu, u violet. Le m est en plein et les autres sons sont détourés : l’enfant voit où tombe le son de l’unité dans map et dans gum. La page 38 place six mots illustrés dans une grille arrondie, une aquarelle par case, puis trois lignes à tracer. Les aquarelles ont été générées avec des modèles de diffusion ; le bandeau, les icônes et les lignes à tracer sont dessinés en code. La page 39 porte quatre phrases en 24 pt, un encadré de mots à colorier et une note pour les parents et les accompagnants.

L’édition espagnole est une autre unité, pas une traduction : l’unité 7 de Letra a letra, sur la même lettre. L’espagnol s’enseigne par syllabes ouvertes (ma, me, mi, mo, mu) : ses pastilles contiennent donc des syllabes, {mi·mo·sa}. Ces syllabes ne sont pas des mots anglais, et les livrets anglais avancent son par son : l’unité anglaise assemble donc les sons de mots courts, {m·a·p}, et les groupe en familles qui riment (-at, -an, -ug, -op). Les deux unités sortent du même pen : les mêmes styles de pastille, la même fonction syllables(), la même grille, les mêmes lignes à tracer et les mêmes encadrés.

Cette recette répond à

  • Comment faire des pastilles en ligne : touches de clavier, étiquettes, banques de mots pour des exercices ?
  • Comment mettre des images ou des icônes dans les cellules d’un tableau ?
  • Comment ajouter de l’espace vertical entre deux blocs, quand les lignes vides n’y font rien ?
  • Comment construire une fiche d’exercices : lignes à remplir, cases de réponse, banques de mots, listes de contrôle ?

La réponse courte

script.js · lignes 27–48dans le code complet
// 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(''));

Ingrédients

Typographie
Andika, DynaPuff, Playpen Sans (SIL OFL 1.1)
Ressources
  • 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, original)
  • A watercolour of the apple on the opener (Generated With Diffusion Models, original)
  • A watercolour of the hand in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the map in the picture grid of both units (Generated With Diffusion Models, original)
  • A watercolour of the table in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the mimosa sprig in the picture grid (Generated With Diffusion Models, original)
  • 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, original)
  • A watercolour of the doll in the picture grid (Generated With Diffusion Models, original)
  • A watercolour of the moon on the opener of the English unit (Generated With Diffusion Models, original)
  • A watercolour of the mat in the picture grid of the English unit (Generated With Diffusion Models, original)
  • A watercolour of the man in the picture grid of the English unit (Generated With Diffusion Models, original)
  • A watercolour of the mug in the picture grid of the English unit (Generated With Diffusion Models, original)
  • A watercolour of the mop in the picture grid of the English unit (Generated With Diffusion Models, original)
  • A watercolour of the mud in the picture grid of the English unit (Generated With Diffusion Models, original)
  • The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Préparation

#1 · Un style de pastille par voyelle et une fonction qui écrit les pastilles

La réponse courte construit 21 styles de pastille en reprenant un seul objet syllable. Il y a un style plein par voyelle pour le m, un style détouré par voyelle pour les autres sons, un jumeau -frase de chacun de ces dix, et pinta, le contour à l’encre qu’un enfant colorie. syllables() transforme {m·a·p} en trois :chip[…] sans rien entre eux : les sons d’un mot se touchent et c’est la bordure blanche qui dessine la jointure. Une pastille prend la couleur de sa voyelle, et un morceau sans voyelle, ici le m et le p, prend la voyelle de son mot : map est rouge d’un bout à l’autre. De part et d’autre d’une espace entre mots, une pastille garde au moins son gap, et 0,5 em écarte de 4,2 mm les mots d’une rangée de 24 pt, là où l’espace seule laisse 2,3 mm. Dans une phrase, cet écart doublerait presque l’espace avant le nouveau mot : un mot qui suit un autre mot reçoit donc les jumeaux -frase, dont le gap de 0 laisse l’espace de 2,3 mm telle quelle. Dans l’édition espagnole, les morceaux sont des syllabes, {mi·mo·sa}, et chacune a sa propre voyelle.

#2 · Un interlignage qui dégage les pastilles

script.js · lignes 144–154dans le code complet
  // 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 marge intérieure verticale et la bordure d’une pastille se peignent hors de sa ligne et n’ajoutent pas d’interlignage : une pastille plus haute que le pas des lignes empiète sur les pastilles de la ligne du dessous. Une pastille de 24 pt mesure 10,4 mm de haut ; avec un interlignage de 30 pt (10,6 mm), les pastilles de deux phrases ne seraient qu’à 0,1 mm l’une de l’autre. Les lignes que lit l’enfant reçoivent 45 pt, et les rangées de 34 pt de la première activité 60 pt, soit trois et quatre lignes de la grille de 15 pt des consignes.

#3 · Une image dans chaque case

script.js · lignes 96–109dans le code complet
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 place une image au-dessus du texte d’une cellule, à une fraction de la largeur intérieure de la cellule, ici 0,72 (36,5 mm), et la ligne grandit pour la contenir. Chaque image est un JPEG peint sur blanc et multiplié par le crème des tuiles : elle se pose sur la tuile sans former de bloc. Sous chaque image figure le mot en pastilles de sons. Une cellule de tableau accepte le balisage des pastilles comme n’importe quel paragraphe. Le style de tableau dibujos arrondit le cadre et ses fonds à 5 mm et sépare les tuiles crème par des filets blancs de 4 pt.

#4 · Une ligne d’espace avant l’encadré

script.js · lignes 157–169dans le code complet
  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) } },
  ],

Le Markdown place :::space{lines=1} entre la dernière phrase et l’encadré à colorier, une ligne de 15 pt (5,3 mm). La marginTop de l’encadré vaut 0 : cette ligne fait donc tout l’écart ; sans elle, l’encadré commencerait là où finit la ligne de la dernière phrase. Un :::space qui ouvre un encadré est supprimé : l’encadré commence donc par sa ligne de consigne et les pastilles de la légende des couleurs, et son propre :::space vient après, entre la légende et l’activité de coloriage.

#5 · L’ouverture de la lettre

script.js · lignes 60–79dans le code complet
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
  ] } };

Le Mm est le texte du titre ({titleText}) ; le numéro de l’unité, la collection et le nom sous la lettre sont des attributs de la même ligne de titre. Le bandeau, le singe et la lune sont des images. Le bandeau fait 104 mm de haut, et l’ouverture réserve la page jusqu’à la première ligne de grille sous son pied : le premier titre d’activité commence donc à 118,5 mm, sur la grille, 16,6 mm sous le point le plus bas de la vague (101,9 mm).

#6 · Des icônes à côté des titres d’activité

script.js · lignes 83–92dans le code complet
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
] } } });

Chaque titre d’activité nomme son style de titre, comme dans ## Read the sentences {style="lee"}, et le style dessine une icône avec le titre centré à côté. Les titres restent des titres de niveau 2 : c’est donc le niveau H2 qui les espace, avec deux lignes de grille de lineHeight pour le titre et deux autres de marginTop au-dessus.

La recette complète

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`---
Exemple Markdown · 90 lignes · 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 : identique dans toutes les recettes · 270 lignes// ─── 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 ───────────────────────────────────────────────────────────────────────

Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)

Variantes

#Arrondir les pastilles en pilules

Un rayon supérieur à la moitié de la hauteur de la pastille est ramené à cette moitié : une grande valeur dessine donc des bouts arrondis. Une pastille d’une seule lettre devient presque un cercle, et les jointures à l’intérieur d’un mot deviennent des encoches.

-  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) };

Erreurs fréquentes

Erreur fréquente

Les pastilles plus hautes que le pas des lignes touchent la ligne suivante

Une pastille dont la boîte est plus haute que le pas des lignes touche les pastilles de la ligne suivante (l’avertissement chipOverlap). Réduisez sa marge intérieure verticale, sa bordure ou son corps, ou ouvrez l’interlignage. Pastilles dans le texte →

Erreur fréquente

:::space est supprimé en haut d’un encadré ou d’une colonne

:::space est supprimé en haut d’une colonne, d’un groupe :::columns, d’un encadré sans titre qui a d’autres contenus et de la suite d’un encadré coupé. Depuis postext 1.5, un encadré fait seulement de :::space est aussi haut que l’espace le demande (une case de réponse, de la place pour écrire), et un espace juste sous le titre d’un encadré fait commencer le corps d’autant plus bas. Pour ouvrir un encadré sans titre par un espace, donnez-lui un titre ou une première ligne et ajoutez l’espace après. Espace vertical explicite →

Vérification du Sandbox · chipOverlap

Pastilles touchant la ligne voisine

Pourquoi. Le cadre d’un style de pastille est plus haut que le pas des lignes : les pastilles de lignes consécutives se touchent.

Correction. Réduisez la marge intérieure verticale, le contour ou la taille de la pastille, ou augmentez l’interlignage. Docs →

Crédits

Texte
Texte original, CC BY 4.0
Images
  • A watercolour of the butterfly on the opener · Generated With Diffusion Models · original
  • A watercolour of the apple on the opener · Generated With Diffusion Models · original
  • A watercolour of the hand in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the map in the picture grid of both units · Generated With Diffusion Models · original
  • A watercolour of the table in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the mimosa sprig in the picture grid · Generated With Diffusion Models · original
  • 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 · original
  • A watercolour of the doll in the picture grid · Generated With Diffusion Models · original
  • A watercolour of the moon on the opener of the English unit · Generated With Diffusion Models · original
  • A watercolour of the mat in the picture grid of the English unit · Generated With Diffusion Models · original
  • A watercolour of the man in the picture grid of the English unit · Generated With Diffusion Models · original
  • A watercolour of the mug in the picture grid of the English unit · Generated With Diffusion Models · original
  • A watercolour of the mop in the picture grid of the English unit · Generated With Diffusion Models · original
  • A watercolour of the mud in the picture grid of the English unit · Generated With Diffusion Models · original
  • The cream band, the activity icons and the tracing rows, drawn in code in the page’s palette · Ignacio Ferro · MIT
Polices
Andika (SIL OFL 1.1) · DynaPuff (SIL OFL 1.1) · Playpen Sans (SIL OFL 1.1)
Sandbox