|
|
@@ -1,8 +1,6 @@
|
|
|
-"""docx 合并 + 导出排版模块"""
|
|
|
+"""docx 合并 + 导出排版模块 — 使用 deepcopy 保留图片和公式"""
|
|
|
|
|
|
-import re
|
|
|
-import shutil
|
|
|
-import tempfile
|
|
|
+from copy import deepcopy
|
|
|
from datetime import datetime
|
|
|
from pathlib import Path
|
|
|
|
|
|
@@ -28,26 +26,23 @@ def build_export_docx(
|
|
|
title: str = "试卷",
|
|
|
include_solution: bool = True,
|
|
|
) -> Path:
|
|
|
- """从题目 ID 列表合并生成导出 docx
|
|
|
-
|
|
|
- Args:
|
|
|
- problem_ids: 题目 ID 列表(已按题型分组、按难度排序)
|
|
|
- problems_dir: 单题 docx 存储目录
|
|
|
- title: 试卷标题
|
|
|
- include_solution: True = 教师版, False = 学生版
|
|
|
-
|
|
|
- Returns: 生成的 docx 文件路径
|
|
|
- """
|
|
|
+ """从题目 ID 列表合并生成导出 docx(保留图片和公式)"""
|
|
|
problems_dir = Path(problems_dir)
|
|
|
doc = Document()
|
|
|
|
|
|
# 设置默认样式
|
|
|
style = doc.styles["Normal"]
|
|
|
- font = style.font
|
|
|
- font.name = "宋体"
|
|
|
- font.size = Pt(10.5)
|
|
|
+ style.font.name = "宋体"
|
|
|
+ style.font.size = Pt(10.5)
|
|
|
style.element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
|
|
|
|
|
|
+ # 设置页面边距
|
|
|
+ for section in doc.sections:
|
|
|
+ section.top_margin = Cm(2)
|
|
|
+ section.bottom_margin = Cm(2)
|
|
|
+ section.left_margin = Cm(2.5)
|
|
|
+ section.right_margin = Cm(2.5)
|
|
|
+
|
|
|
# 标题
|
|
|
title_para = doc.add_paragraph()
|
|
|
title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
@@ -63,7 +58,10 @@ def build_export_docx(
|
|
|
sub_run = sub_para.add_run(subtitle)
|
|
|
sub_run.font.size = Pt(10.5)
|
|
|
|
|
|
- doc.add_paragraph() # 空行
|
|
|
+ doc.add_paragraph()
|
|
|
+
|
|
|
+ # 设置 Word 自动编号
|
|
|
+ _setup_auto_numbering(doc)
|
|
|
|
|
|
# 从数据库获取题目并按题型分组
|
|
|
from app.database import get_connection
|
|
|
@@ -75,7 +73,6 @@ def build_export_docx(
|
|
|
if row:
|
|
|
problems.append(dict(row))
|
|
|
|
|
|
- # 按题型分组
|
|
|
groups: dict[str, list] = {}
|
|
|
for p in problems:
|
|
|
qt = p.get("question_type", "")
|
|
|
@@ -83,9 +80,7 @@ def build_export_docx(
|
|
|
groups[qt] = []
|
|
|
groups[qt].append(p)
|
|
|
|
|
|
- # 按题型排序
|
|
|
sorted_groups = [(qt, groups[qt]) for qt in TYPE_ORDER if qt in groups]
|
|
|
- # 加上未知题型
|
|
|
for qt, items in groups.items():
|
|
|
if qt not in TYPE_ORDER:
|
|
|
sorted_groups.append((qt, items))
|
|
|
@@ -107,17 +102,13 @@ def build_export_docx(
|
|
|
if not docx_path.exists():
|
|
|
continue
|
|
|
|
|
|
- # 复制该题的内容
|
|
|
- _copy_question(doc, docx_path, question_num, include_solution)
|
|
|
+ _copy_question_deep(doc, docx_path, question_num, include_solution)
|
|
|
question_num += 1
|
|
|
|
|
|
finally:
|
|
|
conn.close()
|
|
|
|
|
|
- # 添加 Word 自动编号
|
|
|
- _apply_auto_numbering(doc)
|
|
|
-
|
|
|
- # 保存
|
|
|
+ # 保存临时文件 → 复制图片 → 最终保存
|
|
|
papers_dir = problems_dir.parent / "papers"
|
|
|
papers_dir.mkdir(parents=True, exist_ok=True)
|
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
@@ -126,22 +117,34 @@ def build_export_docx(
|
|
|
output_path = papers_dir / f"{safe_title}_{ts}_{suffix}.docx"
|
|
|
doc.save(str(output_path))
|
|
|
|
|
|
+ # 从源 docx 复制图片到导出 docx
|
|
|
+ _copy_images_for_export(output_path, problem_ids, problems_dir)
|
|
|
+
|
|
|
return output_path
|
|
|
|
|
|
|
|
|
-def _copy_question(doc: Document, source_path: Path, num: int, include_solution: bool):
|
|
|
- """复制题目的段落到目标 docx"""
|
|
|
- source = Document(str(source_path))
|
|
|
+def _copy_question_deep(dst_doc: Document, src_path: Path, num: int, include_solution: bool):
|
|
|
+ """Deep-copy 题目段落到目标文档,保留图片和公式,并加上自动编号"""
|
|
|
+ src_doc = Document(str(src_path))
|
|
|
+ src_body = src_doc.element.body
|
|
|
+ dst_body = dst_doc.element.body
|
|
|
+
|
|
|
in_answer = False
|
|
|
+ first_para = True # 标记是否为该题的第一个段落(需要编号)
|
|
|
+ src_paras = src_body.findall(qn('w:p'))
|
|
|
+
|
|
|
+ for i, para_elem in enumerate(src_paras):
|
|
|
+ # Get text from the paragraph element
|
|
|
+ all_text = ''
|
|
|
+ for run_elem in para_elem.findall(qn('w:r')):
|
|
|
+ for t_elem in run_elem.findall(qn('w:t')):
|
|
|
+ all_text += (t_elem.text or '')
|
|
|
+ text = all_text.strip()
|
|
|
|
|
|
- for para in source.paragraphs:
|
|
|
- text = para.text.strip()
|
|
|
if not text:
|
|
|
- # 复制空段落(保留格式间距)
|
|
|
- p = doc.add_paragraph()
|
|
|
+ dst_body.append(deepcopy(para_elem))
|
|
|
continue
|
|
|
|
|
|
- # 检测答案区
|
|
|
if text.startswith("【答案】"):
|
|
|
in_answer = True
|
|
|
if not include_solution:
|
|
|
@@ -150,58 +153,195 @@ def _copy_question(doc: Document, source_path: Path, num: int, include_solution:
|
|
|
if in_answer and not include_solution:
|
|
|
break
|
|
|
|
|
|
- # 复制段落
|
|
|
- p = doc.add_paragraph()
|
|
|
- for run in para.runs:
|
|
|
- r = p.add_run(run.text)
|
|
|
- r.bold = run.bold
|
|
|
- r.italic = run.italic
|
|
|
- r.font.name = "宋体"
|
|
|
- r._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
|
|
|
- r.font.size = Pt(10.5)
|
|
|
-
|
|
|
- # 保留段落对齐
|
|
|
- if para.alignment:
|
|
|
- p.alignment = para.alignment
|
|
|
-
|
|
|
-
|
|
|
-def _apply_auto_numbering(doc: Document):
|
|
|
- """对题号段落应用 Word 自动编号"""
|
|
|
- # 识别题型标题后的题号段落
|
|
|
- numbering_id = 1
|
|
|
- in_section = False
|
|
|
-
|
|
|
- for para in doc.paragraphs:
|
|
|
- text = para.text.strip()
|
|
|
-
|
|
|
- # 检测题型标题
|
|
|
- is_header = False
|
|
|
- for qt, header in TYPE_HEADERS.items():
|
|
|
- if header in text:
|
|
|
- is_header = True
|
|
|
- in_section = True
|
|
|
- numbering_id += 1
|
|
|
- break
|
|
|
+ copied = deepcopy(para_elem)
|
|
|
|
|
|
- if is_header:
|
|
|
- continue
|
|
|
+ # 给第一个非空段落加自动编号
|
|
|
+ if first_para and text:
|
|
|
+ _apply_numbering(copied)
|
|
|
+ first_para = False
|
|
|
|
|
|
- # 对题号段落加编号
|
|
|
- if in_section and re.match(r'\d+[..]', text):
|
|
|
- _add_numbering(para, numbering_id)
|
|
|
+ dst_body.append(copied)
|
|
|
|
|
|
|
|
|
-def _add_numbering(para, num_id: int):
|
|
|
- """给段落添加 Word 自动编号"""
|
|
|
- pPr = para._element.get_or_add_pPr()
|
|
|
- numPr = OxmlElement('w:numPr')
|
|
|
+def _apply_numbering(para_element):
|
|
|
+ """给段落应用自动编号 (numId=1, ilvl=0)"""
|
|
|
+ pPr = para_element.find(qn('w:pPr'))
|
|
|
+ if pPr is None:
|
|
|
+ pPr = OxmlElement('w:pPr')
|
|
|
+ para_element.insert(0, pPr)
|
|
|
|
|
|
+ numPr = OxmlElement('w:numPr')
|
|
|
ilvl = OxmlElement('w:ilvl')
|
|
|
ilvl.set(qn('w:val'), '0')
|
|
|
numPr.append(ilvl)
|
|
|
|
|
|
numId = OxmlElement('w:numId')
|
|
|
- numId.set(qn('w:val'), str(num_id))
|
|
|
+ numId.set(qn('w:val'), '1')
|
|
|
numPr.append(numId)
|
|
|
|
|
|
- pPr.insert(0, numPr)
|
|
|
+ pPr.append(numPr)
|
|
|
+
|
|
|
+
|
|
|
+def _setup_auto_numbering(doc: Document):
|
|
|
+ """在文档中设置 Word 自动编号(每道题加题号)"""
|
|
|
+ numbering_part = doc.part.numbering_part
|
|
|
+ numbering_xml = numbering_part._element
|
|
|
+
|
|
|
+ # 定义抽象编号 (abstractNum)
|
|
|
+ abstract_num = OxmlElement('w:abstractNum')
|
|
|
+ abstract_num.set(qn('w:abstractNumId'), '0')
|
|
|
+
|
|
|
+ lvl = OxmlElement('w:lvl')
|
|
|
+ lvl.set(qn('w:ilvl'), '0')
|
|
|
+ lvl.set(qn('w:tentative'), '0')
|
|
|
+ start = OxmlElement('w:start')
|
|
|
+ start.set(qn('w:val'), '1')
|
|
|
+ lvl.append(start)
|
|
|
+
|
|
|
+ num_fmt = OxmlElement('w:numFmt')
|
|
|
+ num_fmt.set(qn('w:val'), 'decimal')
|
|
|
+ lvl.append(num_fmt)
|
|
|
+
|
|
|
+ lvl_text = OxmlElement('w:lvlText')
|
|
|
+ lvl_text.set(qn('w:val'), '%1.')
|
|
|
+ lvl.append(lvl_text)
|
|
|
+
|
|
|
+ lvl_jc = OxmlElement('w:lvlJc')
|
|
|
+ lvl_jc.set(qn('w:val'), 'left')
|
|
|
+ lvl.append(lvl_jc)
|
|
|
+
|
|
|
+ p_pr = OxmlElement('w:pPr')
|
|
|
+ ind = OxmlElement('w:ind')
|
|
|
+ ind.set(qn('w:left'), '420')
|
|
|
+ ind.set(qn('w:hanging'), '420')
|
|
|
+ p_pr.append(ind)
|
|
|
+ lvl.append(p_pr)
|
|
|
+
|
|
|
+ abstract_num.append(lvl)
|
|
|
+ numbering_xml.append(abstract_num)
|
|
|
+
|
|
|
+ # 定义编号实例 (num)
|
|
|
+ num = OxmlElement('w:num')
|
|
|
+ num.set(qn('w:numId'), '1')
|
|
|
+ num_ref = OxmlElement('w:abstractNumId')
|
|
|
+ num_ref.set(qn('w:val'), '0')
|
|
|
+ num.append(num_ref)
|
|
|
+ numbering_xml.append(num)
|
|
|
+
|
|
|
+
|
|
|
+def _copy_images_for_export(output_path: Path, problem_ids: list[str], problems_dir: Path):
|
|
|
+ """从各个单题 docx 中复制图片到导出 docx"""
|
|
|
+ import zipfile
|
|
|
+ from lxml import etree
|
|
|
+ import shutil
|
|
|
+
|
|
|
+ # 收集导出 docx 中引用的所有图片 rId,以及需要从哪些源文件复制
|
|
|
+ ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
|
|
|
+ ns_r_embed = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
|
|
+ ns_rel = 'http://schemas.openxmlformats.org/package/2006/relationships'
|
|
|
+
|
|
|
+ with zipfile.ZipFile(str(output_path), 'r') as dst_zip:
|
|
|
+ doc_xml = dst_zip.read('word/document.xml')
|
|
|
+ try:
|
|
|
+ rels_xml = dst_zip.read('word/_rels/document.xml.rels')
|
|
|
+ except KeyError:
|
|
|
+ rels_xml = None
|
|
|
+ try:
|
|
|
+ ct_xml = dst_zip.read('[Content_Types].xml')
|
|
|
+ except KeyError:
|
|
|
+ ct_xml = None
|
|
|
+
|
|
|
+ tree = etree.fromstring(doc_xml)
|
|
|
+ needed_embeds = set()
|
|
|
+ for blip in tree.findall(f'.//{{{ns_a}}}blip'):
|
|
|
+ embed = blip.get(f'{{{ns_r_embed}}}embed')
|
|
|
+ if embed:
|
|
|
+ needed_embeds.add(embed)
|
|
|
+
|
|
|
+ if not needed_embeds:
|
|
|
+ return
|
|
|
+
|
|
|
+ # 从源文件复制图片
|
|
|
+ img_data_map = {} # {target_filename: image_bytes}
|
|
|
+ embed_target_map = {} # {rId: target_filename}
|
|
|
+
|
|
|
+ for pid in problem_ids:
|
|
|
+ src_docx = problems_dir / f'{pid}.docx'
|
|
|
+ if not src_docx.exists():
|
|
|
+ continue
|
|
|
+ with zipfile.ZipFile(str(src_docx)) as src_zip:
|
|
|
+ try:
|
|
|
+ src_rels = etree.fromstring(src_zip.read('word/_rels/document.xml.rels'))
|
|
|
+ except KeyError:
|
|
|
+ continue
|
|
|
+ for rel in src_rels:
|
|
|
+ r_id = rel.get('Id')
|
|
|
+ if r_id in needed_embeds:
|
|
|
+ target = rel.get('Target', '')
|
|
|
+ if target not in img_data_map:
|
|
|
+ try:
|
|
|
+ img_data_map[target] = src_zip.read(f'word/{target}')
|
|
|
+ embed_target_map[r_id] = target
|
|
|
+ except KeyError:
|
|
|
+ pass
|
|
|
+
|
|
|
+ if not img_data_map:
|
|
|
+ return
|
|
|
+
|
|
|
+ # 写入导出 docx
|
|
|
+ tmp_path = output_path.with_suffix('.tmp.docx')
|
|
|
+ skip = {'word/_rels/document.xml.rels', 'word/document.xml', '[Content_Types].xml'}
|
|
|
+
|
|
|
+ with zipfile.ZipFile(str(output_path), 'r') as dst_zip:
|
|
|
+ with zipfile.ZipFile(str(tmp_path), 'w', zipfile.ZIP_DEFLATED) as out_zip:
|
|
|
+ for item in dst_zip.infolist():
|
|
|
+ if item.filename in skip:
|
|
|
+ continue
|
|
|
+ out_zip.writestr(item, dst_zip.read(item.filename))
|
|
|
+
|
|
|
+ # 添加图片
|
|
|
+ for target, data in img_data_map.items():
|
|
|
+ out_zip.writestr(f'word/{target}', data)
|
|
|
+
|
|
|
+ # 更新关系
|
|
|
+ if rels_xml:
|
|
|
+ rels_tree = etree.fromstring(rels_xml)
|
|
|
+ next_rid = max(
|
|
|
+ (int(rel.get('Id', 'rId0')[3:]) for rel in rels_tree if rel.get('Id', '').startswith('rId')),
|
|
|
+ default=100
|
|
|
+ ) + 1
|
|
|
+ for old_rid, target in embed_target_map.items():
|
|
|
+ rel = etree.SubElement(rels_tree, f'{{{ns_rel}}}Relationship')
|
|
|
+ rel.set('Id', f'rId{next_rid}')
|
|
|
+ rel.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image')
|
|
|
+ rel.set('Target', target)
|
|
|
+ # 更新 document.xml 中的引用
|
|
|
+ for blip in tree.findall(f'.//{{{ns_a}}}blip'):
|
|
|
+ if blip.get(f'{{{ns_r_embed}}}embed') == old_rid:
|
|
|
+ blip.set(f'{{{ns_r_embed}}}embed', f'rId{next_rid}')
|
|
|
+ next_rid += 1
|
|
|
+ out_zip.writestr('word/_rels/document.xml.rels',
|
|
|
+ etree.tostring(rels_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
|
|
|
+
|
|
|
+ # 更新 document.xml
|
|
|
+ out_zip.writestr('word/document.xml',
|
|
|
+ etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True))
|
|
|
+
|
|
|
+ # 更新 Content_Types
|
|
|
+ if ct_xml:
|
|
|
+ ct_tree = etree.fromstring(ct_xml)
|
|
|
+ ct_ns = 'http://schemas.openxmlformats.org/package/2006/content-types'
|
|
|
+ for target in img_data_map:
|
|
|
+ ext = target.rsplit('.', 1)[-1].lower()
|
|
|
+ mime_map = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
|
|
+ 'gif': 'image/gif', 'bmp': 'image/bmp', 'webp': 'image/webp'}
|
|
|
+ mime = mime_map.get(ext, 'image/png')
|
|
|
+ exists = any(ov.get('PartName') == f'/word/{target}' for ov in ct_tree.findall(f'{{{ct_ns}}}Override'))
|
|
|
+ if not exists:
|
|
|
+ ov = etree.SubElement(ct_tree, f'{{{ct_ns}}}Override')
|
|
|
+ ov.set('PartName', f'/word/{target}')
|
|
|
+ ov.set('ContentType', mime)
|
|
|
+ out_zip.writestr('[Content_Types].xml',
|
|
|
+ etree.tostring(ct_tree, xml_declaration=True, encoding='UTF-8', standalone=True))
|
|
|
+
|
|
|
+ shutil.move(str(tmp_path), str(output_path))
|