105 lines
3.5 KiB
Python
105 lines
3.5 KiB
Python
"""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
|