324 lines
9.4 KiB
Python
324 lines
9.4 KiB
Python
"""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)
|