docx_parser.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. """docx 拆分 + 元数据提取模块
  2. 导入整卷 docx → 按题拆分 → 生成每题独立 docx
  3. 关键:通过 deep-copy 段落 XML 保留图片、公式、格式
  4. """
  5. import hashlib
  6. import json
  7. import re
  8. import io
  9. import zipfile
  10. from copy import deepcopy
  11. from pathlib import Path
  12. from docx import Document
  13. from docx.oxml.ns import qn
  14. from docx.shared import Pt
  15. # 题型标题关键词映射
  16. TYPE_KEYWORDS = {
  17. "单选题": "单选题",
  18. "多选题": "多选题",
  19. "填空题": "填空题",
  20. "解答题": "解答题",
  21. }
  22. def detect_type_header(text: str) -> str | None:
  23. for key, value in TYPE_KEYWORDS.items():
  24. if key in text:
  25. return value
  26. return None
  27. def is_question_number(text: str) -> bool:
  28. text = text.strip()
  29. return bool(re.match(r'\d+[..]', text))
  30. def strip_question_number(text: str) -> str:
  31. return re.sub(r'^\d+[..]\s*', '', text.strip())
  32. def extract_metadata_from_text(text: str) -> dict:
  33. """从答案区文本中提取元数据"""
  34. result = {
  35. "answer": "", "difficulty": None, "knowledge_names": [],
  36. "analysis": "", "detail": "", "highlight": "",
  37. }
  38. sections = re.split(r'【(答案|难度|知识点|分析|详解|点睛)】', text)
  39. start = 0
  40. if sections and not sections[0].strip():
  41. start = 1
  42. for i in range(start, len(sections) - 1, 2):
  43. tag = sections[i].strip()
  44. content = sections[i + 1].strip()
  45. if tag == "答案":
  46. result["answer"] = content
  47. elif tag == "难度":
  48. try:
  49. result["difficulty"] = float(content.split()[0])
  50. except (ValueError, IndexError):
  51. pass
  52. elif tag == "知识点":
  53. names = re.split(r'[,,、\n]', content)
  54. result["knowledge_names"] = [n.strip() for n in names if n.strip()]
  55. elif tag == "分析":
  56. result["analysis"] = content
  57. elif tag == "详解":
  58. result["detail"] = content
  59. elif tag == "点睛":
  60. result["highlight"] = content
  61. return result
  62. def _get_answer_boundary(paras, start_idx: int, end_idx: int) -> int:
  63. """在给定段落范围内找到【答案】分隔线的位置(绝对索引)"""
  64. for i in range(start_idx, end_idx + 1):
  65. text = paras[i].text.strip()
  66. if text.startswith("【答案】"):
  67. return i
  68. return end_idx + 1 # 没找到答案标记,全部算题干
  69. def split_docx_to_questions(docx_path: str | Path) -> list[dict]:
  70. """将整卷 docx 拆分为题目段落范围列表
  71. Returns list of dicts:
  72. {question_type, question_num, para_start, para_end, answer_start, metadata}
  73. """
  74. docx_path = Path(docx_path)
  75. doc = Document(str(docx_path))
  76. paras = doc.paragraphs
  77. questions = []
  78. current_type = ""
  79. current_start = None
  80. question_num = 0
  81. for i, para in enumerate(paras):
  82. text = para.text.strip()
  83. if not text:
  84. continue
  85. # 检测题型标题 → 结束当前题(如果有),切换题型
  86. detected = detect_type_header(text)
  87. if detected:
  88. # 保存当前正在处理的题目(如果有)
  89. if current_start is not None:
  90. answer_start = _get_answer_boundary(paras, current_start, i - 1)
  91. answer_text = "\n".join(
  92. paras[j].text.strip()
  93. for j in range(answer_start, i)
  94. )
  95. questions.append({
  96. "question_type": current_type,
  97. "question_num": question_num,
  98. "para_start": current_start,
  99. "para_end": i - 1,
  100. "answer_start": answer_start,
  101. "metadata": extract_metadata_from_text(answer_text),
  102. })
  103. question_num += 1
  104. current_start = None
  105. current_type = detected
  106. continue
  107. # 跳过非题号的抬头信息(标题、校徽等)
  108. if not current_type:
  109. continue
  110. # 检测题号:新题开始
  111. if is_question_number(text):
  112. # 保存上一题
  113. if current_start is not None:
  114. answer_start = _get_answer_boundary(paras, current_start, i - 1)
  115. answer_text = "\n".join(
  116. paras[j].text.strip()
  117. for j in range(answer_start, i)
  118. )
  119. questions.append({
  120. "question_type": current_type,
  121. "question_num": question_num,
  122. "para_start": current_start,
  123. "para_end": i - 1,
  124. "answer_start": answer_start,
  125. "metadata": extract_metadata_from_text(answer_text),
  126. })
  127. question_num += 1
  128. current_start = i
  129. # 保存最后一题
  130. if current_start is not None:
  131. answer_start = _get_answer_boundary(paras, current_start, len(paras) - 1)
  132. answer_text = "\n".join(
  133. paras[j].text.strip()
  134. for j in range(answer_start, len(paras))
  135. )
  136. questions.append({
  137. "question_type": current_type,
  138. "question_num": question_num,
  139. "para_start": current_start,
  140. "para_end": len(paras) - 1,
  141. "answer_start": answer_start,
  142. "metadata": extract_metadata_from_text(answer_text),
  143. })
  144. return questions
  145. def create_single_docx(
  146. original_docx_path: str | Path,
  147. question_data: dict,
  148. import_type: str,
  149. source_name: str,
  150. knowledge_map: dict,
  151. problems_dir: str | Path = "data/problems",
  152. ) -> tuple[Path, str, dict]:
  153. """从原始 docx 的段落范围创建单题 docx(保留图片和格式)
  154. 关键:deep-copy 原始段落的 XML 元素,完全保留图片、公式、格式
  155. """
  156. original_docx_path = Path(original_docx_path)
  157. problems_dir = Path(problems_dir)
  158. problems_dir.mkdir(parents=True, exist_ok=True)
  159. src_doc = Document(str(original_docx_path))
  160. src_paras = src_doc.paragraphs
  161. src_body = src_doc.element.body
  162. para_start = question_data["para_start"]
  163. para_end = question_data["para_end"]
  164. answer_start = question_data["answer_start"]
  165. meta = question_data["metadata"]
  166. # ── 创建新文档 ──
  167. dst_doc = Document()
  168. # 复制默认样式
  169. style = dst_doc.styles["Normal"]
  170. style.font.name = "宋体"
  171. style.font.size = Pt(10.5)
  172. # ── 复制题干段落(para_start → answer_start - 1)──
  173. for i in range(para_start, answer_start):
  174. _copy_paragraph(src_body, dst_doc.element.body, i)
  175. # ── 去掉第一个段落的题号(如 "1.")──
  176. first_para = dst_doc.element.body.findall(qn('w:p'))[0] if len(dst_doc.element.body.findall(qn('w:p'))) > 0 else None
  177. if first_para is not None:
  178. _strip_number_from_para(first_para)
  179. # ── 统一字体:宋体五号 (10.5pt) ──
  180. _normalize_font(dst_doc)
  181. # ── 复制答案区(跳过【难度】【知识点】段)──
  182. for i in range(answer_start, para_end + 1):
  183. text = src_paras[i].text.strip()
  184. if text.startswith("【难度】") or text.startswith("【知识点】"):
  185. continue
  186. _copy_paragraph(src_body, dst_doc.element.body, i)
  187. # ── 处理知识点编码 ──
  188. knowledge_codes = []
  189. knowledge_names = meta.get("knowledge_names", [])
  190. for name in knowledge_names:
  191. from app.services.knowledge import find_code_by_name_fuzzy
  192. code = find_code_by_name_fuzzy(name, knowledge_map)
  193. if code:
  194. knowledge_codes.append(code)
  195. # ── 写入元数据到自定义属性 ──
  196. _set_custom_props(dst_doc, {
  197. "question_type": question_data["question_type"],
  198. "difficulty": meta["difficulty"] or 0.5,
  199. "knowledge_codes": json.dumps(knowledge_codes, ensure_ascii=False),
  200. "import_type": import_type,
  201. "comment": "",
  202. "source": source_name,
  203. })
  204. # ── 计算 ID ──
  205. # 先把新文档保存为临时文件来计算 ID
  206. tmp_bytes = io.BytesIO()
  207. dst_doc.save(tmp_bytes)
  208. checksum = hashlib.sha256(tmp_bytes.getvalue()).hexdigest()
  209. problem_id = checksum[:8]
  210. # ── 处理图片:从原始 docx 复制到新 docx ──
  211. docx_path = problems_dir / f"{problem_id}.docx"
  212. dst_doc.save(str(docx_path))
  213. # ── 嵌入图片(从原始 docx 的 ZIP 中提取,写入新 docx 的 ZIP)──
  214. _copy_images_to_docx(docx_path, original_docx_path)
  215. # ── 章节 ──
  216. chapters = _get_chapters(knowledge_codes, knowledge_map)
  217. info = {
  218. "id": problem_id,
  219. "file_path": str(docx_path),
  220. "checksum": checksum,
  221. "chapters": chapters,
  222. "difficulty": meta["difficulty"] or 0.5,
  223. "question_type": question_data["question_type"],
  224. "import_type": import_type,
  225. "knowledge_codes": knowledge_codes,
  226. "comment": "",
  227. "source": source_name,
  228. }
  229. return docx_path, problem_id, info
  230. def _normalize_font(doc: Document):
  231. """统一所有段落的字体为宋体五号 (10.5pt)"""
  232. style = doc.styles["Normal"]
  233. style.font.name = "宋体"
  234. style.font.size = Pt(10.5)
  235. # Also set East Asian font
  236. rPr = style.element.get_or_add_rPr()
  237. rFonts = rPr.find(qn('w:rFonts'))
  238. if rFonts is None:
  239. rFonts = OxmlElement('w:rFonts')
  240. rPr.insert(0, rFonts)
  241. rFonts.set(qn('w:eastAsia'), '宋体')
  242. # Normalize all runs in the document
  243. for para in doc.paragraphs:
  244. for run in para.runs:
  245. run.font.name = "宋体"
  246. run_rPr = run._element.get_or_add_rPr()
  247. run_rFonts = run_rPr.find(qn('w:rFonts'))
  248. if run_rFonts is None:
  249. run_rFonts = OxmlElement('w:rFonts')
  250. run_rPr.insert(0, run_rFonts)
  251. run_rFonts.set(qn('w:eastAsia'), '宋体')
  252. if run.font.size is None or run.font.size > Pt(20):
  253. run.font.size = Pt(10.5)
  254. def _copy_paragraph(src_body, dst_body, para_index: int):
  255. """Deep-copy 一个段落从 src_body 到 dst_body"""
  256. paragraphs = src_body.findall(qn('w:p'))
  257. if para_index >= len(paragraphs):
  258. return
  259. src_p = paragraphs[para_index]
  260. # 找到插入位置(dst_body 的最后一个元素之后)
  261. dst_body.append(deepcopy(src_p))
  262. def _strip_number_from_para(para_element):
  263. """去掉段落开头的题号,如 '1.' '1.' ' 1.'
  264. 处理数字和标点可能在不同 run 中的情况
  265. """
  266. import re
  267. all_runs = para_element.findall(qn('w:r'))
  268. # 收集前几个 run 中的所有 text 元素
  269. text_slots = [] # [(run_idx, t_element)]
  270. for i, run in enumerate(all_runs[:4]): # 只看前4个 run
  271. for t_elem in run.findall(qn('w:t')):
  272. text_slots.append((i, t_elem))
  273. if len(text_slots) >= 4:
  274. break
  275. if not text_slots:
  276. return
  277. # 拼接文本,判断是否以题号开头
  278. combined = ''.join(t_elem.text or '' for _, t_elem in text_slots)
  279. m = re.match(r'^\s*(\d+)([..])', combined)
  280. if not m:
  281. return
  282. digits = m.group(1)
  283. dot = m.group(2)
  284. chars_to_remove = len(digits) + len(dot)
  285. # 从第一个 text slot 开始,逐个删字符
  286. for _, t_elem in text_slots:
  287. text = t_elem.text or ''
  288. if chars_to_remove <= 0:
  289. break
  290. if len(text) <= chars_to_remove:
  291. chars_to_remove -= len(text)
  292. t_elem.text = ''
  293. else:
  294. t_elem.text = text[chars_to_remove:]
  295. t_elem.set(qn('xml:space'), 'preserve')
  296. chars_to_remove = 0
  297. def _copy_images_to_docx(dst_path: Path, src_path: Path):
  298. """将原始 docx 中被引用的图片复制到新 docx 中"""
  299. from lxml import etree
  300. import shutil
  301. # 读取目标 docx 中引用的所有图片 rId
  302. with zipfile.ZipFile(str(dst_path), 'r') as dst_zip:
  303. doc_xml = dst_zip.read('word/document.xml')
  304. try:
  305. dst_rels_xml = dst_zip.read('word/_rels/document.xml.rels')
  306. except KeyError:
  307. dst_rels_xml = None
  308. # 从目标 document.xml 中提取所有 a:blip 的 r:embed
  309. ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
  310. ns_r = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
  311. ns_rel = 'http://schemas.openxmlformats.org/package/2006/relationships'
  312. tree = etree.fromstring(doc_xml)
  313. needed_embeds = set()
  314. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  315. embed = blip.get(f'{{{ns_r}}}embed')
  316. if embed:
  317. needed_embeds.add(embed)
  318. if not needed_embeds:
  319. return
  320. # 从原始 docx 中获取图片数据
  321. with zipfile.ZipFile(str(src_path), 'r') as src_zip:
  322. # 读取原始关系文件
  323. src_rels_xml = src_zip.read('word/_rels/document.xml.rels')
  324. src_rels_tree = etree.fromstring(src_rels_xml)
  325. # 找到每个 embed 对应的 target
  326. embed_targets = {}
  327. for rel in src_rels_tree:
  328. r_id = rel.get('Id')
  329. target = rel.get('Target', '')
  330. if r_id in needed_embeds:
  331. # target like "media/image1.png"
  332. embed_targets[r_id] = target
  333. # 读写目标 docx,添加图片和关系
  334. tmp_path = dst_path.with_suffix('.tmp.docx')
  335. with zipfile.ZipFile(str(dst_path), 'r') as dst_zip:
  336. with zipfile.ZipFile(str(tmp_path), 'w', zipfile.ZIP_DEFLATED) as out_zip:
  337. # 复制所有现有条目(跳过将被更新的文件)
  338. skip = {'word/document.xml', 'word/_rels/document.xml.rels', '[Content_Types].xml'}
  339. for item in dst_zip.infolist():
  340. if item.filename in skip:
  341. continue
  342. data = dst_zip.read(item.filename)
  343. out_zip.writestr(item, data)
  344. # 添加图片文件并记录扩展名
  345. next_rid = _get_max_rid(dst_zip) + 1
  346. new_rels = []
  347. content_types_additions = []
  348. for embed, target in embed_targets.items():
  349. try:
  350. img_data = src_zip.read(f'word/{target}')
  351. out_zip.writestr(f'word/{target}', img_data)
  352. new_rels.append((f'rId{next_rid}', target, embed))
  353. next_rid += 1
  354. # 记录 content type
  355. ext = target.rsplit('.', 1)[-1].lower()
  356. mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
  357. 'gif': 'image/gif', 'bmp': 'image/bmp'}.get(ext, 'image/png')
  358. content_types_additions.append((f'/word/{target}', mime))
  359. except KeyError:
  360. pass
  361. # 更新关系文件
  362. if new_rels and dst_rels_xml is not None:
  363. rels_tree = etree.fromstring(dst_rels_xml)
  364. for new_rid, target, old_rid in new_rels:
  365. rel_elem = etree.SubElement(rels_tree, f'{{{ns_rel}}}Relationship')
  366. rel_elem.set('Id', new_rid)
  367. rel_elem.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image')
  368. rel_elem.set('Target', target)
  369. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  370. if blip.get(f'{{{ns_r}}}embed') == old_rid:
  371. blip.set(f'{{{ns_r}}}embed', new_rid)
  372. rels_out = etree.tostring(rels_tree, xml_declaration=True, encoding='UTF-8', standalone=True)
  373. doc_out = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
  374. else:
  375. rels_out = dst_rels_xml if dst_rels_xml is not None else b''
  376. doc_out = doc_xml
  377. out_zip.writestr('word/_rels/document.xml.rels', rels_out)
  378. out_zip.writestr('word/document.xml', doc_out)
  379. # 更新 [Content_Types].xml
  380. ct_xml = dst_zip.read('[Content_Types].xml')
  381. ct_tree = etree.fromstring(ct_xml)
  382. ct_ns = 'http://schemas.openxmlformats.org/package/2006/content-types'
  383. for partname, mime in content_types_additions:
  384. # Check if override already exists
  385. exists = False
  386. for ov in ct_tree.findall(f'{{{ct_ns}}}Override'):
  387. if ov.get('PartName') == partname:
  388. exists = True
  389. break
  390. if not exists:
  391. ov = etree.SubElement(ct_tree, f'{{{ct_ns}}}Override')
  392. ov.set('PartName', partname)
  393. ov.set('ContentType', mime)
  394. out_zip.writestr('[Content_Types].xml',
  395. etree.tostring(ct_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  396. # 替换原文件
  397. shutil.move(str(tmp_path), str(dst_path))
  398. def _get_max_rid(zf: zipfile.ZipFile) -> int:
  399. """获取 ZIP 中关系文件的最大 rId 编号"""
  400. try:
  401. rels_xml = zf.read('word/_rels/document.xml.rels')
  402. tree = etree.fromstring(rels_xml)
  403. max_id = 0
  404. for rel in tree:
  405. r_id = rel.get('Id', '')
  406. if r_id.startswith('rId'):
  407. try:
  408. num = int(r_id[3:])
  409. max_id = max(max_id, num)
  410. except ValueError:
  411. pass
  412. return max_id
  413. except Exception:
  414. return 100 # fallback
  415. # Import for etree
  416. from lxml import etree
  417. def _set_custom_props(doc: Document, props: dict):
  418. """设置 docx 自定义属性"""
  419. for key, value in props.items():
  420. try:
  421. doc.custom_document_properties[key] = value
  422. except Exception:
  423. pass
  424. def _get_chapters(knowledge_codes: list[str], knowledge_map: dict) -> list[str]:
  425. """从知识点编码反推章节名"""
  426. from app.services.knowledge import get_chapter_from_code
  427. chapters = set()
  428. for code in knowledge_codes:
  429. ch = get_chapter_from_code(code, knowledge_map)
  430. if ch:
  431. chapters.add(ch)
  432. return sorted(chapters)
  433. def read_custom_props(docx_path: str | Path) -> dict:
  434. """读取 docx 自定义属性"""
  435. doc = Document(str(docx_path))
  436. props = {}
  437. try:
  438. for name in doc.custom_document_properties:
  439. props[name] = doc.custom_document_properties[name]
  440. except Exception:
  441. pass
  442. if not props:
  443. try:
  444. from lxml import etree
  445. custom_xml = doc.part.package.parts
  446. for part in custom_xml:
  447. if part.partname.endswith('/custom.xml'):
  448. root = etree.fromstring(part.blob)
  449. for prop in root:
  450. tag = prop.tag.split('}')[-1] if '}' in prop.tag else prop.tag
  451. value = prop.text or ""
  452. try:
  453. if value:
  454. props[tag] = float(value)
  455. except ValueError:
  456. props[tag] = value
  457. except Exception:
  458. pass
  459. return props
  460. def extract_plain_text(docx_path: str | Path) -> str:
  461. """提取 docx 中所有段落的纯文本(用于 FTS 索引)"""
  462. doc = Document(str(docx_path))
  463. texts = []
  464. for para in doc.paragraphs:
  465. text = para.text.strip()
  466. if text:
  467. texts.append(text)
  468. return "\n".join(texts)
  469. def compute_id_from_docx(file_path: str | Path) -> str:
  470. """从 docx 计算题目 ID"""
  471. import zipfile as zf
  472. try:
  473. with zf.ZipFile(str(file_path), 'r') as z:
  474. if 'word/document.xml' in z.namelist():
  475. return hashlib.sha256(z.read('word/document.xml')).hexdigest()[:8]
  476. except Exception:
  477. pass
  478. with open(file_path, 'rb') as f:
  479. return hashlib.sha256(f.read()).hexdigest()[:8]
  480. def compute_checksum(file_path: str | Path) -> str:
  481. try:
  482. with zipfile.ZipFile(str(file_path), 'r') as z:
  483. if 'word/document.xml' in z.namelist():
  484. return hashlib.sha256(z.read('word/document.xml')).hexdigest()
  485. except Exception:
  486. pass
  487. with open(file_path, 'rb') as f:
  488. return hashlib.sha256(f.read()).hexdigest()