|
|
@@ -79,6 +79,14 @@ class PdfStructureExtractor:
|
|
|
"l1": re.compile(r"^第\s*[一二三四五六七八九十百零两]+\s*[章部分篇][\s、]*(.*)"),
|
|
|
"l2": re.compile(r"^[【\[]\s*(\d+)\s*[\]】][\s]*([\u4e00-\u9fa5A-Za-z].*)"),
|
|
|
},
|
|
|
+ "Rule_8_cn_list_l1_numeric_l2": {
|
|
|
+ "l1": re.compile(
|
|
|
+ r"^(?:[一二三四五六七八九十百零两]+)[、\)\]\uFF09]\s*[\u4e00-\u9fa5A-Za-z].*"
|
|
|
+ ),
|
|
|
+ "l2": re.compile(
|
|
|
+ r"^\d{1,2}(?:[、\.\uFF0E\u3002\)\]\uFF09])\s*(?!\d)[\u4e00-\u9fa5A-Za-z].*"
|
|
|
+ ),
|
|
|
+ },
|
|
|
}
|
|
|
TOC_PATTERN = re.compile(r"\.{3,}|…{2,}")
|
|
|
|
|
|
@@ -165,6 +173,10 @@ class PdfStructureExtractor:
|
|
|
try:
|
|
|
structure = self._extract_from_doc(doc, progress_callback)
|
|
|
if result.get("catalog"):
|
|
|
+ # 正文抽取和目录检测是两条独立链路:
|
|
|
+ # 1. 正文抽取更容易拿到连续 content
|
|
|
+ # 2. 目录检测更容易保留顺序和层级
|
|
|
+ # 这里先用目录骨架对齐正文,再按标题边界重建内容,尽量减少漏标题造成的结构缺失。
|
|
|
structure["chapters"] = self._reconcile_structure_with_catalog(
|
|
|
structure.get("chapters", {}),
|
|
|
result["catalog"],
|
|
|
@@ -210,7 +222,13 @@ class PdfStructureExtractor:
|
|
|
return self._toc_extractor.detect_and_extract(file_content, progress_callback)
|
|
|
|
|
|
def _extract_from_doc(self, doc: fitz.Document, progress_callback=None) -> Dict[str, Any]:
|
|
|
- """提取文档结构(支持 OCR 异步并发)"""
|
|
|
+ """提取文档结构(支持 OCR 异步并发)。
|
|
|
+
|
|
|
+ 整体分三步:
|
|
|
+ 1. 先扫描页面,找出需要 OCR 替换的表格区域
|
|
|
+ 2. 并发执行 OCR,并把识别结果按页回填
|
|
|
+ 3. 重新遍历页面文本,按标题规则切出 chapter / section 结构
|
|
|
+ """
|
|
|
|
|
|
def _emit_progress(stage: str, current: int, message: str):
|
|
|
"""发送进度回调"""
|
|
|
@@ -265,6 +283,7 @@ class PdfStructureExtractor:
|
|
|
|
|
|
# === 阶段3: 提取页面文本(应用 OCR 结果)并切分章节 ===
|
|
|
structured_data: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
|
+ # body_lines 保留过滤页眉页脚后的线性正文,后续目录回填时会再次按标题边界切段。
|
|
|
body_lines: List[Dict[str, Any]] = []
|
|
|
current_chapter = "未分类前言"
|
|
|
current_section = "默认部分"
|
|
|
@@ -313,6 +332,8 @@ class PdfStructureExtractor:
|
|
|
|
|
|
# 跳过目录阶段
|
|
|
if not in_body:
|
|
|
+ # 只有首次遇到真正的一级标题后,才认为进入正文。
|
|
|
+ # 这样可以避免目录页虽然命中标题规则,却被误当成正文结构。
|
|
|
matched_rules = self._matching_rule_names(line, "l1")
|
|
|
if matched_rules and not self.TOC_PATTERN.search(line):
|
|
|
in_body = True
|
|
|
@@ -324,6 +345,9 @@ class PdfStructureExtractor:
|
|
|
if self.TOC_PATTERN.search(line):
|
|
|
continue
|
|
|
|
|
|
+ # candidate_rule_names 表示“这篇文档可能使用的标题体系”;
|
|
|
+ # active_rule_name 表示“已经确认正在使用的二级标题规则”。
|
|
|
+ # 先宽松候选、后收敛到单一规则,可以减少混合编号文档里的串匹配。
|
|
|
active_scope = [active_rule_name] if active_rule_name else candidate_rule_names
|
|
|
|
|
|
# 匹配章标题
|
|
|
@@ -394,6 +418,11 @@ class PdfStructureExtractor:
|
|
|
return result
|
|
|
|
|
|
def _normalize_catalog(self, catalog: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
+ """统一目录来源并择优合并。
|
|
|
+
|
|
|
+ 目录检测器输出的 chapters 更像“骨架”,raw_ocr_text 更接近页面原文。
|
|
|
+ 这里会分别解析两份结果,判断谁更可信,再做一次合并补齐。
|
|
|
+ """
|
|
|
if not catalog:
|
|
|
return {}
|
|
|
|
|
|
@@ -425,6 +454,11 @@ class PdfStructureExtractor:
|
|
|
return normalized
|
|
|
|
|
|
def _parse_catalog_from_raw_text(self, text: str) -> List[Dict[str, Any]]:
|
|
|
+ """把目录页 OCR 原文解析成章节树。
|
|
|
+
|
|
|
+ 解析时会先根据首批命中的一级标题推断文档的目录样式,
|
|
|
+ 后续再尽量沿用同一套规则收敛二级标题,避免不同编号体系互相污染。
|
|
|
+ """
|
|
|
if not text or not text.strip():
|
|
|
return []
|
|
|
|
|
|
@@ -616,6 +650,9 @@ class PdfStructureExtractor:
|
|
|
if existing_is_suspicious:
|
|
|
return True
|
|
|
|
|
|
+ if cls._should_prefer_single_level_parsed_catalog(parsed_chapters, existing_chapters):
|
|
|
+ return True
|
|
|
+
|
|
|
parsed_score = cls._catalog_structure_score(parsed_chapters)
|
|
|
existing_score = cls._catalog_structure_score(existing_chapters)
|
|
|
if parsed_score <= existing_score:
|
|
|
@@ -631,6 +668,29 @@ class PdfStructureExtractor:
|
|
|
|
|
|
return True
|
|
|
|
|
|
+ @classmethod
|
|
|
+ def _should_prefer_single_level_parsed_catalog(
|
|
|
+ cls,
|
|
|
+ parsed_chapters: List[Dict[str, Any]],
|
|
|
+ existing_chapters: List[Dict[str, Any]],
|
|
|
+ ) -> bool:
|
|
|
+ """特判“单层目录被误识别成一章多节”的场景。"""
|
|
|
+ if len(parsed_chapters) < 2 or len(existing_chapters) != 1:
|
|
|
+ return False
|
|
|
+
|
|
|
+ if any(chapter.get("subsections") for chapter in parsed_chapters):
|
|
|
+ return False
|
|
|
+
|
|
|
+ existing_subsections = existing_chapters[0].get("subsections", []) or []
|
|
|
+ if len(existing_subsections) < len(parsed_chapters) - 1:
|
|
|
+ return False
|
|
|
+
|
|
|
+ parsed_pages = [
|
|
|
+ cls._safe_page_number(chapter.get("page"), 1)
|
|
|
+ for chapter in parsed_chapters
|
|
|
+ ]
|
|
|
+ return parsed_pages == sorted(parsed_pages)
|
|
|
+
|
|
|
@classmethod
|
|
|
def _catalog_has_suspicious_structure(cls, chapters: List[Dict[str, Any]]) -> bool:
|
|
|
if not chapters:
|
|
|
@@ -991,11 +1051,18 @@ class PdfStructureExtractor:
|
|
|
chapters: Dict[str, Dict[str, Dict[str, Any]]],
|
|
|
catalog: Dict[str, Any],
|
|
|
) -> Dict[str, Dict[str, Dict[str, Any]]]:
|
|
|
+ """把正文抽取结果挂回目录骨架。
|
|
|
+
|
|
|
+ 正文抽取结果通常 content 更完整,但层级可能漏掉;
|
|
|
+ 目录结果层级更稳,但 content 为空或不完整。
|
|
|
+ 这里按标题归一化后顺序匹配,把正文内容重新映射回目录结构。
|
|
|
+ """
|
|
|
catalog_chapters = catalog.get("chapters", []) if isinstance(catalog, dict) else []
|
|
|
if not chapters or not catalog_chapters:
|
|
|
return chapters
|
|
|
|
|
|
section_title_key = "章节标题"
|
|
|
+ # 将正文结构拆成“章标题内容”和“所有节标题内容”两条索引,方便后续按目录顺序逐项匹配。
|
|
|
chapter_title_payloads: Dict[str, List[Dict[str, Any]]] = {}
|
|
|
flat_sections: List[Tuple[str, Dict[str, Any]]] = []
|
|
|
matched_chapter_count = 0
|
|
|
@@ -1025,6 +1092,7 @@ class PdfStructureExtractor:
|
|
|
))
|
|
|
|
|
|
rebuilt: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
|
+ # 优先按顺序向后匹配,找不到时再全局回退一次,兼顾正确率和容错性。
|
|
|
search_start = 0
|
|
|
used_indices = set()
|
|
|
|
|
|
@@ -1111,6 +1179,7 @@ class PdfStructureExtractor:
|
|
|
|
|
|
@classmethod
|
|
|
def _prepare_page_lines(cls, text: str) -> List[str]:
|
|
|
+ """清洗页面文本行,并尝试把被换行拆开的标题重新合并。"""
|
|
|
raw_lines = [line.strip() for line in text.split("\n") if line.strip()]
|
|
|
prepared_lines: List[str] = []
|
|
|
index = 0
|
|
|
@@ -1133,6 +1202,7 @@ class PdfStructureExtractor:
|
|
|
lines: List[str],
|
|
|
start_index: int,
|
|
|
) -> Tuple[Optional[str], int]:
|
|
|
+ """尝试把当前位置开始的 2~3 行拼成完整标题。"""
|
|
|
first_line = lines[start_index].strip()
|
|
|
if not first_line:
|
|
|
return None, 1
|
|
|
@@ -1148,6 +1218,7 @@ class PdfStructureExtractor:
|
|
|
continue
|
|
|
if not (cls._matching_rule_names(candidate_text, "l1") or cls._matching_rule_names(candidate_text, "l2")):
|
|
|
continue
|
|
|
+ # 只有首行本身像“半截标题”,或者合并后明显更像标题时才吞并后续行,避免误吃正文。
|
|
|
if first_is_incomplete or not first_is_heading:
|
|
|
return candidate_text, span
|
|
|
|
|
|
@@ -1175,10 +1246,17 @@ class PdfStructureExtractor:
|
|
|
catalog: Dict[str, Any],
|
|
|
body_lines: List[Dict[str, Any]],
|
|
|
) -> Dict[str, Dict[str, Dict[str, Any]]]:
|
|
|
+ """基于目录顺序和正文行号,重新切分 section content。
|
|
|
+
|
|
|
+ 当正文结构抽取漏掉部分标题时,直接使用结构化结果容易出现 content 缺段。
|
|
|
+ 这里把目录拍平成一条标题时间线,再在线性正文里定位这些标题,
|
|
|
+ 用“当前标题到下一个标题”之间的文本作为当前 section 的正文。
|
|
|
+ """
|
|
|
catalog_chapters = catalog.get("chapters", []) if isinstance(catalog, dict) else []
|
|
|
if not catalog_chapters or not body_lines:
|
|
|
return chapters
|
|
|
|
|
|
+ # 先把目录展开成顺序列表,方便统一定位每个标题在正文中的起点。
|
|
|
expected_items: List[Dict[str, Any]] = []
|
|
|
total_sections = 0
|
|
|
for chapter in catalog_chapters:
|
|
|
@@ -1260,6 +1338,7 @@ class PdfStructureExtractor:
|
|
|
if item["kind"] != "section" or item["line_index"] is None:
|
|
|
continue
|
|
|
|
|
|
+ # 下一个已定位标题就是当前 section 的右边界;没有下一个则取到文末。
|
|
|
next_heading_index = len(body_lines)
|
|
|
for later in expected_items[idx + 1:]:
|
|
|
if later["line_index"] is not None:
|
|
|
@@ -1295,6 +1374,11 @@ class PdfStructureExtractor:
|
|
|
heading_kind: str,
|
|
|
start_index: int,
|
|
|
) -> Optional[int]:
|
|
|
+ """在线性正文中查找目标标题行。
|
|
|
+
|
|
|
+ 先做归一化后的精确匹配;若 OCR / PDF 抽取给标题前面带了噪声前缀,
|
|
|
+ 再退一步做“候选行后缀等于目标标题”的宽松匹配。
|
|
|
+ """
|
|
|
target_key = self._normalize_heading_key(target_title)
|
|
|
if not target_key:
|
|
|
return None
|
|
|
@@ -1313,6 +1397,7 @@ class PdfStructureExtractor:
|
|
|
return index
|
|
|
|
|
|
raw_candidate_key = self._normalize_heading_key(candidate_text)
|
|
|
+ # 某些 PDF 会把页码、序号或残余字符拼到标题前面,这里允许有限前缀噪声。
|
|
|
if raw_candidate_key.endswith(target_key):
|
|
|
prefix = raw_candidate_key[:-len(target_key)]
|
|
|
if not prefix or re.fullmatch(
|
|
|
@@ -1741,6 +1826,12 @@ class PdfStructureExtractor:
|
|
|
title = numeric_section_match.group(2).strip()
|
|
|
return f"{prefix} {title}".strip()
|
|
|
|
|
|
+ numeric_list_match = re.match(r"^(\d{1,2})(?:[、\.\uFF0E\u3002\)\]\uFF09])\s*(.*)$", cleaned)
|
|
|
+ if numeric_list_match:
|
|
|
+ prefix = numeric_list_match.group(1)
|
|
|
+ title = numeric_list_match.group(2).strip()
|
|
|
+ return f"{prefix} {title}".strip()
|
|
|
+
|
|
|
cn_section_match = re.match(r"^(第\s*[一二三四五六七八九十百零两]+\s*节)[\s、::\.-]*(.*)$", cleaned)
|
|
|
if cn_section_match:
|
|
|
prefix = re.sub(r"\s+", "", cn_section_match.group(1))
|