sahil-12kumar/IL_CMS_Tools
1
1#!/usr/bin/env python32"""make_sample_docx.py — build samples/sample_questions.docx, the Word template3SMEs download and type over.4 5The file holds ONLY the four line kinds the parser needs:6 7 question -> options (unless numerical) -> answer -> solution8 9No headings, no title page, no "Question Type:"/"Difficulty Level:" lines — an SME10replaces the sample questions in place and uploads. Option markers may be a) b) c) d)11or 1) 2) 3) 4); both are shown, and either may be used throughout.12 13 python make_sample_docx.py # -> samples/sample_questions.docx14 python make_sample_docx.py --out x.docx15 python make_sample_docx.py --check # regenerate, then parse it back16 17Format rules: samples/WORD_FORMAT.md.18"""19import argparse20import io21import sys22from pathlib import Path23 24from docx import Document25from docx.shared import Inches, Pt26from lxml import etree27 28HERE = Path(__file__).resolve().parent29M_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/math'30 31 32# ── Word equations (OMML), written the way Word's equation editor writes them ──33def _r(text):34 return f'<m:r><m:t>{text}</m:t></m:r>'35 36 37def _omath(inner):38 return etree.fromstring(f'<m:oMath xmlns:m="{M_NS}">{inner}</m:oMath>')39 40 41def _sqrt(inner):42 # degHide is what makes the converter emit <msqrt> rather than an empty <mroot>43 return (f'<m:rad><m:radPr><m:degHide m:val="1"/></m:radPr><m:deg/>'44 f'<m:e>{inner}</m:e></m:rad>')45 46 47def _frac(num, den):48 return f'<m:f><m:num>{num}</m:num><m:den>{den}</m:den></m:f>'49 50 51def _sup(base, sup):52 return f'<m:sSup><m:e>{base}</m:e><m:sup>{sup}</m:sup></m:sSup>'53 54 55EQ_SQRT_2GH = _sqrt(_r('2gh')) # √(2gh)56EQ_HALF_MV2 = f'{_frac(_r("1"), _r("2"))}{_r("m")}{_sup(_r("v"), _r("2"))}' # ½mv²57EQ_G_FORMULA = _frac(f'{_r("4")}{_sup(_r("π"), _r("2"))}{_r("L")}',58 _sup(_r('T'), _r('2'))) # 4π²L/T²59 60 61def circuit_png():62 """A small, legible figure — stands in for diagrams SMEs paste from Word."""63 from PIL import Image, ImageDraw64 im = Image.new('RGB', (420, 200), 'white')65 d = ImageDraw.Draw(im)66 d.rectangle([30, 40, 390, 160], outline='black', width=3)67 d.rectangle([150, 25, 270, 55], fill='white', outline='black', width=3)68 d.text((186, 33), 'R = 4 ohm', fill='black')69 d.line([30, 100, 8, 100], fill='black', width=3)70 d.line([8, 85, 8, 115], fill='black', width=3)71 d.line([20, 92, 20, 108], fill='black', width=5)72 d.text((36, 105), '12 V', fill='black')73 buf = io.BytesIO()74 im.save(buf, 'PNG')75 return buf.getvalue()76 77 78class Paper:79 def __init__(self):80 self.doc = Document()81 style = self.doc.styles['Normal']82 style.font.name = 'Calibri'83 style.font.size = Pt(11)84 85 def para(self, text=''):86 return self.doc.add_paragraph(text)87 88 def mixed(self, *parts):89 """A paragraph of interleaved text / ('math', omml) / ('img', bytes)."""90 p = self.doc.add_paragraph()91 for part in parts:92 if isinstance(part, str):93 p.add_run(part)94 elif part[0] == 'math':95 p._p.append(_omath(part[1]))96 elif part[0] == 'img':97 p.add_run().add_picture(io.BytesIO(part[1]), width=Inches(2.2))98 return p99 100 def gap(self):101 self.doc.add_paragraph()102 103 def save(self, path):104 Path(path).parent.mkdir(parents=True, exist_ok=True)105 self.doc.save(str(path))106 107 108def build(path):109 d = Paper()110 111 # The very first line must be a question — any other opening line would be112 # parsed as one. So the file starts here, with no title and no headings.113 114 # ── Q1 — options lettered a) b) c) d), equation in the stem ──────────────115 d.mixed('1. A body is released from rest at a height h. Its speed on reaching the '116 'ground is v = ', ('math', EQ_SQRT_2GH),117 '. Find the speed after falling 20 m. (Take g = 10 m/s²)')118 d.para('a) 10 m/s')119 d.para('b) 20 m/s')120 d.para('c) 30 m/s')121 d.para('d) 40 m/s')122 d.para('Answer: b')123 d.mixed('Using v = ', ('math', EQ_SQRT_2GH), ' with h = 20 m and g = 10 m/s², '124 'v = square root of (2 × 10 × 20) = square root of 400 = 20 m/s.')125 d.para('Hence option b is correct.')126 d.gap()127 128 # ── Q2 — options numbered 1) 2) 3) 4) on one line, with a figure ─────────129 d.para('2. For the circuit shown below, find the current through the resistor R.')130 d.mixed(('img', circuit_png()))131 d.para('1) 1 A 2) 2 A 3) 3 A 4) 4 A')132 d.para('Answer: 3')133 d.para("By Ohm's law, I = V / R = 12 / 4 = 3 A.")134 d.para('Hence option 3 is correct.')135 d.gap()136 137 # ── Q3 — numerical: NO options at all ───────────────────────────────────138 d.mixed('3. A simple pendulum of length L = 1 m has a time period of 2 s. '139 'Using g = ', ('math', EQ_G_FORMULA), ', find g in m/s².')140 d.para('Answer: 9.87')141 d.mixed('Substituting L = 1 m and T = 2 s into g = ', ('math', EQ_G_FORMULA),142 ' gives g = 4 × 9.8696 × 1 / 4.')143 d.para('So g = 9.8696 ≈ 9.87 m/s².')144 d.gap()145 146 # ── Q4 — numerical with an accepted band (hyphen form is the one that parses) ──147 d.para('4. A 2 kg block moving at 3 m/s is brought to rest by friction over 1.5 m. '148 'Find the coefficient of friction. (g = 10 m/s²)')149 d.para('Answer: 0.28-0.32')150 d.mixed('By the work-energy theorem ', ('math', EQ_HALF_MV2),151 ' = μmgs, so μ = v² / (2gs).')152 d.para('μ = 9 / (2 × 10 × 1.5) = 0.30, so any value from 0.28 to 0.32 is accepted.')153 d.gap()154 155 # ── Q5 — options lettered (A) (B) (C) (D), equations as the options ──────156 d.para('5. Which expression gives the kinetic energy of a body of mass m moving '157 'with speed v?')158 d.mixed('(A) ', ('math', _r('mgh')))159 d.mixed('(B) ', ('math', EQ_HALF_MV2))160 d.mixed('(C) ', ('math', EQ_SQRT_2GH))161 d.mixed('(D) ', ('math', _r('mv')))162 d.para('Answer: B')163 d.mixed('Kinetic energy is ', ('math', EQ_HALF_MV2),164 '. Option A is potential energy and the others are not energies at all.')165 d.para('Hence option B is correct.')166 167 d.save(path)168 return path169 170 171def check(path):172 """Parse the generated file back and report what the uploader would show."""173 sys.path.insert(0, str(HERE))174 import app175 176 qs = app.parse_word(str(path))177 app.validate_questions(qs)178 print(f'\nparsed {len(qs)} question(s) from {Path(path).name}')179 warn = app.omml_support_warning()180 if warn:181 print(f'WARNING: {warn}')182 ok = True183 for i, q in enumerate(qs, 1):184 opts = [q.get(f'option_{k}', '') for k in 'abcd']185 filled = sum(1 for o in opts if app._option_filled(o))186 maths = sum(o.count('<math') for o in opts) + q['question_text'].count('<math')187 imgs = q['question_text'].count('<img')188 band = f' band={q["answer_range"]}' if q.get('answer_range') else ''189 print(f' Q{i}: type={app.normalise_qtype(q["question_type"]):<24} '190 f'ans={q["correct_answer"]!r:<12} opts={filled} math={maths} img={imgs} '191 f'sol={"yes" if q["solution"].strip() else "NO":<3} '192 f'{q["confidence"]}{band}')193 if q['issues']:194 print(f' issues: {q["issues"]}')195 if q['confidence'] != 'ok' or not q['solution'].strip():196 ok = False197 print('\nEvery question parsed cleanly with an answer and a solution.' if ok198 else '\nSome questions did NOT parse cleanly — see above.')199 return ok200 201 202def main():203 ap = argparse.ArgumentParser(description='Build the Word sample paper')204 ap.add_argument('--out', default=str(HERE.parent / 'samples' / 'sample_questions.docx'))205 ap.add_argument('--check', action='store_true',206 help='parse the generated file back and report')207 args = ap.parse_args()208 path = build(args.out)209 print(f'wrote {path}')210 if args.check and not check(path):211 raise SystemExit(1)212 213 214if __name__ == '__main__':215 main()216 