""" 模板 DOCX 解析器 策略:参考标书 diff + 模板自身结构标记 1. 与参考标书逐段模糊匹配 → 区分骨架段落(模板与参考一致的)和可变段落(需填充的) 2. 在骨架段落中查找"第X章"、分区标题等结构标记 → 划分 section 3. 扫描占位符和表格 用法: ts = parse_template( template_path="src/templates/申勤投标模板.docx", reference_bid_path="test_data/.../投标文件.docx", ) """ from __future__ import annotations import logging import os import re from difflib import SequenceMatcher from typing import Dict, List, Set, Tuple from docx import Document as DocxDocument from models import ( TemplateStructure, TemplateSection, TemplatePlaceholder, TemplateTable, ) logger = logging.getLogger(__name__) DEFAULT_TEMPLATE = "templates/申勤投标模板.docx" _PLACEHOLDER_PATTERN = re.compile(r"%%(.+?)%%") SKELETON_SIMILARITY = 0.75 # 模板自身结构标记 _PART_MARKERS = {"商务部分", "技术部分", "附件", "附录"} _CHAPTER_PATTERN = re.compile(r"^第[一二三四五六七八九十\d]+章[::]?\s*(.*)") _HEADING_STYLE_PATTERN = re.compile(r"^(?:heading|标题)\s*([1-4])$", re.I) def heading_level_from_style(value: str) -> int: """读取 Word Heading 1-4 的层级语义,不读取或复制其视觉格式。""" match = _HEADING_STYLE_PATTERN.match((value or "").strip()) return int(match.group(1)) if match else 0 def paragraph_heading_level(para) -> int: """返回 python-docx 段落的显式标题级别;未标注返回 0。""" values = [] try: values.extend([para.style.name or "", para.style.style_id or ""]) except Exception: pass for value in values: level = heading_level_from_style(value) if level: return level return 0 def parse_template( template_path: str = "", reference_bid_path: str = "", reference_texts=None, ) -> TemplateStructure: """解析模板 DOCX Args: template_path: 模板路径(空则用默认) reference_bid_path: 参考投标文件(用于 diff 识别骨架) reference_texts: 参考文件正文段落文本(可选,传入则不再重复打开参考文件) """ if not template_path: template_path = _resolve_default_template() if not os.path.exists(template_path): raise FileNotFoundError(f"模板文件不存在: {template_path}") doc = DocxDocument(template_path) all_paras = list(doc.paragraphs) # ---- 1. Diff 识别骨架/可变 ---- skeleton: Set[int] = set() if reference_bid_path and os.path.exists(reference_bid_path): if reference_texts is None: from doc_reader.reader import read_docx_paragraph_texts_et reference_texts = read_docx_paragraph_texts_et(reference_bid_path) skeleton, _ = _diff_skeleton(doc, reference_texts) logger.info(f"参考标书 diff: {len(skeleton)} 骨架段落") else: logger.info("无参考标书,所有段落视为可变") # ---- 2. 在骨架段落中找章节边界 ---- boundaries = _find_boundaries_in_skeleton(all_paras, skeleton) logger.info(f"章节边界: {len(boundaries)} 个") # ---- 3. 扫描占位符 ---- placeholders = _scan_placeholders(doc) # ---- 4. 扫描表格 ---- tables, table_para_idx = _scan_tables_fast(doc) # ---- 5. 按边界划分 section ---- sections = _build_sections( all_paras, boundaries, placeholders, tables, table_para_idx, skeleton, ) return TemplateStructure( file_path=os.path.abspath(template_path), sections=sections, total_paragraphs=len(all_paras), total_tables=len(doc.tables), ) def _resolve_default_template() -> str: return os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), DEFAULT_TEMPLATE, ) # ============================================================ # Diff:识别骨架段落 # ============================================================ def _diff_skeleton( template_doc: DocxDocument, reference_texts: List[str], ) -> Tuple[Set[int], Set[int]]: """逐段模糊匹配,划分骨架和可变段落""" # 构建参考段落索引 ref_index: Dict[str, List[str]] = {} for text in reference_texts: t = _clean(text) if t and len(t) >= 8: key = _norm_prefix(t[:20]) ref_index.setdefault(key, []).append(t) skeleton = set() fillable_paras = set() for i, p in enumerate(template_doc.paragraphs): tt = _clean(p.text) if not tt or len(tt) < 4: continue key = _norm_prefix(tt[:20]) candidates = ref_index.get(key, []) best = 0.0 for rt in candidates: score = SequenceMatcher(None, _norm(tt), _norm(rt)).ratio() if score > best: best = score if best >= SKELETON_SIMILARITY: skeleton.add(i) else: fillable_paras.add(i) return skeleton, fillable_paras def _clean(text: str) -> str: """清洗段落文本""" return re.sub(r'\d{1,3}$', '', text.strip()).strip() def _norm(s: str) -> str: """归一化:数字→#""" return re.sub(r'\d+', '#', s) def _norm_prefix(s: str) -> str: """归一化前缀""" return re.sub(r'\d+', '#', s) # ============================================================ # 章节边界识别(骨架中查找) # ============================================================ def _find_boundaries_in_skeleton( all_paras: list, skeleton: Set[int], ) -> List[Tuple[int, str]]: """在模板段落中查找章节边界 优先从骨架段落中查找(与参考标书一致的标题), 骨架中找不到时才从全部段落中补充。 Returns: [(para_idx, title_text), ...] 按位置排序 """ boundaries: List[Tuple[int, str]] = [] seen_positions: Set[int] = set() # 第一遍:骨架段落(与参考标书一致的章节标题) search_skeleton = sorted(skeleton) if skeleton else [] for i in search_skeleton: if i >= len(all_paras): continue text = all_paras[i].text.strip() if not text: continue if text in _PART_MARKERS: boundaries.append((i, text)) seen_positions.add(i) elif _CHAPTER_PATTERN.match(text) and _is_top_level_chapter(all_paras[i]): # 骨架段落中的"第X章"同样只接受顶级章节格式,避免参考标书中的 # 管理制度内嵌章(如"第二章 安全生产管理制度的建立")成为边界, # 把真实章节(第四章 十二~十六)截断到章节区域之外。 boundaries.append((i, text)) seen_positions.add(i) # 第二遍:全部段落中的"第X章"(补充骨架中缺失的章节) # 只接受"顶级章节"格式的段落;模板内嵌的制度章节(如"第四章 安全生产工作 # 例会制度""第五章 安全设施管理维护制度")是 12pt 非粗体的正文,不能作为 # 章节边界,否则会把真实章节截断(如第四章 十二、创新管理 ~ 十六、服务考核 # 方法和标准 会被切到章节区域之外而丢失)。 for i, p in enumerate(all_paras): if i in seen_positions: continue text = p.text.strip() if not text: continue if text in _PART_MARKERS: boundaries.append((i, text)) seen_positions.add(i) elif _CHAPTER_PATTERN.match(text) and _is_top_level_chapter(p): boundaries.append((i, text)) seen_positions.add(i) boundaries.sort(key=lambda x: x[0]) return boundaries # ============================================================ # 占位符 & 表格 # ============================================================ def _scan_placeholders(doc: DocxDocument) -> List[TemplatePlaceholder]: """扫描 %%...%% 占位符""" result: List[TemplatePlaceholder] = [] for pi, para in enumerate(doc.paragraphs): for ri, run in enumerate(para.runs): for m in _PLACEHOLDER_PATTERN.finditer(run.text): result.append(TemplatePlaceholder( key=m.group(1).strip(), full_text=m.group(0), location="paragraph", paragraph_idx=pi, run_idx=ri, )) for ti, table in enumerate(doc.tables): for row in table.rows: for cell in row.cells: for para in cell.paragraphs: for run in para.runs: for m in _PLACEHOLDER_PATTERN.finditer(run.text): result.append(TemplatePlaceholder( key=m.group(1).strip(), full_text=m.group(0), location="table_cell", paragraph_idx=ti, run_idx=-1, )) return result def _scan_tables_fast( doc: DocxDocument, ) -> Tuple[List[TemplateTable], Dict[int, int]]: """单次 body 遍历:扫描表格 + 缓存段落位置""" # 遍历 body,记录每个表格前最近的段落索引 table_para_idx: Dict[int, int] = {} last_para_idx = -1 para_counter = 0 table_counter = 0 for child in doc.element.body: if child.tag.endswith("}p"): text = "".join( n.text or "" for n in child.iter() if n.tag.endswith("}t") and n.text ) if text.strip(): last_para_idx = para_counter para_counter += 1 elif child.tag.endswith("}tbl"): table_para_idx[table_counter] = last_para_idx table_counter += 1 tables: List[TemplateTable] = [] for ti, table in enumerate(doc.tables): headers = ( [c.text.strip()[:50] for c in table.rows[0].cells] if table.rows else [] ) fillable = any( "%%" in c.text for row in table.rows for c in row.cells ) tables.append(TemplateTable( table_idx=ti, caption="", headers=headers, row_count=len(table.rows), fillable=fillable, fill_strategy="replace_placeholder" if fillable else "", )) return tables, table_para_idx # ============================================================ # Section 构建 # ============================================================ def _build_sections( all_paras: list, boundaries: List[Tuple[int, str]], placeholders: List[TemplatePlaceholder], tables: List[TemplateTable], table_para_idx: Dict[int, int], skeleton: Set[int], ) -> List[TemplateSection]: """按边界划分 section""" sections: List[TemplateSection] = [] for idx in range(len(boundaries)): para_idx, title = boundaries[idx] end_idx = ( boundaries[idx + 1][0] - 1 if idx + 1 < len(boundaries) else len(all_paras) - 1 ) # 占位符 sec_ph = [p for p in placeholders if para_idx <= p.paragraph_idx <= end_idx] # 表格 sec_tb = [ t for t in tables if para_idx <= table_para_idx.get(t.table_idx, -1) <= end_idx ] # 类型 if sec_ph: stype = "placeholder" elif sec_tb: stype = "table_form" elif any(kw in title for kw in ["方案", "管理", "服务", "措施", "预案"]): stype = "prose" else: stype = "static_text" sections.append(TemplateSection( name=title, start_para=para_idx, end_para=end_idx, section_type=stype, placeholders=sec_ph, tables=sec_tb, )) return sections # ============================================================ # 公开接口 # ============================================================ def _read_template_body_texts(file_path: str) -> List[str]: """独立流式解析正文,保留 Document.paragraphs 的索引和文本语义。""" import zipfile import xml.etree.ElementTree as ET w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" texts = [] with zipfile.ZipFile(file_path) as archive, archive.open("word/document.xml") as stream: depth = 0 body_depth = None for event, element in ET.iterparse(stream, events=("start", "end")): if event == "start": depth += 1 if element.tag == w + "body": body_depth = depth continue if body_depth is not None and depth == body_depth + 1: if element.tag == w + "p": parts = [] for child in element: runs = [child] if child.tag == w + "r" else ( child.findall(w + "r") if child.tag == w + "hyperlink" else [] ) for run in runs: for item in run: if item.tag == w + "t": parts.append(item.text or "") elif item.tag in (w + "tab", w + "ptab"): parts.append("\t") elif item.tag == w + "cr" or ( item.tag == w + "br" and item.get(w + "type", "textWrapping") == "textWrapping" ): parts.append("\n") elif item.tag == w + "noBreakHyphen": parts.append("-") texts.append("".join(parts)) element.clear() depth -= 1 return texts def get_template_text_for_chapter( template_structure: TemplateStructure, chapter_title: str, use_llm: bool = True, ) -> str: """从模板中提取指定章节的完整文本。 匹配策略(混合): 1. 确定性打分(_titles_match_score)找出全部候选; 2. 唯一候选或最高分明显领先(≥20 分)→ 直接采用,不调 LLM; 3. 同号章节多候选且分数接近(如模板内嵌"管理制度"与真正的技术章 同名同号)→ 用 LLM 仲裁一次,提高准确率;LLM 失败则回退最高分。 """ candidates: List[Tuple[int, TemplateSection]] = [] for section in template_structure.sections: score = _titles_match_score(section.name, chapter_title) if score > 0: candidates.append((score, section)) if not candidates: return "" candidates.sort(key=lambda x: x[0], reverse=True) best_section = candidates[0][1] if ( use_llm and len(candidates) > 1 and candidates[0][0] - candidates[1][0] < 20 ): llm_section = _llm_disambiguate_section( template_structure, chapter_title, candidates ) if llm_section is not None: best_section = llm_section paragraphs = _read_template_body_texts(template_structure.file_path) parts = [] for i in range( best_section.start_para, min(best_section.end_para + 1, len(paragraphs)), ): text = paragraphs[i].strip() if text: parts.append(text) return "\n".join(parts) _SECTION_DISAMBIGUATE_PROMPT = """你是投标文件结构匹配专家。给定一个章节标题和若干个候选模板区域, 请选出与该章节标题最匹配的候选区域。 输出严格 JSON:{"index": 候选序号} 规则: 1. 候选区域按序号列出,序号从 1 开始; 2. 若某个候选区域名称与章节标题一致或高度吻合(含章节编号一致、标题文字 相同/包含),选它; 3. 若所有候选都明显不匹配,输出 {"index": null}; 4. 只输出 JSON。""" def _llm_disambiguate_section( template_structure: TemplateStructure, chapter_title: str, candidates: List[Tuple[int, TemplateSection]], ) -> Optional[TemplateSection]: """分数接近的同号候选章节用 LLM 仲裁(如模板内嵌管理制度与真正技术章)""" try: from llm_client import LLMClient lines = [] for idx, (score, section) in enumerate(candidates, start=1): lines.append( f"{idx}. {section.name} " f"(段落 {section.start_para}~{section.end_para}," f"确定性得分 {score})" ) llm = LLMClient() result = llm.extract_json( system_prompt=_SECTION_DISAMBIGUATE_PROMPT, user_prompt=( f"章节标题:{chapter_title}\n\n候选模板区域:\n" + "\n".join(lines) ), max_tokens=2048, temperature=0.0, ) if not isinstance(result, dict): return None idx = result.get("index") if idx is None: return None try: idx = int(idx) except (TypeError, ValueError): return None if 1 <= idx <= len(candidates): logger.info( f"LLM 章节匹配仲裁: {chapter_title!r} → " f"{candidates[idx - 1][1].name!r}" ) return candidates[idx - 1][1] except Exception as e: logger.warning(f"LLM 章节匹配仲裁失败(回退确定性最高分): {e}") return None def _titles_match_score(section_name: str, chapter_title: str) -> int: """计算模板 section 与章节标题的匹配分数(取最优,而非第一个命中)。 模板中存在"管理制度"内嵌的同号章节(如"第五章 安全设施管理维护制度"), 与真正的"第五章:各分项服务的实施安排"同号。若用宽松的"第X章"关键词 匹配,会命中错误的制度章节。因此: - 标题含"第X章"时,先要求章节编号一致; - 完整标题互相包含得分最高; - 再按关键词命中数、Jaccard 相似度逐级降分; - 分数 > 0 才算匹配。 """ sn = (section_name or "").strip() ct = (chapter_title or "").strip() if not sn or not ct: return -1 # 提取章节编号(第X章) m_sn = re.match(r"^第\s*([一二三四五六七八九十百\d]+)\s*章", sn) m_ct = re.match(r"^第\s*([一二三四五六七八九十百\d]+)\s*章", ct) if m_ct: if not m_sn or m_sn.group(1) != m_ct.group(1): # 同号章节才可匹配(避免命中管理制度内嵌的同号章) return -1 # 1. 完整标题互相包含 if ct in sn or sn in ct: return 100 words = [w for w in re.split(r"[、,,::\s]+", ct) if len(w) >= 2] hits_3plus = 0 hits_2char = 0 for w in words: if w in sn: if len(w) >= 3: hits_3plus += 1 elif len(w) == 2: hits_2char += 1 if hits_3plus >= 1: return 50 + hits_3plus if hits_2char >= 2: return 30 + hits_2char # Jaccard 相似度兜底 sn_chars = set(sn.replace(":", "").replace(":", "").replace(" ", "")) ct_chars = set(ct.replace(":", "").replace(":", "").replace(" ", "")) if sn_chars and ct_chars: sim = len(sn_chars & ct_chars) / len(sn_chars | ct_chars) if sim >= 0.35: return int(sim * 30) return -1 def get_template_text_for_section( template_structure: TemplateStructure, section_name: str, ) -> str: """按 Step 3 持久化的区域名精确获取模板文本(避免再次标题匹配)""" if not section_name: return "" for section in template_structure.sections: if section.name.strip() == section_name.strip(): paragraphs = _read_template_body_texts(template_structure.file_path) parts = [] for i in range( section.start_para, min(section.end_para + 1, len(paragraphs)), ): text = paragraphs[i].strip() if text: parts.append(text) return "\n".join(parts) return "" def _titles_match(section_name: str, chapter_title: str) -> bool: """判断模板 section 名称与章节标题是否匹配 多层匹配策略(由严到宽): 1. 直接包含 2. 3+ 字关键词命中(高置信度) 3. 2 字关键词命中 ≥ 2 个(组合置信度) 4. 字符级 Jaccard 相似度 ≥ 0.35(宽匹配兜底) """ # 归一化 sn = section_name.strip() ct = chapter_title.strip() # 1. 直接包含 if ct in sn or sn in ct: return True # 拆分章节标题为词组 words = [w for w in re.split(r"[、,,::\s]+", ct) if len(w) >= 2] # 统计各长度关键词命中 hits_3plus = 0 hits_2char = 0 for w in words: if w in sn: if len(w) >= 3: hits_3plus += 1 elif len(w) == 2: hits_2char += 1 # 2. 任一 3+ 字词命中 if hits_3plus >= 1: return True # 3. 至少 2 个 2 字词命中 if hits_2char >= 2: return True # 4. 字符级 Jaccard 相似度兜底 sn_chars = set(sn.replace(":", "").replace(":", "")) ct_chars = set(ct) if sn_chars and ct_chars: intersection = sn_chars & ct_chars union = sn_chars | ct_chars jaccard = len(intersection) / len(union) if union else 0 if jaccard >= 0.35: return True return False def get_template_chapter_titles( template_path: str, reference_bid_path: str = "", ) -> List[Tuple[str, str]]: """从模板中提取顶级章节编号和标题 策略(V2.2 增强版): 1. 优先通过骨架 diff 获取精准边界(有参考标书时) 2. 降级:字体格式过滤(尝试检测粗体/大字号段落) 3. 终级降级:所有匹配"第X章"模式的段落(不依赖格式判断) Args: template_path: 模板 DOCX 路径 reference_bid_path: 参考投标文件路径(可选,用于优先匹配骨架段落) Returns: [(chapter_id, title_text), ...] 按出现顺序排列 """ if not os.path.exists(template_path): return [] # 先尝试通过骨架 diff 获取精准边界(有参考标书时) if reference_bid_path and os.path.exists(reference_bid_path): try: ts = parse_template( template_path=template_path, reference_bid_path=reference_bid_path, ) titles: List[Tuple[str, str]] = [] for section in ts.sections: m = _CHAPTER_PATTERN.match(section.name) if m: raw_id = m.group(0).split("章")[0].replace("第", "") ch_id = _cn_num_to_arabic(raw_id) title = (m.group(1) or "").strip() titles.append((ch_id, title)) if titles: return _dedupe_template_chapter_titles(titles) except Exception: pass # 降级到格式过滤方案 # 大文件(≥20MB,如 104MB 模板):用标准库 ET 读取(含字号/加粗), # 避免 python-docx/lxml 反复解析大文件导致的原生崩溃(0xC0000005)。 if os.path.getsize(template_path) >= 20 * 1024 * 1024: try: from doc_reader.reader import read_docx_paragraphs_et paras_info = read_docx_paragraphs_et(template_path) return _dedupe_template_chapter_titles( _extract_chapter_titles_from_paras(paras_info) ) except Exception as e: logger.warning(f"模板章节标题 ET 读取失败,回退 python-docx: {e}") # 降级路径:扫描所有"第X章"段落,用启发式判断顶级章节 doc = DocxDocument(template_path) titles: List[Tuple[str, str]] = [] seen = set() # 收集所有匹配"第X章"的段落 all_chapters: list = [] for para in doc.paragraphs: text = para.text.strip() m = _CHAPTER_PATTERN.match(text) if not m: continue raw_id = m.group(0).split("章")[0].replace("第", "") ch_id = _cn_num_to_arabic(raw_id) title = (m.group(1) or "").strip() is_top = _is_top_level_chapter(para) all_chapters.append((ch_id, title, is_top, para)) # 策略 A:如果有任何顶级章节(粗体/大字号),只用顶级章节 top_chapters = [(cid, t) for cid, t, is_top, _ in all_chapters if is_top] if top_chapters: top_nums = sorted({int(c) for c, _ in top_chapters if c.isdigit()}) for ch_id, title in top_chapters: if ch_id not in seen: seen.add(ch_id) titles.append((ch_id, title)) # 容错:顶层章编号应连续。若存在编号缺口(如 1,2,4 缺 3), # 说明个别章格式不一致(未加粗/字号不足)被 _is_top_level_chapter # 漏判为"子节伪装"——补收录并警告,避免章节丢失。 if len(top_nums) >= 2: for ch_id, title, is_top2, _para in all_chapters: if is_top2 or ch_id in seen or not ch_id.isdigit(): continue num = int(ch_id) if top_nums[0] <= num <= top_nums[-1] and num not in top_nums: seen.add(ch_id) titles.append((ch_id, title)) logger.warning( f"格式不一致的顶层章已补充(编号缺口 {num}): " f"第{ch_id}章 {title[:40]}" ) # 记录被格式过滤的章(多为正文内部子目录,但至少不静默) filtered = [ f"第{cid}章 {t[:20]}" for cid, t, is_top2, _para in all_chapters if not is_top2 and cid not in seen ] if filtered: logger.info( "模板正文中检测到但未纳入投书顶层大纲的“第X章”标题" f"(常见于附件/制度子目录): {filtered[:10]}" ) return _dedupe_template_chapter_titles(titles) # 策略 B:没有检测到格式差异,全部视为顶级章节 for ch_id, title, _is_top, _para in all_chapters: if ch_id not in seen: seen.add(ch_id) titles.append((ch_id, title)) return _dedupe_template_chapter_titles(titles) # 模板内嵌"管理制度"等附件章节的标题特征词(如"第五章 安全设施管理维护制度") _EMBEDDED_CHAPTER_KEYWORDS = ( "总则", "安全生产", "安全责任", "安全设施", "生产安全", "考评", "预警", "档案存档", "责任追究", "制度", ) def _dedupe_template_chapter_titles( titles: List[Tuple[str, str]], ) -> List[Tuple[str, str]]: """同号章节去重,并剔除模板内嵌的"管理制度"等附件章节。 模板中真正的顶层章(第1~8章)与内嵌的"管理制度"章节(第1~10章)同号, dict(titles) 后段覆盖前段会把第一章~第四章标题污染成制度标题,并把 第九章/第十章(制度章)带进大纲。规则: - 同一编号保留第一个"非制度特征"标题; - 全部候选都是制度特征标题(如第9/10章)→ 剔除该编号。 """ by_id: Dict[str, List[str]] = {} for ch_id, title in titles: by_id.setdefault(ch_id, []).append(title) result: List[Tuple[str, str]] = [] for ch_id in sorted(by_id, key=lambda x: int(x) if x.isdigit() else 0): best = None for title in by_id[ch_id]: if not any(kw in title for kw in _EMBEDDED_CHAPTER_KEYWORDS): best = title break if best: result.append((ch_id, best)) return result def _is_top_level_chapter(para) -> bool: """判断段落是否为顶级章节(非子节伪装) 增强版检测标准: - 存在 run 满足:粗体=True AND 字号 >= 12pt(放宽到 12pt) - 存在 run 满足:字号 >= 14pt(即使未显式设粗体,大字号默认是标题) - 段落的首个 run 粗体=True(即使在段样式级别设置) 子节的"第X章"通常为 12pt + 非粗体 + 有缩进。 """ from docx.shared import Pt explicit_level = paragraph_heading_level(para) if explicit_level: return explicit_level == 1 if not para.runs: return False has_bold_large = False has_large_font = False first_run_bold = para.runs[0].bold is True for run in para.runs: # 检查粗体+大字号组合 if run.bold and run.font.size and run.font.size >= Pt(12): has_bold_large = True # 检查大字号(即使没设粗体) if run.font.size and run.font.size >= Pt(14): has_large_font = True # 检查段落级别的粗体(通过 pPr) if not has_bold_large and not first_run_bold: try: from docx.oxml.ns import qn pPr = para._element.find(qn('w:pPr')) if pPr is not None: pStyle = pPr.find(qn('w:pStyle')) if pStyle is not None: style_val = pStyle.get(qn('w:val'), '') if style_val and style_val.lower().startswith('heading'): has_bold_large = True except Exception: pass return has_bold_large or has_large_font or first_run_bold def _is_top_level_chapter_from_info(info: dict) -> bool: """按 ET 提取的段落信息判断是否为顶级章节(与 _is_top_level_chapter 同规则)""" explicit_level = heading_level_from_style(info.get("style_name") or "") if explicit_level: return explicit_level == 1 runs = info.get("runs") or [] if not runs: return False first_run_bold = runs[0][0] is True has_bold_large = False has_large_font = False for bold, size in runs: if bold and size is not None and size >= 12: has_bold_large = True if size is not None and size >= 14: has_large_font = True if not has_bold_large and not first_run_bold: if heading_level_from_style(info.get("style") or "") == 1: has_bold_large = True return has_bold_large or has_large_font or first_run_bold def _extract_chapter_titles_from_paras(paras_info: list) -> List[Tuple[str, str]]: """从段落信息列表提取顶级章节标题(含编号缺口补充与过滤日志)""" titles: List[Tuple[str, str]] = [] seen = set() all_chapters = [] for info in paras_info: text = info.get("text", "").strip() m = _CHAPTER_PATTERN.match(text) if not m: continue raw_id = m.group(0).split("章")[0].replace("第", "") ch_id = _cn_num_to_arabic(raw_id) title = (m.group(1) or "").strip() is_top = _is_top_level_chapter_from_info(info) all_chapters.append((ch_id, title, is_top, text)) top_chapters = [(cid, t) for cid, t, is_top, _ in all_chapters if is_top] if top_chapters: top_nums = sorted({int(c) for c, _ in top_chapters if c.isdigit()}) for ch_id, title in top_chapters: if ch_id not in seen: seen.add(ch_id) titles.append((ch_id, title)) if len(top_nums) >= 2: for ch_id, title, is_top2, _text in all_chapters: if is_top2 or ch_id in seen or not ch_id.isdigit(): continue num = int(ch_id) if top_nums[0] <= num <= top_nums[-1] and num not in top_nums: seen.add(ch_id) titles.append((ch_id, title)) logger.warning( f"格式不一致的顶层章已补充(编号缺口 {num}): " f"第{ch_id}章 {title[:40]}" ) filtered = [ f"第{cid}章 {t[:20]}" for cid, t, is_top2, _text in all_chapters if not is_top2 and cid not in seen ] if filtered: logger.info( "模板正文中检测到但未纳入投书顶层大纲的“第X章”标题" f"(常见于附件/制度子目录): {filtered[:10]}" ) return titles def _cn_num_to_arabic(cn: str) -> str: """中文数字 → 阿拉伯数字字符串 支持任意常见范围("十"~"百"~"千"组合);无法解析时返回原串 (调用方需自行过滤非数字 id)。 """ if cn.isdigit(): return cn cn_digits = { "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, } if cn in cn_digits: return str(cn_digits[cn]) # 组合数解析:"十"→10,"十六"→16,"二十"→20, # "二十一"→21,"一百零五"→105("零"忽略) total = 0 section = 0 number = 0 for ch in cn: if ch in cn_digits: number = cn_digits[ch] elif ch == "十": section += (number or 1) * 10 number = 0 elif ch == "百": section += (number or 1) * 100 number = 0 elif ch == "千": section += (number or 1) * 1000 number = 0 elif ch == "万": section += number total += section * 10000 section = 0 number = 0 elif ch == "零": number = 0 # "一百零五":百位后清零个位数字 else: return cn # 无法解析,返回原串 total += section + number if total <= 0: return cn return str(total) __all__ = [ "parse_template", "get_template_text_for_chapter", "DEFAULT_TEMPLATE", ]