From Textbook PDF to a Ready-to-Import Moodle Course, in Three Steps

Written by

in

If you teach with Moodle, you already know the tedium: you have a great textbook section, a scanned or born-digital PDF, and a course shell that needs one Page activity per topic, per worked example, per exercise. Doing that by hand — click “Add an activity,” pick “Page,” type the title, paste the content, fix the math, repeat thirty times — is the kind of work that eats an afternoon and produces nothing you’ll be proud of.

This post walks through a small pipeline that turns that afternoon into about five minutes: PDF → structured Markdown (via a Claude prompt) → Moodle backup file (via a Python script). The whole thing lives in one repo.

Repo: https://github.com/krajit/moodle-bulk-page-creater

Video walkthrough


The problem in one picture

A Moodle course section is really just a list of activities. If your source material is a textbook section — some exposition, a few worked examples, a problem set — then conceptually what you want is:

Section: "13.4 Motion in Space"
 ├─ Page: Velocity and Acceleration        (reading)
 ├─ Page: Example 1                        (example)
 ├─ Page: Example 2                        (example)
 ├─ Page: Exercise 1                       (exercise)
 ├─ Page: Exercise 2                       (exercise)
 └─ ...

Moodle stores all of that — course metadata, section metadata, every activity, every file reference — as XML inside a .mbz (Moodle backup) archive. That XML is precisely-shaped and unforgiving: get an ID or a reference wrong and the restore silently fails or half-imports. Nobody wants to hand-write it. So the pipeline splits into two very different jobs, done by two very different tools:

  1. Understanding the textbook page — reading the layout, figuring out where one example ends and the next begins, deciding what’s a sidebar versus real content. This is a job for an LLM with vision, not code.
  2. Emitting valid Moodle XML — mechanical, deterministic, must be byte-perfect. This is a job for a plain Python script, not an LLM.

The repo is built around that split.


Step 1 — PDF in, structured Markdown out (the Claude prompt)

The first stage uses Claude (with a PDF attached) and a purpose-built prompt, kept in claude_prompt.txt. You attach the section PDF — in the sample case, a section from Stewart’s Calculus: Early Transcendentals — and Claude reads it, reasons about its structure, and writes out a .md file shaped for the second stage.

What the prompt actually asks for

The prompt is dense on purpose — it’s encoding editorial judgment calls so you don’t have to repeat them by hand every time. The key instructions:

  • Every chunk of content becomes a <page> block, tagged as one of three types:
    • reading — one page per named sub-topic (each sub-heading with its own bullet marker in the book), including boxed definitions, theorems, proofs, and the connecting prose.
    • example — one page per numbered worked example, always split out from the reading material rather than left inline.
    • exercise — one page per numbered problem.
  • Discovery/Laboratory Projects get folded in as a trailing reading page; biographical sidebars (the little “who was Hamilton” boxes) are dropped entirely — they don’t belong in a course page.
  • No paraphrasing. The prompt explicitly tells Claude to preserve the original wording rather than “clean it up,” because this is supposed to be a faithful transcription, not a summary.
  • Exercises must be self-sufficient. Textbooks routinely give one shared instruction for a block of exercises (e.g., “2–10 Find a·b”). The prompt requires that shared instruction to be copied into each individual exercise page, since each one becomes an independent Moodle activity with no shared context to inherit from.
  • Figures are not extracted, but if an exercise depends on numeric data shown only in a diagram, the prompt asks Claude to transcribe that data as plain text and leave a [See textbook figure N.] note rather than silently dropping the problem.
  • A fixed markup vocabulary, matched to what the Python converter in step 2 understands:ElementMarkupInline math\(...\)Display math\[...\]Numbered equations\tag{N} inside \[...\]Vectors\mathbf{a}\mathbf{b}Determinants\begin{vmatrix}...\end{vmatrix}Bold / italic<strong>...</strong> / <em>...</em>TablesHTML <table>
  • A short, disciplined workflow: rasterize the PDF at 150 DPI with pdftoppm so Claude visually reviews every page before writing anything, plan the full page breakdown first, then write and run a small Python script (built around a pg() helper that assembles each <page> block) to actually produce the .md file.

The output is a single markdown file — see sample-section/sample-section.md for a worked example — that previews cleanly in GitHub or VS Code and slots directly into step 2 with no manual cleanup.

Why a prompt instead of a fixed script for this step

PDFs of textbook sections don’t have consistent, parseable structure — headings vary, examples are numbered inconsistently across chapters, sidebars are visually distinct but not tagged in any machine-readable way. That’s exactly the kind of “read it like a human would” task an LLM is suited for and a regex is not. Once the output conforms to the <page> schema, though, everything downstream is mechanical — which is why step 2 is a deterministic script rather than another prompt.


Step 2 — Structured Markdown in, .mbz out (generate_moodle_course.py)

The second stage is a single, dependency-free Python script: generate_moodle_course.py. It reads the <page> blocks produced in step 1 and assembles a complete Moodle 2 backup — course, section, one Page activity per <page> block, and any referenced images — ready to hand to Moodle’s restore wizard.

Configuring a run

At the top of the script there’s a small config block:

INPUT_MARKDOWN   = r"my_content.md"           # path to your markdown file
COURSE_FULLNAME  = "My Course Title"          # shown in Moodle
COURSE_SHORTNAME = "MYC-101"                  # short identifier (no spaces)
SECTION_TITLE    = "Topic 1: Introduction"    # name of the course section/topic
SECTION_SUMMARY  = "Brief description."       # text shown under the section

Edit those five lines and run:

python generate_moodle_course.py

Or skip editing the file entirely and pass everything on the command line — handy for scripting a batch of sections:

python generate_moodle_course.py \
  --input section_13_4_chunks.md \
  --fullname "13.4 Motion in Space" \
  --shortname CALC-13-4 \
  --section "13.4 Motion in Space: Velocity and Acceleration" \
  --summary "Velocity, acceleration, and Kepler's First Law."

Every CLI flag overrides its matching config-block value; --outdir and --outmbz let you redirect the generated folder and archive, and otherwise auto-derive from the course shortname.

What the script does under the hood

  1. Parses <page> blocks out of the markdown with a small regex-based reader (parse_pages), pulling out typetitledescription, and content for each one. HTML comments are stripped first, so the <!-- type: ... --> hint line in the file header is safely ignored.
  2. Resolves images. Any ![alt](path) inside a page’s content is rewritten to a Moodle @@PLUGINFILE@@ reference, and the referenced file is copied into the backup’s files/ tree, keyed by SHA-1 content hash (so identical images used on multiple pages are stored once). Paths are resolved relative to the markdown file itself, not your terminal’s working directory.
  3. Converts markdown to HTML. A small hand-rolled converter (md_to_html / inline_md) handles headings, bold/italic, bullet lists, blockquotes, tables, and passes LaTeX math ($...$$$...$$\(...\)\[...\]) through untouched, since that’s Moodle/MathJax’s job to render, not the script’s.
  4. Builds the Moodle XML tree — one page.xml + module.xml + supporting boilerplate per activity, a section.xmlcourse.xmlfiles.xmlmoodle_backup.xml, and the .ARCHIVE_INDEX manifest Moodle’s restore code expects.
  5. Zips it all into a .mbz — literally a renamed ZIP — ready to upload.

Output

<SHORTNAME>-moodle/        # the exploded XML tree (useful for debugging)
<SHORTNAME>-moodle.mbz     # what you actually import into Moodle

To bring it into Moodle: Site administration → Courses → Restore course (or, inside an existing course, the gear icon → Restore), upload the .mbz, and step through the wizard.

Full field-by-field markdown schema, the supported markdown/math/image syntax, and known limitations (single section per run, no nested-list support beyond one level, etc.) are documented in the repo’s README.md.


The full cycle, end to end

Putting both stages together, generating a Moodle-ready section from a textbook PDF looks like this:

  1. Get the PDF. A section from your source textbook — a scanned chapter, a publisher PDF, whatever you have.
  2. Convert to structured Markdown. Attach the PDF to Claude along with the prompt in claude_prompt.txt. Claude rasterizes the pages, reads them visually, plans the reading/example/exercise breakdown, and writes a section_XX_Y_chunks.md file shaped as <page> blocks.
  3. Generate the Moodle restore file. Point generate_moodle_course.py at that markdown file (editing the config block or passing CLI flags), run it, and get back a .mbz.
  4. Restore into Moodle. Upload the .mbz through Moodle’s Restore course wizard. The section appears with every reading, example, and exercise as its own Page activity, math rendering intact (assuming MathJax is enabled), and images wired up correctly.

What used to be an afternoon of manual activity creation becomes: attach a PDF, run one prompt, run one script, upload one file.


Try it yourself

  • Clone the repo: link
  • Watch the walkthrough: https://www.youtube.com/watch?v=mtIXSoDJM8g
  • Start from sample-section/sample-section.md and sample-section/sample-section.pdf if you want to see the expected input/output shapes before running it against your own material.

If you adapt the prompt for a different textbook or subject, the only real constraint is the markup vocabulary the Python converter understands (see the table above) — keep your <page> blocks and math delimiters consistent with that, and the rest of the pipeline works unchanged.


About the author

Ajit Kumar is an Assistant Professor of Mathematics at Shiv Nadar University.