| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362 |
- """docx 合并 + 导出排版模块 — 使用 deepcopy 保留图片和公式"""
- from copy import deepcopy
- from datetime import datetime
- from pathlib import Path
- from docx import Document
- from docx.enum.text import WD_ALIGN_PARAGRAPH
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
- from docx.shared import Pt, Cm
- TYPE_ORDER = ["单选题", "多选题", "填空题", "解答题"]
- TYPE_HEADERS = {
- "单选题": "一、单选题",
- "多选题": "二、多选题",
- "填空题": "三、填空题",
- "解答题": "四、解答题",
- }
- def build_export_docx(
- problem_ids: list[str],
- problems_dir: str | Path,
- title: str = "试卷",
- include_solution: bool = True,
- ) -> Path:
- """从题目 ID 列表合并生成导出 docx(保留图片和公式)"""
- problems_dir = Path(problems_dir)
- doc = Document()
- # 设置默认样式
- style = doc.styles["Normal"]
- 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
- title_run = title_para.add_run(title)
- title_run.bold = True
- title_run.font.size = Pt(14)
- title_run.font.name = "宋体"
- title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
- subtitle = "教师版" if include_solution else "学生版"
- sub_para = doc.add_paragraph()
- sub_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
- sub_run = sub_para.add_run(subtitle)
- sub_run.font.size = Pt(10.5)
- doc.add_paragraph()
- # 设置 Word 自动编号
- _setup_auto_numbering(doc)
- # 从数据库获取题目并按题型分组
- from app.database import get_connection
- conn = get_connection(problems_dir.parent / "bank.db")
- try:
- problems = []
- for pid in problem_ids:
- row = conn.execute("SELECT * FROM problems WHERE id = ?", (pid,)).fetchone()
- if row:
- problems.append(dict(row))
- groups: dict[str, list] = {}
- for p in problems:
- qt = p.get("question_type", "")
- if qt not in groups:
- 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))
- question_num = 1
- for qt, items in sorted_groups:
- # 题型标题
- header_text = TYPE_HEADERS.get(qt, qt)
- header_para = doc.add_paragraph()
- header_run = header_para.add_run(header_text)
- header_run.bold = True
- header_run.font.size = Pt(12)
- header_run.font.name = "宋体"
- header_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体')
- for item in items:
- pid = item["id"]
- docx_path = problems_dir / f"{pid}.docx"
- if not docx_path.exists():
- continue
- _copy_question_deep(doc, docx_path, question_num, include_solution)
- question_num += 1
- finally:
- conn.close()
- # 保存临时文件 → 复制图片 → 最终保存
- papers_dir = problems_dir.parent / "papers"
- papers_dir.mkdir(parents=True, exist_ok=True)
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
- suffix = "教师版" if include_solution else "学生版"
- safe_title = title.replace("/", "_").replace(" ", "_")
- 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_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()
- if not text:
- dst_body.append(deepcopy(para_elem))
- continue
- if text.startswith("【答案】"):
- in_answer = True
- if not include_solution:
- break
- if in_answer and not include_solution:
- break
- copied = deepcopy(para_elem)
- # 给第一个非空段落加自动编号
- if first_para and text:
- _apply_numbering(copied)
- first_para = False
- dst_body.append(copied)
- 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'), '99')
- numPr.append(numId)
- pPr.append(numPr)
- def _setup_auto_numbering(doc: Document):
- """在文档中设置 Word 自动编号,格式: 1. 2. 3."""
- numbering_part = doc.part.numbering_part
- numbering_xml = numbering_part._element
- # 用不会被 python-docx 默认编号冲突的唯一 ID
- OUR_ABSTRACT_NUM_ID = '99'
- OUR_NUM_ID = '99'
- # 抽象编号
- abstract_num = OxmlElement('w:abstractNum')
- abstract_num.set(qn('w:abstractNumId'), OUR_ABSTRACT_NUM_ID)
- nsid = OxmlElement('w:nsid')
- nsid.set(qn('w:val'), '7A1B2C3D')
- abstract_num.append(nsid)
- mlt = OxmlElement('w:multiLevelType')
- mlt.set(qn('w:val'), 'singleLevel')
- abstract_num.append(mlt)
- tmpl = OxmlElement('w:tmpl')
- tmpl.set(qn('w:val'), '0A1B2C3D')
- abstract_num.append(tmpl)
- 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')
- tabs = OxmlElement('w:tabs')
- tab = OxmlElement('w:tab')
- tab.set(qn('w:val'), 'left')
- tab.set(qn('w:pos'), '720')
- tabs.append(tab)
- p_pr.append(tabs)
- ind = OxmlElement('w:ind')
- ind.set(qn('w:left'), '720')
- ind.set(qn('w:hanging'), '360')
- p_pr.append(ind)
- lvl.append(p_pr)
- abstract_num.append(lvl)
- numbering_xml.append(abstract_num)
- # 编号实例
- num = OxmlElement('w:num')
- num.set(qn('w:numId'), OUR_NUM_ID)
- num_ref = OxmlElement('w:abstractNumId')
- num_ref.set(qn('w:val'), OUR_ABSTRACT_NUM_ID)
- 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))
|