225 lines
7.8 KiB
Python
225 lines
7.8 KiB
Python
"""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 ---
|
||
|
||
# ```uml / ```mermaid / ```uml-c4 … — кавычки и тире там синтаксис
|
||
_FENCE_RE = re.compile(
|
||
r"^(?P<fence>`{3,}|~{3,})(?P<info>[^\n]*)\n"
|
||
r"(?P<body>[\s\S]*?)"
|
||
r"^(?P=fence)[ \t]*(?:\n|$)",
|
||
re.M,
|
||
)
|
||
|
||
|
||
def _fence_lang(info: str) -> str:
|
||
return (info.strip().split() or [""])[0].lower()
|
||
|
||
|
||
def _is_diagram_fence_info(info: str) -> bool:
|
||
from .diagram_schemes import is_diagram_lang
|
||
return is_diagram_lang(_fence_lang(info))
|
||
|
||
|
||
def _map_outside_diagram_fences(text: str, fn) -> str:
|
||
"""Apply fn only outside UML / Mermaid / scheme fences."""
|
||
out: list[str] = []
|
||
pos = 0
|
||
for m in _FENCE_RE.finditer(text):
|
||
if not _is_diagram_fence_info(m.group("info")):
|
||
continue
|
||
out.append(fn(text[pos:m.start()]))
|
||
out.append(m.group(0))
|
||
pos = m.end()
|
||
out.append(fn(text[pos:]))
|
||
return "".join(out)
|
||
|
||
|
||
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)
|
||
|
||
def _apply(chunk: str) -> str:
|
||
chunk = fix_russian_quotes(chunk)
|
||
if _EMDASH_TO_HYPHEN:
|
||
return replace_emdash_with_hyphen(chunk)
|
||
return fix_dashes(chunk)
|
||
|
||
text = _map_outside_diagram_fences(text, _apply)
|
||
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
|