[ADD] вывод предупреждений и ошибок LaTeX наружу из контейнера
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user