"""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