docx_builder.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  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'), '1')
  144. numPr.append(numId)
  145. pPr.append(numPr)
  146. def _setup_auto_numbering(doc: Document):
  147. """在文档中设置 Word 自动编号(每道题加题号)"""
  148. numbering_part = doc.part.numbering_part
  149. numbering_xml = numbering_part._element
  150. # 定义抽象编号 (abstractNum)
  151. abstract_num = OxmlElement('w:abstractNum')
  152. abstract_num.set(qn('w:abstractNumId'), '0')
  153. lvl = OxmlElement('w:lvl')
  154. lvl.set(qn('w:ilvl'), '0')
  155. lvl.set(qn('w:tentative'), '0')
  156. start = OxmlElement('w:start')
  157. start.set(qn('w:val'), '1')
  158. lvl.append(start)
  159. num_fmt = OxmlElement('w:numFmt')
  160. num_fmt.set(qn('w:val'), 'decimal')
  161. lvl.append(num_fmt)
  162. lvl_text = OxmlElement('w:lvlText')
  163. lvl_text.set(qn('w:val'), '%1.')
  164. lvl.append(lvl_text)
  165. lvl_jc = OxmlElement('w:lvlJc')
  166. lvl_jc.set(qn('w:val'), 'left')
  167. lvl.append(lvl_jc)
  168. p_pr = OxmlElement('w:pPr')
  169. ind = OxmlElement('w:ind')
  170. ind.set(qn('w:left'), '420')
  171. ind.set(qn('w:hanging'), '420')
  172. p_pr.append(ind)
  173. lvl.append(p_pr)
  174. abstract_num.append(lvl)
  175. numbering_xml.append(abstract_num)
  176. # 定义编号实例 (num)
  177. num = OxmlElement('w:num')
  178. num.set(qn('w:numId'), '1')
  179. num_ref = OxmlElement('w:abstractNumId')
  180. num_ref.set(qn('w:val'), '0')
  181. num.append(num_ref)
  182. numbering_xml.append(num)
  183. def _copy_images_for_export(output_path: Path, problem_ids: list[str], problems_dir: Path):
  184. """从各个单题 docx 中复制图片到导出 docx"""
  185. import zipfile
  186. from lxml import etree
  187. import shutil
  188. # 收集导出 docx 中引用的所有图片 rId,以及需要从哪些源文件复制
  189. ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
  190. ns_r_embed = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
  191. ns_rel = 'http://schemas.openxmlformats.org/package/2006/relationships'
  192. with zipfile.ZipFile(str(output_path), 'r') as dst_zip:
  193. doc_xml = dst_zip.read('word/document.xml')
  194. try:
  195. rels_xml = dst_zip.read('word/_rels/document.xml.rels')
  196. except KeyError:
  197. rels_xml = None
  198. try:
  199. ct_xml = dst_zip.read('[Content_Types].xml')
  200. except KeyError:
  201. ct_xml = None
  202. tree = etree.fromstring(doc_xml)
  203. needed_embeds = set()
  204. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  205. embed = blip.get(f'{{{ns_r_embed}}}embed')
  206. if embed:
  207. needed_embeds.add(embed)
  208. if not needed_embeds:
  209. return
  210. # 从源文件复制图片
  211. img_data_map = {} # {target_filename: image_bytes}
  212. embed_target_map = {} # {rId: target_filename}
  213. for pid in problem_ids:
  214. src_docx = problems_dir / f'{pid}.docx'
  215. if not src_docx.exists():
  216. continue
  217. with zipfile.ZipFile(str(src_docx)) as src_zip:
  218. try:
  219. src_rels = etree.fromstring(src_zip.read('word/_rels/document.xml.rels'))
  220. except KeyError:
  221. continue
  222. for rel in src_rels:
  223. r_id = rel.get('Id')
  224. if r_id in needed_embeds:
  225. target = rel.get('Target', '')
  226. if target not in img_data_map:
  227. try:
  228. img_data_map[target] = src_zip.read(f'word/{target}')
  229. embed_target_map[r_id] = target
  230. except KeyError:
  231. pass
  232. if not img_data_map:
  233. return
  234. # 写入导出 docx
  235. tmp_path = output_path.with_suffix('.tmp.docx')
  236. skip = {'word/_rels/document.xml.rels', 'word/document.xml', '[Content_Types].xml'}
  237. with zipfile.ZipFile(str(output_path), 'r') as dst_zip:
  238. with zipfile.ZipFile(str(tmp_path), 'w', zipfile.ZIP_DEFLATED) as out_zip:
  239. for item in dst_zip.infolist():
  240. if item.filename in skip:
  241. continue
  242. out_zip.writestr(item, dst_zip.read(item.filename))
  243. # 添加图片
  244. for target, data in img_data_map.items():
  245. out_zip.writestr(f'word/{target}', data)
  246. # 更新关系
  247. if rels_xml:
  248. rels_tree = etree.fromstring(rels_xml)
  249. next_rid = max(
  250. (int(rel.get('Id', 'rId0')[3:]) for rel in rels_tree if rel.get('Id', '').startswith('rId')),
  251. default=100
  252. ) + 1
  253. for old_rid, target in embed_target_map.items():
  254. rel = etree.SubElement(rels_tree, f'{{{ns_rel}}}Relationship')
  255. rel.set('Id', f'rId{next_rid}')
  256. rel.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image')
  257. rel.set('Target', target)
  258. # 更新 document.xml 中的引用
  259. for blip in tree.findall(f'.//{{{ns_a}}}blip'):
  260. if blip.get(f'{{{ns_r_embed}}}embed') == old_rid:
  261. blip.set(f'{{{ns_r_embed}}}embed', f'rId{next_rid}')
  262. next_rid += 1
  263. out_zip.writestr('word/_rels/document.xml.rels',
  264. etree.tostring(rels_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  265. # 更新 document.xml
  266. out_zip.writestr('word/document.xml',
  267. etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  268. # 更新 Content_Types
  269. if ct_xml:
  270. ct_tree = etree.fromstring(ct_xml)
  271. ct_ns = 'http://schemas.openxmlformats.org/package/2006/content-types'
  272. for target in img_data_map:
  273. ext = target.rsplit('.', 1)[-1].lower()
  274. mime_map = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
  275. 'gif': 'image/gif', 'bmp': 'image/bmp', 'webp': 'image/webp'}
  276. mime = mime_map.get(ext, 'image/png')
  277. exists = any(ov.get('PartName') == f'/word/{target}' for ov in ct_tree.findall(f'{{{ct_ns}}}Override'))
  278. if not exists:
  279. ov = etree.SubElement(ct_tree, f'{{{ct_ns}}}Override')
  280. ov.set('PartName', f'/word/{target}')
  281. ov.set('ContentType', mime)
  282. out_zip.writestr('[Content_Types].xml',
  283. etree.tostring(ct_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
  284. shutil.move(str(tmp_path), str(output_path))