本文へスキップ
レシピ番号179

レシピ集 · 第10章 · 出力と組み込み

画面で答えるテスト付きの講座モジュール

ひとつの Markdown に二つの問題スタイル。練習は何度でも、テストは答え合わせ一回だけ。HTML と EPUB で解け、SCORM 教材で採点されます。

このページの内容
出力
Canvas · HTML · EPUB · SCORM
レベル
上級
Postext
Postext 1.65.0で検証済み
1.65.0以上が必要
ライセンス
2026年11月1日更新
コードMIT · 本文CC BY 4.0

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり176 × 250 mm
  • 1段
  • Source Serif 4 10.8/15.5
  • Archivo
  • 4ページ
  • レベル
  • Postext 1.65.0
  • 組版時間120 ms
  • コード239行

かんたんな説明

地震の一課分の教材です。読んで、好きなだけ練習し、最後に答え合わせが一回だけの小テストを受けます。冊子として印刷でき、画面でも電子書籍でも学習プラットフォームでも使えます。

できあがり

地球科学の入門講座 The Restless Planet の第4モジュールです。地震を扱う4ページで、判型は 176 × 250 mm、乾いた平原を横切る断層の絵で始まります。本文のあとに練習とテストが続き、どちらも Markdown に答え付きの :::exercise ブロックとして書きます。印刷したモジュールには練習の解答が載り、テストの解答は載りません。画面では同じページにそのまま答えられます。練習は何度でも答え合わせとやり直しができ、テストの各問は答え合わせが一回だけで、やり直しはできず、その一回を使い切ったときに初めて解答が見られます。ページの横のパネルは、学習プラットフォームが記録する内容をまとめます。得点、テストが終わったかどうか、70 % での合否です。 同じページは固定レイアウト EPUB と SCORM 2004 パッケージにも書き出します。本文とテストを、プラットフォームが別々に採点する二つの部分にした教材です。

このレシピが答える質問

  • HTML や EPUB 版で、読者が問題に答えて答え合わせできるようにするには?
  • 本の練習問題を、学習プラットフォームに得点を報告する SCORM 教材にし、答え合わせが1回だけのテストを設けるには?

手短な答え

script.js · 35〜60行コード全体で見る
// 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 },
};

材料

種類
Source Serif 4, Archivo (SIL OFL 1.1)
素材
  • fault-1500.jpg
  • A fault across a dry plain in late afternoon light (the module's opener) (Generated With Diffusion Models, オリジナル)

作り方

#1 · 二つのスタイル、二つのルール

コードは手短な答えにあります。問題そのものは何を問うかだけを書き、ブロックの style がふるまいを決めます。practice は本の既定値のままなので、バーには答え合わせ、解答を見る、やり直すが回数の制限なしに並びます。test はそれを digital ブロックで拡張します。maxChecks: 1、reset: false、showSolution: 'noChecksLeft' によって、バーは「答え合わせは終わりです」まで数え、そこで初めて解答を出します。紙では同じスタイルが解答を印刷せず(placement: 'none')、練習の解答は :::solutions の行がある位置に印刷されます。

#2 · 保存した解答をストレージから戻す

script.js · 276〜292行コード全体で見る
// 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 は各問の解答を getItem で読み、setItem で書きます。キーは storageKey とその問題のアンカーです。この三つのメソッドを持つオブジェクトなら何でも localStorage の代わりになり、学習プラットフォームは同じ JSON を生徒のデータとして保存します。ここでは答え合わせ済みのテスト三問を返し、そのうち二か所が誤り(文3を正しいとし、距離を 180 km としたもの)です。そのためページは、戻ってきた生徒が見るとおりに開きます。三問は締め切られ、解答が見られ、並べ替えの問題だけがまだ残っています。

#3 · ページで答え、答え合わせのたびに受け取る

script.js · 296〜333行コード全体で見る
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 がページと問題のデータを書き出し、attachExercises がその上に入力欄、選択肢、バーを重ねます。onResult は各問の準備ができたときと、答え合わせ、やり直し、解答表示のたびに呼ばれ、採点対象の正答数(right)と総数(total)、使った答え合わせの回数、締め切られたかどうか(locked)を受け取ります。パネルは SCORM プレーヤーと同じようにテストを集計します。テスト全体の正答数を採点数で割り、各問が答え合わせ済みか締め切りなら完了、passingScore 以上で合格です。ページは dpi: 96 なので、印刷ページの 1 mm が画面の CSS の 1 mm になります。

#4 · 同じ問題を EPUB に

script.js · 337〜357行コード全体で見る
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');

固定レイアウトの EPUB には問題のデータと、答えられるようにするスクリプトが入ります。スクリプトを実行するリーダーでは同じ入力欄と同じ制限が使え、実行しないリーダーでは書き出し側が添える解答が残ります。フォントはバイト列で渡し、ページが読み込んだのと同じ Fontsource のファイルを使います。

#5 · テストを独立させた SCORM 教材

script.js · 360〜370行コード全体で見る
// 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 は renderToEpub と同じ文書とオプションに、SCORM の version と教材の items を加えて受け取ります。item の一つ一つが SCO で、学習プラットフォームが単独で起動し採点する教材の部分です。一つの SCO は含まれる問題をすべて数えます。scormTree はモジュールの見出しと、それぞれが受け持つ Markdown の範囲を読み取るので、最初の SCO はタイトルからテストの手前まで(練習はここで採点)、二つ目はテストだけを最後まで含みます。どちらも exercises.digital.passingScore で合否を報告します。パッケージの中では、このページの platform の役をプレーヤーが務めます。解答をプラットフォームの記録に保存し、答え合わせのたびに報告するので、戻ってきた学習者は離れたときのままのテストに出会います。

レシピの全体

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 = {

組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#SCORM 1.2、またはリフローのテキスト

古いプラットフォームは SCORM 1.2 しか取り込めないことが多く、その場合の合格点は各 SCO の習熟スコアになります。layout: 'reflowable' は印刷ページの代わりにテキストを送るので、スマートフォンで学ぶ人に向きます。

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

#答えるそばから採点する

答え合わせボタンのない小テストにするなら、exercises.digital に check: 'instant' を指定します。即時採点では答え合わせの回数制限がなくなり、テストのスタイルは showSolution を保ちます。

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

よくあるつまずき

採点する問題にはすべてアンカーを付けます(:::exercise{#test-scales})。解答はアンカーをキーに保存されます。アンカーがないとキーは文書内の位置から作られ、前に問題を一つ足しただけで、戻ってきた生徒に別の問題の解答が返されます。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
画像
  • A fault across a dry plain in late afternoon light (the module's opener) · Generated With Diffusion Models · オリジナル
フォント
Source Serif 4 (SIL OFL 1.1) · Archivo (SIL OFL 1.1)
Sandbox