Word试卷docx解析:OMML转LaTeX与题库全文检索 简介电子科技大学物理光学期末考试试卷以 docx 单文件形式提供面向修读物理光学课程、准备期末考试或考研复习的高校学生与授课教师用于对照真题查漏补缺。压缩包仅 48KB内含 1 个 docx 文档主体为 2006—2007 学年第一学期物理光学 A 卷试题并附选择题答案、计算简答题的完整解答。试卷内容覆盖干涉、衍射、偏振、全反射与劈尖干涉等核心章节具体包括双缝干涉光强变化、偏振片组合光强计算、方解石双折射临界角分析、光栅主极大与缺级判断、劈尖移动距离推导以及线偏振器、全波片与 1/4 波片的消光检测方法。选择题答案直接给出简答题按步骤列出公式与推导过程便于读者理解评分要点与解题规范。文档目前已有 238 人学习下载适合需要系统刷题、核对答案或补充课堂笔记的物理光学学习者。1. 从一份「物理光学期末考试试卷.docx」说起真正的难点不在排版期末季一到教务系统里那份《电子科技大学物理光学期末考试试卷.docx》就会被反复下载、转发、再打印同一门物理光学课攒上十来版是常事。真正让人头疼的从来不是排版而是这份文件把三种性质完全不同的东西塞进了同一个正文流可读的文字段落、藏在 OMML 结构里的数学公式以及干脆就是截图的公式图片。你拿它做题库检索时会发现「干涉光强 II₁I₂2√(I₁I₂)cosδ」在纯文本抽取之后只剩「干涉光强」四个字后面的式子一个字都不剩再往后翻双折射、琼斯矩阵那几道大题整段都是图片连题号都没了。本文围绕这一类试卷 docx 的非结构化处理展开从解包看 XML、OMML 转 LaTeX、分题落库到版本比对一步步把一份只能看不能搜的试卷变成能查询、能去重、能比对的题库数据。适合做教学系统、教务工具、资料归档的工程师也适合想把历年真题数字化的人。2. 解开物理光学期末考试试卷.docx 的 zip 结构先看清里面有什么docx 本质是一个 zip 包把后缀改掉或者直接 unzip就能看到 Word 真正存的东西。很多人卡在第一步是因为一上来就用 python-docx 读paragraphs然后发现公式和图片全变成了空字符串于是开始怀疑库有问题。问题不在库在于p.text只拼接了w:t节点。2.1 用 unzip 和 grep 在不写代码的情况下数清公式与图片先做一次静态盘点这一步花不到一分钟却能决定后面走哪条路cp 物理光学期末考试试卷.docx /tmp/poex/ cd /tmp/poex # -l 只列表不解压先看包内文件规模 unzip -l 物理光学期末考试试卷.docx | head -30 # -o 覆盖同名文件-q 静默-d 指定解压目录 unzip -o -q 物理光学期末考试试卷.docx -d unz ls -lh unz/word/ # 数 OMML 公式与绘图锚点的出现次数 grep -c m:oMath unz/word/document.xml grep -c w:drawing unz/word/document.xmlgrep -c的输出是两个关键指标。如果m:oMath计数是几十说明公式是可解析的文本结构后面写递归遍历就够了如果这个数是 0 而w:drawing有上百那这份卷子基本是扫描或截图排版必须走图像识别兜底。unzip -l里如果word/media/下的文件占了包体九成以上也能佐证这一点。包内路径内容对试卷解析的意义word/document.xml正文流题干、题号、公式、图片锚点都在这里word/styles.xml样式定义用样式名区分大题标题行和普通题干word/media/图片实体公式截图、光路图、衍射图样都在这里word/_rels/document.xml.relsrId 到文件的映射把a:blip的 rId 还原成图片文件名word/header1.xml页眉常放「考试时间 120 分钟」这类说明别当题干2.2 段落-运行-公式的三层结构以及 p.text 丢公式的原因正文的最小单位是w:p段落段落里装w:r运行携带字体样式运行的文字在w:t里。行内公式会被塞进m:oMath节点整行公式包在m:oMathPara里图片则是一个w:drawing包着a:blip真正的图片路径要靠r:embed属性去 rels 里查。from docx import Document doc Document(物理光学期末考试试卷.docx) print(段落数, len(doc.paragraphs), 表格数, len(doc.tables)) for i, p in enumerate(doc.paragraphs[:15]): print(i, p.style.name, |, p.text[:50])打印结果里的「光强」「相位差」后面直接断掉就是公式被丢掉的现场。要走全量解析绕开 python-docx 的封装、直接用 lxml 遍历更省心因为它能同时看到公式、图片和文本框import zipfile from lxml import etree W {http://schemas.openxmlformats.org/wordprocessingml/2006/main} M {http://schemas.openxmlformats.org/officeDocument/2006/math} root etree.fromstring(zipfile.ZipFile(物理光学期末考试试卷.docx).read(word/document.xml)) for i, p in enumerate(root.iter(f{W}p)): tags [] if p.find(f.//{M}oMath) is not None: tags.append(formula) if p.find(f.//{W}drawing) is not None: tags.append(image) text .join(t.text or for t in p.iter(f{W}t)) if text.strip() or tags: print(i, .join(tags) or text, text[:40])这里root.iter(f{W}p)是整棵树遍历w:txbxContent文本框里的段落也会被扫到而doc.paragraphs不会进入文本框。参数上唯一需要留意的是iter的结果包含嵌套在表格单元格里的段落做分题切分时要靠父节点判断是在w:tbl内还是正文流内。2.3 通过 rId 把图片锚点还原成 media 文件名只统计图片数量没有意义必须把锚点和实体对上后面做公式识别和图文关联时才不会错位import zipfile, posixpath from lxml import etree A {http://schemas.openxmlformats.org/drawingml/2006/main} R {http://schemas.openxmlformats.org/officeDocument/2006/relationships} z zipfile.ZipFile(物理光学期末考试试卷.docx) rels etree.fromstring(z.read(word/_rels/document.xml.rels)) rid2target {r.get(Id): r.get(Target) for r in rels} doc etree.fromstring(z.read(word/document.xml)) for blip in doc.iter(f{A}blip): rid blip.get(f{R}embed) # Target 是相对 word/ 的相对路径normpath 处理掉 ../ print(rid, -, posixpath.normpath(posixpath.join(word, rid2target[rid])))Target有时写成media/image13.png有时写成../media/image13.pngposixpath.normpath把两种情况统一掉否则拼出来的路径会指向不存在的文件。这一步做完一份试卷的「文字段落 / OMML 公式 / 图片」三类内容就分清楚了。3. 物理光学公式的 OMML 解析与 LaTeX 转换怎么写才不翻车物理光学卷子里公式密度极高干涉、衍射、偏振、晶体光学几乎每道题都带式子。把这些式子从 OMML 转成 LaTeX是整条链路里最容易翻车的一环。3.1 为什么用正则抠公式一定会失败先看一段真实的 OMML 片段分式的分子分母各自嵌着运行节点m:oMath m:f m:numm:rm:tI/m:t/m:r/m:num m:denm:rm:t2/m:t/m:r/m:den /m:f /m:oMath分式、上标、根号、积分这些结构是互相嵌套的正则没有栈处理不了「一个m:f里还套着两个m:sSup」这种情况。正确做法是递归下降碰到什么标签就按标签的语义拼对应的 LaTeX 片段再递归拼子节点。OMML 标签语义输出 LaTeXm:f分式\frac{num}{den}m:sSup/m:sSub上标 / 下标e^{sup}/e_{sub}m:sSubSup上下标同时e_{sub}^{sup}m:rad根号\sqrt{e}带m:deg时输出\sqrt[deg]{e}m:d括号\left( e \right)m:nary积分、求和\int_{sub}^{sup} em:acc重音符号\vec{e}、\hat{e}m:func函数名\sin\left( e \right)3.2 手写递归函数把 m:oMath 转成 LaTeXfrom lxml import etree M {http://schemas.openxmlformats.org/officeDocument/2006/math} W {http://schemas.openxmlformats.org/wordprocessingml/2006/main} NARY {∫: r\int, ∑: r\sum, ∏: r\prod, ∮: r\oint} ACC {\u20d7: r\vec, \u0302: r\hat, \u0304: r\bar, \u0307: r\dot} def cvt(node): 递归把 OMML 子树转成 LaTeX 片段空节点返回空串保证容错 if node is None: return name etree.QName(node).localname # 这些标签只是容器直接拼接子节点 if name in (oMath, oMathPara, e, num, den, sub, sup, deg, fName, lim): return .join(cvt(c) for c in node) if name r: # 数学运行节点文字全在 m:t 里 return .join(t.text or for t in node.iter(f{M}t)) if name f: return r\frac{%s}{%s} % (cvt(node.find(f{M}num)), cvt(node.find(f{M}den))) if name sSup: return %s^{%s} % (cvt(node.find(f{M}e)), cvt(node.find(f{M}sup))) if name sSub: return %s_{%s} % (cvt(node.find(f{M}e)), cvt(node.find(f{M}sub))) if name sSubSup: return %s_{%s}^{%s} % (cvt(node.find(f{M}e)), cvt(node.find(f{M}sub)), cvt(node.find(f{M}sup))) if name rad: e, deg cvt(node.find(f{M}e)), cvt(node.find(f{M}deg)) return f\\sqrt[{deg}]{{{e}}} if deg else f\\sqrt{{{e}}} if name d: return r\left(%s\right) % cvt(node.find(f{M}e)) if name nary: pr node.find(f{M}naryPr/{M}chr) op NARY.get(pr.get(f{M}val), r\int) if pr is not None else r\int return %s_{%s}^{%s} %s % (op, cvt(node.find(f{M}sub)), cvt(node.find(f{M}sup)), cvt(node.find(f{M}e))) if name func: return r%s\left(%s\right) % (cvt(node.find(f{M}fName)), cvt(node.find(f{M}e))) if name acc: pr node.find(f{M}accPr/{M}chr) ch pr.get(f{M}val) if pr is not None else None return r%s{%s} % (ACC.get(ch, r\hat), cvt(node.find(f{M}e))) return .join(cvt(c) for c in node) def para_markdown(p): 按段落拼 Markdown行内公式加 $普通文字原样保留 out [] for child in p: ln etree.QName(child).localname if ln oMath: out.append(f${cvt(child)}$) elif ln in (r, hyperlink, smartTag): out.append(.join(t.text or for t in child.iter(f{W}t))) return .join(out)三个参数点值得盯住。第一m:nary的m:sub和m:sup经常缺失所以cvt开头必须先做 None 判断否则etree.QName(None)直接抛异常一卷子几十道题会在同一处崩掉。第二m:acc里声调符号存在m:accPr/m:chr的m:val属性上取不到时退回\hat比返回空串安全。第三m:d的括号字符在m:dPr/m:begChr默认圆括号光学题里出现矩阵用的方括号时就靠这个属性没做映射的话琼斯矩阵会被渲染成圆括号。3.3 图片型公式的兜底尺寸粗筛加人工校对队列老卷子里的公式常是截图。导出的方法是遍历word/media/按像素尺寸先粗筛一遍——公式图片通常矮而窄光路图则接近方形import io, zipfile from PIL import Image z zipfile.ZipFile(物理光学期末考试试卷.docx) for name in z.namelist(): if not name.startswith(word/media/): continue im Image.open(io.BytesIO(z.read(name))) w, h im.size # 高度小于 90px 且宽高比大于 2 的大概率是行内公式截图 kind formula if h 90 and w / max(h, 1) 2 else figure print(name, (w, h), im.mode, kind)粗筛只是把候选集缩小真正的识别交给公式识别模型LaTeX-OCR 这类开源模型体量很小本地就能跑识别结果按置信度分流高于阈值的直接写回题干低于阈值的落到一张校对表里。校对表用 CSV 就够字段是image_path, ocr_latex, confidence, reviewer, status人工改完再合并回题库。这套「机器先跑一遍、人只补低置信」的分工比全量人工快得多也比纯自动可靠。4. 把试卷 docx 批量转成可检索题库分题、落库与参数调优公式能抽出来了接下来是把一份试卷拆成一道一道结构化的题并且让它们能被搜到。4.1 大纲号加题号的两段式正则切分物理光学卷的结构相当固定先是大题行「一、选择题每题 3 分共 30 分」下面跟小题「1. ...」。用两条正则分工比一条大正则更稳。import re # 大题行中文数字 顿号/点 标题后面可能带分值括号 SEC re.compile(r^\s*([一二三四五六七八九十])\s*[、.]\s*(.?)(?:(.?))?\s*$) # 小题行阿拉伯数字 顿号/点 QNO re.compile(r^\s*(\d{1,2})\s*[、.]\s*) # 分值每题 N 分 或 共 N 分 SCORE re.compile(r每(?:小)?题\s*(\d(?:\.\d)?)\s*分|共\s*(\d)\s*分) def split_paper(paras): sec, cur None, None for text in paras: m SEC.match(text) # 长度阈值 40 是关键题干里出现的「一、」不会这么短 if m and len(text.strip()) 40: if cur: yield cur sec, cur text.strip(), None continue m QNO.match(text) if m: if cur: yield cur cur {section: sec or , qno: int(m.group(1)), stem: QNO.sub(, text).strip(), extra: []} continue if cur is not None: cur[extra].append(text) if cur: yield curlen(text.strip()) 40是必须的因为物理光学题干里经常出现「一、二象限」「一、二级衍射」这类表述不加长度约束会把题干硬切成大题行导致后面的题全部挂错父节点。SCORE的两个分支对应「每题 3 分」和「共 30 分」两种写法选择题按前者算单题分大题行按后者算总分两者做校验时可以互相验证。4.2 落库SQLite FTS5 加 trigram中文题干才搜得动题库表用普通表存内容用虚拟表做索引。中文全文检索的坑在于 SQLite 默认的unicode61分词器不认识中文会把整句当成一个词。SQLite 3.34 之后内置的trigram分词器按三字符滑窗切分中文直接可用。CREATE TABLE question ( id INTEGER PRIMARY KEY, paper_id TEXT NOT NULL, -- 例如 2023-A section TEXT, qno INTEGER, score REAL, stem_md TEXT NOT NULL, -- 题干 Markdown公式为 $...$ stem_hash TEXT NOT NULL, -- 归一化后的 md5用于去重与比对 images TEXT DEFAULT [] -- 关联的 media 文件名 JSON 数组 ); CREATE UNIQUE INDEX idx_q_hash ON question(paper_id, qno); CREATE VIRTUAL TABLE question_fts USING fts5( stem_md, contentquestion, content_rowidid, tokenizetrigram );contentquestion是外部内容表模式索引不重复存正文省空间代价是插入后必须手动同步索引。查询用snippet打高亮用bm25排序SELECT q.qno, snippet(question_fts, 0, [, ], …, 12) AS hit FROM question_fts f JOIN question q ON q.id f.rowid WHERE f MATCH 偏振 ORDER BY bm25(question_fts) LIMIT 20;snippet的第二个参数是列序号0 表示第一列第五个参数 12 是截断长度按显示宽度调到 12 到 20 之间比较合适。如果运行环境里的 SQLite 版本低于 3.34tokenizetrigram会直接报错这时改用 jieba 预分词写一列stem_tokens再用默认分词器也能达到接近的效果。4.3 入库脚本与三个必调参数import json, hashlib, re, sqlite3 def norm(s): 归一化去空白、去排版噪声保证同一道题在不同版本里 hash 一致 return re.sub(r\s, , s).replace(\\left, ).replace(\\right, ) conn sqlite3.connect(optics_bank.db) BATCH 500 rows [] for it in split_paper(all_paragraphs): stem it[stem] .join(it[extra]) rows.append((it[paper_id], it[section], it[qno], it.get(score), stem, hashlib.md5(norm(stem).encode()).hexdigest(), json.dumps(it.get(images, []), ensure_asciiFalse))) if len(rows) BATCH: conn.executemany( INSERT OR IGNORE INTO question(paper_id,section,qno,score,stem_md,stem_hash,images) VALUES(?,?,?,?,?,?,?), rows) conn.commit() rows.clear() conn.executemany( INSERT OR IGNORE INTO question(paper_id,section,qno,score,stem_md,stem_hash,images) VALUES(?,?,?,?,?,?,?), rows) # 外部内容表模式下批量写入后必须重建索引 conn.execute(INSERT INTO question_fts(question_fts) VALUES(rebuild)) conn.commit()三个参数直接决定成败。BATCH500是executemany的吞吐拐点太小就是几千次小事务太大内存里堆着不释放历年卷子几百份的量级下 500 比较稳。norm的归一化强度要克制去空白和\left\right是为了消除排版差异如果顺手把标点也归一化会把「I₁I₂」和「I₁−I₂」算成同一个 hash。图片是否纳入 hash 要想清楚同一道题只换了一张光路图该不该算新题常见做法是图片不参与 hash只记录在images字段里比对时单独看。4.4 常见故障的定位顺序现象最可能的原因处置方式公式全部丢失document.xml里m:oMath计数为 0是图片公式走 3.3 的图像识别链路题号跳号缺号大纲正则长度阈值过大吃掉了题干打印被吃掉的那几行调小阈值选择题选项散架选项排版在表格里单独遍历w:tbl按单元格顺序拼选项题干整段消失题干在文本框w:txbxContent里用root.iter(W.p)全树遍历别用doc.paragraphs同一道题入库两次paper_id传了文件名而不是版本号把版本号从文件名正则里提出来作为paper_id排查顺序建议固定成「先数公式和图片 → 再看切分边界 → 最后查索引同步」因为公式丢失和切分错位是两个完全独立的问题混在一起查会来回打转。5. 试卷版本比对与质检把 hash 和 diff 用到实处历年卷子最有价值的用法不是查单题而是横向比哪几年换了题、哪几年只改了数字。这件事用前面存下的stem_hash加上行级 diff 就能做不需要额外的比对工具。5.1 归一化后做题目级 diff用 ratio 区分换题与改数字import difflib def index(conn, paper_id): cur conn.execute( SELECT qno, stem_md FROM question WHERE paper_id? ORDER BY qno, (paper_id,)) return {q: t for q, t in cur} old, new index(conn, 2019-A), index(conn, 2023-A) for q in sorted(set(old) set(new)): if old[q] new[q]: continue # hash 相同原题未动 ratio difflib.SequenceMatcher(None, old[q], new[q]).ratio() print(q, round(ratio, 2)) if ratio 0.95: print(.join(difflib.unified_diff([old[q]], [new[q]], lineterm)))SequenceMatcher.ratio()输出的是两串的最长匹配比例落到经验区间上很好用低于 0.6 基本是换了题0.6 到 0.95 之间多是把波长从 589nm 改成 632.8nm、把折射率从 1.5 改成 1.6 这类改数字高于 0.95 通常是排版噪声比如多了一个空格或少了一组\left\right。第二层的unified_diff只对 ratio 低于阈值的题输出避免几万行 diff 刷屏。5.2 四十五条质检规则里最该先跑的四条质检项判定规则处置公式定界符配对stem_md中$数量为偶数说明某处m:oMath没被识别回到 3.2 补标签分支花括号配平\frac后紧跟的{与}数量相等递归里m:num或m:den缺失检查容错分支题号连续同一 section 内qno从 1 递增无跳号跳号处打印原始段落多半是分题正则漏匹配分值合计各题分数之和等于大纲行声明的总分差值固定为某题分值时说明分值正则只吃到了其中一种写法这四条的共同点是「不需要人工判断就能给出确切的通过或失败」适合挂到每次批量入库之后自动跑输出一份失败清单。至于公式语义是否正确、图像识别结果是否用错了符号机器判断不了只能交给 3.3 里那张校对表。把ratio阈值压到 0.6 以下再跑一遍 5.1 的脚本你会看到真正被改掉的往往只是几个折射率数字而不是整道题。本文还有配套的精品资源点击获取