| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990 |
- """
- 模板 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",
- ]
|