[ADD] вывод предупреждений и ошибок LaTeX наружу из контейнера

This commit is contained in:
Arseny
2026-09-02 12:52:05 +03:00
parent 4f0c2ce16d
commit 6b1f508825
4 changed files with 277 additions and 10 deletions
+128
View File
@@ -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"^(?P<kind>Overfull|Underfull)\s+\\hbox\s+"
r"\((?:(?P<pt>[\d.]+)pt too wide|badness (?P<badness>\d+))\)"
r"(?:.*?(?:at lines (?P<lines>\d+)--\d+|detected at line (?P<line>\d+)))?",
re.MULTILINE,
)
_ERROR_RE = re.compile(r"^! (?P<message>.+)$", re.MULTILINE)
_WARNING_RE = re.compile(
r"^(?P<message>(?: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)
+28 -6
View File
@@ -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()
+112
View File
@@ -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
+9 -4
View File
@@ -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