docx_builder.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. """docx 合并 + 导出排版模块 — 使用 deepcopy 保留图片和公式"""
  2. from copy import deepcopy
  3. from datetime import datetime
  4. from pathlib import Path
  5. from docx import Document
  6. from docx.enum.text import WD_ALIGN_PARAGRAPH
  7. from docx.oxml import OxmlElement
  8. from docx.oxml.ns import qn
  9. from docx.shared import Pt, Cm
  10. TYPE_ORDER = ["单选题", "多选题", "填空题", "解答题"]
  11. TYPE_HEADERS = {
  12. "单选题": "一、单选题",
  13. "多选题": "二、多选题",
  14. "填空题": "三、填空题",
  15. "解答题": "四、解答题",
  16. }
  17. def build_export_docx(
  18. problem_ids: list[str],
  19. problems_dir: str | Path,
  20. title: str = "试卷",
  21. include_solution: bool = True,
  22. ) -> Path:
  23. """从题目 ID 列表合并生成导出 docx(保留图片和公式)"""
  24. problems_dir = Path(problems_dir)
  25. doc = Document()
  26. # 设置默认样式
  27. style = doc.styles["Normal"]
  28. style.font.name = "宋体"
  29. style.font.size = Pt(10.5)
  30. style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  31. # 设置页面边距
  32. for section in doc.sections:
  33. section.top_margin = Cm(2)
  34. section.bottom_margin = Cm(2)
  35. section.left_margin = Cm(2.5)
  36. section.right_margin = Cm(2.5)
  37. # 标题
  38. title_para = doc.add_paragraph()
  39. title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  40. title_run = title_para.add_run(title)
  41. title_run.bold = True
  42. title_run.font.size = Pt(14)
  43. title_run.font.name = "宋体"
  44. title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  45. subtitle = "教师版" if include_solution else "学生版"
  46. sub_para = doc.add_paragraph()
  47. sub_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  48. sub_run = sub_para.add_run(subtitle)
  49. sub_run.font.size = Pt(10.5)
  50. doc.add_paragraph()
  51. # 设置 Word 自动编号
  52. _setup_auto_numbering(doc)
  53. # 从数据库获取题目并按题型分组
  54. from app.database import get_connection
  55. conn = get_connection(problems_dir.parent / "bank.db")
  56. try:
  57. problems = []
  58. for pid in problem_ids:
  59. row = conn.execute("SELECT * FROM problems WHERE id = ?", (pid,)).fetchone()
  60. if row:
  61. problems.append(dict(row))
  62. groups: dict[str, list] = {}
  63. for p in problems:
  64. qt = p.get("question_type", "")
  65. if qt not in groups:
  66. groups[qt] = []
  67. groups[qt].append(p)
  68. sorted_groups = [(qt, groups[qt]) for qt in TYPE_ORDER if qt in groups]
  69. for qt, items in groups.items():
  70. if qt not in TYPE_ORDER:
  71. sorted_groups.append((qt, items))
  72. question_num = 1
  73. for qt, items in sorted_groups:
  74. # 题型标题
  75. header_text = TYPE_HEADERS.get(qt, qt)
  76. header_para = doc.add_paragraph()
  77. header_run = header_para.add_run(header_text)
  78. header_run.bold = True
  79. header_run.font.size = Pt(12)
  80. header_run.font.name = "宋体"
  81. header_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  82. for item in items:
  83. pid = item["id"]
  84. docx_path = problems_dir / f"{pid}.docx"
  85. if not docx_path.exists():
  86. continue
  87. _copy_question_deep(doc, docx_path, question_num, include_solution)
  88. question_num += 1
  89. finally:
  90. conn.close()
  91. # 保存临时文件 → 复制图片 → 最终保存
  92. papers_dir = problems_dir.parent / "papers"
  93. papers_dir.mkdir(parents=True, exist_ok=True)
  94. ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  95. suffix = "教师版" if include_solution else "学生版"
  96. safe_title = title.replace("/", "_").replace(" ", "_")
  97. output_path = papers_dir / f"{safe_title}_{ts}_{suffix}.docx"
  98. doc.save(str(output_path))
  99. # 从源 docx 复制图片到导出 docx
  100. _copy_images_for_export(output_path, problem_ids, problems_dir)
  101. return output_path
  102. def _copy_question_deep(dst_doc: Document, src_path: Path, num: int, include_solution: bool):
  103. """Deep-copy 题目段落到目标文档,保留图片和公式,并加上自动编号"""
  104. src_doc = Document(str(src_path))
  105. src_body = src_doc.element.body
  106. dst_body = dst_doc.element.body
  107. in_answer = False
  108. first_para = True # 标记是否为该题的第一个段落(需要编号)
  109. src_paras = src_body.findall(qn('w:p'))
  110. for i, para_elem in enumerate(src_paras):
  111. # Get text from the paragraph element
  112. all_text = ''
  113. for run_elem in para_elem.findall(qn('w:r')):
  114. for t_elem in run_elem.findall(qn('w:t')):
  115. all_text += (t_elem.text or '')
  116. text = all_text.strip()
  117. if not text:
  118. dst_body.append(deepcopy(para_elem))
  119. continue
  120. if text.startswith("【答案】"):
  121. in_answer = True
  122. if not include_solution:
  123. break
  124. if in_answer and not include_solution:
  125. break
  126. copied = deepcopy(para_elem)
  127. # 给第一个非空段落加自动编号
  128. if first_para and text:
  129. _apply_numbering(copied)
  130. first_para = False
  131. dst_body.append(copied)
  132. def _apply_numbering(para_element):
  133. """给段落应用自动编号 (numId=1, ilvl=0)"""
  134. pPr = para_element.find(qn('w:pPr'))
  135. if pPr is None:
  136. pPr = OxmlElement('w:pPr')
  137. para_element.insert(0, pPr)
  138. numPr = OxmlElement('w:numPr')
  139. ilvl = OxmlElement('w:ilvl')
  140. ilvl.set(qn('w:val'), '0')
  141. numPr.append(ilvl)
  142. numId = OxmlElement('w:numId')
  143. numId.set(qn('w:val'), '99')
  144. numPr.append(numId)
  145. pPr.append(numPr)
  146. def _setup_auto_numbering(doc: Document):
  147. """在文档中设置 Word 自动编号,格式: 1. 2. 3."""
  148. numbering_part = doc.part.numbering_part
  149. numbering_xml = numbering_part._element
  150. # 用不会被 python-docx 默认编号冲突的唯一 ID
  151. OUR_ABSTRACT_NUM_ID = '99'
  152. OUR_NUM_ID = '99'
  153. # 抽象编号
  154. abstract_num = OxmlElement('w:abstractNum')
  155. abstract_num.set(qn('w:abstractNumId'), OUR_ABSTRACT_NUM_ID)
  156. nsid = OxmlElement('w:nsid')
  157. nsid.set(qn('w:val'), '7A1B2C3D')
  158. abstract_num.append(nsid)
  159. mlt = OxmlElement('w:multiLevelType')
  160. mlt.set(qn('w:val'), 'singleLevel')
  161. abstract_num.append(mlt)
  162. tmpl = OxmlElement('w:tmpl')
  163. tmpl.set(qn('w:val'), '0A1B2C3D')
  164. abstract_num.append(tmpl)
  165. lvl = OxmlElement('w:lvl')
  166. lvl.set(qn('w:ilvl'), '0')
  167. lvl.set(qn('w:tentative'), '0')
  168. start = OxmlElement('w:start')
  169. start.set(qn('w:val'), '1')
  170. lvl.append(start)
  171. num_fmt = OxmlElement('w:numFmt')
  172. num_fmt.set(qn('w:val'), 'decimal')
  173. lvl.append(num_fmt)
  174. lvl_text = OxmlElement('w:lvlText')
  175. lvl_text.set(qn('w:val'), '%1.')
  176. lvl.append(lvl_text)
  177. lvl_jc = OxmlElement('w:lvlJc')
  178. lvl_jc.set(qn('w:val'), 'left')
  179. lvl.append(lvl_jc)
  180. p_pr = OxmlElement('w:pPr')
  181. tabs = OxmlElement('w:tabs')
  182. tab = OxmlElement('w:tab')
  183. tab.set(qn('w:val'), 'left')
  184. tab.set(qn('w:pos'), '720')
  185. tabs.append(tab)
  186. p_pr.append(tabs)
  187. ind = OxmlElement('w:ind')
  188. ind.set(qn('w:left'), '720')
  189. ind.set(qn('w:hanging'), '360')
  190. p_pr.append(ind)
  191. lvl.append(p_pr)
  192. abstract_num.append(lvl)
  193. numbering_xml.append(abstract_num)
  194. # 编号实例
  195. num = OxmlElement('w:num')
  196. num.set(qn('w:numId'), OUR_NUM_ID)
  197. num_ref = OxmlElement('w:abstractNumId')
  198. num_ref.set(qn('w:val'), OUR_ABSTRACT_NUM_ID)
  199. num.append(num_ref)
  200. numbering_xml.append(num)
  201. def _copy_images_for_export(output_path: Path, problem_ids: list[str], problems_dir: Path):
  202. """从各个单题 docx 中复制图片到导出 docx"""
  203. import zipfile
  204. from lxml import etree
  205. import shutil
  206. # 收集导出 docx 中引用的所有图片 rId,以及需要从哪些源文件复制
  207. ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
  208. ns_r_embed = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
  209. ns_rel = 'http://schemas.openxmlformats.org/package/2006/relationships'
  210. with zipfile.ZipFile(str(output_path), 'r') as dst_zip:
  211. doc_xml = dst_zip.read('word/document.xml')
  212. try:
  213. rels_xml = dst_zip.read('word/_rels/document.xml.rels')
  214. except KeyError:
  215. rels_xml = None
  216. try:
  217. ct_xml = dst_zip.read('[Content_Types].xml')
  218. except KeyError:
  219. ct_xml = None
  220. tree = etree.fromstring(doc_xml)
  221. needed_embeds = set()
  222. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  223. embed = blip.get(f'{{{ns_r_embed}}}embed')
  224. if embed:
  225. needed_embeds.add(embed)
  226. if not needed_embeds:
  227. return
  228. # 从源文件复制图片
  229. img_data_map = {} # {target_filename: image_bytes}
  230. embed_target_map = {} # {rId: target_filename}
  231. for pid in problem_ids:
  232. src_docx = problems_dir / f'{pid}.docx'
  233. if not src_docx.exists():
  234. continue
  235. with zipfile.ZipFile(str(src_docx)) as src_zip:
  236. try:
  237. src_rels = etree.fromstring(src_zip.read('word/_rels/document.xml.rels'))
  238. except KeyError:
  239. continue
  240. for rel in src_rels:
  241. r_id = rel.get('Id')
  242. if r_id in needed_embeds:
  243. target = rel.get('Target', '')
  244. if target not in img_data_map:
  245. try:
  246. img_data_map[target] = src_zip.read(f'word/{target}')
  247. embed_target_map[r_id] = target
  248. except KeyError:
  249. pass
  250. if not img_data_map:
  251. return
  252. # 写入导出 docx
  253. tmp_path = output_path.with_suffix('.tmp.docx')
  254. skip = {'word/_rels/document.xml.rels', 'word/document.xml', '[Content_Types].xml'}
  255. with zipfile.ZipFile(str(output_path), 'r') as dst_zip:
  256. with zipfile.ZipFile(str(tmp_path), 'w', zipfile.ZIP_DEFLATED) as out_zip:
  257. for item in dst_zip.infolist():
  258. if item.filename in skip:
  259. continue
  260. out_zip.writestr(item, dst_zip.read(item.filename))
  261. # 添加图片
  262. for target, data in img_data_map.items():
  263. out_zip.writestr(f'word/{target}', data)
  264. # 更新关系
  265. if rels_xml:
  266. rels_tree = etree.fromstring(rels_xml)
  267. next_rid = max(
  268. (int(rel.get('Id', 'rId0')[3:]) for rel in rels_tree if rel.get('Id', '').startswith('rId')),
  269. default=100
  270. ) + 1
  271. for old_rid, target in embed_target_map.items():
  272. rel = etree.SubElement(rels_tree, f'{{{ns_rel}}}Relationship')
  273. rel.set('Id', f'rId{next_rid}')
  274. rel.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image')
  275. rel.set('Target', target)
  276. # 更新 document.xml 中的引用
  277. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  278. if blip.get(f'{{{ns_r_embed}}}embed') == old_rid:
  279. blip.set(f'{{{ns_r_embed}}}embed', f'rId{next_rid}')
  280. next_rid += 1
  281. out_zip.writestr('word/_rels/document.xml.rels',
  282. etree.tostring(rels_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  283. # 更新 document.xml
  284. out_zip.writestr('word/document.xml',
  285. etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  286. # 更新 Content_Types
  287. if ct_xml:
  288. ct_tree = etree.fromstring(ct_xml)
  289. ct_ns = 'http://schemas.openxmlformats.org/package/2006/content-types'
  290. for target in img_data_map:
  291. ext = target.rsplit('.', 1)[-1].lower()
  292. mime_map = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
  293. 'gif': 'image/gif', 'bmp': 'image/bmp', 'webp': 'image/webp'}
  294. mime = mime_map.get(ext, 'image/png')
  295. exists = any(ov.get('PartName') == f'/word/{target}' for ov in ct_tree.findall(f'{{{ct_ns}}}Override'))
  296. if not exists:
  297. ov = etree.SubElement(ct_tree, f'{{{ct_ns}}}Override')
  298. ov.set('PartName', f'/word/{target}')
  299. ov.set('ContentType', mime)
  300. out_zip.writestr('[Content_Types].xml',
  301. etree.tostring(ct_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  302. shutil.move(str(tmp_path), str(output_path))