Files
Igor20264 510f7e7adf
Python application / build (push) Waiting to run
v0.5.2
Что то сделал
2026-09-08 19:37:54 +03:00

324 lines
9.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)