docx_parser.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479
  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. current_type = detected
  89. continue
  90. # 检测题号:新题开始
  91. if is_question_number(text) and current_type:
  92. # 保存上一题
  93. if current_start is not None:
  94. answer_start = _get_answer_boundary(paras, current_start, i - 1)
  95. # 提取元数据
  96. answer_text = "\n".join(
  97. paras[j].text.strip()
  98. for j in range(answer_start, i)
  99. )
  100. questions.append({
  101. "question_type": current_type,
  102. "question_num": question_num,
  103. "para_start": current_start,
  104. "para_end": i - 1,
  105. "answer_start": answer_start,
  106. "metadata": extract_metadata_from_text(answer_text),
  107. })
  108. question_num += 1
  109. current_start = i
  110. # 保存最后一题
  111. if current_start is not None:
  112. answer_start = _get_answer_boundary(paras, current_start, len(paras) - 1)
  113. answer_text = "\n".join(
  114. paras[j].text.strip()
  115. for j in range(answer_start, len(paras))
  116. )
  117. questions.append({
  118. "question_type": current_type,
  119. "question_num": question_num,
  120. "para_start": current_start,
  121. "para_end": len(paras) - 1,
  122. "answer_start": answer_start,
  123. "metadata": extract_metadata_from_text(answer_text),
  124. })
  125. return questions
  126. def create_single_docx(
  127. original_docx_path: str | Path,
  128. question_data: dict,
  129. import_type: str,
  130. source_name: str,
  131. knowledge_map: dict,
  132. problems_dir: str | Path = "data/problems",
  133. ) -> tuple[Path, str, dict]:
  134. """从原始 docx 的段落范围创建单题 docx(保留图片和格式)
  135. 关键:deep-copy 原始段落的 XML 元素,完全保留图片、公式、格式
  136. """
  137. original_docx_path = Path(original_docx_path)
  138. problems_dir = Path(problems_dir)
  139. problems_dir.mkdir(parents=True, exist_ok=True)
  140. src_doc = Document(str(original_docx_path))
  141. src_paras = src_doc.paragraphs
  142. src_body = src_doc.element.body
  143. para_start = question_data["para_start"]
  144. para_end = question_data["para_end"]
  145. answer_start = question_data["answer_start"]
  146. meta = question_data["metadata"]
  147. # ── 创建新文档 ──
  148. dst_doc = Document()
  149. # 复制默认样式
  150. style = dst_doc.styles["Normal"]
  151. style.font.name = "宋体"
  152. style.font.size = Pt(10.5)
  153. # ── 复制题干段落(para_start → answer_start - 1)──
  154. for i in range(para_start, answer_start):
  155. _copy_paragraph(src_body, dst_doc.element.body, i)
  156. # ── 复制答案区(跳过【难度】【知识点】段)──
  157. for i in range(answer_start, para_end + 1):
  158. text = src_paras[i].text.strip()
  159. if text.startswith("【难度】") or text.startswith("【知识点】"):
  160. continue
  161. _copy_paragraph(src_body, dst_doc.element.body, i)
  162. # ── 处理知识点编码 ──
  163. knowledge_codes = []
  164. knowledge_names = meta.get("knowledge_names", [])
  165. for name in knowledge_names:
  166. from app.services.knowledge import find_code_by_name_fuzzy
  167. code = find_code_by_name_fuzzy(name, knowledge_map)
  168. if code:
  169. knowledge_codes.append(code)
  170. # ── 写入元数据到自定义属性 ──
  171. _set_custom_props(dst_doc, {
  172. "question_type": question_data["question_type"],
  173. "difficulty": meta["difficulty"] or 0.5,
  174. "knowledge_codes": json.dumps(knowledge_codes, ensure_ascii=False),
  175. "import_type": import_type,
  176. "comment": "",
  177. "source": source_name,
  178. })
  179. # ── 计算 ID ──
  180. # 先把新文档保存为临时文件来计算 ID
  181. tmp_bytes = io.BytesIO()
  182. dst_doc.save(tmp_bytes)
  183. checksum = hashlib.sha256(tmp_bytes.getvalue()).hexdigest()
  184. problem_id = checksum[:8]
  185. # ── 处理图片:从原始 docx 复制到新 docx ──
  186. docx_path = problems_dir / f"{problem_id}.docx"
  187. dst_doc.save(str(docx_path))
  188. # ── 嵌入图片(从原始 docx 的 ZIP 中提取,写入新 docx 的 ZIP)──
  189. _copy_images_to_docx(docx_path, original_docx_path)
  190. # ── 章节 ──
  191. chapters = _get_chapters(knowledge_codes, knowledge_map)
  192. info = {
  193. "id": problem_id,
  194. "file_path": str(docx_path),
  195. "checksum": checksum,
  196. "chapters": chapters,
  197. "difficulty": meta["difficulty"] or 0.5,
  198. "question_type": question_data["question_type"],
  199. "import_type": import_type,
  200. "knowledge_codes": knowledge_codes,
  201. "comment": "",
  202. "source": source_name,
  203. }
  204. return docx_path, problem_id, info
  205. def _copy_paragraph(src_body, dst_body, para_index: int):
  206. """Deep-copy 一个段落从 src_body 到 dst_body"""
  207. paragraphs = src_body.findall(qn('w:p'))
  208. if para_index >= len(paragraphs):
  209. return
  210. src_p = paragraphs[para_index]
  211. # 找到插入位置(dst_body 的最后一个元素之后)
  212. dst_body.append(deepcopy(src_p))
  213. def _copy_images_to_docx(dst_path: Path, src_path: Path):
  214. """将原始 docx 中被引用的图片复制到新 docx 中"""
  215. from lxml import etree
  216. import shutil
  217. # 读取目标 docx 中引用的所有图片 rId
  218. with zipfile.ZipFile(str(dst_path), 'r') as dst_zip:
  219. doc_xml = dst_zip.read('word/document.xml')
  220. try:
  221. dst_rels_xml = dst_zip.read('word/_rels/document.xml.rels')
  222. except KeyError:
  223. dst_rels_xml = None
  224. # 从目标 document.xml 中提取所有 a:blip 的 r:embed
  225. ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
  226. ns_r = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
  227. ns_rel = 'http://schemas.openxmlformats.org/package/2006/relationships'
  228. tree = etree.fromstring(doc_xml)
  229. needed_embeds = set()
  230. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  231. embed = blip.get(f'{{{ns_r}}}embed')
  232. if embed:
  233. needed_embeds.add(embed)
  234. if not needed_embeds:
  235. return
  236. # 从原始 docx 中获取图片数据
  237. with zipfile.ZipFile(str(src_path), 'r') as src_zip:
  238. # 读取原始关系文件
  239. src_rels_xml = src_zip.read('word/_rels/document.xml.rels')
  240. src_rels_tree = etree.fromstring(src_rels_xml)
  241. # 找到每个 embed 对应的 target
  242. embed_targets = {}
  243. for rel in src_rels_tree:
  244. r_id = rel.get('Id')
  245. target = rel.get('Target', '')
  246. if r_id in needed_embeds:
  247. # target like "media/image1.png"
  248. embed_targets[r_id] = target
  249. # 读写目标 docx,添加图片和关系
  250. tmp_path = dst_path.with_suffix('.tmp.docx')
  251. with zipfile.ZipFile(str(dst_path), 'r') as dst_zip:
  252. with zipfile.ZipFile(str(tmp_path), 'w', zipfile.ZIP_DEFLATED) as out_zip:
  253. # 复制所有现有条目(跳过将被更新的文件)
  254. skip = {'word/document.xml', 'word/_rels/document.xml.rels', '[Content_Types].xml'}
  255. for item in dst_zip.infolist():
  256. if item.filename in skip:
  257. continue
  258. data = dst_zip.read(item.filename)
  259. out_zip.writestr(item, data)
  260. # 添加图片文件并记录扩展名
  261. next_rid = _get_max_rid(dst_zip) + 1
  262. new_rels = []
  263. content_types_additions = []
  264. for embed, target in embed_targets.items():
  265. try:
  266. img_data = src_zip.read(f'word/{target}')
  267. out_zip.writestr(f'word/{target}', img_data)
  268. new_rels.append((f'rId{next_rid}', target, embed))
  269. next_rid += 1
  270. # 记录 content type
  271. ext = target.rsplit('.', 1)[-1].lower()
  272. mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
  273. 'gif': 'image/gif', 'bmp': 'image/bmp'}.get(ext, 'image/png')
  274. content_types_additions.append((f'/word/{target}', mime))
  275. except KeyError:
  276. pass
  277. # 更新关系文件
  278. if new_rels and dst_rels_xml is not None:
  279. rels_tree = etree.fromstring(dst_rels_xml)
  280. for new_rid, target, old_rid in new_rels:
  281. rel_elem = etree.SubElement(rels_tree, f'{{{ns_rel}}}Relationship')
  282. rel_elem.set('Id', new_rid)
  283. rel_elem.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image')
  284. rel_elem.set('Target', target)
  285. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  286. if blip.get(f'{{{ns_r}}}embed') == old_rid:
  287. blip.set(f'{{{ns_r}}}embed', new_rid)
  288. rels_out = etree.tostring(rels_tree, xml_declaration=True, encoding='UTF-8', standalone=True)
  289. doc_out = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
  290. else:
  291. rels_out = dst_rels_xml if dst_rels_xml is not None else b''
  292. doc_out = doc_xml
  293. out_zip.writestr('word/_rels/document.xml.rels', rels_out)
  294. out_zip.writestr('word/document.xml', doc_out)
  295. # 更新 [Content_Types].xml
  296. ct_xml = dst_zip.read('[Content_Types].xml')
  297. ct_tree = etree.fromstring(ct_xml)
  298. ct_ns = 'http://schemas.openxmlformats.org/package/2006/content-types'
  299. for partname, mime in content_types_additions:
  300. # Check if override already exists
  301. exists = False
  302. for ov in ct_tree.findall(f'{{{ct_ns}}}Override'):
  303. if ov.get('PartName') == partname:
  304. exists = True
  305. break
  306. if not exists:
  307. ov = etree.SubElement(ct_tree, f'{{{ct_ns}}}Override')
  308. ov.set('PartName', partname)
  309. ov.set('ContentType', mime)
  310. out_zip.writestr('[Content_Types].xml',
  311. etree.tostring(ct_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  312. # 替换原文件
  313. shutil.move(str(tmp_path), str(dst_path))
  314. def _get_max_rid(zf: zipfile.ZipFile) -> int:
  315. """获取 ZIP 中关系文件的最大 rId 编号"""
  316. try:
  317. rels_xml = zf.read('word/_rels/document.xml.rels')
  318. tree = etree.fromstring(rels_xml)
  319. max_id = 0
  320. for rel in tree:
  321. r_id = rel.get('Id', '')
  322. if r_id.startswith('rId'):
  323. try:
  324. num = int(r_id[3:])
  325. max_id = max(max_id, num)
  326. except ValueError:
  327. pass
  328. return max_id
  329. except Exception:
  330. return 100 # fallback
  331. # Import for etree
  332. from lxml import etree
  333. def _set_custom_props(doc: Document, props: dict):
  334. """设置 docx 自定义属性"""
  335. for key, value in props.items():
  336. try:
  337. doc.custom_document_properties[key] = value
  338. except Exception:
  339. pass
  340. def _get_chapters(knowledge_codes: list[str], knowledge_map: dict) -> list[str]:
  341. """从知识点编码反推章节名"""
  342. from app.services.knowledge import get_chapter_from_code
  343. chapters = set()
  344. for code in knowledge_codes:
  345. ch = get_chapter_from_code(code, knowledge_map)
  346. if ch:
  347. chapters.add(ch)
  348. return sorted(chapters)
  349. def read_custom_props(docx_path: str | Path) -> dict:
  350. """读取 docx 自定义属性"""
  351. doc = Document(str(docx_path))
  352. props = {}
  353. try:
  354. for name in doc.custom_document_properties:
  355. props[name] = doc.custom_document_properties[name]
  356. except Exception:
  357. pass
  358. if not props:
  359. try:
  360. from lxml import etree
  361. custom_xml = doc.part.package.parts
  362. for part in custom_xml:
  363. if part.partname.endswith('/custom.xml'):
  364. root = etree.fromstring(part.blob)
  365. for prop in root:
  366. tag = prop.tag.split('}')[-1] if '}' in prop.tag else prop.tag
  367. value = prop.text or ""
  368. try:
  369. if value:
  370. props[tag] = float(value)
  371. except ValueError:
  372. props[tag] = value
  373. except Exception:
  374. pass
  375. return props
  376. def extract_plain_text(docx_path: str | Path) -> str:
  377. """提取 docx 中所有段落的纯文本(用于 FTS 索引)"""
  378. doc = Document(str(docx_path))
  379. texts = []
  380. for para in doc.paragraphs:
  381. text = para.text.strip()
  382. if text:
  383. texts.append(text)
  384. return "\n".join(texts)
  385. def compute_id_from_docx(file_path: str | Path) -> str:
  386. """从 docx 计算题目 ID"""
  387. import zipfile as zf
  388. try:
  389. with zf.ZipFile(str(file_path), 'r') as z:
  390. if 'word/document.xml' in z.namelist():
  391. return hashlib.sha256(z.read('word/document.xml')).hexdigest()[:8]
  392. except Exception:
  393. pass
  394. with open(file_path, 'rb') as f:
  395. return hashlib.sha256(f.read()).hexdigest()[:8]
  396. def compute_checksum(file_path: str | Path) -> str:
  397. try:
  398. with zipfile.ZipFile(str(file_path), 'r') as z:
  399. if 'word/document.xml' in z.namelist():
  400. return hashlib.sha256(z.read('word/document.xml')).hexdigest()
  401. except Exception:
  402. pass
  403. with open(file_path, 'rb') as f:
  404. return hashlib.sha256(f.read()).hexdigest()