Jelajahi Sumber

fix: preserve images/formulas in export, strip question numbers on import, add Word auto-numbering

nonvioce 3 bulan lalu
induk
melakukan
559bdc6380
2 mengubah file dengan 265 tambahan dan 78 penghapusan
  1. 218 78
      tiku-server/app/services/docx_builder.py
  2. 47 0
      tiku-server/app/services/docx_parser.py

+ 218 - 78
tiku-server/app/services/docx_builder.py

@@ -1,8 +1,6 @@
-"""docx 合并 + 导出排版模块"""
+"""docx 合并 + 导出排版模块 — 使用 deepcopy 保留图片和公式"""
 
 
-import re
-import shutil
-import tempfile
+from copy import deepcopy
 from datetime import datetime
 from datetime import datetime
 from pathlib import Path
 from pathlib import Path
 
 
@@ -28,26 +26,23 @@ def build_export_docx(
     title: str = "试卷",
     title: str = "试卷",
     include_solution: bool = True,
     include_solution: bool = True,
 ) -> Path:
 ) -> Path:
-    """从题目 ID 列表合并生成导出 docx
-
-    Args:
-        problem_ids: 题目 ID 列表(已按题型分组、按难度排序)
-        problems_dir: 单题 docx 存储目录
-        title: 试卷标题
-        include_solution: True = 教师版, False = 学生版
-
-    Returns: 生成的 docx 文件路径
-    """
+    """从题目 ID 列表合并生成导出 docx(保留图片和公式)"""
     problems_dir = Path(problems_dir)
     problems_dir = Path(problems_dir)
     doc = Document()
     doc = Document()
 
 
     # 设置默认样式
     # 设置默认样式
     style = doc.styles["Normal"]
     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'), '宋体')
     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 = doc.add_paragraph()
     title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
     title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
@@ -63,7 +58,10 @@ def build_export_docx(
     sub_run = sub_para.add_run(subtitle)
     sub_run = sub_para.add_run(subtitle)
     sub_run.font.size = Pt(10.5)
     sub_run.font.size = Pt(10.5)
 
 
-    doc.add_paragraph()  # 空行
+    doc.add_paragraph()
+
+    # 设置 Word 自动编号
+    _setup_auto_numbering(doc)
 
 
     # 从数据库获取题目并按题型分组
     # 从数据库获取题目并按题型分组
     from app.database import get_connection
     from app.database import get_connection
@@ -75,7 +73,6 @@ def build_export_docx(
             if row:
             if row:
                 problems.append(dict(row))
                 problems.append(dict(row))
 
 
-        # 按题型分组
         groups: dict[str, list] = {}
         groups: dict[str, list] = {}
         for p in problems:
         for p in problems:
             qt = p.get("question_type", "")
             qt = p.get("question_type", "")
@@ -83,9 +80,7 @@ def build_export_docx(
                 groups[qt] = []
                 groups[qt] = []
             groups[qt].append(p)
             groups[qt].append(p)
 
 
-        # 按题型排序
         sorted_groups = [(qt, groups[qt]) for qt in TYPE_ORDER if qt in groups]
         sorted_groups = [(qt, groups[qt]) for qt in TYPE_ORDER if qt in groups]
-        # 加上未知题型
         for qt, items in groups.items():
         for qt, items in groups.items():
             if qt not in TYPE_ORDER:
             if qt not in TYPE_ORDER:
                 sorted_groups.append((qt, items))
                 sorted_groups.append((qt, items))
@@ -107,17 +102,13 @@ def build_export_docx(
                 if not docx_path.exists():
                 if not docx_path.exists():
                     continue
                     continue
 
 
-                # 复制该题的内容
-                _copy_question(doc, docx_path, question_num, include_solution)
+                _copy_question_deep(doc, docx_path, question_num, include_solution)
                 question_num += 1
                 question_num += 1
 
 
     finally:
     finally:
         conn.close()
         conn.close()
 
 
-    # 添加 Word 自动编号
-    _apply_auto_numbering(doc)
-
-    # 保存
+    # 保存临时文件 → 复制图片 → 最终保存
     papers_dir = problems_dir.parent / "papers"
     papers_dir = problems_dir.parent / "papers"
     papers_dir.mkdir(parents=True, exist_ok=True)
     papers_dir.mkdir(parents=True, exist_ok=True)
     ts = datetime.now().strftime("%Y%m%d_%H%M%S")
     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"
     output_path = papers_dir / f"{safe_title}_{ts}_{suffix}.docx"
     doc.save(str(output_path))
     doc.save(str(output_path))
 
 
+    # 从源 docx 复制图片到导出 docx
+    _copy_images_for_export(output_path, problem_ids, problems_dir)
+
     return output_path
     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
     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:
         if not text:
-            # 复制空段落(保留格式间距)
-            p = doc.add_paragraph()
+            dst_body.append(deepcopy(para_elem))
             continue
             continue
 
 
-        # 检测答案区
         if text.startswith("【答案】"):
         if text.startswith("【答案】"):
             in_answer = True
             in_answer = True
             if not include_solution:
             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:
         if in_answer and not include_solution:
             break
             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 = OxmlElement('w:ilvl')
     ilvl.set(qn('w:val'), '0')
     ilvl.set(qn('w:val'), '0')
     numPr.append(ilvl)
     numPr.append(ilvl)
 
 
     numId = OxmlElement('w:numId')
     numId = OxmlElement('w:numId')
-    numId.set(qn('w:val'), str(num_id))
+    numId.set(qn('w:val'), '1')
     numPr.append(numId)
     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))

+ 47 - 0
tiku-server/app/services/docx_parser.py

@@ -186,6 +186,11 @@ def create_single_docx(
     for i in range(para_start, answer_start):
     for i in range(para_start, answer_start):
         _copy_paragraph(src_body, dst_doc.element.body, i)
         _copy_paragraph(src_body, dst_doc.element.body, i)
 
 
+    # ── 去掉第一个段落的题号(如 "1.")──
+    first_para = dst_doc.element.body.findall(qn('w:p'))[0] if len(dst_doc.element.body.findall(qn('w:p'))) > 0 else None
+    if first_para is not None:
+        _strip_number_from_para(first_para)
+
     # ── 复制答案区(跳过【难度】【知识点】段)──
     # ── 复制答案区(跳过【难度】【知识点】段)──
     for i in range(answer_start, para_end + 1):
     for i in range(answer_start, para_end + 1):
         text = src_paras[i].text.strip()
         text = src_paras[i].text.strip()
@@ -255,6 +260,48 @@ def _copy_paragraph(src_body, dst_body, para_index: int):
     dst_body.append(deepcopy(src_p))
     dst_body.append(deepcopy(src_p))
 
 
 
 
+def _strip_number_from_para(para_element):
+    """去掉段落开头的题号,如 '1.' '1.' ' 1.'
+    处理数字和标点可能在不同 run 中的情况
+    """
+    import re
+    all_runs = para_element.findall(qn('w:r'))
+
+    # 收集前几个 run 中的所有 text 元素
+    text_slots = []  # [(run_idx, t_element)]
+    for i, run in enumerate(all_runs[:4]):  # 只看前4个 run
+        for t_elem in run.findall(qn('w:t')):
+            text_slots.append((i, t_elem))
+        if len(text_slots) >= 4:
+            break
+
+    if not text_slots:
+        return
+
+    # 拼接文本,判断是否以题号开头
+    combined = ''.join(t_elem.text or '' for _, t_elem in text_slots)
+    m = re.match(r'^\s*(\d+)([..])', combined)
+    if not m:
+        return
+
+    digits = m.group(1)
+    dot = m.group(2)
+    chars_to_remove = len(digits) + len(dot)
+
+    # 从第一个 text slot 开始,逐个删字符
+    for _, t_elem in text_slots:
+        text = t_elem.text or ''
+        if chars_to_remove <= 0:
+            break
+        if len(text) <= chars_to_remove:
+            chars_to_remove -= len(text)
+            t_elem.text = ''
+        else:
+            t_elem.text = text[chars_to_remove:]
+            t_elem.set(qn('xml:space'), 'preserve')
+            chars_to_remove = 0
+
+
 def _copy_images_to_docx(dst_path: Path, src_path: Path):
 def _copy_images_to_docx(dst_path: Path, src_path: Path):
     """将原始 docx 中被引用的图片复制到新 docx 中"""
     """将原始 docx 中被引用的图片复制到新 docx 中"""
     from lxml import etree
     from lxml import etree