| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577 |
- """docx 拆分 + 元数据提取模块
- 导入整卷 docx → 按题拆分 → 生成每题独立 docx
- 关键:通过 deep-copy 段落 XML 保留图片、公式、格式
- """
- import hashlib
- import json
- import re
- import io
- import zipfile
- from copy import deepcopy
- from pathlib import Path
- from docx import Document
- from docx.oxml.ns import qn
- from docx.shared import Pt
- # 题型标题关键词映射
- TYPE_KEYWORDS = {
- "单选题": "单选题",
- "多选题": "多选题",
- "填空题": "填空题",
- "解答题": "解答题",
- }
- def detect_type_header(text: str) -> str | None:
- for key, value in TYPE_KEYWORDS.items():
- if key in text:
- return value
- return None
- def is_question_number(text: str) -> bool:
- text = text.strip()
- return bool(re.match(r'\d+[..]', text))
- def strip_question_number(text: str) -> str:
- return re.sub(r'^\d+[..]\s*', '', text.strip())
- def extract_metadata_from_text(text: str) -> dict:
- """从答案区文本中提取元数据"""
- result = {
- "answer": "", "difficulty": None, "knowledge_names": [],
- "analysis": "", "detail": "", "highlight": "",
- }
- sections = re.split(r'【(答案|难度|知识点|分析|详解|点睛)】', text)
- start = 0
- if sections and not sections[0].strip():
- start = 1
- for i in range(start, len(sections) - 1, 2):
- tag = sections[i].strip()
- content = sections[i + 1].strip()
- if tag == "答案":
- result["answer"] = content
- elif tag == "难度":
- try:
- result["difficulty"] = float(content.split()[0])
- except (ValueError, IndexError):
- pass
- elif tag == "知识点":
- names = re.split(r'[,,、\n]', content)
- result["knowledge_names"] = [n.strip() for n in names if n.strip()]
- elif tag == "分析":
- result["analysis"] = content
- elif tag == "详解":
- result["detail"] = content
- elif tag == "点睛":
- result["highlight"] = content
- return result
- def _get_answer_boundary(paras, start_idx: int, end_idx: int) -> int:
- """在给定段落范围内找到【答案】分隔线的位置(绝对索引)"""
- for i in range(start_idx, end_idx + 1):
- text = paras[i].text.strip()
- if text.startswith("【答案】"):
- return i
- return end_idx + 1 # 没找到答案标记,全部算题干
- def split_docx_to_questions(docx_path: str | Path) -> list[dict]:
- """将整卷 docx 拆分为题目段落范围列表
- Returns list of dicts:
- {question_type, question_num, para_start, para_end, answer_start, metadata}
- """
- docx_path = Path(docx_path)
- doc = Document(str(docx_path))
- paras = doc.paragraphs
- questions = []
- current_type = ""
- current_start = None
- question_num = 0
- for i, para in enumerate(paras):
- text = para.text.strip()
- if not text:
- continue
- # 检测题型标题 → 结束当前题(如果有),切换题型
- detected = detect_type_header(text)
- if detected:
- # 保存当前正在处理的题目(如果有)
- if current_start is not None:
- answer_start = _get_answer_boundary(paras, current_start, i - 1)
- answer_text = "\n".join(
- paras[j].text.strip()
- for j in range(answer_start, i)
- )
- questions.append({
- "question_type": current_type,
- "question_num": question_num,
- "para_start": current_start,
- "para_end": i - 1,
- "answer_start": answer_start,
- "metadata": extract_metadata_from_text(answer_text),
- })
- question_num += 1
- current_start = None
- current_type = detected
- continue
- # 跳过非题号的抬头信息(标题、校徽等)
- if not current_type:
- continue
- # 检测题号:新题开始
- if is_question_number(text):
- # 保存上一题
- if current_start is not None:
- answer_start = _get_answer_boundary(paras, current_start, i - 1)
- answer_text = "\n".join(
- paras[j].text.strip()
- for j in range(answer_start, i)
- )
- questions.append({
- "question_type": current_type,
- "question_num": question_num,
- "para_start": current_start,
- "para_end": i - 1,
- "answer_start": answer_start,
- "metadata": extract_metadata_from_text(answer_text),
- })
- question_num += 1
- current_start = i
- # 保存最后一题
- if current_start is not None:
- answer_start = _get_answer_boundary(paras, current_start, len(paras) - 1)
- answer_text = "\n".join(
- paras[j].text.strip()
- for j in range(answer_start, len(paras))
- )
- questions.append({
- "question_type": current_type,
- "question_num": question_num,
- "para_start": current_start,
- "para_end": len(paras) - 1,
- "answer_start": answer_start,
- "metadata": extract_metadata_from_text(answer_text),
- })
- return questions
- def create_single_docx(
- original_docx_path: str | Path,
- question_data: dict,
- import_type: str,
- source_name: str,
- knowledge_map: dict,
- problems_dir: str | Path = "data/problems",
- ) -> tuple[Path, str, dict]:
- """从原始 docx 的段落范围创建单题 docx(保留图片和格式)
- 关键:deep-copy 原始段落的 XML 元素,完全保留图片、公式、格式
- """
- original_docx_path = Path(original_docx_path)
- problems_dir = Path(problems_dir)
- problems_dir.mkdir(parents=True, exist_ok=True)
- src_doc = Document(str(original_docx_path))
- src_paras = src_doc.paragraphs
- src_body = src_doc.element.body
- para_start = question_data["para_start"]
- para_end = question_data["para_end"]
- answer_start = question_data["answer_start"]
- meta = question_data["metadata"]
- # ── 创建新文档 ──
- dst_doc = Document()
- # 复制默认样式
- style = dst_doc.styles["Normal"]
- style.font.name = "宋体"
- style.font.size = Pt(10.5)
- # ── 复制题干段落(para_start → answer_start - 1)──
- for i in range(para_start, answer_start):
- _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)
- # ── 统一字体:宋体五号 (10.5pt) ──
- _normalize_font(dst_doc)
- # ── 复制答案区(跳过【难度】【知识点】段)──
- for i in range(answer_start, para_end + 1):
- text = src_paras[i].text.strip()
- if text.startswith("【难度】") or text.startswith("【知识点】"):
- continue
- _copy_paragraph(src_body, dst_doc.element.body, i)
- # ── 处理知识点编码 ──
- knowledge_codes = []
- knowledge_names = meta.get("knowledge_names", [])
- for name in knowledge_names:
- from app.services.knowledge import find_code_by_name_fuzzy
- code = find_code_by_name_fuzzy(name, knowledge_map)
- if code:
- knowledge_codes.append(code)
- # ── 写入元数据到自定义属性 ──
- _set_custom_props(dst_doc, {
- "question_type": question_data["question_type"],
- "difficulty": meta["difficulty"] or 0.5,
- "knowledge_codes": json.dumps(knowledge_codes, ensure_ascii=False),
- "import_type": import_type,
- "comment": "",
- "source": source_name,
- })
- # ── 计算 ID ──
- # 先把新文档保存为临时文件来计算 ID
- tmp_bytes = io.BytesIO()
- dst_doc.save(tmp_bytes)
- checksum = hashlib.sha256(tmp_bytes.getvalue()).hexdigest()
- problem_id = checksum[:8]
- # ── 处理图片:从原始 docx 复制到新 docx ──
- docx_path = problems_dir / f"{problem_id}.docx"
- dst_doc.save(str(docx_path))
- # ── 嵌入图片(从原始 docx 的 ZIP 中提取,写入新 docx 的 ZIP)──
- _copy_images_to_docx(docx_path, original_docx_path)
- # ── 章节 ──
- chapters = _get_chapters(knowledge_codes, knowledge_map)
- info = {
- "id": problem_id,
- "file_path": str(docx_path),
- "checksum": checksum,
- "chapters": chapters,
- "difficulty": meta["difficulty"] or 0.5,
- "question_type": question_data["question_type"],
- "import_type": import_type,
- "knowledge_codes": knowledge_codes,
- "comment": "",
- "source": source_name,
- }
- return docx_path, problem_id, info
- def _normalize_font(doc: Document):
- """统一所有段落的字体为宋体五号 (10.5pt)"""
- style = doc.styles["Normal"]
- style.font.name = "宋体"
- style.font.size = Pt(10.5)
- # Also set East Asian font
- rPr = style.element.get_or_add_rPr()
- rFonts = rPr.find(qn('w:rFonts'))
- if rFonts is None:
- rFonts = OxmlElement('w:rFonts')
- rPr.insert(0, rFonts)
- rFonts.set(qn('w:eastAsia'), '宋体')
- # Normalize all runs in the document
- for para in doc.paragraphs:
- for run in para.runs:
- run.font.name = "宋体"
- run_rPr = run._element.get_or_add_rPr()
- run_rFonts = run_rPr.find(qn('w:rFonts'))
- if run_rFonts is None:
- run_rFonts = OxmlElement('w:rFonts')
- run_rPr.insert(0, run_rFonts)
- run_rFonts.set(qn('w:eastAsia'), '宋体')
- if run.font.size is None or run.font.size > Pt(20):
- run.font.size = Pt(10.5)
- def _copy_paragraph(src_body, dst_body, para_index: int):
- """Deep-copy 一个段落从 src_body 到 dst_body"""
- paragraphs = src_body.findall(qn('w:p'))
- if para_index >= len(paragraphs):
- return
- src_p = paragraphs[para_index]
- # 找到插入位置(dst_body 的最后一个元素之后)
- 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):
- """将原始 docx 中被引用的图片复制到新 docx 中"""
- from lxml import etree
- import shutil
- # 读取目标 docx 中引用的所有图片 rId
- with zipfile.ZipFile(str(dst_path), 'r') as dst_zip:
- doc_xml = dst_zip.read('word/document.xml')
- try:
- dst_rels_xml = dst_zip.read('word/_rels/document.xml.rels')
- except KeyError:
- dst_rels_xml = None
- # 从目标 document.xml 中提取所有 a:blip 的 r:embed
- ns_a = 'http://schemas.openxmlformats.org/drawingml/2006/main'
- ns_r = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
- ns_rel = 'http://schemas.openxmlformats.org/package/2006/relationships'
- tree = etree.fromstring(doc_xml)
- needed_embeds = set()
- for blip in tree.findall(f'.//{{{ns_a}}}blip'):
- embed = blip.get(f'{{{ns_r}}}embed')
- if embed:
- needed_embeds.add(embed)
- if not needed_embeds:
- return
- # 从原始 docx 中获取图片数据
- with zipfile.ZipFile(str(src_path), 'r') as src_zip:
- # 读取原始关系文件
- src_rels_xml = src_zip.read('word/_rels/document.xml.rels')
- src_rels_tree = etree.fromstring(src_rels_xml)
- # 找到每个 embed 对应的 target
- embed_targets = {}
- for rel in src_rels_tree:
- r_id = rel.get('Id')
- target = rel.get('Target', '')
- if r_id in needed_embeds:
- # target like "media/image1.png"
- embed_targets[r_id] = target
- # 读写目标 docx,添加图片和关系
- tmp_path = dst_path.with_suffix('.tmp.docx')
- with zipfile.ZipFile(str(dst_path), 'r') as dst_zip:
- with zipfile.ZipFile(str(tmp_path), 'w', zipfile.ZIP_DEFLATED) as out_zip:
- # 复制所有现有条目(跳过将被更新的文件)
- skip = {'word/document.xml', 'word/_rels/document.xml.rels', '[Content_Types].xml'}
- for item in dst_zip.infolist():
- if item.filename in skip:
- continue
- data = dst_zip.read(item.filename)
- out_zip.writestr(item, data)
- # 添加图片文件并记录扩展名
- next_rid = _get_max_rid(dst_zip) + 1
- new_rels = []
- content_types_additions = []
- for embed, target in embed_targets.items():
- try:
- img_data = src_zip.read(f'word/{target}')
- out_zip.writestr(f'word/{target}', img_data)
- new_rels.append((f'rId{next_rid}', target, embed))
- next_rid += 1
- # 记录 content type
- ext = target.rsplit('.', 1)[-1].lower()
- mime = {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
- 'gif': 'image/gif', 'bmp': 'image/bmp'}.get(ext, 'image/png')
- content_types_additions.append((f'/word/{target}', mime))
- except KeyError:
- pass
- # 更新关系文件
- if new_rels and dst_rels_xml is not None:
- rels_tree = etree.fromstring(dst_rels_xml)
- for new_rid, target, old_rid in new_rels:
- rel_elem = etree.SubElement(rels_tree, f'{{{ns_rel}}}Relationship')
- rel_elem.set('Id', new_rid)
- rel_elem.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image')
- rel_elem.set('Target', target)
- for blip in tree.findall(f'.//{{{ns_a}}}blip'):
- if blip.get(f'{{{ns_r}}}embed') == old_rid:
- blip.set(f'{{{ns_r}}}embed', new_rid)
- rels_out = etree.tostring(rels_tree, xml_declaration=True, encoding='UTF-8', standalone=True)
- doc_out = etree.tostring(tree, xml_declaration=True, encoding='UTF-8', standalone=True)
- else:
- rels_out = dst_rels_xml if dst_rels_xml is not None else b''
- doc_out = doc_xml
- out_zip.writestr('word/_rels/document.xml.rels', rels_out)
- out_zip.writestr('word/document.xml', doc_out)
- # 更新 [Content_Types].xml
- ct_xml = dst_zip.read('[Content_Types].xml')
- ct_tree = etree.fromstring(ct_xml)
- ct_ns = 'http://schemas.openxmlformats.org/package/2006/content-types'
- for partname, mime in content_types_additions:
- # Check if override already exists
- exists = False
- for ov in ct_tree.findall(f'{{{ct_ns}}}Override'):
- if ov.get('PartName') == partname:
- exists = True
- break
- if not exists:
- ov = etree.SubElement(ct_tree, f'{{{ct_ns}}}Override')
- ov.set('PartName', partname)
- 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(dst_path))
- def _get_max_rid(zf: zipfile.ZipFile) -> int:
- """获取 ZIP 中关系文件的最大 rId 编号"""
- try:
- rels_xml = zf.read('word/_rels/document.xml.rels')
- tree = etree.fromstring(rels_xml)
- max_id = 0
- for rel in tree:
- r_id = rel.get('Id', '')
- if r_id.startswith('rId'):
- try:
- num = int(r_id[3:])
- max_id = max(max_id, num)
- except ValueError:
- pass
- return max_id
- except Exception:
- return 100 # fallback
- # Import for etree
- from lxml import etree
- def _set_custom_props(doc: Document, props: dict):
- """设置 docx 自定义属性"""
- for key, value in props.items():
- try:
- doc.custom_document_properties[key] = value
- except Exception:
- pass
- def _get_chapters(knowledge_codes: list[str], knowledge_map: dict) -> list[str]:
- """从知识点编码反推章节名"""
- from app.services.knowledge import get_chapter_from_code
- chapters = set()
- for code in knowledge_codes:
- ch = get_chapter_from_code(code, knowledge_map)
- if ch:
- chapters.add(ch)
- return sorted(chapters)
- def read_custom_props(docx_path: str | Path) -> dict:
- """读取 docx 自定义属性"""
- doc = Document(str(docx_path))
- props = {}
- try:
- for name in doc.custom_document_properties:
- props[name] = doc.custom_document_properties[name]
- except Exception:
- pass
- if not props:
- try:
- from lxml import etree
- custom_xml = doc.part.package.parts
- for part in custom_xml:
- if part.partname.endswith('/custom.xml'):
- root = etree.fromstring(part.blob)
- for prop in root:
- tag = prop.tag.split('}')[-1] if '}' in prop.tag else prop.tag
- value = prop.text or ""
- try:
- if value:
- props[tag] = float(value)
- except ValueError:
- props[tag] = value
- except Exception:
- pass
- return props
- def extract_plain_text(docx_path: str | Path) -> str:
- """提取 docx 中所有段落的纯文本(用于 FTS 索引)"""
- doc = Document(str(docx_path))
- texts = []
- for para in doc.paragraphs:
- text = para.text.strip()
- if text:
- texts.append(text)
- return "\n".join(texts)
- def compute_id_from_docx(file_path: str | Path) -> str:
- """从 docx 计算题目 ID"""
- import zipfile as zf
- try:
- with zf.ZipFile(str(file_path), 'r') as z:
- if 'word/document.xml' in z.namelist():
- return hashlib.sha256(z.read('word/document.xml')).hexdigest()[:8]
- except Exception:
- pass
- with open(file_path, 'rb') as f:
- return hashlib.sha256(f.read()).hexdigest()[:8]
- def compute_checksum(file_path: str | Path) -> str:
- try:
- with zipfile.ZipFile(str(file_path), 'r') as z:
- if 'word/document.xml' in z.namelist():
- return hashlib.sha256(z.read('word/document.xml')).hexdigest()
- except Exception:
- pass
- with open(file_path, 'rb') as f:
- return hashlib.sha256(f.read()).hexdigest()
|