Aller au contenu principal
Recette numéro 179

Recettes · Chapitre 10 · Sortie et intégration

Un module de cours avec un test à faire à l'écran

Deux styles d'exercice pour un même Markdown : entraînement libre et test à une seule vérification ; réponses en HTML et en EPUB, notes dans un cours SCORM.

Sur cette page
Sortie
Canvas · HTML · EPUB · SCORM
Niveau
Avancé
Postext
Testé avec Postext 1.65.0
Nécessite ≥ 1.65.0
Licence
Mis à jour le 1 nov. 2026
Code MIT · Texte CC BY 4.0
  • Exemple en anglais : pas encore d’édition française
  • Format 176 × 250 mm
  • 1 colonne
  • Source Serif 4 10,8/15,5
  • Archivo
  • 4 pages
  • Niveau
  • Postext 1.65.0
  • Mis en page en 120 ms
  • 239 lignes de code

En bref

Une leçon sur les séismes : on la lit, on s'entraîne autant qu'on veut, puis on passe un court test où chaque réponse ne se vérifie qu'une fois. Elle s'imprime et fonctionne à l'écran, en livre numérique et sur une plateforme.

Ce que vous allez composer

Le module 4 de The Restless Planet, un cours d'initiation aux sciences de la Terre : quatre pages sur les séismes, au format 176 × 250 mm, qui s'ouvrent sous une peinture d'une faille traversant une plaine sèche. La lecture mène à un entraînement et à un test, écrits dans le Markdown en blocs :::exercise avec leurs réponses. Le module imprimé donne le corrigé de l'entraînement et garde celui du test. À l'écran, les mêmes pages se remplissent : l'entraînement se vérifie et se recommence à volonté, tandis que chaque question du test n'admet qu'une vérification, ne se recommence pas et ne montre sa solution qu'une fois cette vérification épuisée. Un panneau à côté des pages additionne ce qu'une plateforme pédagogique enregistrerait : la note, si le test est terminé, et la réussite ou l'échec à 70 %. Les mêmes pages sont écrites en EPUB à mise en page fixe et en paquet SCORM 2004 : un cours où la leçon et le test sont deux parties que la plateforme note chacune de son côté.

Cette recette répond à

  • Comment permettre au lecteur de répondre aux exercices et de les vérifier en HTML ou en EPUB ?
  • Comment faire des exercices d’un livre un cours SCORM qui envoie les notes à une plateforme pédagogique, avec un test qui ne permet qu’une vérification ?

La réponse courte

script.js · lignes 35–60dans le code complet
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
  id: 'practice',
  number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
  instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
  match: { look: 'dots', gap: em(3), color: col('rust') },
  bank: { box: 'bank' },
  marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {
  id: 'test', extends: 'practice', box: 'test',
  number: { shape: 'square', background: col('slate') },
  match: { color: col('slate') },
  solutions: { placement: 'none' },
  digital: { maxChecks: 1, reset: false, showSolution: 'noChecksLeft' },
};
const exercises = {
  solutions: { placement: 'directive', layout: 'key' }, // the practice key where :::solutions is
  // The pass mark the course's results are judged against (a SCORM package reports passed or
  // failed with it); the HTML and EPUB editions carry it with the exercise data.
  digital: { passingScore: 70 },
};

Ingrédients

Typographie
Source Serif 4, Archivo (SIL OFL 1.1)
Ressources
  • fault-1500.jpg
  • A fault across a dry plain in late afternoon light (the module's opener) (Generated With Diffusion Models, original)

Préparation

#1 · Deux styles, deux règles

Le code est dans la réponse courte. Les exercices disent seulement ce qu'ils demandent ; le style du bloc décide de leur comportement. practice garde les valeurs du livre, donc sa barre offre Vérifier, Voir la solution et Recommencer sans limite. test l'étend avec un bloc digital : maxChecks: 1, reset: false et showSolution: 'noChecksLeft', si bien que la barre décompte jusqu'à « Plus de vérifications » et ne propose qu'alors la solution. Sur papier, le même style n'imprime pas ses solutions (placement: 'none'), alors que le corrigé de l'entraînement s'imprime là où se trouve la ligne :::solutions.

#2 · Une tentative enregistrée revient du stockage

script.js · lignes 276–292dans le code complet
// The platform keeps each exercise's answers under its key; this learner checked three
// questions of the test, with two mistakes, and left the ordering one to do.
const tests = doc.exercises.filter((ex) => ex.style === 'test');
const mistakes = { 'test-statements': 2, 'test-distance': 1 }; // a wrong slot in each
const attempt = (ex) => ({ checked: true, n: 1, v: Object.fromEntries(ex.slots.map((slot, i) => {
  const right = exerciseSolutionValue(slot, ex);
  const wrong = slot.kind === 'truefalse' ? 3 - right : '180'; // 25 s × 8 km, slipped
  return [slot.key, mistakes[ex.anchor] === i ? wrong : right];
})) });
const kept = new Map(tests.filter((ex) => ex.anchor !== 'test-sequence')
  .map((ex) => [ex.anchor, JSON.stringify(attempt(ex))]));
const saved = new Map();
const platform = { // the three calls of localStorage, over the platform's record
  getItem: (key) => saved.get(key) ?? kept.get(key.split(':').pop()) ?? null,
  setItem: (key, value) => saved.set(key, value),
  removeItem: (key) => saved.delete(key),
};

attachExercises lit les réponses de chaque exercice avec getItem et les écrit avec setItem, sous la storageKey et l'ancre de l'exercice. Tout objet doté de ces trois appels peut remplacer localStorage ; une plateforme pédagogique conserve ce même JSON dans les données de l'apprenant. Celui-ci renvoie trois questions du test déjà vérifiées, avec deux erreurs (vrai pour l'affirmation 3 et 180 km pour la distance) : la page s'ouvre comme la retrouverait un apprenant qui revient, ces questions verrouillées, leurs solutions disponibles, la question de mise en ordre encore à faire.

#3 · Répondre sur les pages et entendre chaque vérification

script.js · lignes 296–333dans le code complet
const el = (tag, css, text) => Object.assign(document.createElement(tag),
  { textContent: text ?? '' }, { style: css });
const course = el('section', 'display: flex; flex-wrap: wrap; gap: 16px; justify-content: center;'
  + 'padding: 24px 16px 0; font: 13px/1.5 system-ui, sans-serif; color: #d9d5cc');
course.id = 'course';
const frame = el('div', 'width: min(700px, 94vw); height: 720px; overflow: auto;'
  + 'border-radius: 6px; background: #2a2e35');
frame.innerHTML = renderToHtml(doc, { mode: 'single', background: palette.paper, padding: 16,
  resourceImageUrl: imageUrl });
const card = el('aside', 'width: min(300px, 94vw); padding: 14px 16px; border-radius: 6px;'
  + 'border: 1px solid #2c3038; background: #15181d');
const results = new Map();
function report(result) { // every check, fresh start and solution shown
  if (result.style !== 'test') return;
  results.set(result.key, result);
  const all = [...results.values()];
  const [right, total] = all.reduce(([r, n], x) => [r + x.right, n + x.total], [0, 0]);
  const score = Math.round((100 * right) / total);
  const done = all.length === tests.length && all.every((x) => x.checked || x.locked);
  const verdict = !done ? t({ en: 'incomplete', es: 'sin terminar' })
    : score >= exercises.digital.passingScore ? t({ en: 'passed', es: 'aprobado' })
      : t({ en: 'failed', es: 'suspenso' });
  card.replaceChildren(el('h2', 'margin: 0; font-size: 15px; color: #f4f1ea',
    t({ en: 'What the platform hears', es: 'Lo que recibe la plataforma' })),
  el('p', 'margin: 6px 0; font-size: 22px; color: #f4f1ea', `${score} % · ${verdict}`),
  el('p', 'margin: 0 0 8px', t({ en: `${right} of ${total} answers right in the test, pass `
    + `mark ${exercises.digital.passingScore} %`, es: `${right} de ${total} respuestas bien en el `
    + `examen, aprueba con el ${exercises.digital.passingScore} %` })),
  ...all.map((x) => el('p', 'margin: 0; font-variant-numeric: tabular-nums',
    `${t({ en: 'Exercise', es: 'Ejercicio' })} ${x.number} · `
    + `${x.right}/${x.total} · ${x.checked || x.locked ? t({ en: 'checked', es: 'comprobado' })
      : t({ en: 'to do', es: 'por hacer' })}`)));
}
attachExercises(frame, { storageKey: 'restless-planet:4', store: platform, onResult: report });
course.append(frame, card);
document.getElementById('pages').before(course);
const first = frame.querySelector(`[data-pt-ex="${tests[0].id}"]`);
frame.scrollTop = first.getBoundingClientRect().top - frame.getBoundingClientRect().top - 120;

renderToHtml écrit les pages et les données des exercices ; attachExercises y pose les champs, les cases et la barre. onResult reçoit chaque exercice à sa mise en place, puis après chaque vérification, chaque nouveau départ et chaque solution affichée, avec les réponses notées justes (right) et totales (total), les vérifications utilisées et l'état verrouillé (locked). Le panneau additionne les réponses du test comme le fait un lecteur SCORM : réponses justes sur réponses notées pour tout le test, terminé quand chaque question est vérifiée ou verrouillée, réussi à partir de passingScore. La page utilise dpi: 96, donc un millimètre de la page imprimée vaut un millimètre CSS à l'écran.

#4 · Les mêmes exercices dans un EPUB

script.js · lignes 337–357dans le code complet
const epubFonts = (faces) => Promise.all(Object.entries(faces).flatMap(([family, specs]) =>
  specs.map(async (spec) => {
    const [weight, style, id] = [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal',
      fontsourceId(family)];
    const res = await fetch(`${await fontsourceAt(id)}files/${id}-latin-${weight}-${style}.woff2`);
    if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style}`);
    const bytes = new Uint8Array(await res.arrayBuffer());
    return { family, weight, style, format: 'woff2', bytes };
  })));
const metadata = { title: doc.metadata.title, subtitle: doc.metadata.subtitle, language: LANG,
  modified: new Date('2026-10-31T00:00:00Z') }; // fixed: the same bytes on every run
const fonts = await epubFonts(FONTS);
const resourceBytes = (fileId) => ({ bytes: imageBytes(fileId), mediaType: 'image/jpeg' });
const epub = await renderToEpub([doc], { layout: 'fixed', metadata, fonts, resourceBytes });
const offer = (bytes, name, type) => {
  const link = el('a', 'color: #d8a21a', `${name} · ${Math.round(bytes.length / 1024)} KB`);
  link.href = URL.createObjectURL(new Blob([bytes], { type }));
  link.download = name;
  document.getElementById('pt-actions').append(link);
};
offer(epub, `${RECIPE}.epub`, 'application/epub+zip');

L'EPUB à mise en page fixe contient les données des exercices et le script qui les rend actifs : un système de lecture qui exécute les scripts propose les mêmes champs et les mêmes limites, et un autre garde le corrigé que l'outil d'écriture ajoute. Les polices lui sont données en octets, les mêmes fichiers Fontsource que la page a chargés.

#5 · Un cours SCORM, le test à part

script.js · lignes 360–370dans le code complet
// The module's headings as postext-scorm reads them; the last one, the test, runs to the end.
const sections = treeLeaves(scormTree([{ markdown }], config)[0]);
const exam = sections.at(-1);
const items = [
  { title: t({ en: 'Lesson and practice', es: 'Lección y práctica' }), doc: 0,
    range: { start: sections[0].range.start, end: exam.range.start } },
  { title: exam.title, doc: 0, range: exam.range }, // its score is the test's alone
];
const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
  fonts, resourceBytes }); // the pass mark comes from exercises.digital.passingScore
offer(scorm, `${RECIPE}-scorm.zip`, 'application/zip');

renderToScorm prend les documents et les options de renderToEpub, plus la version SCORM et les items du cours. Chaque item est un SCO, la partie d'un cours que la plateforme lance et note seule, et un SCO compte tous les exercices qu'il contient. scormTree lit les titres du module avec la portion de Markdown que couvre chacun : le premier SCO va du titre au test (l'entraînement y est noté), le second ne contient que le test, jusqu'à la fin. Chacun transmet la réussite ou l'échec selon exercises.digital.passingScore. Dans le paquet, un lecteur tient la place que platform tient sur cette page : il garde les réponses dans le dossier de la plateforme et transmet chaque vérification, si bien que l'apprenant qui revient retrouve le test tel qu'il l'a laissé.

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 179 · A course module with a test answered on screen ═══
// https://postext.dev/en/cookbook/self-checking-course-module
// Code: MIT · Text: original (CC BY 4.0) · Painting: generated with diffusion models
// Fonts: Source Serif 4, Archivo (SIL OFL 1.1) · Needs postext ≥ 1.65.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, renderToHtml,
  attachExercises, exerciseSolutionValue,
} from 'https://esm.sh/postext';
import { renderToEpub } from 'https://esm.sh/postext-epub';
import { renderToScorm, scormTree, treeLeaves } from 'https://esm.sh/postext-scorm';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'self-checking-course-module';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1f2523', // text: a green-tinted near-black
  rust: '#9c4420', // the course's accent: kicker, practice numbers, rules
  slate: '#2f5266', // the test: its numbers and its frame
  tint: '#f5efe4', // the safety box, the word bank
  rule: '#d6cdbf', // hairlines
  muted: '#646d69', // running heads, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'rust (defaults)', value: { hex: palette.rust, model: 'hex' } },
];
const [TEXT, SANS] = ['Source Serif 4', 'Archivo'];
const [TOP, OUTER, LEAD] = [22, 21, 15.5]; // mm, mm, pt
const BAND = 100; // mm: the painting's depth from the trim, over 3 mm of bleed

// #region answer: practice and test from one Markdown: the test style limits each attempt
// Practice: check, start again and see the solution as often as the learner likes.
const practice = {
  id: 'practice',
  number: { shape: 'disc', background: col('rust'), fontFamily: SANS, fontWeight: 700 },
  instruction: { fontFamily: SANS, bold: true, fontSize: pt(10) },
  options: { marker: 'circle' },
  blank: { look: 'line', color: col('ink'), weight: pt(0.6) },
  match: { look: 'dots', gap: em(3), color: col('rust') },
  bank: { box: 'bank' },
  marginTop: mm(5), marginBottom: mm(1), itemSpacing: em(0.15),
};
// The test: one check, no fresh start, the solution only once the check is spent. On paper
// the same questions print without their key.
const test = {

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

#SCORM 1.2, ou le texte recomposé

Beaucoup de plateformes anciennes n'importent que SCORM 1.2 ; la note de réussite y devient le seuil de maîtrise de chaque SCO. layout: 'reflowable' envoie le texte au lieu des pages imprimées, pour qui apprend sur un téléphone.

-const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
+const scorm = await renderToScorm([doc], { version: '1.2', layout: 'reflowable', items, metadata,

#Corriger chaque réponse dès qu'elle est donnée

Pour un quiz sans bouton Vérifier, mettez check: 'instant' dans exercises.digital. La correction immédiate supprime la limite de vérifications ; le style du test garde showSolution.

-  digital: { passingScore: 70 },
+  digital: { passingScore: 70, check: 'instant' },

Erreurs fréquentes

Donnez une ancre à chaque exercice noté (:::exercise{#test-scales}). Ses réponses sont conservées sous l'ancre ; sans elle, la clé vient de la place de l'exercice dans le document, et une question ajoutée avant rend à l'apprenant qui revient les réponses enregistrées d'une autre.

Crédits

Texte
Texte original, CC BY 4.0
Images
  • A fault across a dry plain in late afternoon light (the module's opener) · Generated With Diffusion Models · original
Polices
Source Serif 4 (SIL OFL 1.1) · Archivo (SIL OFL 1.1)
Sandbox