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
// 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
- Fonctionnalités
- Exercices interactifsStyles d'exercicePaquets SCORMExercicesChoix et vrai ou fauxTextes à trous et banques de motsRelier et classerRemettre dans l'ordreCorrigésÉdition de lecture HTMLExport EPUBOuvertures composéesAttributs de titreTitres courants et foliosTitres courants selon le rôle de la pageEncadrésPalette de couleurs sémantiqueVeuves, orphelines et fins de paragraphe trop courtes
- Utilise aussi
- Encadrés qui se coupent ou restent entiersRenvoisCouleur du papierStyles de paragrapheFigures et tableaux comme ressources
- 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
// 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
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
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
// 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
// ═══ 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
- Recette
- Ignacio Ferro
- 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)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


