Files
RAG-CUT/backend/rag_cut/parsers/pdf/text_extract.py
T
2026-07-16 11:12:17 +08:00

105 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Text block extraction from analyzed layout."""
from __future__ import annotations
import re
from rag_cut.models import Block, BlockType
from rag_cut.parsers.pdf.layout import LayoutRegion, PageLayout
_UI_LABEL_RE = re.compile(r"^[\d\s\W]{0,6}[\u4e00-\u9fff]{1,6}$")
# "10.上升三角形態" / "1.2 ACCOUNT STATUS" (space after number optional)
_NUMBERED_HEADING_RE = re.compile(
r"^\s*(\d+(?:\.\d+)*)(?:\.|.)?\s*([A-Za-z0-9\u4e00-\u9fff][A-Za-z0-9\u4e00-\u9fff&/ \-_::]{2,})\s*$"
)
# Title+body merges must not become headings; real section titles stay shorter.
MAX_HEADING_CHARS = 100
def _numbered_heading_level(text: str) -> int | None:
match = _NUMBERED_HEADING_RE.match(text.strip())
if not match or len(match.group(2).strip()) < 3:
return None
return match.group(1).count(".") + 1
def _looks_like_ui_label(text: str) -> bool:
"""True for short UI chips; exclude numbered / CJK section titles."""
stripped = text.strip()
if _numbered_heading_level(stripped) is not None:
return False
if re.match(r"^\d+(?:\.\d+)*(?:\.|.)", stripped):
return False
# Short Chinese section banners like 「形態指標」are not toolbar labels.
if re.fullmatch(r"[\u4e00-\u9fff]{2,12}", stripped):
return False
return bool(_UI_LABEL_RE.match(stripped))
def _font_heading_level(size: float, body_size: float, text: str) -> int | None:
stripped = text.strip()
numbered = _numbered_heading_level(stripped)
if numbered and len(stripped) <= MAX_HEADING_CHARS:
return numbered
if len(stripped) < 4 or len(stripped) > MAX_HEADING_CHARS:
return None
if _looks_like_ui_label(stripped):
return None
# Compact CJK section titles such as 「形態指標」.
if (
size >= body_size + 3
and re.fullmatch(r"[\u4e00-\u9fff]{2,12}", stripped)
and not _numbered_heading_level(stripped)
):
return 1
if size >= body_size + 6:
return 1 if len(stripped) >= 10 else 2
if size >= body_size + 3:
return 2 if len(stripped) >= 8 else 3
if size >= body_size + 1.5:
return 3
return None
def extract_text_blocks(layout: PageLayout, chapter_title: str | None = None) -> tuple[list[Block], str | None]:
"""Extract heading/paragraph blocks; update chapter title when headings appear."""
blocks: list[Block] = []
current_chapter = chapter_title
for region in layout.regions:
if region.kind != "text":
continue
text = region.data.get("text", "").strip()
if not text:
continue
font_size = float(region.data.get("font_size", layout.body_font_size))
level = _font_heading_level(font_size, layout.body_font_size, text)
page_no = layout.page_index + 1
meta = {
"page": page_no,
"bbox": [region.x0, region.y0, region.x1, region.y1],
"font_size": font_size,
"body_font_size": layout.body_font_size,
"page_height": layout.page_height,
}
if current_chapter:
meta["chapter"] = current_chapter
if level:
if level <= 2:
current_chapter = text
if current_chapter:
meta["chapter"] = current_chapter
blk = Block(type=BlockType.HEADING, text=text, level=level, meta=meta)
else:
blk = Block(type=BlockType.PARAGRAPH, text=text, meta=meta)
blocks.append(blk)
return blocks, current_chapter