@@ -0,0 +1,431 @@
|
||||
"""MIREA TZ markdown checker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from ..bibliography import CITE_RE, BIBLIO_LINE_RE, parse_biblio_block, find_citations, extract_year
|
||||
from ..profiles import get_profile, current_year, DocProfile
|
||||
|
||||
|
||||
@dataclass
|
||||
class Issue:
|
||||
id: str
|
||||
severity: str # error | warning
|
||||
message: str
|
||||
line: int | None = None
|
||||
|
||||
|
||||
SPECIAL_UNNUMBERED = {
|
||||
"СОДЕРЖАНИЕ", "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ",
|
||||
"СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ",
|
||||
"СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ",
|
||||
"ПРИЛОЖЕНИЕ", "ПРИЛОЖЕНИЯ",
|
||||
}
|
||||
|
||||
HEADING_RE = re.compile(r"^(#{1,6})\s+(\*?)(.+)$", re.M)
|
||||
FOOTNOTE_RE = re.compile(r"\[\^[^\]]+\]")
|
||||
RIS_RE = re.compile(r"(?i)\bрис\.")
|
||||
TABL_RE = re.compile(r"(?i)\bтабл\.")
|
||||
CAPTION_HYPHEN_RE = re.compile(r"^%\w+\s+.+\s+-\s+", re.M)
|
||||
NOPP_RE = re.compile(r"№\s*п\s*/\s*п", re.I)
|
||||
IMAGE_RE = re.compile(r"!\[.*?\]\([^)]+\)")
|
||||
TABLE_CAPTION_RE = re.compile(r"^%(\w+)", re.M)
|
||||
APPENDIX_LETTER_RE = re.compile(r"(?i)приложение\s+([А-ЯA-ZЁ])")
|
||||
FORBIDDEN_APPENDIX = set("ЁЗЙОЧЬЫЪ")
|
||||
|
||||
|
||||
def _line_of(text: str, pos: int) -> int:
|
||||
return text.count("\n", 0, pos) + 1
|
||||
|
||||
|
||||
def _sections(text: str) -> list[tuple[str, str, int]]:
|
||||
"""Return list of (heading_upper, body, start_line)."""
|
||||
parts = []
|
||||
matches = list(HEADING_RE.finditer(text))
|
||||
for i, m in enumerate(matches):
|
||||
start = m.end()
|
||||
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
||||
title = m.group(3).strip()
|
||||
parts.append((title.upper(), text[start:end], _line_of(text, m.start())))
|
||||
return parts
|
||||
|
||||
|
||||
PRACTICAL_H1_RE = re.compile(
|
||||
r"^#\s+(?!\*)(.+)$",
|
||||
re.M,
|
||||
)
|
||||
PRACTICAL_TITLE_RE = re.compile(
|
||||
r"(?i)практическая\s+работа\s*№?\s*\d+",
|
||||
)
|
||||
|
||||
|
||||
def check_structure(text: str, profile: DocProfile) -> list[Issue]:
|
||||
issues = []
|
||||
upper = text.upper()
|
||||
|
||||
if profile.require_practical_works:
|
||||
# PIS_custom: содержание + практические работы как H1
|
||||
if "СОДЕРЖАНИЕ" not in upper:
|
||||
issues.append(Issue(
|
||||
"structure.missing", "error",
|
||||
"Отсутствует раздел «СОДЕРЖАНИЕ»",
|
||||
))
|
||||
if "[TOC]" not in text and "[toc]" not in text:
|
||||
issues.append(Issue(
|
||||
"structure.toc", "error",
|
||||
"Нет маркера [TOC] для оглавления (обязательно для итогового отчёта)",
|
||||
))
|
||||
h1_titles = [m.group(1).strip() for m in PRACTICAL_H1_RE.finditer(text)]
|
||||
practicals = [t for t in h1_titles if PRACTICAL_TITLE_RE.search(t)]
|
||||
if not practicals:
|
||||
issues.append(Issue(
|
||||
"structure.practical", "error",
|
||||
"Нет заголовков вида «Практическая работа №N. …» "
|
||||
"(H1 без *, каждая работа — отдельный раздел)",
|
||||
))
|
||||
for t in practicals:
|
||||
if t.rstrip().endswith("."):
|
||||
issues.append(Issue(
|
||||
"heading.trailing_dot", "warning",
|
||||
f"Заголовок раздела без точки в конце: «{t.rstrip('.')}»",
|
||||
))
|
||||
return issues
|
||||
|
||||
required = ["СОДЕРЖАНИЕ", "ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ", "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ"]
|
||||
if not profile.require_intro_conclusion:
|
||||
required = ["СОДЕРЖАНИЕ"]
|
||||
if profile.require_bibliography and "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ" not in required:
|
||||
required.append("СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ")
|
||||
# allow alternate spelling
|
||||
if "СПИСОК ИСПОЛЬЗОВАННЫХ ИСТОЧНИКОВ" not in upper and "СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ" in upper:
|
||||
required = [
|
||||
("СПИСОК ИСПОЛЬЗУЕМЫХ ИСТОЧНИКОВ" if r.startswith("СПИСОК") else r)
|
||||
for r in required
|
||||
]
|
||||
for name in required:
|
||||
if name not in upper:
|
||||
issues.append(Issue("structure.missing", "error",
|
||||
f"Отсутствует обязательный раздел «{name}»"))
|
||||
if "[TOC]" not in text and "[toc]" not in text:
|
||||
issues.append(Issue("structure.toc", "warning",
|
||||
"Нет маркера [TOC] для автособираемого содержания"))
|
||||
# numbered chapter exists
|
||||
if not re.search(r"^#\s+[^*\n]", text, re.M):
|
||||
issues.append(Issue("structure.body", "error",
|
||||
"Нет нумерованного раздела основной части (# заголовок без *)"))
|
||||
if profile.require_graphic_appendix:
|
||||
if "ГРАФИЧЕСКИЙ МАТЕРИАЛ" not in upper:
|
||||
issues.append(Issue(
|
||||
"structure.graphic", "error",
|
||||
"Для ВКР обязательно приложение «Графический материал»",
|
||||
))
|
||||
if profile.require_apid_kr_structure:
|
||||
issues.extend(check_apid_kr_structure(text))
|
||||
return issues
|
||||
|
||||
|
||||
APID_REQUIRED_HEADING_PATTERNS = [
|
||||
(r"теоретическ\w+\s+аспект", "1. Теоретические аспекты разработки архитектуры приложений и данных: …"),
|
||||
(r"прикладн\w+\s+аспект", "2. Прикладные аспекты разработки архитектуры приложений и данных …"),
|
||||
(r"2\.1\b.{0,40}описани[ея]\s+проекта", "2.1 Описание проекта команды и разрабатываемого программного приложения"),
|
||||
(r"2\.2\b.{0,40}описани[ея]\s+роли", "2.2 Описание роли … в приложении …"),
|
||||
(r"2\.3\b.{0,80}архитектур", "2.3 Описание архитектуры программного приложения и данных для роли"),
|
||||
(r"2\.4\b.{0,40}вариант", "2.4 Варианты развития архитектуры программного приложения"),
|
||||
]
|
||||
|
||||
|
||||
def check_apid_kr_structure(text: str) -> list[Issue]:
|
||||
"""Обязательные разделы КР по методичке АПИД (Аншина, Лагунова)."""
|
||||
issues: list[Issue] = []
|
||||
headings = [m.group(3).strip() for m in HEADING_RE.finditer(text)]
|
||||
blob = "\n".join(headings)
|
||||
for pat, title in APID_REQUIRED_HEADING_PATTERNS:
|
||||
if not re.search(pat, blob, re.I):
|
||||
issues.append(Issue(
|
||||
"structure.apid", "error",
|
||||
f"Для КР АПИД отсутствует обязательный раздел: «{title}»",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_pis_headings(text: str, profile: DocProfile) -> list[Issue]:
|
||||
"""PIS: подразделы с прописной, без точки; H1 — практические работы."""
|
||||
if not profile.require_practical_works:
|
||||
return []
|
||||
issues = []
|
||||
for m in HEADING_RE.finditer(text):
|
||||
level = len(m.group(1))
|
||||
title = m.group(3).strip()
|
||||
line = _line_of(text, m.start())
|
||||
if title.endswith("."):
|
||||
issues.append(Issue(
|
||||
"heading.trailing_dot", "warning",
|
||||
f"Заголовок без точки в конце: «{title.rstrip('.')}»",
|
||||
line,
|
||||
))
|
||||
if level >= 2 and title and title[0].islower():
|
||||
issues.append(Issue(
|
||||
"heading.case", "warning",
|
||||
f"Подраздел с прописной буквы: «{title[0].upper() + title[1:]}»",
|
||||
line,
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_special_headings(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in HEADING_RE.finditer(text):
|
||||
starred = bool(m.group(2))
|
||||
title = m.group(3).strip()
|
||||
upper = title.upper()
|
||||
line = _line_of(text, m.start())
|
||||
for special in SPECIAL_UNNUMBERED:
|
||||
if upper == special or (special.startswith("ПРИЛОЖЕН") and upper.startswith("ПРИЛОЖЕН")):
|
||||
if not starred and m.group(1) == "#":
|
||||
issues.append(Issue(
|
||||
"heading.numbered_special", "error",
|
||||
f"Спецраздел «{title}» должен быть без номера: # *{title}",
|
||||
line,
|
||||
))
|
||||
if title != title.upper() and upper in SPECIAL_UNNUMBERED:
|
||||
issues.append(Issue(
|
||||
"heading.case", "warning",
|
||||
f"Спецраздел лучше писать ПРОПИСНЫМИ: «{upper}»",
|
||||
line,
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_forbidden_abbreviations(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in RIS_RE.finditer(text):
|
||||
issues.append(Issue("ref.ris", "error",
|
||||
"Используйте «Рисунок», не «рис.»", _line_of(text, m.start())))
|
||||
for m in TABL_RE.finditer(text):
|
||||
issues.append(Issue("ref.tabl", "error",
|
||||
"Используйте «Таблица», не «табл.»", _line_of(text, m.start())))
|
||||
for m in FOOTNOTE_RE.finditer(text):
|
||||
issues.append(Issue("footnote", "error",
|
||||
"Сноски в конце страницы не допускаются", _line_of(text, m.start())))
|
||||
for m in NOPP_RE.finditer(text):
|
||||
issues.append(Issue("table.nopp", "error",
|
||||
"Графу «№ п/п» в таблицы не включают", _line_of(text, m.start())))
|
||||
return issues
|
||||
|
||||
|
||||
def check_citations_in_intro_conclusion(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for title, body, line in _sections(text):
|
||||
if title in ("ВВЕДЕНИЕ", "ЗАКЛЮЧЕНИЕ"):
|
||||
for m in CITE_RE.finditer(body):
|
||||
issues.append(Issue(
|
||||
"cite.intro", "error",
|
||||
f"Во разделе «{title}» ссылки на источники не указываются",
|
||||
line + body[:m.start()].count("\n"),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_bibliography(text: str, profile: DocProfile) -> list[Issue]:
|
||||
issues = []
|
||||
if profile.min_sources <= 0 and not profile.require_bibliography:
|
||||
# PIS_custom и др.: список источников не обязателен
|
||||
m_probe = re.search(
|
||||
r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$",
|
||||
text, re.M | re.I,
|
||||
)
|
||||
if not m_probe:
|
||||
return issues
|
||||
# Extract biblio block
|
||||
m = re.search(
|
||||
r"^#\s*\*?\s*СПИСОК\s+ИСПОЛЬЗОВАНН?ЫХ\s+ИСТОЧНИКОВ\s*$",
|
||||
text, re.M | re.I,
|
||||
)
|
||||
if not m:
|
||||
return issues
|
||||
start = m.end()
|
||||
next_h = re.search(r"^#\s+", text[start:], re.M)
|
||||
block = text[start: start + next_h.start()] if next_h else text[start:]
|
||||
|
||||
entries = []
|
||||
for line in block.splitlines():
|
||||
bm = BIBLIO_LINE_RE.match(line.strip())
|
||||
if bm:
|
||||
entries.append((bm.group(1), bm.group(2), extract_year(bm.group(2))))
|
||||
|
||||
n = len(entries)
|
||||
if profile.sectional_biblio:
|
||||
# Count per ## section roughly
|
||||
if n < profile.min_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "warning",
|
||||
f"ВКР: в каждом разделе списка желательно ≥{profile.min_sources} источников "
|
||||
f"(сейчас всего {n})",
|
||||
))
|
||||
else:
|
||||
if n < profile.min_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "error",
|
||||
f"Источников должно быть от {profile.min_sources} до {profile.max_sources} "
|
||||
f"(сейчас {n})",
|
||||
))
|
||||
elif profile.max_sources and n > profile.max_sources:
|
||||
issues.append(Issue(
|
||||
"biblio.count", "error",
|
||||
f"Источников должно быть от {profile.min_sources} до {profile.max_sources} "
|
||||
f"(сейчас {n})",
|
||||
))
|
||||
|
||||
year_now = current_year()
|
||||
for key, txt, year in entries:
|
||||
if year and year < year_now - profile.max_source_age_years:
|
||||
issues.append(Issue(
|
||||
"biblio.age", "error",
|
||||
f"Источник [{key}] старше {profile.max_source_age_years} лет (год {year})",
|
||||
))
|
||||
|
||||
# Citation order vs first appearance
|
||||
# Exclude intro/conclusion from citation scan for order
|
||||
body_for_cites = text
|
||||
cites = find_citations(body_for_cites)
|
||||
# Filter cites that appear only in intro/conclusion — still listed
|
||||
keys = [e[0] for e in entries]
|
||||
if cites and keys:
|
||||
# First N unique cites should match order of keys for simple lists
|
||||
if not profile.sectional_biblio:
|
||||
expected = cites[:len(keys)]
|
||||
if keys != expected and set(keys) == set(expected):
|
||||
issues.append(Issue(
|
||||
"biblio.order", "warning",
|
||||
"Порядок источников в списке должен совпадать с порядком первого упоминания",
|
||||
))
|
||||
for c in cites:
|
||||
if c not in keys:
|
||||
issues.append(Issue(
|
||||
"biblio.missing", "error",
|
||||
f"Ссылка [{c}] есть в тексте, но нет в списке источников",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_object_refs(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
# Captions / images
|
||||
labels = set(re.findall(r"^%(\w+)", text, re.M))
|
||||
# image titles with %id
|
||||
labels.update(re.findall(r'!\[[^\]]*\]\([^)]*%(\w+)', text))
|
||||
refs = set(re.findall(r"@[\wА-Яа-я]+:(\w+)", text))
|
||||
for lab in labels:
|
||||
# At least one @?:lab or word reference — soft check
|
||||
if lab not in refs and f"@{lab}" not in text:
|
||||
# only warn if label looks intentional
|
||||
pass
|
||||
for ref in refs:
|
||||
if ref not in labels:
|
||||
issues.append(Issue(
|
||||
"ref.dangling", "error",
|
||||
f"Ссылка на несуществующую метку «{ref}»",
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
def check_appendices(text: str) -> list[Issue]:
|
||||
issues = []
|
||||
for m in APPENDIX_LETTER_RE.finditer(text):
|
||||
letter = m.group(1).upper()
|
||||
if letter in FORBIDDEN_APPENDIX:
|
||||
issues.append(Issue(
|
||||
"appendix.letter", "error",
|
||||
f"Буква «{letter}» не используется для обозначения приложений",
|
||||
_line_of(text, m.start()),
|
||||
))
|
||||
return issues
|
||||
|
||||
|
||||
_TABLE_BLOCK_RE = re.compile(
|
||||
r"(?:^[ \t]*\|.+\|[ \t]*\n)+"
|
||||
r"(?:^[ \t]*\|[-:| ]+\|[ \t]*\n)"
|
||||
r"(?:^[ \t]*\|.+\|[ \t]*\n?)*",
|
||||
re.M,
|
||||
)
|
||||
_MERGE_ONLY_RE = re.compile(r"^\s*(\^{1,2}|>{1,2})\s*$")
|
||||
|
||||
|
||||
def _split_table_row(line: str) -> list[str]:
|
||||
parts = re.split(r"\s*(?<!\\)\|\s*", line.strip())
|
||||
if parts and not parts[0]:
|
||||
parts.pop(0)
|
||||
if parts and not parts[-1]:
|
||||
parts.pop()
|
||||
return parts
|
||||
|
||||
|
||||
def check_table_merge(text: str) -> list[Issue]:
|
||||
"""Validate ^ / > merge markers in pipe tables."""
|
||||
issues: list[Issue] = []
|
||||
for tm in _TABLE_BLOCK_RE.finditer(text):
|
||||
block = tm.group(0)
|
||||
block_start = tm.start()
|
||||
lines = [ln for ln in block.splitlines() if ln.strip()]
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
|
||||
header = _split_table_row(lines[0])
|
||||
data_rows = [_split_table_row(ln) for ln in lines[2:]]
|
||||
n_cols = len(header)
|
||||
header_line = _line_of(text, block_start)
|
||||
|
||||
for cell in header:
|
||||
if _MERGE_ONLY_RE.match(cell):
|
||||
issues.append(Issue(
|
||||
"table.merge_header", "error",
|
||||
"Маркер склеивания ^/> нельзя использовать в первой (заголовочной) строке таблицы",
|
||||
header_line,
|
||||
))
|
||||
break
|
||||
|
||||
for r_i, raw_cells in enumerate(data_rows):
|
||||
line_no = header_line + 2 + r_i
|
||||
cells = (raw_cells + [""] * n_cols)[:n_cols]
|
||||
for c_i, cell in enumerate(cells):
|
||||
m = _MERGE_ONLY_RE.match(cell)
|
||||
if not m:
|
||||
continue
|
||||
if m.group(1).startswith(">") and c_i == 0:
|
||||
issues.append(Issue(
|
||||
"table.merge_col", "error",
|
||||
"Маркер «>» нельзя ставить в первом столбце (нет ячейки слева)",
|
||||
line_no,
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_markdown(text: str, doc_type: str = "coursework") -> list[Issue]:
|
||||
profile = get_profile(doc_type)
|
||||
issues: list[Issue] = []
|
||||
issues.extend(check_structure(text, profile))
|
||||
issues.extend(check_special_headings(text))
|
||||
issues.extend(check_pis_headings(text, profile))
|
||||
issues.extend(check_forbidden_abbreviations(text))
|
||||
if profile.require_intro_conclusion:
|
||||
issues.extend(check_citations_in_intro_conclusion(text))
|
||||
issues.extend(check_bibliography(text, profile))
|
||||
issues.extend(check_object_refs(text))
|
||||
issues.extend(check_appendices(text))
|
||||
issues.extend(check_table_merge(text))
|
||||
return issues
|
||||
|
||||
|
||||
def format_report(issues: Iterable[Issue]) -> str:
|
||||
issues = list(issues)
|
||||
if not issues:
|
||||
return "Проверка ТЗ: замечаний нет."
|
||||
lines = [f"Проверка ТЗ: найдено замечаний — {len(issues)}"]
|
||||
for i in issues:
|
||||
loc = f" (стр. md:{i.line})" if i.line else ""
|
||||
lines.append(f" [{i.severity}] {i.id}{loc}: {i.message}")
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user