"""Document type profiles and preprocess helpers for MIREA TZ.""" from __future__ import annotations import re from dataclasses import dataclass from datetime import datetime DOC_TYPES = ("coursework", "practice", "vkr", "PIS_custom", "APID_coursework") # auto — Word multilevel list numbers headings; strip digits from md text # manual — digits stay in md (# 1 … / ## 1.1 …); Word auto-numbering disabled HEADING_NUMBERING_MODES = ("auto", "manual") DEFAULT_HEADING_NUMBERING = "manual" # manual — content built by md2gost with page numbers from layout tracker # native — Word TOC field (обновить поле при открытии в Word) TOC_MODES = ("manual", "native") DEFAULT_TOC_MODE = "native" # off — одна таблица Word, пагинацию делает Word (без автоподписи) # legacy — режем по оценке высоты + «Продолжение…» с page_break_before # caption — режем по оценке + явный PageBreak + «Продолжение…» # soft — синоним off (автоподпись mid-page без точной вёрстки Word невозможна в DOCX) # word — как off при рендере; после save Word COM режет по реальной пагинации + «Продолжение…» TABLE_CONTINUATION_MODES = ("off", "legacy", "caption", "soft", "word") DEFAULT_TABLE_CONTINUATION = "word" # Same modes as tables (word = post-process via Word COM after save). LISTING_CONTINUATION_MODES = TABLE_CONTINUATION_MODES DEFAULT_LISTING_CONTINUATION = DEFAULT_TABLE_CONTINUATION # section — Рисунок 1.1 / 2.1 (ТЗ МИРЭА); continuous — Рисунок 1, 2, 3 (ПИС) NUMBERING_SCOPES = ("section", "continuous") STYLE_PRESETS = ("mirea", "pis_custom") @dataclass class DocProfile: name: str min_sources: int max_sources: int | None # None = no upper bound sectional_biblio: bool require_graphic_appendix: bool max_source_age_years: int = 5 # mirea — ГОСТ/методичка МИРЭА; pis_custom — итоговый отчёт по практическим работам style_preset: str = "mirea" numbering_scope: str = "section" # Structure expectations for checker require_intro_conclusion: bool = True require_bibliography: bool = True require_practical_works: bool = False # КР по «Архитектуре приложений и данных» (методичка Аншиной/Лагуновой) require_apid_kr_structure: bool = False PROFILES = { "coursework": DocProfile("coursework", 5, 7, False, False), "practice": DocProfile("practice", 5, 7, False, False), "vkr": DocProfile("vkr", 10, None, True, True), # Курсовая АПИД: оформление ГОСТ/Кириллина, источники 7–20 по методичке дисциплины "APID_coursework": DocProfile( "APID_coursework", min_sources=7, max_sources=20, sectional_biblio=False, require_graphic_appendix=False, max_source_age_years=5, style_preset="mirea", numbering_scope="section", require_intro_conclusion=True, require_bibliography=True, require_practical_works=False, require_apid_kr_structure=True, ), # Итоговый отчёт по практическим работам (чек-лист ПИС) "PIS_custom": DocProfile( "PIS_custom", min_sources=0, max_sources=None, sectional_biblio=False, require_graphic_appendix=False, max_source_age_years=10, style_preset="pis_custom", numbering_scope="continuous", require_intro_conclusion=False, require_bibliography=False, require_practical_works=True, ), } def get_profile(doc_type: str) -> DocProfile: if doc_type not in PROFILES: raise ValueError(f"Unknown document type: {doc_type}. Expected one of {DOC_TYPES}") return PROFILES[doc_type] # --- text autofixes --- def fix_russian_quotes(text: str) -> str: """Replace "..." with «...» for Russian text segments (heuristic).""" def repl(m): inner = m.group(1) if inner.lstrip().startswith("%"): return m.group(0) return f"«{inner}»" return re.sub(r'"([^"\n]+)"', repl, text) def fix_dashes(text: str) -> str: """Replace spaced hyphen used as dash with em dash; keep range hyphens.""" # "слово - слово" → "слово — слово" text = re.sub(r"(\S) - (\S)", r"\1 — \2", text) return text def replace_emdash_with_hyphen(text: str) -> str: """Replace typographic em dash U+2014 with ASCII hyphen-minus.""" return text.replace("—", "-") # Runtime switch for — vs - (set from CLI / Converter) _EMDASH_TO_HYPHEN = False def set_emdash_to_hyphen(enabled: bool) -> None: global _EMDASH_TO_HYPHEN _EMDASH_TO_HYPHEN = bool(enabled) def emdash_to_hyphen_enabled() -> bool: return _EMDASH_TO_HYPHEN def dash_separator() -> str: """Caption / prose dash: ' - ' or ' — ' depending on CLI flag.""" return " - " if _EMDASH_TO_HYPHEN else " — " def preprocess_markdown(text: str, emdash_to_hyphen: bool | None = None) -> str: if emdash_to_hyphen is not None: set_emdash_to_hyphen(emdash_to_hyphen) text = fix_russian_quotes(text) if _EMDASH_TO_HYPHEN: # Keep ASCII hyphens; flatten any em dashes from the source text = replace_emdash_with_hyphen(text) else: text = fix_dashes(text) text = separate_biblio_lines(text) return text def separate_biblio_lines(text: str) -> str: """Ensure each [n]: bibliography line is its own paragraph (Marko soft-breaks otherwise).""" lines = text.splitlines(keepends=True) out: list[str] = [] in_biblio = False for line in lines: stripped = line.strip() if re.match(r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$", stripped, re.I): in_biblio = True out.append(line) continue if in_biblio and re.match(r"^#\s+", stripped): in_biblio = False if in_biblio and re.match(r"^\[\d+(?:\.\d+)?\]:", stripped): # blank line before entry if previous wasn't blank if out and out[-1].strip(): out.append("\n") out.append(line if line.endswith("\n") else line + "\n") out.append("\n") continue out.append(line) return "".join(out) def find_formula_refs(text: str) -> set[str]: """Labels of formulas that are cited, e.g. @Формула:euler or (1.1) after %eq.""" refs = set() for m in re.finditer(r"@Формула:(\w+)", text, re.IGNORECASE): refs.add(m.group(1)) for m in re.finditer(r"@Formula:(\w+)", text, re.IGNORECASE): refs.add(m.group(1)) return refs def current_year() -> int: return datetime.now().year