[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
|
from pathlib import Path
|
||||||
|
|
||||||
import md2gost
|
import md2gost
|
||||||
|
from md2gost.latex.log import STRICT_KINDS, format_diagnostics, parse_log
|
||||||
from md2gost.parser import Parser, iter_image_sources
|
from md2gost.parser import Parser, iter_image_sources
|
||||||
from md2gost.render import Renderer
|
from md2gost.render import Renderer
|
||||||
|
|
||||||
|
|
||||||
def _print_latex_errors(log_path: Path) -> None:
|
def _report_latex_log(log_path: Path) -> bool:
|
||||||
if log_path.exists():
|
"""Печатает диагностику сборки. Возвращает True при серьёзных дефектах.
|
||||||
for line in log_path.read_text(errors="replace").splitlines():
|
|
||||||
if line.startswith("!"):
|
Журнал живёт во временном каталоге и исчезает вместе с ним, поэтому всё,
|
||||||
print(f" {line}", file=sys.stderr)
|
о чём сообщил 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:
|
def run_xelatex(tex_path: Path, output_dir: Path) -> bool:
|
||||||
@@ -85,6 +95,11 @@ def main() -> None:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="только .tex, без компиляции в PDF",
|
help="только .tex, без компиляции в PDF",
|
||||||
)
|
)
|
||||||
|
cli.add_argument(
|
||||||
|
"--strict",
|
||||||
|
action="store_true",
|
||||||
|
help="выйти с ошибкой, если xelatex сообщил о предупреждениях",
|
||||||
|
)
|
||||||
args = cli.parse_args()
|
args = cli.parse_args()
|
||||||
|
|
||||||
if not args.input.exists():
|
if not args.input.exists():
|
||||||
@@ -118,13 +133,20 @@ def main() -> None:
|
|||||||
if not run_xelatex(tex_path, cache_dir):
|
if not run_xelatex(tex_path, cache_dir):
|
||||||
stage = "" if attempt == 1 else " (2-й проход)"
|
stage = "" if attempt == 1 else " (2-й проход)"
|
||||||
print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr)
|
print(f"ошибка: xelatex{stage} не собрал PDF", file=sys.stderr)
|
||||||
_print_latex_errors(log_path)
|
_report_latex_log(log_path)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
final_pdf = args.output / pdf_name
|
final_pdf = args.output / pdf_name
|
||||||
shutil.copy2(cache_dir / pdf_name, final_pdf)
|
shutil.copy2(cache_dir / pdf_name, final_pdf)
|
||||||
print(f"-> {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__":
|
if __name__ == "__main__":
|
||||||
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:
|
def test_ptp_report_team_line_without_repository() -> None:
|
||||||
fm = PTP_REPORT_FM.replace(
|
fm = PTP_REPORT_FM.replace("хранилище: https://monitor.yss.su/IU9/radio-serv\n", "")
|
||||||
"хранилище: https://monitor.yss.su/IU9/radio-serv\n", ""
|
|
||||||
)
|
|
||||||
tex = render_fm(fm)
|
tex = render_fm(fm)
|
||||||
assert "хранилище" not in tex
|
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
|
from md2gost.latex.titlepages import ptp_task
|
||||||
|
|
||||||
source = inspect.getsource(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
|
assert forbidden not in source
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user