docx_builder.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. """docx 合并 + 导出排版模块"""
  2. import re
  3. import shutil
  4. import tempfile
  5. from datetime import datetime
  6. from pathlib import Path
  7. from docx import Document
  8. from docx.enum.text import WD_ALIGN_PARAGRAPH
  9. from docx.oxml import OxmlElement
  10. from docx.oxml.ns import qn
  11. from docx.shared import Pt, Cm
  12. TYPE_ORDER = ["单选题", "多选题", "填空题", "解答题"]
  13. TYPE_HEADERS = {
  14. "单选题": "一、单选题",
  15. "多选题": "二、多选题",
  16. "填空题": "三、填空题",
  17. "解答题": "四、解答题",
  18. }
  19. def build_export_docx(
  20. problem_ids: list[str],
  21. problems_dir: str | Path,
  22. title: str = "试卷",
  23. include_solution: bool = True,
  24. ) -> Path:
  25. """从题目 ID 列表合并生成导出 docx
  26. Args:
  27. problem_ids: 题目 ID 列表(已按题型分组、按难度排序)
  28. problems_dir: 单题 docx 存储目录
  29. title: 试卷标题
  30. include_solution: True = 教师版, False = 学生版
  31. Returns: 生成的 docx 文件路径
  32. """
  33. problems_dir = Path(problems_dir)
  34. doc = Document()
  35. # 设置默认样式
  36. style = doc.styles["Normal"]
  37. font = style.font
  38. font.name = "宋体"
  39. font.size = Pt(10.5)
  40. style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  41. # 标题
  42. title_para = doc.add_paragraph()
  43. title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  44. title_run = title_para.add_run(title)
  45. title_run.bold = True
  46. title_run.font.size = Pt(14)
  47. title_run.font.name = "宋体"
  48. title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  49. subtitle = "教师版" if include_solution else "学生版"
  50. sub_para = doc.add_paragraph()
  51. sub_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
  52. sub_run = sub_para.add_run(subtitle)
  53. sub_run.font.size = Pt(10.5)
  54. doc.add_paragraph() # 空行
  55. # 从数据库获取题目并按题型分组
  56. from app.database import get_connection
  57. conn = get_connection(problems_dir.parent / "bank.db")
  58. try:
  59. problems = []
  60. for pid in problem_ids:
  61. row = conn.execute("SELECT * FROM problems WHERE id = ?", (pid,)).fetchone()
  62. if row:
  63. problems.append(dict(row))
  64. # 按题型分组
  65. groups: dict[str, list] = {}
  66. for p in problems:
  67. qt = p.get("question_type", "")
  68. if qt not in groups:
  69. groups[qt] = []
  70. groups[qt].append(p)
  71. # 按题型排序
  72. sorted_groups = [(qt, groups[qt]) for qt in TYPE_ORDER if qt in groups]
  73. # 加上未知题型
  74. for qt, items in groups.items():
  75. if qt not in TYPE_ORDER:
  76. sorted_groups.append((qt, items))
  77. question_num = 1
  78. for qt, items in sorted_groups:
  79. # 题型标题
  80. header_text = TYPE_HEADERS.get(qt, qt)
  81. header_para = doc.add_paragraph()
  82. header_run = header_para.add_run(header_text)
  83. header_run.bold = True
  84. header_run.font.size = Pt(12)
  85. header_run.font.name = "宋体"
  86. header_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  87. for item in items:
  88. pid = item["id"]
  89. docx_path = problems_dir / f"{pid}.docx"
  90. if not docx_path.exists():
  91. continue
  92. # 复制该题的内容
  93. _copy_question(doc, docx_path, question_num, include_solution)
  94. question_num += 1
  95. finally:
  96. conn.close()
  97. # 添加 Word 自动编号
  98. _apply_auto_numbering(doc)
  99. # 保存
  100. papers_dir = problems_dir.parent / "papers"
  101. papers_dir.mkdir(parents=True, exist_ok=True)
  102. ts = datetime.now().strftime("%Y%m%d_%H%M%S")
  103. suffix = "教师版" if include_solution else "学生版"
  104. safe_title = title.replace("/", "_").replace(" ", "_")
  105. output_path = papers_dir / f"{safe_title}_{ts}_{suffix}.docx"
  106. doc.save(str(output_path))
  107. return output_path
  108. def _copy_question(doc: Document, source_path: Path, num: int, include_solution: bool):
  109. """复制题目的段落到目标 docx"""
  110. source = Document(str(source_path))
  111. in_answer = False
  112. for para in source.paragraphs:
  113. text = para.text.strip()
  114. if not text:
  115. # 复制空段落(保留格式间距)
  116. p = doc.add_paragraph()
  117. continue
  118. # 检测答案区
  119. if text.startswith("【答案】"):
  120. in_answer = True
  121. if not include_solution:
  122. break
  123. if in_answer and not include_solution:
  124. break
  125. # 复制段落
  126. p = doc.add_paragraph()
  127. for run in para.runs:
  128. r = p.add_run(run.text)
  129. r.bold = run.bold
  130. r.italic = run.italic
  131. r.font.name = "宋体"
  132. r._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
  133. r.font.size = Pt(10.5)
  134. # 保留段落对齐
  135. if para.alignment:
  136. p.alignment = para.alignment
  137. def _apply_auto_numbering(doc: Document):
  138. """对题号段落应用 Word 自动编号"""
  139. # 识别题型标题后的题号段落
  140. numbering_id = 1
  141. in_section = False
  142. for para in doc.paragraphs:
  143. text = para.text.strip()
  144. # 检测题型标题
  145. is_header = False
  146. for qt, header in TYPE_HEADERS.items():
  147. if header in text:
  148. is_header = True
  149. in_section = True
  150. numbering_id += 1
  151. break
  152. if is_header:
  153. continue
  154. # 对题号段落加编号
  155. if in_section and re.match(r'\d+[..]', text):
  156. _add_numbering(para, numbering_id)
  157. def _add_numbering(para, num_id: int):
  158. """给段落添加 Word 自动编号"""
  159. pPr = para._element.get_or_add_pPr()
  160. numPr = OxmlElement('w:numPr')
  161. ilvl = OxmlElement('w:ilvl')
  162. ilvl.set(qn('w:val'), '0')
  163. numPr.append(ilvl)
  164. numId = OxmlElement('w:numId')
  165. numId.set(qn('w:val'), str(num_id))
  166. numPr.append(numId)
  167. pPr.insert(0, numPr)