diff --git a/md2gost/latex/log.py b/md2gost/latex/log.py new file mode 100644 index 0000000..8b14ed0 --- /dev/null +++ b/md2gost/latex/log.py @@ -0,0 +1,128 @@ +"""Разбор журнала xelatex: ошибки, предупреждения и дефекты вёрстки. + +Без этого разбора журнал остаётся во временном каталоге сборки и исчезает +вместе с ним — при запуске в контейнере узнать о выходе текста за поле +можно было только открыв PDF и заметив это глазом. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# «Overfull \hbox (56.7pt too wide) in alignment at lines 220--220» +# «Underfull \hbox (badness 7000) in paragraph at lines 10--11» +# «Overfull \hbox (7.2pt too wide) detected at line 14» +_BOX_RE = re.compile( + r"^(?POverfull|Underfull)\s+\\hbox\s+" + r"\((?:(?P[\d.]+)pt too wide|badness (?P\d+))\)" + r"(?:.*?(?:at lines (?P\d+)--\d+|detected at line (?P\d+)))?", + re.MULTILINE, +) +_ERROR_RE = re.compile(r"^! (?P.+)$", re.MULTILINE) +_WARNING_RE = re.compile( + r"^(?P(?:LaTeX|Package|Class)[^\n]*Warning:[^\n]*" + r"(?:\n(?!\s*$)(?!(?:LaTeX|Package|Class)[^\n]*Warning:)[^\n]*)*)", + re.MULTILINE, +) + +# Пакет unicode-math на каждой сборке сообщает, что перекрывает команды +# mathtools. Это ожидаемо и ничего не требует, но два многострочных сообщения +# в каждом документе заглушают собой те предупреждения, на которые надо смотреть. +_IGNORED_WARNINGS = ( + "unicode-math Warning: Using \\overbracket", + "unicode-math Warning: I'm going to overwrite", + # Оформление подписей объявлено в преамбуле для всех видов объектов сразу; + # в документе без таблиц или листингов часть настроек закономерно не нужна. + "caption Warning: Unused \\captionsetup", +) + +# Виды, при которых --strict считает сборку неудачной. Разреженные строки и +# предупреждения пакетов бывают следствием намеренной вёрстки (растяжки на +# титульном листе), а выход текста за поле — всегда видимый брак. +STRICT_KINDS = ("error", "overfull") + +_TITLES = { + "error": "ошибки LaTeX", + "warning": "предупреждения LaTeX", + "overfull": "текст выходит за правое поле", + "underfull": "разреженные строки", +} +_MAX_PER_KIND = 20 + + +@dataclass(frozen=True) +class Diagnostic: + kind: str + message: str + line: int | None = None + amount: float | None = None + + +def parse_log( + text: str, + *, + overfull_threshold_pt: float = 1.0, + underfull_threshold: int = 5000, +) -> list[Diagnostic]: + """Диагностика из журнала сборки. + + Пороги отсекают шум: вылет в доли пункта глазом не виден, а строки с + небольшой разреженностью встречаются в любом нормально свёрстанном тексте. + """ + diags: list[Diagnostic] = [] + + for match in _ERROR_RE.finditer(text): + diags.append(Diagnostic("error", match.group("message").strip())) + + for match in _WARNING_RE.finditer(text): + message = " ".join(match.group("message").split()) + if any(noise in message for noise in _IGNORED_WARNINGS): + continue + diags.append(Diagnostic("warning", message)) + + for match in _BOX_RE.finditer(text): + line_group = match.group("lines") or match.group("line") + line = int(line_group) if line_group else None + if match.group("pt") is not None: + amount = float(match.group("pt")) + if amount < overfull_threshold_pt: + continue + diags.append(Diagnostic("overfull", match.group(0).strip(), line, amount)) + else: + badness = float(match.group("badness")) + if badness < underfull_threshold: + continue + diags.append(Diagnostic("underfull", match.group(0).strip(), line, badness)) + + return diags + + +def _describe(diag: Diagnostic) -> str: + where = f"строка {diag.line}" if diag.line is not None else "место не указано" + if diag.kind == "overfull": + return f" {where}: на {diag.amount:.1f} pt шире поля" + if diag.kind == "underfull": + return f" {where}: разреженность {diag.amount:.0f}" + return f" {diag.message}" + + +def format_diagnostics(diags: list[Diagnostic]) -> str: + """Человекочитаемая сводка; пустая строка, если сообщать не о чем.""" + if not diags: + return "" + + blocks: list[str] = [] + for kind in ("error", "warning", "overfull", "underfull"): + group = [d for d in diags if d.kind == kind] + if not group: + continue + if kind in ("overfull", "underfull"): + group.sort(key=lambda d: d.amount or 0.0, reverse=True) + lines = [f"{_TITLES[kind]}: {len(group)}"] + lines += [_describe(d) for d in group[:_MAX_PER_KIND]] + if len(group) > _MAX_PER_KIND: + lines.append(f" и ещё {len(group) - _MAX_PER_KIND}") + blocks.append("\n".join(lines)) + + return "\n".join(blocks) diff --git a/md2gost/main.py b/md2gost/main.py index 4722f47..36a1bed 100644 --- a/md2gost/main.py +++ b/md2gost/main.py @@ -6,15 +6,25 @@ import tempfile from pathlib import Path import md2gost +from md2gost.latex.log import STRICT_KINDS, format_diagnostics, parse_log from md2gost.parser import Parser, iter_image_sources from md2gost.render import Renderer -def _print_latex_errors(log_path: Path) -> None: - if log_path.exists(): - for line in log_path.read_text(errors="replace").splitlines(): - if line.startswith("!"): - print(f" {line}", file=sys.stderr) +def _report_latex_log(log_path: Path) -> bool: + """Печатает диагностику сборки. Возвращает True при серьёзных дефектах. + + Журнал живёт во временном каталоге и исчезает вместе с ним, поэтому всё, + о чём сообщил xelatex, нужно вывести наружу здесь — иначе при запуске + в контейнере узнать о дефектах вёрстки неоткуда. + """ + if not log_path.exists(): + return False + diags = parse_log(log_path.read_text(errors="replace")) + summary = format_diagnostics(diags) + if summary: + print(summary, file=sys.stderr) + return any(d.kind in STRICT_KINDS for d in diags) def run_xelatex(tex_path: Path, output_dir: Path) -> bool: @@ -85,6 +95,11 @@ def main() -> None: action="store_true", help="только .tex, без компиляции в PDF", ) + cli.add_argument( + "--strict", + action="store_true", + help="выйти с ошибкой, если xelatex сообщил о предупреждениях", + ) args = cli.parse_args() if not args.input.exists(): @@ -118,13 +133,20 @@ def main() -> None: if not run_xelatex(tex_path, cache_dir): stage = "" if attempt == 1 else " (2-й проход)" print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr) - _print_latex_errors(log_path) + _report_latex_log(log_path) sys.exit(1) final_pdf = args.output / pdf_name shutil.copy2(cache_dir / pdf_name, final_pdf) print(f"-> {final_pdf}") + # Разбирается журнал второго прохода: после первого ссылки и номера + # страниц ещё не разложены, и часть предупреждений ложная. + has_defects = _report_latex_log(log_path) + if has_defects and args.strict: + print("ошибка: --strict, а вёрстка содержит дефекты", file=sys.stderr) + sys.exit(1) + if __name__ == "__main__": main() diff --git a/tests/test_log.py b/tests/test_log.py new file mode 100644 index 0000000..ca1d3de --- /dev/null +++ b/tests/test_log.py @@ -0,0 +1,112 @@ +from md2gost.latex.log import ( + STRICT_KINDS, + Diagnostic, + format_diagnostics, + parse_log, +) + +ERROR_LOG = """ +This is XeTeX +! LaTeX Error: File `emblem' not found. +See the LaTeX manual or LaTeX Companion for explanation. +""" + +OVERFULL_LOG = r""" +Overfull \hbox (56.70982pt too wide) in alignment at lines 220--220 + [] +Overfull \hbox (7.2pt too wide) detected at line 14 + [] +Overfull \hbox (0.4pt too wide) in paragraph at lines 30--31 +""" + +UNDERFULL_LOG = r""" +Underfull \hbox (badness 3000) in paragraph at lines 10--11 +Underfull \hbox (badness 7000) in paragraph at lines 20--21 +""" + +WARNING_LOG = """ +LaTeX Warning: Reference `fig:1' on page 3 undefined on input line 42. +LaTeX Warning: There were undefined references. +""" + + +def test_error_is_parsed() -> None: + diags = parse_log(ERROR_LOG) + assert len(diags) == 1 + assert diags[0].kind == "error" + assert "File `emblem' not found" in diags[0].message + + +def test_overfull_amount_and_line() -> None: + diags = [d for d in parse_log(OVERFULL_LOG) if d.kind == "overfull"] + assert [d.amount for d in diags] == [56.70982, 7.2] + assert [d.line for d in diags] == [220, 14] + + +def test_overfull_below_threshold_is_dropped() -> None: + """Вылеты в доли пункта не видны глазом и только зашумляют вывод.""" + diags = parse_log(OVERFULL_LOG, overfull_threshold_pt=1.0) + assert all(d.amount != 0.4 for d in diags if d.amount is not None) + + +def test_underfull_threshold() -> None: + diags = [d for d in parse_log(UNDERFULL_LOG, underfull_threshold=5000)] + assert len(diags) == 1 + assert diags[0].kind == "underfull" + assert diags[0].amount == 7000 + assert diags[0].line == 20 + + +def test_warning_is_parsed() -> None: + diags = parse_log(WARNING_LOG) + assert [d.kind for d in diags] == ["warning", "warning"] + assert "undefined" in diags[0].message + + +def test_clean_log_gives_nothing() -> None: + assert parse_log("This is XeTeX\nOutput written on a.pdf (3 pages).\n") == [] + + +def test_format_sorts_overfull_by_amount() -> None: + text = format_diagnostics(parse_log(OVERFULL_LOG)) + first = text.index("56.7") + second = text.index("7.2") + assert first < second + + +def test_format_is_empty_for_no_diagnostics() -> None: + assert format_diagnostics([]) == "" + + +def test_format_mentions_every_kind() -> None: + diags = parse_log(ERROR_LOG + OVERFULL_LOG + UNDERFULL_LOG + WARNING_LOG) + text = format_diagnostics(diags) + for word in ("ошибк", "предупрежд", "за правое поле", "разрежен"): + assert word in text + + +def test_diagnostic_is_hashable() -> None: + assert Diagnostic("error", "x", None, None) == Diagnostic("error", "x", None, None) + + +NOISE_LOG = r""" +Package unicode-math Warning: Using \overbracket and \underbracket from +(unicode-math) `mathtools' package. + +LaTeX Warning: There were undefined references. +""" + + +def test_known_package_noise_is_suppressed() -> None: + """Постоянный шум unicode-math заглушает собой полезные предупреждения.""" + diags = parse_log(NOISE_LOG) + assert len(diags) == 1 + assert "undefined references" in diags[0].message + + +def test_strict_ignores_underfull_and_warnings() -> None: + """Разреженная строка бывает намеренной, выход за поле — никогда.""" + assert "overfull" in STRICT_KINDS + assert "error" in STRICT_KINDS + assert "underfull" not in STRICT_KINDS + assert "warning" not in STRICT_KINDS diff --git a/tests/test_render.py b/tests/test_render.py index 62cf41e..37bf014 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -221,9 +221,7 @@ def test_ptp_report_team_line_includes_repository() -> None: def test_ptp_report_team_line_without_repository() -> None: - fm = PTP_REPORT_FM.replace( - "хранилище: https://monitor.yss.su/IU9/radio-serv\n", "" - ) + fm = PTP_REPORT_FM.replace("хранилище: https://monitor.yss.su/IU9/radio-serv\n", "") tex = render_fm(fm) assert "хранилище" not in tex @@ -313,7 +311,14 @@ def test_ptp_task_has_no_hardcoded_assignment_text() -> None: from md2gost.latex.titlepages import ptp_task source = inspect.getsource(ptp_task) - for forbidden in ("Web", "2D-игра", "GitFlic", "Javascript", "Посевин", "Коновалов"): + for forbidden in ( + "Web", + "2D-игра", + "GitFlic", + "Javascript", + "Посевин", + "Коновалов", + ): assert forbidden not in source