<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ryan</title>
    <description>The latest articles on DEV Community by ryan (@ryan_ce8e8cab20b5a47b2f03).</description>
    <link>https://dev.to/ryan_ce8e8cab20b5a47b2f03</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4193059%2F29ec6cdb-9054-4c1f-8798-9ad0c139d3d0.png</url>
      <title>DEV Community: ryan</title>
      <link>https://dev.to/ryan_ce8e8cab20b5a47b2f03</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ryan_ce8e8cab20b5a47b2f03"/>
    <language>en</language>
    <item>
      <title>How I Built a Document-to-Quiz Parser: Turning Messy Word, PDF, and Google Docs into Structured Question Banks</title>
      <dc:creator>ryan</dc:creator>
      <pubDate>Mon, 19 Oct 2026 01:55:55 +0000</pubDate>
      <link>https://dev.to/ryan_ce8e8cab20b5a47b2f03/how-i-built-a-document-to-quiz-parser-turning-messy-word-pdf-and-google-docs-into-structured-an1</link>
      <guid>https://dev.to/ryan_ce8e8cab20b5a47b2f03/how-i-built-a-document-to-quiz-parser-turning-messy-word-pdf-and-google-docs-into-structured-an1</guid>
      <description>&lt;p&gt;&lt;em&gt;A teardown of the parsing pipeline behind ExamParser — and the lessons that cost me a few weekends.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem nobody puts on the roadmap
&lt;/h2&gt;

&lt;p&gt;So I built &lt;strong&gt;ExamParser&lt;/strong&gt; (&lt;a href="https://examparser.com" rel="noopener noreferrer"&gt;https://examparser.com&lt;/a&gt;) to close that gap — drop in a Word, PDF, or Google Doc and get a Canvas-ready quiz out. No login, files never reused. This post is the engineering teardown: how the parser actually works, where it breaks, and what I'd tell myself at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "just parse the file" is a trap
&lt;/h2&gt;

&lt;p&gt;The trap is assuming a document is a list of questions. It isn't. A &lt;code&gt;.docx&lt;/code&gt; is a ZIP archive of XML where "question" is just a paragraph that happens to start with "1." A PDF is either real text with coordinates or a picture of text. A Google Doc is the only one that's honest — it gives you structural elements — but you still have to interpret them.&lt;/p&gt;

&lt;p&gt;There is no &lt;code&gt;document.getQuestions()&lt;/code&gt;. You have to &lt;em&gt;infer&lt;/em&gt; questions, types, and answers from layout, styling, and a handful of authoring conventions. That inference is the whole product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Word / PDF / Google Docs
        │
        ▼
┌─────────────────┐
│ Format Extractor │  → blocks: {text, style, coords, is_list}
└─────────────────┘
        │
        ▼
┌─────────────────┐
│   Normalizer     │  → common block schema across formats
└─────────────────┘
        │
        ▼
┌─────────────────┐   ┌──────────────────┐
│   Segmenter      │ → │   Classifier      │  → type: mcq / tf / cloze / match
└─────────────────┘   └──────────────────┘
        │
        ▼
┌─────────────────┐
│ Answer Extractor │  → correct option(s), rationale
└─────────────────┘
        │
        ▼
┌─────────────────┐
│   Emitter        │  → QTI 1.2 zip (Canvas) | Excel/CSV (migration)
└─────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most important design decision: &lt;strong&gt;one normalizer, many extractors.&lt;/strong&gt; Each format is messy in its own way, but once they all emit the same &lt;code&gt;block&lt;/code&gt; shape, everything downstream is format-agnostic. Don't write three segmenters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Extracting text &lt;em&gt;and&lt;/em&gt; structure
&lt;/h2&gt;

&lt;p&gt;For DOCX, &lt;code&gt;python-docx&lt;/code&gt; gets you started, but go one level deeper. You want the paragraph style and whether it's a list item, because that's your cheapest "is this a new question?" signal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;docx&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Document&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_blocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;paragraphs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;pPr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pPr&lt;/span&gt;
        &lt;span class="n"&gt;is_list&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pPr&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;pPr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;numPr&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;style&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_list&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;is_list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For PDF, coordinates are the secret. With &lt;code&gt;pdfplumber&lt;/code&gt; you get each character's &lt;code&gt;x0/y0/x1/y1&lt;/code&gt;. Two pieces of text at the same &lt;code&gt;y&lt;/code&gt; but different &lt;code&gt;x&lt;/code&gt; are side-by-side — that's how you tell a two-column layout from a single column, and how you separate an option label (&lt;code&gt;A)&lt;/code&gt;) from its text. Scanned PDFs have no text layer, so they fall through to OCR + a layout model; everything downstream is the same once you have blocks with coordinates.&lt;/p&gt;

&lt;p&gt;Google Docs is the easy one: the Docs API returns &lt;code&gt;Paragraph&lt;/code&gt; and &lt;code&gt;TextRun&lt;/code&gt; elements with explicit nesting. If you can ask authors to use Google Docs, do it — the structure is already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Segmenting questions
&lt;/h2&gt;

&lt;p&gt;Once you have blocks, split them into questions. The signal is almost always a numbered start, but authors number inconsistently: &lt;code&gt;1.&lt;/code&gt;, &lt;code&gt;1)&lt;/code&gt;, &lt;code&gt;(1)&lt;/code&gt;, &lt;code&gt;一、&lt;/code&gt;, &lt;code&gt;Q1&lt;/code&gt;, &lt;code&gt;Question 1&lt;/code&gt;. A tolerant regex covers most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="n"&gt;QUESTION_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;^\s*(?:\(?(\d{1,3})[.)]|[一二三四五六七八九十]+[、.]|Q\s*\d+|Question\s+\d+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;split_questions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;QUESTION_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;questions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;questions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;and current&lt;/code&gt; guard is what stops question 2 from splitting mid-question when an option happens to start with a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Classifying the type
&lt;/h2&gt;

&lt;p&gt;You don't need ML for this. Signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MCQ&lt;/strong&gt;: option markers &lt;code&gt;A)&lt;/code&gt;, &lt;code&gt;a.&lt;/code&gt;, &lt;code&gt;(B)&lt;/code&gt;, bullet list of 2–5 short items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;True/False&lt;/strong&gt;: exactly two options that are variants of true/false/对/错.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloze / fill-in&lt;/strong&gt;: the stem contains &lt;code&gt;____&lt;/code&gt; or &lt;code&gt;（ ）&lt;/code&gt; or &lt;code&gt;___&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching&lt;/strong&gt;: two parallel columns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short answer&lt;/strong&gt;: long option-shaped text or no options at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Order matters — check cloze and T/F before generic MCQ, because a T/F question also has "options."&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Extracting the answer
&lt;/h2&gt;

&lt;p&gt;Authors mark the right answer in a dozen ways: an asterisk (&lt;code&gt;*a.&lt;/code&gt;), bold, highlight, or a trailing &lt;code&gt;Ans: B&lt;/code&gt;. Normalize them into one rule: &lt;strong&gt;the option carrying the annotation is correct; if more than one is annotated, it's a "select all that apply."&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_answer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;marked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;annotated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# flag for human review
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;marked&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# len&amp;gt;1 → multi-select
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning &lt;code&gt;None&lt;/code&gt; instead of guessing is the discipline that keeps the tool trustworthy. A flagged question beats a wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Emitting what LMSes actually accept
&lt;/h2&gt;

&lt;p&gt;Canvas will not take your beautiful JSON. It takes &lt;strong&gt;QTI 1.2&lt;/strong&gt; as a &lt;code&gt;.zip&lt;/code&gt;. That's the only import path that works reliably:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;questestinterop&lt;/span&gt; &lt;span class="na"&gt;xmlns=&lt;/span&gt;&lt;span class="s"&gt;"http://www.imsglobal.org/xsd/ims_qtiasiv1p2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;assessment&lt;/span&gt; &lt;span class="na"&gt;ident=&lt;/span&gt;&lt;span class="s"&gt;"quiz_001"&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Chapter 9"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;section&lt;/span&gt; &lt;span class="na"&gt;ident=&lt;/span&gt;&lt;span class="s"&gt;"sec_001"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;ident=&lt;/span&gt;&lt;span class="s"&gt;"q1"&lt;/span&gt; &lt;span class="na"&gt;title=&lt;/span&gt;&lt;span class="s"&gt;"Q1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;presentation&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;material&amp;gt;&amp;lt;mattext&lt;/span&gt; &lt;span class="na"&gt;texttype=&lt;/span&gt;&lt;span class="s"&gt;"text/plain"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Chris' favorite color is:&lt;span class="nt"&gt;&amp;lt;/mattext&amp;gt;&amp;lt;/material&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;response_lid&lt;/span&gt; &lt;span class="na"&gt;ident=&lt;/span&gt;&lt;span class="s"&gt;"r1"&lt;/span&gt; &lt;span class="na"&gt;rcardinality=&lt;/span&gt;&lt;span class="s"&gt;"Single"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;render_choice&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;response_label&lt;/span&gt; &lt;span class="na"&gt;ident=&lt;/span&gt;&lt;span class="s"&gt;"a"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;material&amp;gt;&amp;lt;mattext&amp;gt;&lt;/span&gt;Black&lt;span class="nt"&gt;&amp;lt;/mattext&amp;gt;&amp;lt;/material&amp;gt;&amp;lt;/response_label&amp;gt;&lt;/span&gt;
              &lt;span class="nt"&gt;&amp;lt;response_label&lt;/span&gt; &lt;span class="na"&gt;ident=&lt;/span&gt;&lt;span class="s"&gt;"b"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;material&amp;gt;&amp;lt;mattext&amp;gt;&lt;/span&gt;Blue&lt;span class="nt"&gt;&amp;lt;/mattext&amp;gt;&amp;lt;/material&amp;gt;&amp;lt;/response_label&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;/render_choice&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/response_lid&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/presentation&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;resprocessing&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;respcondition&lt;/span&gt; &lt;span class="na"&gt;continue=&lt;/span&gt;&lt;span class="s"&gt;"No"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;conditionvar&amp;gt;&amp;lt;varequal&lt;/span&gt; &lt;span class="na"&gt;respident=&lt;/span&gt;&lt;span class="s"&gt;"r1"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;a&lt;span class="nt"&gt;&amp;lt;/varequal&amp;gt;&amp;lt;/conditionvar&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;setvar&lt;/span&gt; &lt;span class="na"&gt;action=&lt;/span&gt;&lt;span class="s"&gt;"Set"&lt;/span&gt; &lt;span class="na"&gt;varname=&lt;/span&gt;&lt;span class="s"&gt;"SCORE"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;100&lt;span class="nt"&gt;&amp;lt;/setvar&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/respcondition&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/resprocessing&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/section&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/assessment&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/questestinterop&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For migrations (old bank → new system), a structured &lt;strong&gt;Excel/CSV&lt;/strong&gt; with &lt;code&gt;stem | option_a | option_b | ... | answer&lt;/code&gt; columns is what downstream scripts actually want. That column-aligned output is also what made batch jobs of hundreds of papers finish in days instead of weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases that ate my weekends
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two-column publisher layouts.&lt;/strong&gt; Without coordinates you merge column A's question 5 with column B's question 6. Coordinates fix it; reading order doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Math formulas.&lt;/strong&gt; Preserve LaTeX/MathML or the equation becomes an image alt-text blob. Authors notice immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images and tables inside a stem.&lt;/strong&gt; They break naive paragraph iteration — you need to attach media to the question, not drop it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed &lt;code&gt;1.&lt;/code&gt; / &lt;code&gt;一、&lt;/code&gt; numbering&lt;/strong&gt; in the same document (common in bilingual or localized exams). The regex above handles both; a single-format matcher silently drops half the test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sub-questions&lt;/strong&gt; (&lt;code&gt;1a&lt;/code&gt;, &lt;code&gt;1b&lt;/code&gt;). They need to nest under the parent or the grader scores them independently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Design decisions, and why they stuck
&lt;/h2&gt;

&lt;p&gt;A few calls came straight from watching real users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flag, don't guess.&lt;/strong&gt; Every uncertain question surfaces for review instead of shipping wrong. Trust compounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;If you build something like this, the lever that pays off hardest is the &lt;strong&gt;shared normalizer&lt;/strong&gt; between format-specific extractors — it's what lets one segmenter serve Word, PDF, and Docs. Spend your cleverness there, not on three parallel pipelines.&lt;/p&gt;

&lt;p&gt;And if you'd rather not rebuild the whole thing, that's the exact problem &lt;strong&gt;ExamParser&lt;/strong&gt; solves today: paste a Word, PDF, or Google Doc at &lt;strong&gt;&lt;a href="https://examparser.com" rel="noopener noreferrer"&gt;https://examparser.com&lt;/a&gt;&lt;/strong&gt;, get a Canvas quiz back, no account, files gone after. The Chinese edition lives at &lt;a href="https://examparser.com/cn/" rel="noopener noreferrer"&gt;https://examparser.com/cn/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Happy parsing.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>programming</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
