Aller au contenu principal
Recette numéro 22

Recettes · Chapitre 6 · Encadrés et notes

Fiche d’exercices avec cases réponse et banque de mots

Une fiche de sciences de quatre pages : cases réponse blanches dans des cartes vert pâle, 2 mm sous chaque question, hors grille ; mots et blancs en pastilles.

  • Exemple en anglais : pas encore d’édition française
  • Format 200 × 260 mm
  • 1 colonne
  • Andika 12/17
  • Baloo 2
  • Fredoka
  • 4 pages
  • Niveau
  • Postext 1.29.1
  • Mis en page en 53 ms
  • 185 lignes de code

En bref

Une fiche de sciences pour des enfants de huit ans, sur les parties d’une plante. Chaque question a une case en dessous pour la réponse, et certaines phrases ont des trous à remplir avec une liste de mots.

Ce que vous allez composer

La fiche 3A de Seedlings Science, une collection inventée pour des enfants de huit ans, sur quatre pages de 200 × 260 mm imprimées au recto. Une bande vert feuille porte une pilule jaune d’unité, le titre et des champs blancs pour le nom et la date ; à côté, un plant de pois à l’aquarelle dans un disque blanc a ses six parties numérotées en jaune. L’aquarelle a été générée par des modèles de diffusion et arrive déjà placée dans son disque, sous forme de deux JPEG qui se rejoignent au bord de la bande : celui du haut a le vert de la bande dans ses coins, celui du pied du blanc ; les numéros et les lignes de rappel restent en code, par-dessus l’image. Chaque activité est un titre numéroté au-dessus d’une carte vert pâle. Les cartes contiennent des cases réponse blanches avec une petite consigne grise et la place de plusieurs lignes, des banques de mots en pastilles jaunes, des blancs détourés dans des phrases, un tableau d’aliments à relier aux parties de la plante et une liste de cercles à colorier. Chaque case réponse se place 2 mm sous sa question et 5 mm au-dessus de ce qui suit, et chaque carte se termine 9 mm au-dessus de ce qui vient ensuite sur sa page. Le texte est composé en Andika, un caractère dessiné par SIL pour les enfants qui apprennent à lire.

Cette recette répond à

  • Comment imbriquer des encadrés, comme une fiche d’exercices contenant des cases de réponse, avec un espacement exact ?
  • Comment construire une fiche d’exercices : lignes à remplir, cases de réponse, banques de mots, listes de contrôle ?
  • 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 ?

La réponse courte

script.js · lignes 25–57dans le code complet
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← the prompt first: a :::space that opens a box is dropped
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].

Ingrédients

Typographie
Andika, Baloo 2, Fredoka (SIL OFL 1.1)
Ressources
  • plant-1092.jpg
  • plant-foot-1092.jpg
  • The pea plant on the opener, a watercolour (top of the disc) (Generated With Diffusion Models, original)
  • The pea plant’s roots, the foot of the same watercolour below the band (Generated With Diffusion Models, original)
  • The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette (Ignacio Ferro, MIT)

Préparation

#1 · Sortir aussi les titres de la grille

script.js · lignes 162–170dans le code complet
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // parity 'any': a sheet opens the next page, on either side.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },

Dans la réponse courte, les marges de chaque encadré fusionnent avec celles de ses voisins et la plus grande l’emporte. Les 5 mm d’une banque de mots l’emportent sur les 2 mm au-dessus de la liste qui la suit, et les 5 mm de la dernière case réponse tombent dans les 5 mm de marge intérieure du bas de la carte au lieu de s’y ajouter. Laissé sur la grille, un titre de niveau 2 réserve entre 10,8 et 14,1 mm au lieu des 8,6 mm de son disque, parce que ce qui le suit se recale sur la ligne de 17 pt suivante ; headings.snapToGrid: false le garde à 8,6 mm. Régler snapToGrid sur les styles imbriqués ne change rien, car seule la carte extérieure peut quitter la grille (encadrés imbriqués). L’équilibrage des colonnes est désactivé, parce qu’il ajoute de l’espace au-dessus des titres pour remplir une page ; en page 2, il mettrait 33 mm au-dessus de l’activité 3 au lieu de 9.

#2 · Faire de chaque blanc une pastille

script.js · lignes 61–75dans le code complet
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip, :chip[ ], is a box of padding and outline alone. It is a band 1.25 em tall,
// so paddingX ROUND makes it a circle.
const [ROUND, OUTLINE] = [em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];

Une pastille est une boîte insécable dans la ligne, et sa marge intérieure se peint au-delà de la ligne sans la rendre plus haute. Une banque de mots est une rangée de pastilles dans un encadré bank imbriqué. Les blancs, les points à relier et les cercles à colorier sont aussi des pastilles, chacune écrite :chip[ ] : une pastille d’une espace est une boîte vide faite de marge intérieure et de contour (pastilles). paddingX fixe la largeur : un blanc fait 34 mm de large dans une phrase et 38 mm seul dans une colonne, et un cercle reçoit de chaque côté la moitié de ses 1,25 em de hauteur. Dans la liste à cocher, le cercle se tient là où serait la puce (bulletChar: ''). À 19,6 pt, un blanc est plus haut que la ligne de 17 pt : c’est pourquoi chacun a un élément de liste pour lui seul, à 24 pt du suivant.

#3 · Numéroter les activités avec une maquette de titre

script.js · lignes 79–93dans le code complet
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };

Un titre de niveau 2 avec un advancedDesign garde sa place dans la colonne, et la maquette se dessine à la place de son texte : un disque qui contient {number}, qui imprime le numberingTemplate du niveau, et {titleText} à sa droite (emplacements et maquette avancée). Le disque et le titre font tous deux 8,6 mm de haut, et hors grille le titre réserve exactement cette hauteur : la maquette n’a donc pas besoin de minHeight. Comme les numéros viennent du compteur de niveau 2, une activité insérée dans le Markdown renumérote celles qui la suivent. Le titre de l’autoévaluation emploie un style avec numbered: false, qui le sort du décompte, et une étoile SVG se place là où serait le disque.

#4 · Placer les images dans les cellules du tableau

script.js · lignes 131–147dans le code complet
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  rules: 'none' }]; // the white panel is the body fill alone
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(':chip[ ]{style="dot"}', { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, built once FOODS exist. A box keeps a line of its text, 6 mm, over
// an embedded table; with that off (see layout), a :::space{lines=0.33} sets ASK instead.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });

TableCell.image dessine une ressource dans sa cellule, ici à 76 % de la largeur de la cellule, et la rangée grandit pour la contenir. verticalAlign: 'middle' centre le nom de l’aliment, les points et la pastille sur l’image. La colonne la plus large, 31 % du tableau, reste vide pour le trait de l’élève. Le style nommé arrondit le cadre du tableau et y découpe le fond blanc du corps. Il ne dessine aucun filet : le panneau n’est donc que le fond blanc (styles de tableau nommés). Son type de ressource a un captionPrefix vide et le tableau n’a pas de texte de légende : aucune légende ne s’imprime dessous. placement: { position: 'here' } place le tableau là où se trouve sa ligne ::resource, dans la carte. Un encadré garde une ligne de son propre texte au-dessus d’un tel tableau, ici 6 mm ; layout.inlineResourceGapInBoxes: false la supprime, et un :::space{lines=0.33} avant la ligne ::resource fixe les 2 mm que les cases réponse gardent sous leurs questions.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 022 · Worksheet with answer boxes and a word bank ══════
// https://postext.dev/en/cookbook/worksheet-answer-boxes
// Code: MIT · Text: original (CC BY 4.0) · Plant: diffusion models · Icons: drawn in code
// Fonts: Andika, Baloo 2, Fredoka (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, prepareFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'worksheet-answer-boxes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { ink: '#243040', muted: '#5d6975', paper: '#ffffff', // type; prompts; boxes
  leaf: '#2f7d4a', sun: '#f4b43a', soil: '#8a5a36', // the accent; words to pick; drawings
  tint: '#e5f1e7', rule: '#a9c9b1' }; // the activity cards; answer-box outlines
// Each colour names its palette entry and carries its hex.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the leaf, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.leaf })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
const [TEXT, DISPLAY, LABEL] = ['Andika', 'Baloo 2', 'Fredoka']; // Andika: for early readers
const [TOP, SIDE] = [18, 16]; // mm; a sheet printed one-sided, so the margins do not mirror

// #region answer: cards that hold white answer boxes, stacked exactly, off the grid
// In the Markdown a box inside a box is a fence inside a fence (each ::: closes one):
//   :::callout{type="card"}
//   What is inside the pea pods?
//   :::callout{type="answer"}
//   Write here               ← the prompt first: a :::space that opens a box is dropped
//   :::space{lines=3}           (gotcha: space-dropped-box-top)
//   :::
//   :::
const [ASK, AFTER, HEAD, NEXT] = [2, 5, 3, 9]; // mm: question→box, box→next, heading→card
const card = {
  id: 'card', background: col('tint'), borderRadius: mm(4),
  padding: { top: mm(4), right: mm(5), bottom: mm(5), left: mm(5) },
  marginTop: mm(HEAD), marginBottom: mm(NEXT), // NEXT: under a card, to whatever follows
  // Off the grid, or what follows a card drops to the next grid line. Only the outer card can
  // set it: a nested box ignores snapToGrid, span and placement (gotcha: nested-callout-limits).
  snapToGrid: false,
  body: { paragraphSpacing: false }, // the boxes' margins do all the spacing
  lists: { bulletChar: '', // no bullet: in the checklist a circle chip opens each item
    gap: mm(2), itemSpacing: pt(7) }, columnGap: mm(6),
};
// A nested box's margins collapse with its neighbours', the larger winning (ASK, AFTER); the
// last box's marginBottom falls inside the card's padding instead of adding to it.
const nested = (id, look) => ({ id, background: col('paper'), borderRadius: mm(2.5),
  marginTop: mm(ASK), marginBottom: mm(AFTER), ...look });
const answer = nested('answer', { border: { enabled: true, color: col('rule'), width: pt(0.75) },
  padding: { top: mm(1.6), right: mm(3), bottom: mm(2), left: mm(3) },
  // A small grey prompt on a full 17 pt line; :::space counts in these body lines.
  body: { fontFamily: LABEL, fontSize: pt(8), color: col('muted') } });
const bank = nested('bank', { title: t({ en: 'Word bank', es: 'Banco de palabras' }),
  padding: { top: mm(2.2), right: mm(3), bottom: mm(2.4), left: mm(3) },
  titleStyle: { fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, color: col('leaf'),
    textTransform: 'uppercase', letterSpacing: pt(1.3), gap: mm(1.6) } });
// config() plugs in all three: calloutStyles: [card, answer, bank].
// #endregion

// #region chips: words to pick, blanks to fill, dots to join, circles to colour
const chip = (id, fill, look) => ({ id, fontFamily: LABEL, background: col(fill),
  borderWidth: pt(0), borderColor: col('leaf'), borderRadius: em(1), ...look });
// An empty chip, :chip[ ], is a box of padding and outline alone. It is a band 1.25 em tall,
// so paddingX ROUND makes it a circle.
const [ROUND, OUTLINE] = [em(0.625), pt(0.9)];
// A blank is all padding, 2 × paddingX wide and 19.6 pt tall: taller than the 17 pt line, so
// the card's itemSpacing sets its items 24 pt apart (gotcha: chip-overlap).
const blank = (id, width) => chip(id, 'paper', { borderWidth: OUTLINE, borderRadius: mm(1.4),
  paddingX: mm(width / 2), paddingY: em(0.12), fontSize: em(1.15) });
const chipStyles = [
  chip('word', 'sun', { bold: true, paddingX: em(0.6), paddingY: em(0.08), gap: em(0.45) }),
  blank('blank', 34), blank('wide', 38), // mm: in a sentence; alone in a column
  chip('dot', 'leaf', { paddingX: ROUND, fontSize: em(0.6) }),
  chip('tick', 'paper', { borderWidth: OUTLINE, paddingX: ROUND, fontSize: em(1.1), gap: em(0.5) }),
];
// #endregion

// #region activity: an in-column H2 design: the automatic number in a green disc
const DISC_H2 = 8.6; // mm: the disc and the title are this tall, so the heading reserves this
const numberDisc = { kind: 'text', id: 'num', content: '{number}', // numberingTemplate '{2}'
  fontFamily: DISPLAY, fontSize: pt(15), fontWeight: 800, color: col('paper'),
  box: { backgroundColor: col('leaf'), borderRadius: mm(DISC_H2 / 2) }, // centred both ways
  placement: { anchor: { to: 'container', edge: 'top-left' },
    size: { width: mm(DISC_H2), height: mm(DISC_H2) } } };
const activityTitle = { kind: 'text', id: 'title', content: '{titleText}',
  fontFamily: DISPLAY, fontSize: pt(17), fontWeight: 700, color: col('ink'), align: 'left',
  overflow: 'wrap', placement: { anchor: { to: '#num', edge: 'right-of' },
    offset: { x: mm(3) }, size: { width: mm(140), height: mm(DISC_H2) } } };
const activity = { enabled: true, slot: { elements: [numberDisc, activityTitle] } };
// ## How did I do? {style="review"} is not an activity: it has no number and a star for a disc.
const review = { id: 'review', numbered: false, advancedDesign: { ...activity, slot: {
  elements: [{ kind: 'image', id: 'num', resourceId: 'star', placement: numberDisc.placement },
    activityTitle] } } };
// #endregion

const [BAND, AIR, DOT] = [76, 5, 6]; // mm: the opener's band, air under the disc, dot size
const DISC = { x: 108, y: 5, d: 78 }; // mm: the plant's disc on the page
const EDGE = BAND - DISC.y; // mm down the disc: the band's edge, where its two pictures meet
const at = (x, y, size, edge = 'top-left') => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(y) }, size });
const words = (id, content, family, size, weight, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), fontWeight: weight, color: col('paper'),
  align: 'left', overflow: 'wrap', placement, ...extra });
const tag = { textTransform: 'uppercase', letterSpacing: pt(1.3) };
const dot = ([n, x, y]) => words(`dot${n}`, String(n), DISPLAY, 10.5, 800, at(DISC.x + x - DOT / 2,
  DISC.y + y - DOT / 2, { width: mm(DOT), height: mm(DOT) }), { color: col('ink'), align: 'center',
  box: { backgroundColor: col('sun'), borderRadius: mm(DOT / 2) } }); // a yellow disc per number
const field = (id, label, x, w) => [words(`${id}-label`, label, LABEL, 7.5, 600, at(x, 59), tag),
  { kind: 'box', id, style: { backgroundColor: col('paper'), borderRadius: mm(1.6) },
    placement: at(x, 63, { width: mm(w), height: mm(7.5) }) }]; // a label over a white field
// The opener reserves down to the disc's foot; minHeight adds AIR, 5 mm, under it.
const opener = () => ({ enabled: true, minHeight: mm(DISC.y + DISC.d + AIR - TOP),
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('leaf') },
      placement: at(0, 0, { height: mm(BAND) }) },
    ...[['plant', 0, EDGE], ['plant-foot', EDGE, DISC.d - EDGE], ['leaders', 0, DISC.d]]
      .map(([id, y, h]) => ({ kind: 'image', id, resourceId: id, // the leaders last, on top
        placement: at(DISC.x, DISC.y + y, { width: mm(DISC.d), height: mm(h) }) })),
    ...LABELS.map(dot),
    words('unit', '{attr.unit}', LABEL, 9, 600, at(SIDE, TOP - 4), { ...tag, color: col('ink'),
      box: { backgroundColor: col('sun'), borderRadius: mm(3),
        padding: { top: mm(1), right: mm(2.6), bottom: mm(1), left: mm(2.6) } } }),
    words('title', '{titleText}', DISPLAY, 40, 800, at(SIDE, 23.5, { width: mm(88) }),
      { lineHeight: 0.98 }), // two lines: the headings break with \\
    words('series', '{title} · {subtitle} · {attr.sheet}', LABEL, 9.5, 500, at(SIDE, 53)),
    ...field('name', t({ en: 'Name', es: 'Nombre' }), SIDE, 58),
    ...field('date', t({ en: 'Date', es: 'Fecha' }), SIDE + 62, 22),
  ] } });

// #region tables: a rounded panel with a picture in each row and chips to join
const tableStyles = [{ id: 'match', borderRadius: mm(3), // no header row, so no header fill
  headerBackgroundEnabled: false, bodyBackgroundEnabled: true, bodyBackground: col('paper'),
  bodyFontFamily: LABEL, bodyFontSize: pt(13), bodyColor: col('ink'), cellPadding: mm(1.4),
  rules: 'none' }]; // the white panel is the body fill alone
const cell = (content, extra) => ({ content, verticalAlign: 'middle', ...extra });
const dotCell = () => cell(':chip[ ]{style="dot"}', { align: 'center' });
const matchRow = (icon, food, part) => [cell('', { image: { resourceId: icon, width: 0.76 },
  align: 'center' }), cell(food), dotCell(), cell(''), dotCell(),
cell(`:chip[${part}]{style="word"}`)];
const sheetType = { id: 'sheet', name: 'Worksheet item', shortLabel: '', captionPrefix: '',
  numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }; // no caption, no number
// Set 'here' in its card, built once FOODS exist. A box keeps a line of its text, 6 mm, over
// an embedded table; with that off (see layout), a :::space{lines=0.33} sets ASK instead.
const foods = () => ({ id: 'foods', typeId: 'sheet', kind: 'table', createdAt: 0, updatedAt: 0,
  placement: { position: 'here' }, table: { styleId: 'match', model: {
    columnWidths: [0.14, 0.25, 0.06, 0.31, 0.06, 0.18], // the widest gap: room to draw a line
    rows: FOODS.map((name, r) => matchRow(ICONS[r], name, PARTS[r])) } } });
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }), // the hyphenation patterns of the edition
  resourceTypes: [sheetType], colorPalette, chipStyles, tableStyles, header: { elements: [] },
  footer: { elements: [words('foot', '{title} · {subtitle} · {attr.unit} · {pageNumber}', LABEL,
    7.8, 600, at(SIDE, -11, undefined, 'bottom-left'), { ...tag, color: col('muted') })] },
  page: { width: mm(200), height: mm(260), dpi: 150, margins: { top: mm(TOP), bottom: mm(20),
    left: mm(SIDE), right: mm(SIDE) } },
  layout: { layoutType: 'single', inlineResourceGapInBoxes: false }, // ASK over the table
  bodyText: { fontFamily: TEXT, fontSize: pt(12), lineHeight: pt(17), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
  // #region grid: the headings leave the grid too, and nothing stretches the gaps
  headings: { fontFamily: DISPLAY, snapToGrid: false, // or what follows snaps back to the grid
    balancing: { enabled: false }, // no extra space above headings to fill out a page
    levels: [
      // parity 'any': a sheet opens the next page, on either side.
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener() },
      { level: 2, numberingTemplate: '{2}', marginTop: mm(0), marginBottom: mm(0),
        advancedDesign: activity },
    ] },
  // #endregion
  orderedLists: { fontFamily: DISPLAY, fontWeight: 800, marginTop: mm(ASK), // a bank's AFTER wins
    numberVerticalOffset: pt(1.7) }, // centred 0.3 em over the baseline, Baloo 2 sat high
  headingStyles: [review], calloutStyles: [card, answer, bank],
  paragraphStyles: [{ id: 'lead', fontSize: pt(14), lineHeight: pt(20) }, { id: 'colophon',
    fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }],
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 119 lignes · content.en.mdtitle: "Seedlings Science" subtitle: "Year 4" --- # Plants and \\ their parts {unit="Unit 3" sheet="Worksheet 3A"} :::paragraphs{style="lead"} Every part of a plant has a job to do. In this worksheet you will name the parts of a pea plant and find out what each one does and which ones we eat. You will need a pencil and some coloured pencils. ::: ## Look closely :::callout{type="card"} What is inside the pea pods on the plant at the top of the page? :::callout{type="answer"} Write here :::space{lines=4} ::: Which part of the plant grows under the ground? Why does the plant need it? :::callout{type="answer"} Write here :::space{lines=6} ::: ::: ## Name the parts :::callout{type="card"} Six parts of the pea plant on page 1 have a number. Choose a word from the word bank and write it next to the same number. :::callout{type="bank"} :chip[stem]{style="word"} :chip[seeds]{style="word"} :chip[flower]{style="word"} :chip[roots]{style="word"} :chip[leaf]{style="word"} :chip[fruit]{style="word"} ::: :::columns{count=3} 1. :chip[ ]{style="wide"} 2. :chip[ ]{style="wide"} 3. :chip[ ]{style="wide"} 4. :chip[ ]{style="wide"} 5. :chip[ ]{style="wide"} 6. :chip[ ]{style="wide"} ::: ::: ## What does each part do? :::callout{type="card"} Finish each sentence with a word from the word bank. :::callout{type="bank"} :chip[food]{style="word"} :chip[insects]{style="word"} :chip[water]{style="word"} :chip[plant]{style="word"} :chip[upright]{style="word"} :chip[seeds]{style="word"} ::: 1. The **roots** hold the plant in the soil and take in :chip[ ]{style="blank"}. 2. The **stem** keeps the plant :chip[ ]{style="blank"} and carries water to the leaves. 3. The **leaves** use sunlight to make :chip[ ]{style="blank"} for the plant. 4. The **flower** has bright petals that attract :chip[ ]{style="blank"} such as bees. 5. The **fruit** grows around the :chip[ ]{style="blank"} and keeps them safe. 6. A **seed** can grow into a new :chip[ ]{style="blank"}. ::: **Did you know?** A pea plant cannot stand up on its own. It climbs by wrapping thin, curly tendrils around anything it touches. ## Parts we eat :::callout{type="card"} We eat many different plant parts. Draw a line from each food to the part of the plant it comes from. :::space{lines=0.33} ::resource{id="foods"} ::: ## Draw and label :::callout{type="card"} Draw a plant you have seen at home or at school. Label at least four of its parts. :::callout{type="answer"} My plant is called :::space{lines=7} ::: ::: ## Try it at home :::callout{type="card"} Put a dried pea on some wet cotton wool in a clear jar. Keep the cotton wool damp and look at the pea every day for a week. What do you think will come out of the pea first: a root or a shoot? :::callout{type="answer"} I think :::space{lines=3} ::: After a week, draw or write what you saw. :::callout{type="answer"} I saw :::space{lines=6} ::: ::: ## How did I do? {style="review"} :::callout{type="card"} Colour a circle for each thing you can do. - :chip[ ]{style="tick"} I can name the six parts of a plant. - :chip[ ]{style="tick"} I can say what each part does. - :chip[ ]{style="tick"} I can tell which part of a plant a food comes from. ::: :::paragraphs{style="colophon"} Seedlings Science is an invented series made for the Postext Cookbook. Text: CC BY 4.0. Plant: diffusion models. Set in Andika, Baloo 2 and Fredoka (SIL Open Font License). :::
`; // content.<lang>.md, inlined by the Cookbook // The matching table's words. The parts are shuffled so that no food faces its own part. const FOODS = t({ en: 'carrot|asparagus|lettuce|broccoli|orange|kidney beans', es: 'zanahoria|espárrago|lechuga|brócoli|naranja|alubias' }).split('|'); const PARTS = t({ en: 'leaf|seeds|root|fruit|stem|flower', es: 'hoja|semillas|raíz|fruto|tallo|flor' }).split('|'); // #region art: the plant's leader lines, the six foods and the star, in the palette's colours // The pea plant is a watercolour already set in its disc, in two JPEGs that meet at the band's // edge: the top one's corners carry the band's green, the foot's are white. One picture would // resample its green-to-white step and print a pale line along the band's edge. No words in // the drawings: the plant's numbers are design elements set in Baloo 2 over the picture (see // the band). // [number, x, y] in the disc's millimetres: each numbered dot, where its leader line starts. const LABELS = [[1, 58, 8], [2, 68, 33], [3, 9, 44], [4, 13, 20], [5, 62, 52], [6, 18, 66]]; const n = (v) => +v.toFixed(2); const svgDoc = (w, h, body, scale = 10) => `<svg xmlns="http://www.w3.org/2000/svg" ` + `width="${w * scale}" height="${h * scale}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const circle = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const path = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const stroke = (d, color, width) => path(d, 'none', ` stroke="${color}" stroke-width="${width}" ` + 'stroke-linecap="round" stroke-linejoin="round"'); const leafShape = (x, y, len, wid, turn, fill) => `<g transform="translate(${n(x)} ${n(y)}) ` + `rotate(${n(turn)})">${path(`M0 0C${n(len * 0.3)} ${n(-wid)} ${n(len * 0.8)} ${n(-wid)} ` + `${n(len)} 0C${n(len * 0.8)} ${n(wid)} ${n(len * 0.3)} ${n(wid)} 0 0Z`, fill)}</g>`; function leaders() { // from each dot to its part, in ink on a white halo that clears the soil // 1 a flower, 2 the closed pod's wall (the fruit), 3 a pea in the open pod (the seeds), // 4 a leaf's blade, 5 the stem between two leaves, 6 the taproot. const targets = { 1: [45.5, 11.5], 2: [50, 34], 3: [28.4, 33.2], 4: [34.6, 16.4], 5: [38.8, 52], 6: [38.4, 64] }; const lines = LABELS.map(([num, x, y]) => `M${x} ${y}L${targets[num].join(' ')}`).join(''); return svgDoc(78, 78, stroke(lines, palette.paper, 0.9) + stroke(lines, palette.ink, 0.35) + Object.values(targets).map(([x, y]) => circle(x, y, 0.75, palette.ink, ` stroke="${palette.paper}" stroke-width=".3"`)).join('')); } function star() { // the self-check's badge: a white star on a sun disc const pts = Array.from({ length: 10 }, (_, i) => { const a = -Math.PI / 2 + (i * Math.PI) / 5; const rr = i % 2 ? 4.4 : 9.4; return `${n(12 + Math.cos(a) * rr)} ${n(12.6 + Math.sin(a) * rr)}`; }); return svgDoc(24, 24, circle(12, 12, 12, palette.sun) + path(`M${pts.join('L')}Z`, palette.paper), 10); } const food = { carrot: () => path('M3.5 17.5C8 13 12 8.5 16.2 5.2C18 4 20.6 6.4 19.2 8.2C15.8 12.4 10 15.8 ' + '3.5 17.5Z', palette.sun) + stroke('M8 13.6l1.4 1.2M11.6 10.6l1.3 1.3M14.6 8l1.2 1.2', palette.soil, 0.4) + [-120, -80, -40].map((a) => leafShape(18.4, 6.4, 6, 1.4, a, palette.leaf)).join(''), asparagus: () => [[10, 7], [12, 12], [14, 17]].map(([x0, x1]) => path(`M${x0 - 1.3} 19` + `L${x1 - 1} 5Q${x1} 1.4 ${x1 + 1} 5L${x0 + 1.3} 19Z`, palette.leaf) + stroke(`M${x1 - 0.8} ` + `7.6l.8.8.8-.8M${(x0 + x1) / 2 - 0.9} 11.4l.9.9.9-.9`, palette.tint, 0.35)).join('') + path('M7.8 13.4h8.4v2.2H7.8Z', palette.sun), lettuce: () => { // a head seen from the side: two dark outer leaves cupping a pale heart const side = path('M13 19C6 19 2.6 15 3 9.6C3.2 7.4 4.6 6 5.8 7C6.2 5.4 7.8 4.8 8.6 6.2' + 'C9.4 5.2 10.6 5.6 10.6 6.8C9 10 9.4 15 13 19Z', palette.leaf) + stroke('M11 17.6C7.6 16 5.2 12.6 5.2 8.6', palette.tint, 0.4); // a frilled leaf, its vein return `<ellipse cx="12" cy="11.4" rx="5.8" ry="6.8" fill="${palette.rule}"/>` + stroke('M12 17.6C11.6 14 11.8 10 12.8 6', palette.tint, 0.45) + side + `<g transform="translate(24 0) scale(-1 1)">${side}</g>`; }, broccoli: () => path('M10 19l1-7h2l1 7Z', palette.rule) + [[8, 9, 3.4], [12, 7, 3.8], [16, 9, 3.4], [10, 11.5, 2.6], [14, 11.5, 2.6]].map(([x, y, rr]) => circle(x, y, rr, palette.leaf)).join(''), orange: () => circle(12, 11.5, 7.5, palette.sun) + leafShape(12, 4.2, 5, 1.6, -30, palette.leaf) + circle(9, 9, 1.2, palette.paper, ' fill-opacity=".45"'), beans: () => [[7, 13, -20], [13, 9, 15], [16, 15, -35]].map(([x, y, a]) => `<g transform=` + `"translate(${x} ${y}) rotate(${a})">${path('M-4 0C-4 -3 -1 -3 0 -1.5C1 -3 4 -3 4 0' + 'C4 3 -4 3 -4 0Z', palette.soil)}</g>`).join(''), }; const ICONS = Object.keys(food); // carrot … beans: the matching table's rows, in order const drawings = { leaders, star, ...Object.fromEntries(Object.entries(food).map(([id, draw]) => [id, () => svgDoc(24, 20, draw())])) }; const ALT = { ...Object.fromEntries(ICONS.map((id, i) => [id, FOODS[i]])), plant: t({ en: 'A pea plant with its flower, pod, peas, leaf, stem and roots numbered 1 to 6', es: 'Una planta de guisante con la flor, la vaina, los guisantes, la hoja, el tallo y las ' + 'raíces numerados del 1 al 6' }), leaders: '', star: t({ en: 'A white star on a yellow disc', es: 'Una estrella blanca en un disco amarillo' }) }; // Each picture is a resource that the opener, a table cell or the review heading names by // id. None is cited, so none is placed as a figure. Drawings in px, 10 to the millimetre. const pictures = Object.keys(drawings).map((id) => ({ id, typeId: 'sheet', kind: 'svg', altText: ALT[id], createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: 240, height: 200, ...{ leaders: { width: 780, height: 780 }, star: { width: 240, height: 240 } }[id] } })); // The plant's two slices, 14 px to the millimetre (356 dpi): 78 × 71 mm and 78 × 7 mm. pictures.push(...[['plant', 994], ['plant-foot', 98]].map(([id, h]) => ({ id, typeId: 'sheet', kind: 'bitmap', altText: id === 'plant' ? ALT.plant : '', createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-1092.jpg`, format: 'jpeg', width: 1092, height: h } }))); // #endregion const resources = [...pictures, foods()]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Andika: ['400', '700'], 'Baloo 2': ['700', '800'], Fredoka: ['400', '500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([prepareFonts(markdown, config(), kitFonts(FONTS)), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw())), ...['plant-1092.jpg', 'plant-foot-1092.jpg'].map((file) => loadImage(file, asset(file)))]); const doc = await buildDocumentWithFonts({ markdown, resources }, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Plants and their parts', es: 'Las plantas y sus partes' }) });
Kit · core, fonts, viewer, images : 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

#Remettre les cartes sur la grille

Avec la valeur par défaut snapToGrid: true, ce qui suit une carte descend à la ligne de grille de 17 pt suivante, et les 9 mm sous chaque carte deviennent 14,5 mm sous l’activité 2, 13,2 mm sous l’activité 4 et 11,2 mm sous l’activité 6.

-  snapToGrid: false,
+  snapToGrid: true,

#Donner plus de place à une réponse

:::space compte en lignes de 17 pt propres à la case réponse, et chaque ligne ajoutée donne à l’élève 6 mm de plus pour écrire.

 Write here
-:::space{lines=4}
+:::space{lines=6}

Erreurs fréquentes

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 →

Erreur fréquente

Un encadré imbriqué ignore span, placement et snapToGrid

Un encadré imbriqué dans un autre ignore ses span, placement, snapToGrid et floatBarrier : il s’écoule toujours à l’intérieur de son parent, à la largeur intérieure du parent. Encadrés imbriqués →

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 →

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 →

  • Un encadré ne peut pas régler ses propres lignes d’écriture, car les styles d’encadré n’ont pas de fond réglé. Les cases réponse restent vierges et prennent leur hauteur de :::space, et les courtes lignes à remplir sont des pastilles détourées.
  • Un style de titre avec numbered: false dessine quand même la maquette de son niveau, avec {number} laissé vide. Sans maquette propre, l’autoévaluation s’ouvrirait sur un disque vert vide : review copie donc la maquette des activités et met l’étoile à la place du disque.

Crédits

Texte
Texte original, CC BY 4.0
Images
  • The pea plant on the opener, a watercolour (top of the disc) · Generated With Diffusion Models · original
  • The pea plant’s roots, the foot of the same watercolour below the band · Generated With Diffusion Models · original
  • The plant's leader lines, the six food icons and the star, drawn in code in the page’s palette · Ignacio Ferro · MIT
Polices
Andika (SIL OFL 1.1) · Baloo 2 (SIL OFL 1.1) · Fredoka (SIL OFL 1.1)
Sandbox