@@ -0,0 +1,323 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user