168 lines
4.9 KiB
Python
168 lines
4.9 KiB
Python
"""Classify DOCX paragraphs/tables into IR kinds (style first, then heuristics)."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from md2gost.renderable.toc import SPECIAL_TITLES
|
||
|
||
from .captions import parse_any_caption
|
||
|
||
CAPTION_STYLE_KINDS = {
|
||
"Caption Figure": "figure",
|
||
"Название таблицы": "table",
|
||
"Caption Table": "table",
|
||
"Caption Listing": "listing",
|
||
"Caption": None, # need text heuristic
|
||
}
|
||
|
||
HEADING_STYLE_RE = re.compile(r"^(?:Heading|Заголовок)\s*(\d+)$", re.IGNORECASE)
|
||
TOC_STYLE_RE = re.compile(r"^toc\s*\d+$", re.IGNORECASE)
|
||
|
||
SPECIAL_UPPER = {t.upper() for t in SPECIAL_TITLES}
|
||
APPENDIX_RE = re.compile(
|
||
r"^ПРИЛОЖЕНИЕ\s+([А-ЯA-ZЁ])(?:\s+(.+))?$",
|
||
re.IGNORECASE,
|
||
)
|
||
NUMBERED_HEADING_RE = re.compile(r"^(\d+(?:\.\d+)*)\.?\s+(.+)$")
|
||
PRACTICE_RE = re.compile(r"^Практическая\s+работа\b", re.IGNORECASE)
|
||
LIST_MARKER_RE = re.compile(r"^(?:[–—\-•▪]|(\d+)\.)\s*\t?\s*(.*)$")
|
||
BIBLIO_LINE_RE = re.compile(r"^\[(\d+(?:\.\d+)?)\]\s*[:.]?\s*(.+)$")
|
||
BODY_START_TITLES = SPECIAL_UPPER | {"СОДЕРЖАНИЕ"}
|
||
|
||
|
||
def style_name(paragraph) -> str:
|
||
try:
|
||
if paragraph.style is None:
|
||
return ""
|
||
return paragraph.style.name or ""
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def is_toc_style(name: str) -> bool:
|
||
return bool(TOC_STYLE_RE.match((name or "").strip()))
|
||
|
||
|
||
def heading_level_from_style(name: str) -> int | None:
|
||
m = HEADING_STYLE_RE.match((name or "").strip())
|
||
if not m:
|
||
return None
|
||
try:
|
||
level = int(m.group(1))
|
||
except ValueError:
|
||
return None
|
||
return level if 1 <= level <= 9 else None
|
||
|
||
|
||
def is_code_style(name: str) -> bool:
|
||
n = (name or "").strip().lower()
|
||
return n == "code" or n.startswith("code ")
|
||
|
||
|
||
def is_bibliography_style(name: str) -> bool:
|
||
n = (name or "").strip().lower()
|
||
return n in ("bibliography", "bibliography heading")
|
||
|
||
|
||
def is_caption_style(name: str) -> bool:
|
||
return (name or "").strip() in CAPTION_STYLE_KINDS
|
||
|
||
|
||
def caption_kind_from_style(name: str) -> str | None:
|
||
return CAPTION_STYLE_KINDS.get((name or "").strip())
|
||
|
||
|
||
def is_special_title(text: str) -> bool:
|
||
t = (text or "").strip().upper()
|
||
if t in SPECIAL_UPPER:
|
||
return True
|
||
# allow «СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ» variants already in set
|
||
if t.startswith("СПИСОК ИСПОЛЬЗ"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def is_body_start_heading(text: str, style: str = "") -> bool:
|
||
"""True if this heading marks the start of report body (skip title pages before it)."""
|
||
t = (text or "").strip()
|
||
if not t:
|
||
return False
|
||
if heading_level_from_style(style) or style.lower().startswith("heading"):
|
||
upper = t.upper()
|
||
if upper.lstrip("* ").upper() in BODY_START_TITLES or is_special_title(t):
|
||
return True
|
||
if PRACTICE_RE.match(t):
|
||
return True
|
||
if NUMBERED_HEADING_RE.match(t):
|
||
return True
|
||
if APPENDIX_RE.match(t):
|
||
return True
|
||
# Any Heading 1 after front matter
|
||
if heading_level_from_style(style) == 1:
|
||
return True
|
||
# Heuristic without style: special titles alone
|
||
if is_special_title(t) or PRACTICE_RE.match(t):
|
||
return True
|
||
return False
|
||
|
||
|
||
def classify_heading_text(text: str, style_level: int | None) -> tuple[int, str, bool]:
|
||
"""
|
||
Return (level, title_text, numbered).
|
||
numbered=False → emit as # *TITLE
|
||
"""
|
||
raw = (text or "").strip()
|
||
level = style_level or 1
|
||
|
||
app = APPENDIX_RE.match(raw)
|
||
if app:
|
||
letter = app.group(1).upper()
|
||
name = (app.group(2) or "").strip()
|
||
title = f"Приложение {letter}" + (f" {name}" if name else "")
|
||
return 2, title, True
|
||
|
||
if is_special_title(raw):
|
||
return 1, raw.upper(), False
|
||
|
||
m = NUMBERED_HEADING_RE.match(raw)
|
||
if m:
|
||
# keep digits in text for manual numbering dialect
|
||
return level, raw, True
|
||
|
||
if PRACTICE_RE.match(raw):
|
||
return 1, raw, True
|
||
|
||
return level, raw, True
|
||
|
||
|
||
def look_like_list_item(text: str) -> tuple[bool, bool, str] | None:
|
||
"""
|
||
Detect md2gost-style list lines (–\\t / 1.\\t) or plain markers.
|
||
Returns (ordered, marker_ok, body) or None.
|
||
"""
|
||
t = (text or "").strip()
|
||
if not t:
|
||
return None
|
||
if t[0] in ("–", "—", "-", "•", "▪") and (len(t) == 1 or t[1] in "\t "):
|
||
body = t[1:].lstrip("\t ").strip()
|
||
return False, True, body
|
||
m = re.match(r"^(\d+)\.\s*\t?\s*(.+)$", t)
|
||
if m:
|
||
return True, True, m.group(2).strip()
|
||
return None
|
||
|
||
|
||
def parse_biblio_line(text: str) -> tuple[str, str] | None:
|
||
m = BIBLIO_LINE_RE.match((text or "").strip())
|
||
if not m:
|
||
return None
|
||
return m.group(1), m.group(2).strip()
|
||
|
||
|
||
def paragraph_has_numpr(paragraph) -> bool:
|
||
pPr = paragraph._element.pPr
|
||
if pPr is None:
|
||
return False
|
||
return pPr.numPr is not None
|