"""Generate MIREA practice title page from TitleTemplate.docx + meta sources. Red runs (w:color C00000/FF0000) in the bundled template are placeholders. Sources (later wins for academic fields): info_conv.yaml < ```title fence. Student ФИО/группа come from user profile / CLI, not from yaml. """ from __future__ import annotations import logging import os import re import shutil from dataclasses import dataclass, fields from datetime import datetime from pathlib import Path from docx import Document from docx.oxml.ns import qn from docx.shared import RGBColor from . import package_dir _log = logging.getLogger(__name__) INFO_CONV_FILENAME = "info_conv.yaml" TITLE_FENCE_RE = re.compile( r"^```title[ \t]*\r?\n(.*?)(?:\r?\n)```[ \t]*(?:\r?\n|$)", re.DOTALL | re.IGNORECASE | re.MULTILINE, ) KV_LINE_RE = re.compile(r"^([^\s:#][^:#]*?)\s*:\s*(.*)$") # Canonical field → accepted key aliases (lowercased for match) _KEY_ALIASES: dict[str, tuple[str, ...]] = { "institute": ( "institute", "институт", "инститиут", ), "department": ( "department", "кафедра", ), "discipline": ( "discipline", "дисциплина", ), "teacher": ( "teacher", "преподаватель", "преподователь", ), "teacher_position": ( "teacher_position", "teacherposition", "должность", "должность преподавателя", ), "number": ("number", "номер", "№", "n"), "year": ("year", "год"), "student": ("student", "фио", "студент"), "group": ("group", "группа"), } # Paragraph indices in bundled TitleTemplate.docx (do not reorder template). # P0 institute, P1 department, P5 discipline (red inside guillemets), # P8 number, P13 group, P15 student, P19 teacher_position, P21 teacher, P28 year. _SLOT_PARAS: dict[str, int] = { "institute": 0, "department": 1, "discipline": 5, "number": 8, "group": 13, "student": 15, "teacher_position": 19, "teacher": 21, "year": 28, } _RED_VALS = frozenset({"FF0000", "C00000", "FF0", "F00"}) @dataclass class TitleMeta: institute: str = "" department: str = "" discipline: str = "" number: str = "" group: str = "" student: str = "" teacher_position: str = "" teacher: str = "" year: str = "" def academic_filled(self) -> bool: return any( [ self.institute, self.department, self.discipline, self.teacher, self.teacher_position, self.number, self.year, ] ) def default_title_template_path() -> str: return os.path.join(package_dir(), "TitleTemplate.docx") def _norm_key(key: str) -> str: return re.sub(r"\s+", " ", (key or "").strip().lower().replace("ё", "е")) def _alias_to_field(key: str) -> str | None: nk = _norm_key(key) for field_name, aliases in _KEY_ALIASES.items(): if nk in aliases: return field_name return None def parse_kv_text(text: str) -> dict[str, str]: """Parse flat `key: value` lines (title fence / info_conv.yaml).""" out: dict[str, str] = {} for raw in (text or "").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if line in ("---", "..."): continue m = KV_LINE_RE.match(line) if not m: continue field_name = _alias_to_field(m.group(1)) if not field_name: continue value = m.group(2).strip().strip("\"'") # strip inline comment if " #" in value: value = value.split(" #", 1)[0].rstrip() if value: out[field_name] = value return out def parse_and_strip_title_fence(md: str) -> tuple[dict[str, str], str]: """Return (fields from first ```title fence, markdown without that fence).""" m = TITLE_FENCE_RE.search(md or "") if not m: return {}, md or "" fields_map = parse_kv_text(m.group(1)) body = (md or "")[: m.start()] + (md or "")[m.end() :] # collapse leading blank lines left by stripping body = re.sub(r"^\s*\n", "", body, count=1) return fields_map, body def load_info_conv(md_dir: str | Path) -> dict[str, str]: path = Path(md_dir) / INFO_CONV_FILENAME if not path.is_file(): return {} try: text = path.read_text(encoding="utf-8") except OSError as exc: _log.warning("Не удалось прочитать %s: %s", path, exc) return {} return parse_kv_text(text) def info_conv_exists(md_dir: str | Path) -> bool: return (Path(md_dir) / INFO_CONV_FILENAME).is_file() def merge_title_meta( *, yaml_fields: dict[str, str] | None = None, fence_fields: dict[str, str] | None = None, student: str = "", group: str = "", ) -> TitleMeta: """Fence overrides yaml for academic fields; student/group from args.""" merged: dict[str, str] = {} for src in (yaml_fields or {}, fence_fields or {}): for k, v in src.items(): if k in ("student", "group"): continue # never from yaml/fence for title identity if v: merged[k] = v meta = TitleMeta(**{f.name: merged.get(f.name, "") for f in fields(TitleMeta)}) meta.student = (student or "").strip() meta.group = (group or "").strip() if not meta.year: meta.year = str(datetime.now().year) if not meta.number: meta.number = "1" return meta def _run_color_val(run) -> str | None: rPr = run._element.find(qn("w:rPr")) if rPr is None: return None c = rPr.find(qn("w:color")) if c is None: return None return (c.get(qn("w:val")) or "").upper() or None def _is_red_run(run) -> bool: val = _run_color_val(run) if not val: return False if val in _RED_VALS: return True # theme-ish: FF???? with green/blue near zero if len(val) == 6 and val.startswith("FF") and val[2:4] == "00" and val[4:6] == "00": return True if len(val) == 6 and val.startswith("C0") and val[2:] == "0000": return True return val == "C00000" def _clear_run_color(run) -> None: rPr = run._element.find(qn("w:rPr")) if rPr is None: return c = rPr.find(qn("w:color")) if c is not None: rPr.remove(c) try: run.font.color.rgb = RGBColor(0, 0, 0) except Exception: pass def _replace_red_in_paragraph(paragraph, value: str) -> bool: """Replace contiguous red runs' text with ``value``; clear red color.""" runs = list(paragraph.runs) red_idxs = [i for i, r in enumerate(runs) if _is_red_run(r) and (r.text or "")] if not red_idxs: # also consider empty/tab-only already captured red_idxs = [i for i, r in enumerate(runs) if _is_red_run(r)] if not red_idxs: return False first = red_idxs[0] runs[first].text = value _clear_run_color(runs[first]) for i in red_idxs[1:]: runs[i].text = "" _clear_run_color(runs[i]) return True def fill_title_document(doc: Document, meta: TitleMeta) -> None: """Fill red placeholder slots in an opened title template document.""" paras = doc.paragraphs mapping = { "institute": meta.institute, "department": meta.department, "discipline": meta.discipline, "number": meta.number, "group": meta.group, "student": meta.student, "teacher_position": meta.teacher_position, "teacher": meta.teacher, "year": meta.year, } for field_name, idx in _SLOT_PARAS.items(): value = (mapping.get(field_name) or "").strip() if not value: continue if idx >= len(paras): _log.warning("TitleTemplate: нет абзаца %s для поля %s", idx, field_name) continue if not _replace_red_in_paragraph(paras[idx], value): # Fallback: whole paragraph is placeholder (student name) if field_name in ("student", "institute", "department") and paras[idx].runs: paras[idx].runs[0].text = value for r in paras[idx].runs[1:]: r.text = "" _clear_run_color(paras[idx].runs[0]) else: _log.warning( "TitleTemplate: не найдены красные runs в P%s (%s)", idx, field_name ) def build_title_docx( meta: TitleMeta, out_path: str | Path, *, template_path: str | Path | None = None, ) -> Path: """Copy template, fill slots, save to out_path. Returns out_path.""" src = Path(template_path or default_title_template_path()) if not src.is_file(): raise FileNotFoundError(f"Шаблон титульника не найден: {src}") dest = Path(out_path) dest.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(src, dest) doc = Document(str(dest)) fill_title_document(doc, meta) doc.save(str(dest)) return dest def should_generate_title( *, explicit_title: str | None, fence_fields: dict[str, str], md_dir: str | Path, enabled: bool = False, ) -> bool: if not enabled: return False if explicit_title and str(explicit_title).strip(): return False if fence_fields: return True return info_conv_exists(md_dir)