#!/usr/bin/env python3
"""
PPTX to SCORM 1.2 Converter, version 2.0

Turns a slide deck into a SCORM 1.2 package that any learning management
system (LMS) can import: one image per slide, an HTML player, optional audio
narration per slide, and optional multiple-choice quiz questions that are
scored and reported to the LMS.

Usage:
    python pptx_to_scorm.py path/to/course_folder [-o output.zip] [--dpi 200]

Course folder:
    course_folder/
        slides.pdf      Your deck exported to PDF (preferred; any name works)
        slides.pptx     Used only when there is no PDF; needs LibreOffice
        course.json     Optional: title, passing score, quizzes, audio
        audio/          Optional: narration files referenced by course.json

Requirements: Python 3.8+, Poppler (pdftoppm). LibreOffice only for .pptx input.

Copyright (c) 2026 Virabelo, Inc. Released under the MIT License (see LICENSE).
Shared by Loomforth: https://loomforth.ai/tools/pptx-to-scorm/
"""

import argparse
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import unicodedata
import zipfile
from pathlib import Path
from xml.sax.saxutils import escape as xml_escape

__version__ = "2.0.0"

AUDIO_EXTENSIONS = (".mp3", ".wav", ".ogg", ".m4a")
DEFAULT_TITLE = "Training Course"
DEFAULT_ACCENT = "#e94560"


class CourseError(Exception):
    """A problem with the course folder that the author needs to fix."""


# ---------------------------------------------------------------------------
# Finding inputs and tools
# ---------------------------------------------------------------------------
def _visible_files(course_dir, pattern):
    return sorted(
        p for p in course_dir.glob(pattern)
        if p.is_file() and not p.name.startswith((".", "~$"))
    )


def find_slide_source(course_dir):
    """Return the deck to convert: the first PDF, otherwise the first PPTX."""
    for pattern in ("*.pdf", "*.pptx"):
        found = _visible_files(course_dir, pattern)
        if found:
            if len(found) > 1:
                print("  WARNING: more than one %s file found; using %s"
                      % (pattern[1:], found[0].name))
            return found[0]
    raise CourseError(
        "No .pdf or .pptx file found in %s. Export your slides to PDF "
        "(File > Save As > PDF) and put the PDF in the course folder." % course_dir)


def find_soffice():
    """Locate LibreOffice's command-line program on Linux, macOS or Windows."""
    for name in ("soffice", "libreoffice"):
        path = shutil.which(name)
        if path:
            return path
    candidates = [
        "/Applications/LibreOffice.app/Contents/MacOS/soffice",
        r"C:\Program Files\LibreOffice\program\soffice.exe",
        r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
    ]
    for candidate in candidates:
        if os.path.exists(candidate):
            return candidate
    return None


def require_pdftoppm():
    path = shutil.which("pdftoppm")
    if not path:
        raise CourseError(
            "pdftoppm was not found. Install Poppler:\n"
            "    macOS:          brew install poppler\n"
            "    Ubuntu/Debian:  sudo apt install poppler-utils\n"
            "    Windows:        install a Poppler build and add its bin folder to PATH")
    return path


# ---------------------------------------------------------------------------
# Slide conversion
# ---------------------------------------------------------------------------
def pptx_to_pdf(pptx_path, work_dir):
    """Convert a .pptx to PDF with LibreOffice. Returns the PDF path."""
    soffice = find_soffice()
    if not soffice:
        raise CourseError(
            "%s is a PowerPoint file and LibreOffice was not found. Either export "
            "the deck to PDF yourself (recommended; it also lets you check every "
            "slide) or install LibreOffice." % pptx_path.name)
    print("  Converting %s to PDF with LibreOffice..." % pptx_path.name)
    # A private profile lets the conversion run even if LibreOffice is open.
    profile = (work_dir / "lo_profile").resolve().as_uri()
    result = subprocess.run(
        [soffice, "-env:UserInstallation=" + profile, "--headless",
         "--convert-to", "pdf", "--outdir", str(work_dir), str(pptx_path)],
        capture_output=True, text=True)
    pdf_path = work_dir / (pptx_path.stem + ".pdf")
    if result.returncode != 0 or not pdf_path.exists():
        detail = (result.stderr or result.stdout or "").strip()
        raise CourseError("LibreOffice could not convert %s.%s" % (
            pptx_path.name, ("\n" + detail) if detail else ""))
    return pdf_path


def pdf_to_images(pdf_path, slides_dir, dpi):
    """Render every PDF page to a JPEG. Returns file names in page order."""
    pdftoppm = require_pdftoppm()
    print("  Rendering %s at %d DPI..." % (pdf_path.name, dpi))
    result = subprocess.run(
        [pdftoppm, "-jpeg", "-jpegopt", "quality=88", "-r", str(dpi),
         str(pdf_path), str(slides_dir / "slide")],
        capture_output=True, text=True)
    if result.returncode != 0:
        raise CourseError("pdftoppm could not read %s.\n%s" % (
            pdf_path.name, (result.stderr or "").strip()))

    def page_number(path):
        match = re.search(r"-(\d+)$", path.stem)
        return int(match.group(1)) if match else 0

    images = sorted(slides_dir.glob("slide-*.jpg"), key=page_number)
    if not images:
        raise CourseError("No slide images were produced from %s." % pdf_path.name)
    print("  Rendered %d slides" % len(images))
    return [p.name for p in images]


# ---------------------------------------------------------------------------
# course.json
# ---------------------------------------------------------------------------
def load_course_config(course_dir):
    config_path = course_dir / "course.json"
    if not config_path.exists():
        print("  No course.json found; using defaults (no quizzes, no audio)")
        return {}
    try:
        with open(config_path, encoding="utf-8-sig") as handle:
            config = json.load(handle)
    except json.JSONDecodeError as exc:
        raise CourseError("course.json is not valid JSON: line %d, column %d: %s"
                          % (exc.lineno, exc.colno, exc.msg))
    if not isinstance(config, dict):
        raise CourseError("course.json must contain a JSON object ({ ... }).")
    print('  Loaded course.json: "%s"' % config.get("title", DEFAULT_TITLE))
    return config


def make_identifier(text):
    """A stable, XML-safe identifier, so re-uploads replace the same course."""
    text = unicodedata.normalize("NFKD", str(text)).encode("ascii", "ignore").decode("ascii")
    ident = re.sub(r"[^A-Za-z0-9_.-]+", "_", text).strip("_.-").lower()
    if not ident or not (ident[0].isalpha() or ident[0] == "_"):
        ident = "course_" + ident
    return ident[:80]


def validate_config(config, course_dir, slide_count):
    """Check course.json against the deck. Returns a cleaned configuration."""
    errors, warnings = [], []

    title = str(config.get("title") or DEFAULT_TITLE).strip()
    description = str(config.get("description") or "").strip()
    identifier = config.get("identifier") or make_identifier(
        config.get("title") or course_dir.name)
    identifier = str(identifier)
    if not re.match(r"^[A-Za-z_][A-Za-z0-9_.-]*$", identifier):
        errors.append('"identifier" may use only letters, digits, "_", "." and "-", '
                      "and must start with a letter.")

    passing = config.get("passing_score", 80)
    if isinstance(passing, bool) or not isinstance(passing, (int, float)) \
            or not 0 <= passing <= 100:
        errors.append('"passing_score" must be a number from 0 to 100.')
        passing = 80

    accent = str(config.get("accent_color") or DEFAULT_ACCENT)
    if not re.match(r"^#[0-9A-Fa-f]{6}$", accent):
        errors.append('"accent_color" must look like "#1f7a5c".')
        accent = DEFAULT_ACCENT

    slides_in = config.get("slides") or {}
    if not isinstance(slides_in, dict):
        errors.append('"slides" must be an object keyed by slide number, like {"1": {...}}.')
        slides_in = {}

    audio_files = {}   # source path -> name inside the package
    used_names = set()
    slides = {}
    root = course_dir.resolve()
    for key, entry in slides_in.items():
        where = 'slides["%s"]' % key
        try:
            number = int(key)
        except (TypeError, ValueError):
            errors.append("%s: slide keys must be numbers like \"1\"." % where)
            continue
        if not 1 <= number <= slide_count:
            warnings.append("%s refers to a slide that does not exist (the deck has %d)."
                            % (where, slide_count))
            continue
        if not isinstance(entry, dict):
            errors.append("%s must be an object." % where)
            continue
        clean = {}

        if entry.get("audio"):
            source = (course_dir / str(entry["audio"])).resolve()
            if root != source and root not in source.parents:
                errors.append("%s: audio must be inside the course folder." % where)
            elif not source.is_file():
                errors.append("%s: audio file not found: %s" % (where, entry["audio"]))
            elif source.suffix.lower() not in AUDIO_EXTENSIONS:
                errors.append("%s: audio must be one of %s." % (where, ", ".join(AUDIO_EXTENSIONS)))
            else:
                if source not in audio_files:
                    base = re.sub(r"[^A-Za-z0-9._-]+", "_", source.stem).strip("_") or "audio"
                    name = base + source.suffix.lower()
                    counter = 2
                    while name in used_names:
                        name = "%s_%d%s" % (base, counter, source.suffix.lower())
                        counter += 1
                    used_names.add(name)
                    audio_files[source] = name
                clean["audio"] = "audio/" + audio_files[source]

        quiz = entry.get("quiz")
        if quiz is not None:
            if not isinstance(quiz, dict):
                errors.append("%s.quiz must be an object." % where)
            else:
                question = str(quiz.get("question") or "").strip()
                choices = quiz.get("choices")
                correct = quiz.get("correct")
                if not question:
                    errors.append("%s.quiz needs a \"question\"." % where)
                if not isinstance(choices, list) or len(choices) < 2 \
                        or not all(str(c).strip() for c in choices):
                    errors.append("%s.quiz needs at least two non-empty \"choices\"." % where)
                    choices = []
                elif len(choices) > 6:
                    warnings.append("%s.quiz has %d choices; 2 to 4 read best." % (where, len(choices)))
                if choices and (isinstance(correct, bool) or not isinstance(correct, int)
                                or not 0 <= correct < len(choices)):
                    errors.append("%s.quiz \"correct\" must be the zero-based index of the "
                                  "right choice (0 for the first)." % where)
                if question and choices and isinstance(correct, int) \
                        and not isinstance(correct, bool) and 0 <= correct < len(choices):
                    clean["quiz"] = {
                        "question": question,
                        "choices": [str(c) for c in choices],
                        "correct": correct,
                    }
                    if quiz.get("explanation"):
                        clean["quiz"]["explanation"] = str(quiz["explanation"])
        slides[number] = clean

    for message in warnings:
        print("  WARNING: " + message)
    if errors:
        raise CourseError("course.json has %d problem%s:\n  - %s" % (
            len(errors), "" if len(errors) == 1 else "s", "\n  - ".join(errors)))

    return {
        "title": title,
        "description": description,
        "identifier": identifier,
        "passing_score": passing,
        "require_audio_completion": bool(config.get("require_audio_completion", False)),
        "accent_color": accent,
        "slides": slides,
        "audio_files": audio_files,
    }


# ---------------------------------------------------------------------------
# Player
# ---------------------------------------------------------------------------
PLAYER_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="generator" content="PPTX to SCORM Converter {{VERSION}}">
<title>{{TITLE}}</title>
<style>
:root { --accent: {{ACCENT}}; --panel: #16213e; --edge: #0f3460; --bg: #1a1a2e; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
  background: var(--bg); color: #eee; display: flex; flex-direction: column; overflow: hidden; }
button { font: inherit; }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.header { background: var(--panel); padding: 10px 20px; display: flex; justify-content: space-between;
  align-items: center; gap: 16px; border-bottom: 2px solid var(--edge); flex-shrink: 0; }
.header h1 { font-size: 16px; font-weight: 600; color: #e0e0e0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.header-right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.progress-info { font-size: 13px; color: #aaa; white-space: nowrap; }
.progress-track { width: 200px; height: 6px; background: #333; border-radius: 3px; overflow: hidden; }
.progress-bar { height: 100%; width: 0; background: var(--accent); transition: width .3s ease; }
.stage { flex: 1; display: flex; justify-content: center; align-items: center; padding: 15px;
  position: relative; min-height: 0; }
.stage img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px;
  box-shadow: 0 4px 20px rgba(0,0,0,.4); background: #fff; }
.quiz-overlay { position: absolute; inset: 0; background: rgba(0,0,0,.85); display: none;
  justify-content: center; align-items: center; z-index: 10; padding: 16px; }
.quiz-panel { background: var(--panel); border: 1px solid var(--edge); border-radius: 12px;
  padding: 30px 36px; max-width: 600px; width: 100%; max-height: 100%; overflow: auto; }
.quiz-panel h2 { font-size: 18px; margin-bottom: 20px; color: #fff; line-height: 1.4; }
.quiz-choice { display: block; width: 100%; text-align: left; padding: 12px 16px; margin-bottom: 10px;
  background: var(--bg); border: 2px solid #333; border-radius: 8px; color: #ddd; font-size: 15px;
  cursor: pointer; transition: border-color .2s, background .2s; }
.quiz-choice:hover:not(:disabled) { border-color: var(--accent); }
.quiz-choice:disabled { cursor: default; }
.quiz-choice.correct { border-color: #2ecc71; background: #1a3a2a; color: #2ecc71; }
.quiz-choice.incorrect { border-color: #e74c3c; background: #3a1a1a; color: #e74c3c; }
.quiz-explanation { margin-top: 15px; padding: 12px; background: var(--bg); border-left: 3px solid #f39c12;
  border-radius: 4px; font-size: 14px; line-height: 1.5; color: #ccc; display: none; }
.quiz-continue { margin-top: 18px; padding: 10px 24px; background: var(--accent); color: #fff; border: none;
  border-radius: 6px; cursor: pointer; font-size: 14px; display: none; }
.audio-bar { background: var(--panel); padding: 8px 20px; display: flex; align-items: center; gap: 12px;
  border-top: 1px solid var(--edge); flex-shrink: 0; }
.audio-bar.hidden { display: none; }
.audio-btn { background: none; border: none; color: #eee; font-size: 18px; cursor: pointer; padding: 4px 8px;
  min-width: 36px; }
.audio-btn:hover { color: var(--accent); }
.audio-progress { flex: 1; height: 14px; display: flex; align-items: center; cursor: pointer; }
.audio-progress-track { width: 100%; height: 4px; background: #333; border-radius: 2px; overflow: hidden; }
.audio-progress-fill { height: 100%; background: var(--accent); width: 0; }
.audio-time { font-size: 12px; color: #888; min-width: 84px; text-align: right; font-variant-numeric: tabular-nums; }
.nav-bar { background: var(--panel); padding: 12px 20px; display: flex; justify-content: space-between;
  align-items: center; gap: 12px; border-top: 2px solid var(--edge); flex-shrink: 0; }
.nav-hint { color: #888; font-size: 13px; text-align: center; }
.nav-hint.alert { color: #f39c12; }
.nav-btn { padding: 8px 20px; border: 1px solid var(--edge); background: var(--bg); color: #ddd;
  border-radius: 6px; cursor: pointer; font-size: 14px; white-space: nowrap; transition: background .2s; }
.nav-btn:hover:not(:disabled) { background: var(--edge); color: #fff; }
.nav-btn:disabled { opacity: .3; cursor: not-allowed; }
.nav-btn.waiting { opacity: .5; }
.nav-btn.finish { background: #27ae60; border-color: #27ae60; color: #fff; }
.completion-screen { position: fixed; inset: 0; background: rgba(0,0,0,.9); display: flex;
  justify-content: center; align-items: center; z-index: 100; padding: 16px; }
.completion-panel { background: var(--panel); border-radius: 16px; padding: 40px; text-align: center;
  max-width: 480px; width: 100%; }
.completion-panel h2 { font-size: 26px; margin-bottom: 10px; }
.completion-panel .score { font-size: 64px; font-weight: 700; margin: 20px 0; }
.completion-panel .score.pass { color: #2ecc71; }
.completion-panel .score.fail { color: #e74c3c; }
.completion-panel p { color: #aaa; margin-bottom: 16px; font-size: 15px; line-height: 1.5; }
.completion-panel .small { color: #777; font-size: 12px; }
.completion-panel button { margin-top: 6px; padding: 10px 22px; border-radius: 6px; border: 1px solid var(--edge);
  background: var(--bg); color: #ddd; cursor: pointer; font-size: 14px; }
@media (max-width: 560px) {
  .header { padding: 8px 12px; }
  .progress-track { display: none; }
  .nav-bar, .audio-bar { padding-left: 12px; padding-right: 12px; }
  .nav-btn { padding: 8px 12px; }
  .quiz-panel { padding: 22px; }
}
</style>
</head>
<body>
<header class="header">
  <h1>{{TITLE}}</h1>
  <div class="header-right">
    <span class="progress-info" id="progressText"></span>
    <div class="progress-track" aria-hidden="true"><div class="progress-bar" id="progressBar"></div></div>
  </div>
</header>

<main class="stage">
  <img id="slideImage" src="" alt="">
  <div class="quiz-overlay" id="quizOverlay">
    <div class="quiz-panel" role="dialog" aria-modal="true" aria-labelledby="quizQuestion">
      <h2 id="quizQuestion"></h2>
      <div id="quizChoices"></div>
      <div class="quiz-explanation" id="quizExplanation" aria-live="polite"></div>
      <button class="quiz-continue" id="quizContinue" type="button">Continue</button>
    </div>
  </div>
</main>

<div class="audio-bar hidden" id="audioBar">
  <button class="audio-btn" id="audioPlayBtn" type="button" aria-label="Play narration">&#9654;</button>
  <div class="audio-progress" id="audioProgress" aria-hidden="true">
    <div class="audio-progress-track"><div class="audio-progress-fill" id="audioProgressFill"></div></div>
  </div>
  <span class="audio-time" id="audioTime">0:00 / 0:00</span>
</div>

<nav class="nav-bar" aria-label="Slide navigation">
  <button class="nav-btn" id="prevBtn" type="button">&#8592; Previous</button>
  <span class="nav-hint" id="navHint" aria-live="polite"></span>
  <button class="nav-btn" id="nextBtn" type="button">Next &#8594;</button>
</nav>

<script>
(function () {
  "use strict";
  var COURSE = {{DATA}};
  var SLIDES = COURSE.slides;
  var TOTAL = SLIDES.length;

  function el(id) { return document.getElementById(id); }

  /* ---------- SCORM 1.2 connection (runs standalone when no LMS is found) ---------- */
  var api = null, connected = false, finished = false, startedAt = new Date();

  function findAPI(win) {
    for (var tries = 0; win && tries < 500; tries++) {
      try { if (win.API) { return win.API; } } catch (e) { return null; }
      if (win.parent && win.parent !== win) { win = win.parent; } else { break; }
    }
    return null;
  }
  function lmsGet(key) {
    if (!connected) { return ""; }
    try { var v = api.LMSGetValue(key); return v == null ? "" : String(v); } catch (e) { return ""; }
  }
  function lmsSet(key, value) {
    if (connected) { try { api.LMSSetValue(key, String(value)); } catch (e) {} }
  }
  function lmsCommit() {
    if (connected) { try { api.LMSCommit(""); } catch (e) {} }
  }
  function startLMS() {
    api = findAPI(window);
    if (!api && window.opener) { api = findAPI(window.opener); }
    if (!api) { return; }
    try { var r = api.LMSInitialize(""); connected = (r === true || r === "true"); } catch (e) { connected = false; }
    if (!connected) { return; }
    var status = lmsGet("cmi.core.lesson_status");
    if (status === "" || status === "not attempted") {
      lmsSet("cmi.core.lesson_status", "incomplete");
      lmsCommit();
    }
  }
  function pad(n) { return (n < 10 ? "0" : "") + n; }
  function sessionTime() {
    var s = Math.max(0, Math.round((new Date() - startedAt) / 1000));
    return pad(Math.floor(s / 3600)) + ":" + pad(Math.floor(s % 3600 / 60)) + ":" + pad(s % 60);
  }
  function endLMS() {
    if (!connected || finished) { return; }
    finished = true;
    lmsSet("cmi.core.session_time", sessionTime());
    lmsSet("cmi.core.exit", attemptDone ? "" : "suspend");
    lmsCommit();
    try { api.LMSFinish(""); } catch (e) {}
  }
  window.addEventListener("pagehide", endLMS);
  window.addEventListener("beforeunload", endLMS);

  /* ---------- State ---------- */
  var current = 0, answers = {}, heard = {}, quizOpen = false, attemptDone = false, audio = null;
  var quizCount = 0;
  SLIDES.forEach(function (s) { if (s.quiz) { quizCount++; } });

  function saveProgress() {
    if (!connected) { return; }
    var a = [], h = [], k;
    for (k in answers) { a.push([+k, answers[k] ? 1 : 0]); }
    for (k in heard) { h.push(+k); }
    var data = JSON.stringify({ a: a, h: h });
    if (data.length > 4000) { data = JSON.stringify({ a: a }); }
    lmsSet("cmi.core.lesson_location", String(current + 1));
    lmsSet("cmi.core.suspend_data", data);
    lmsCommit();
  }
  function restoreProgress() {
    if (!connected || lmsGet("cmi.core.entry") !== "resume") { return 0; }
    try {
      var d = JSON.parse(lmsGet("cmi.core.suspend_data") || "{}");
      (d.a || []).forEach(function (p) { answers[p[0]] = p[1] === 1; });
      (d.h || []).forEach(function (i) { heard[i] = true; });
    } catch (e) {}
    var loc = parseInt(lmsGet("cmi.core.lesson_location"), 10);
    return (loc >= 1 && loc <= TOTAL) ? loc - 1 : 0;
  }

  /* ---------- Slides ---------- */
  function setHint(text, alert) {
    var hint = el("navHint");
    hint.textContent = text || ((current + 1) + " / " + TOTAL);
    hint.className = "nav-hint" + (alert ? " alert" : "");
  }
  function audioBlocking() {
    return COURSE.requireAudio && !!SLIDES[current].audio && !heard[current];
  }
  function updateNext() {
    el("nextBtn").classList.toggle("waiting", audioBlocking());
  }
  function showSlide(i) {
    current = i;
    var s = SLIDES[i];
    var img = el("slideImage");
    img.src = "slides/" + s.image;
    img.alt = "Slide " + (i + 1) + " of " + TOTAL;
    if (i + 1 < TOTAL) { (new Image()).src = "slides/" + SLIDES[i + 1].image; }
    el("progressText").textContent = "Slide " + (i + 1) + " of " + TOTAL;
    el("progressBar").style.width = ((i + 1) / TOTAL * 100) + "%";
    el("prevBtn").disabled = (i === 0);
    var next = el("nextBtn");
    if (i === TOTAL - 1) { next.textContent = "Finish course"; next.className = "nav-btn finish"; }
    else { next.innerHTML = "Next &#8594;"; next.className = "nav-btn"; }
    setHint("");
    loadAudio(s, i);
    closeQuiz();
    if (s.quiz && !(i in answers)) { openQuiz(s.quiz, i); }
    updateNext();
    saveProgress();
  }
  function nextSlide() {
    if (quizOpen) { return; }
    if (audioBlocking()) { setHint("Listen to the narration to continue.", true); return; }
    if (current >= TOTAL - 1) { finishCourse(); } else { showSlide(current + 1); }
  }
  function prevSlide() {
    if (!quizOpen && current > 0) { showSlide(current - 1); }
  }

  /* ---------- Audio ---------- */
  function setPlaying(playing) {
    var b = el("audioPlayBtn");
    b.innerHTML = playing ? "&#10074;&#10074;" : "&#9654;";
    b.setAttribute("aria-label", playing ? "Pause narration" : "Play narration");
  }
  function stopAudio() {
    if (audio) { audio.pause(); audio = null; }
    el("audioProgressFill").style.width = "0%";
    el("audioTime").textContent = "0:00 / 0:00";
    setPlaying(false);
  }
  function loadAudio(s, i) {
    stopAudio();
    var bar = el("audioBar");
    if (!s.audio) { bar.classList.add("hidden"); return; }
    bar.classList.remove("hidden");
    var a = new Audio(s.audio);
    audio = a;
    a.addEventListener("timeupdate", function () { if (a === audio) { drawAudio(); } });
    a.addEventListener("ended", function () {
      heard[i] = true;
      if (a === audio) { setPlaying(false); updateNext(); setHint(""); }
      saveProgress();
    });
    a.addEventListener("error", function () {
      heard[i] = true;   // never trap a learner behind a file that will not play
      if (a === audio) { updateNext(); }
    });
    var p = a.play();
    if (p && p.then) {
      p.then(function () { if (a === audio) { setPlaying(true); } },
             function () { if (a === audio && !heard[i]) { setHint("Press play to hear the narration."); } });
    }
    else { setPlaying(true); }
  }
  function fmt(sec) { sec = Math.floor(sec || 0); return Math.floor(sec / 60) + ":" + pad(sec % 60); }
  function drawAudio() {
    if (!audio || !audio.duration) { return; }
    el("audioProgressFill").style.width = (audio.currentTime / audio.duration * 100) + "%";
    el("audioTime").textContent = fmt(audio.currentTime) + " / " + fmt(audio.duration);
  }
  el("audioPlayBtn").addEventListener("click", function () {
    if (!audio) { return; }
    if (audio.paused) { audio.play(); setPlaying(true); setHint(""); } else { audio.pause(); setPlaying(false); }
  });
  el("audioProgress").addEventListener("click", function (e) {
    if (!audio || !audio.duration) { return; }
    var r = e.currentTarget.getBoundingClientRect();
    var target = (e.clientX - r.left) / r.width * audio.duration;
    // With required listening, the learner can rewind but not skip ahead.
    if (COURSE.requireAudio && !heard[current]) { target = Math.min(target, audio.currentTime); }
    audio.currentTime = target;
  });

  /* ---------- Quiz ---------- */
  function openQuiz(q, i) {
    quizOpen = true;
    el("quizOverlay").style.display = "flex";
    el("quizQuestion").textContent = q.question;
    var exp = el("quizExplanation");
    exp.style.display = "none"; exp.textContent = "";
    el("quizContinue").style.display = "none";
    var box = el("quizChoices");
    box.innerHTML = "";
    q.choices.forEach(function (c, ci) {
      var b = document.createElement("button");
      b.type = "button"; b.className = "quiz-choice"; b.textContent = c;
      b.addEventListener("click", function () { answerQuiz(i, ci, q); });
      box.appendChild(b);
    });
    var first = box.querySelector("button");
    if (first) { first.focus(); }
  }
  function answerQuiz(i, ci, q) {
    if (i in answers) { return; }
    var ok = (ci === q.correct);
    answers[i] = ok;
    var buttons = el("quizChoices").querySelectorAll(".quiz-choice");
    for (var k = 0; k < buttons.length; k++) {
      buttons[k].disabled = true;
      if (k === q.correct) { buttons[k].className = "quiz-choice correct"; }
      else if (k === ci) { buttons[k].className = "quiz-choice incorrect"; }
    }
    var exp = el("quizExplanation");
    exp.textContent = (ok ? "Correct. " : "Not quite. ") + (q.explanation || "");
    exp.style.display = "block";
    var cont = el("quizContinue");
    cont.style.display = "inline-block";
    cont.focus();
    saveProgress();
  }
  function closeQuiz() { quizOpen = false; el("quizOverlay").style.display = "none"; }
  el("quizContinue").addEventListener("click", closeQuiz);

  /* ---------- Completion ---------- */
  function scorePercent() {
    if (!quizCount) { return 100; }
    var right = 0;
    for (var k in answers) { if (answers[k]) { right++; } }
    return Math.round(right / quizCount * 100);
  }
  function finishCourse() {
    stopAudio();
    var score = scorePercent();
    var passed = score >= COURSE.passingScore;
    attemptDone = true;
    lmsSet("cmi.core.score.min", "0");
    lmsSet("cmi.core.score.max", "100");
    lmsSet("cmi.core.score.raw", String(score));
    lmsSet("cmi.core.lesson_status", passed ? "passed" : "failed");
    lmsSet("cmi.core.suspend_data", "");
    lmsCommit();

    var screen = document.createElement("div");
    screen.className = "completion-screen";
    screen.setAttribute("role", "dialog");
    screen.setAttribute("aria-modal", "true");
    var panel = document.createElement("div");
    panel.className = "completion-panel";
    var h = document.createElement("h2");
    h.textContent = passed ? "Course complete" : "Not passed yet";
    var big = document.createElement("div");
    big.className = "score " + (passed ? "pass" : "fail");
    big.textContent = score + "%";
    var msg = document.createElement("p");
    msg.textContent = passed
      ? (quizCount ? "You passed with a score of " + score + "%." : "You reached the end of the course.")
      : "You scored " + score + "%. A score of " + COURSE.passingScore + "% is needed to pass.";
    var btn = document.createElement("button");
    btn.type = "button";
    btn.textContent = passed ? "Review the course" : "Try again";
    btn.addEventListener("click", function () {
      if (!passed) { answers = {}; attemptDone = false; }
      document.body.removeChild(screen);
      showSlide(0);
    });
    var note = document.createElement("p");
    note.className = "small";
    note.textContent = "Your result has been recorded. You may close this window.";
    if (!connected) { note.textContent = "Preview mode: no LMS connected, so nothing was recorded."; }
    panel.appendChild(h); panel.appendChild(big); panel.appendChild(msg);
    panel.appendChild(note); panel.appendChild(btn);
    screen.appendChild(panel);
    document.body.appendChild(screen);
    btn.focus();
  }

  /* ---------- Controls ---------- */
  el("prevBtn").addEventListener("click", prevSlide);
  el("nextBtn").addEventListener("click", nextSlide);
  document.addEventListener("keydown", function (e) {
    if (quizOpen || document.querySelector(".completion-screen")) { return; }
    var tag = e.target && e.target.tagName;
    if ((e.key === " " || e.key === "Enter") && (tag === "BUTTON" || tag === "A")) { return; }
    if (e.key === "ArrowRight" || e.key === " " || e.key === "PageDown") { e.preventDefault(); nextSlide(); }
    else if (e.key === "ArrowLeft" || e.key === "PageUp") { e.preventDefault(); prevSlide(); }
  });

  startLMS();
  showSlide(restoreProgress());
})();
</script>
</body>
</html>
"""


def script_safe_json(data):
    """JSON that is safe to place inside a <script> element."""
    text = json.dumps(data, ensure_ascii=False, indent=1)
    for char, code in (("<", "\\u003c"), (">", "\\u003e"), ("&", "\\u0026"),
                       ("\u2028", "\\u2028"), ("\u2029", "\\u2029")):
        text = text.replace(char, code)
    return text


def generate_player_html(course, slide_filenames):
    slides = []
    for number, image in enumerate(slide_filenames, start=1):
        entry = {"image": image}
        entry.update(course["slides"].get(number, {}))
        slides.append(entry)
    data = {
        "slides": slides,
        "passingScore": course["passing_score"],
        "requireAudio": course["require_audio_completion"],
    }
    return (PLAYER_TEMPLATE
            .replace("{{TITLE}}", html.escape(course["title"]))
            .replace("{{ACCENT}}", course["accent_color"])
            .replace("{{VERSION}}", __version__)
            .replace("{{DATA}}", script_safe_json(data)))


# ---------------------------------------------------------------------------
# Manifest
# ---------------------------------------------------------------------------
def generate_manifest(course, resource_files):
    title = xml_escape(course["title"])
    description = xml_escape(course["description"])
    files = "\n".join('      <file href="%s"/>' % xml_escape(f) for f in resource_files)
    lom = ""
    if description:
        lom = ("\n    <lom xmlns=\"http://www.imsglobal.org/xsd/imsmd_rootv1p2p1\">"
               "\n      <general>"
               "\n        <title><langstring xml:lang=\"en\">%s</langstring></title>"
               "\n        <description><langstring xml:lang=\"en\">%s</langstring></description>"
               "\n      </general>"
               "\n    </lom>" % (title, description))
    passing = course["passing_score"]
    mastery = str(int(passing)) if float(passing).is_integer() else str(passing)
    return """<?xml version="1.0" encoding="UTF-8"?>
<manifest identifier="{ident}" version="1.0"
          xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
          xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
          xsi:schemaLocation="http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd">
  <metadata>
    <schema>ADL SCORM</schema>
    <schemaversion>1.2</schemaversion>{lom}
  </metadata>
  <organizations default="org_1">
    <organization identifier="org_1">
      <title>{title}</title>
      <item identifier="item_1" identifierref="res_1">
        <title>{title}</title>
        <adlcp:masteryscore>{mastery}</adlcp:masteryscore>
      </item>
    </organization>
  </organizations>
  <resources>
    <resource identifier="res_1" type="webcontent" adlcp:scormtype="sco" href="index.html">
{files}
    </resource>
  </resources>
</manifest>
""".format(ident=xml_escape(course["identifier"]), lom=lom, title=title,
           mastery=mastery, files=files)


# ---------------------------------------------------------------------------
# Build
# ---------------------------------------------------------------------------
def build_scorm_package(course_dir, output_zip, dpi=200):
    course_dir = Path(course_dir).resolve()
    output_zip = Path(output_zip).resolve()

    print("")
    print("=" * 60)
    print("  PPTX to SCORM 1.2 Converter %s" % __version__)
    print("=" * 60)
    print("  Source: %s" % course_dir)
    print("  Output: %s" % output_zip)
    print("")

    source = find_slide_source(course_dir)
    raw_config = load_course_config(course_dir)

    with tempfile.TemporaryDirectory() as tmp:
        work = Path(tmp)
        package = work / "package"
        slides_dir = package / "slides"
        slides_dir.mkdir(parents=True)

        pdf_path = source if source.suffix.lower() == ".pdf" else pptx_to_pdf(source, work)
        slide_files = pdf_to_images(pdf_path, slides_dir, dpi)

        course = validate_config(raw_config, course_dir, len(slide_files))

        if course["audio_files"]:
            (package / "audio").mkdir()
            for src, name in course["audio_files"].items():
                shutil.copy2(str(src), str(package / "audio" / name))
            print("  Added %d audio file%s" % (
                len(course["audio_files"]), "" if len(course["audio_files"]) == 1 else "s"))

        (package / "index.html").write_text(
            generate_player_html(course, slide_files), encoding="utf-8")

        resources = ["index.html"] + sorted(
            p.relative_to(package).as_posix()
            for p in package.rglob("*") if p.is_file() and p.name != "index.html")
        manifest = generate_manifest(course, resources)

        output_zip.parent.mkdir(parents=True, exist_ok=True)
        with zipfile.ZipFile(str(output_zip), "w", zipfile.ZIP_DEFLATED) as zf:
            zf.writestr("imsmanifest.xml", manifest)
            for rel in resources:
                zf.write(str(package / rel), rel)

    quizzes = sum(1 for s in course["slides"].values() if "quiz" in s)
    size_mb = output_zip.stat().st_size / (1024 * 1024)
    print("")
    print("  " + "=" * 58)
    print("  SCORM package created: %s" % output_zip.name)
    print("    Size:          %.1f MB" % size_mb)
    print("    Slides:        %d" % len(slide_files))
    print("    Quizzes:       %d" % quizzes)
    print("    Audio files:   %d" % len(course["audio_files"]))
    print("    Passing score: %s%%" % course["passing_score"])
    print("    Identifier:    %s" % course["identifier"])
    print("  " + "=" * 58)
    print("")
    return output_zip


def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Convert a slide deck (PDF or PPTX) into a SCORM 1.2 package.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Examples:\n"
               "  python pptx_to_scorm.py ./my_course\n"
               "  python pptx_to_scorm.py ./my_course -o training.zip\n"
               "  python pptx_to_scorm.py ./my_course --dpi 150\n")
    parser.add_argument("course_dir", help="course folder with slides.pdf (or .pptx) and course.json")
    parser.add_argument("-o", "--output", help="output .zip path (default: <folder>_scorm.zip)")
    parser.add_argument("--dpi", type=int, default=200,
                        help="slide image resolution, 72 to 400 (default: 200)")
    parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
    args = parser.parse_args(argv)

    if not os.path.isdir(args.course_dir):
        parser.error("'%s' is not a folder" % args.course_dir)
    if not 72 <= args.dpi <= 400:
        parser.error("--dpi must be between 72 and 400")
    output = args.output or "%s_scorm.zip" % os.path.basename(os.path.normpath(args.course_dir))

    try:
        build_scorm_package(args.course_dir, output, dpi=args.dpi)
    except CourseError as exc:
        print("\n  ERROR: %s\n" % exc, file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
