En pocas palabras
Una lección sobre los terremotos: la lees, practicas cuanto quieras y haces un examen corto que solo deja comprobar cada respuesta una vez. Se imprime y funciona en pantalla, en un libro electrónico y en un aula virtual.
Lo que vas a componer
El módulo 4 de El planeta inquieto, un curso de iniciación a las ciencias de la Tierra: cuatro páginas sobre los terremotos, en un formato de 176 × 250 mm que se abre bajo una pintura de una falla que cruza una llanura seca. La lectura lleva a una práctica y a un examen, escritos en el Markdown como bloques :::exercise con sus respuestas. El módulo impreso trae las soluciones de la práctica y deja fuera las del examen. En pantalla, las mismas páginas se contestan: la práctica se comprueba y se repite cuanto se quiera, y cada pregunta del examen admite una sola comprobación, no se puede repetir y solo enseña la solución cuando esa comprobación se ha gastado. Un panel junto a las páginas suma lo que guardaría una plataforma de aprendizaje: la nota, si el examen está terminado y el aprobado o suspenso con el 70 %. Las mismas páginas se escriben como EPUB de maquetación fija y como paquete SCORM 2004: un curso con la lección y el examen como dos partes que la plataforma califica por separado.
Esta receta responde a
- ¿Cómo dejo que el lector conteste y compruebe los ejercicios en la edición HTML o EPUB?
- ¿Cómo convierto los ejercicios de un libro en un curso SCORM que envía las notas a una plataforma de aprendizaje, con un examen que solo deja comprobar una vez?
La respuesta corta
// 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 },
};
Ingredientes
- Funciones
- Ejercicios interactivosEstilos de ejercicioPaquetes SCORMEjerciciosElección y verdadero o falsoHuecos y bancos de palabrasUnir y clasificarOrdenarSolucionesEdición de lectura en HTMLExportación a EPUBAperturas diseñadasAtributos de títuloCabeceras y foliosCabeceras según el tipo de páginaRecuadrosPaleta de color semánticaViudas, huérfanas y líneas cortas
- También usa
- Recuadros que se parten o noReferencias cruzadasColor del papelEstilos de párrafoFiguras y tablas como recursos
- Tipografía
- Source Serif 4, Archivo (SIL OFL 1.1)
- Recursos
fault-1500.jpg- Una falla que cruza una llanura seca con la luz de la tarde (la apertura del módulo) (Generated With Diffusion Models, original)
Elaboración
#1 · Dos estilos, dos reglas
El código está en la respuesta corta. Los ejercicios solo dicen qué preguntan; el style del bloque decide cómo se comportan. practice conserva lo que trae el libro, así que su barra ofrece Comprobar, Ver la solución y Volver a empezar sin límite. test lo amplía con un bloque digital: maxChecks: 1, reset: false y showSolution: 'noChecksLeft', de modo que la barra cuenta hasta «Sin más comprobaciones» y solo entonces ofrece la solución. En papel, ese mismo estilo no imprime sus soluciones (placement: 'none'), mientras que las de la práctica salen donde está la línea :::solutions.
#2 · Un intento guardado vuelve del almacén
// 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 lee las respuestas de cada ejercicio con getItem y las escribe con setItem, bajo la storageKey y el ancla del ejercicio. Cualquier objeto con esas tres llamadas puede ocupar el lugar de localStorage; una plataforma de aprendizaje guarda ese mismo JSON entre los datos del alumno. Este devuelve tres preguntas del examen ya comprobadas, con dos errores (verdadero en la afirmación 3 y 180 km en la distancia), así que la página se abre como la encontraría un alumno que vuelve: esas preguntas bloqueadas, sus soluciones disponibles y la de ordenar aún por hacer.
#3 · Contestar las páginas y oír cada comprobación
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 escribe las páginas y los datos de los ejercicios; attachExercises coloca encima los campos, las casillas y la barra. onResult recibe cada ejercicio al prepararse y después de cada comprobación, cada reinicio y cada solución mostrada, con las respuestas evaluadas acertadas (right) y totales (total), las comprobaciones gastadas y si el intento está bloqueado (locked). El panel suma las respuestas del examen como lo hace un reproductor SCORM: aciertos entre respuestas evaluadas de todo el examen, terminado cuando cada pregunta está comprobada o bloqueada, aprobado a partir de passingScore. La página usa dpi: 96, así que un milímetro de la página impresa es un milímetro CSS en pantalla.
#4 · Los mismos ejercicios en 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');
El EPUB de maquetación fija lleva los datos de los ejercicios y el script que los contesta, de modo que un sistema de lectura que ejecuta scripts ofrece los mismos campos y los mismos límites; uno que no los ejecuta conserva las soluciones que añade el escritor. Las fuentes se le pasan como bytes, los mismos archivos de Fontsource que cargó la página.
#5 · Un curso SCORM con el examen aparte
// 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 toma los documentos y las opciones de renderToEpub, más la version de SCORM y los items del curso. Cada item es un SCO, la parte de un curso que la plataforma abre y califica por su cuenta, y un SCO cuenta todos los ejercicios que contiene. scormTree lee los encabezados del módulo con el tramo de Markdown que abarca cada uno, así que el primer SCO va del título al examen (ahí se califica la práctica) y el segundo lleva solo el examen, hasta el final. Cada uno comunica el aprobado o el suspenso con exercises.digital.passingScore. Dentro del paquete, un reproductor ocupa el sitio que platform ocupa en esta página: guarda las respuestas en el registro de la plataforma y envía cada comprobación, así que el alumno que vuelve encuentra el examen como lo dejó.
La receta completa
// ═══ 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 = 'es'; // @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 = {
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)
Variantes
#SCORM 1.2, o el texto refluido
Muchas plataformas antiguas solo importan SCORM 1.2, donde el aprobado pasa a ser la nota de dominio de cada SCO. layout: 'reflowable' envía el texto en lugar de las páginas impresas, para quien estudia en el móvil.
-const scorm = await renderToScorm([doc], { version: '2004', layout: 'fixed', items, metadata,
+const scorm = await renderToScorm([doc], { version: '1.2', layout: 'reflowable', items, metadata,#Corregir cada respuesta al darla
Para un cuestionario sin botón Comprobar, pon check: 'instant' en exercises.digital. La corrección inmediata quita el límite de comprobaciones; el estilo del examen mantiene showSolution.
- digital: { passingScore: 70 },
+ digital: { passingScore: 70, check: 'instant' },Errores frecuentes
Pon un ancla a cada ejercicio que se evalúa (:::exercise{#test-scales}). Sus respuestas se guardan bajo el ancla; sin ella, la clave sale del lugar del ejercicio en el documento, y una pregunta añadida antes le devuelve al alumno que vuelve las respuestas guardadas de otra.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imágenes
- Una falla que cruza una llanura seca con la luz de la tarde (la apertura del módulo) · Generated With Diffusion Models · original
- Fuentes
- Source Serif 4 (SIL OFL 1.1) · Archivo (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


