|
#!/usr/bin/env python3 |
|
"""List overfull boxes from a .log file with source file and line.""" |
|
|
|
from __future__ import annotations |
|
|
|
import argparse |
|
import pathlib |
|
import re |
|
from dataclasses import dataclass |
|
|
|
|
|
@dataclass |
|
class Overfull: |
|
amount: float |
|
file: str |
|
line_info: str |
|
log_line: int |
|
|
|
|
|
OPEN_PAT = re.compile(r"\(\./([^()\s]+\.tex)") |
|
OVER_PAT = re.compile( |
|
r"Overfull \\hbox \(([-0-9.]+)pt too wide\) " |
|
r"(?:in paragraph at lines ([0-9]+)--([0-9]+)|detected at line ([0-9]+))" |
|
) |
|
|
|
|
|
def parse_log(path: pathlib.Path) -> list[Overfull]: |
|
lines = path.read_text(encoding="latin-1", errors="ignore").splitlines() |
|
current: str | None = None |
|
items: list[Overfull] = [] |
|
default_source = path.with_suffix(".tex").name |
|
use_structured = False |
|
level_stack: list[tuple[int, str]] = [] |
|
struct_pat = re.compile(r"^=+ *\(LEVEL *(\d+) *(START|STOP)\) (.+)$") |
|
for idx, line in enumerate(lines, 1): |
|
if "Package: structuredlog" in line: |
|
use_structured = True |
|
if use_structured: |
|
stripped = line.strip() |
|
m = struct_pat.match(stripped) |
|
if m: |
|
level = int(m.group(1)) |
|
action = m.group(2) |
|
name = m.group(3).strip() |
|
if action == "START": |
|
level_stack.append((level, name)) |
|
else: |
|
# pop matching level or higher |
|
while level_stack and level_stack[-1][0] >= level: |
|
level_stack.pop() |
|
current = level_stack[-1][1] if level_stack else None |
|
else: |
|
matches = OPEN_PAT.findall(line) |
|
if matches: |
|
filtered = [] |
|
for name in matches: |
|
if f"(./{name})" in line: |
|
continue |
|
filtered.append(name) |
|
if filtered: |
|
current = filtered[-1] |
|
match = OVER_PAT.search(line) |
|
if match: |
|
amount = float(match.group(1)) |
|
line_info = match.group(4) if match.group(4) else f"{match.group(2)}--{match.group(3)}" |
|
items.append( |
|
Overfull( |
|
amount=amount, |
|
file=current or default_source, |
|
line_info=line_info, |
|
log_line=idx, |
|
) |
|
) |
|
return items |
|
|
|
|
|
def load_context(path: pathlib.Path, line_info: str, radius: int = 2) -> list[str]: |
|
try: |
|
content = path.read_text(encoding="utf-8").splitlines() |
|
except FileNotFoundError: |
|
return [f"(file not found: {path})"] |
|
except UnicodeDecodeError: |
|
content = path.read_text(encoding="latin-1", errors="ignore").splitlines() |
|
if "--" in line_info: |
|
start_str, end_str = line_info.split("--", 1) |
|
start = int(start_str) |
|
end = int(end_str) |
|
else: |
|
start = end = int(line_info) |
|
start = max(1, start - radius) |
|
end = min(len(content), end + radius) |
|
snippet = [] |
|
for idx in range(start, end + 1): |
|
snippet.append(f"> {idx:>4}: {content[idx - 1]}") |
|
return snippet |
|
|
|
|
|
def infer_log_path(parser: argparse.ArgumentParser) -> pathlib.Path: |
|
candidates = sorted( |
|
tex.with_suffix(".log") |
|
for tex in pathlib.Path.cwd().glob("*.tex") |
|
if tex.with_suffix(".log").is_file() |
|
) |
|
if len(candidates) == 1: |
|
return candidates[0] |
|
if not candidates: |
|
parser.error("no log argument supplied and no .tex file with a matching .log was found") |
|
formatted = ", ".join(str(path) for path in candidates) |
|
parser.error( |
|
"no log argument supplied and more than one .tex file with a matching " |
|
f".log was found: {formatted}" |
|
) |
|
|
|
|
|
def main() -> None: |
|
parser = argparse.ArgumentParser(description="List overfull boxes from a .log file.") |
|
parser.add_argument("log", nargs="?", help="Path to the log file.") |
|
parser.add_argument("--min", type=float, default=20.0, help="Minimum threshold in pt.") |
|
parser.add_argument("-v", action="store_true", help="Show context from the source file.") |
|
parser.add_argument("--debug", action="store_true", help="Print structuredlog parsing debug output.") |
|
args = parser.parse_args() |
|
|
|
log_path = pathlib.Path(args.log) if args.log else infer_log_path(parser) |
|
items = [item for item in parse_log(log_path) if item.amount > args.min] |
|
items.sort(key=lambda x: x.amount, reverse=True) |
|
|
|
if not items: |
|
print("No overfull boxes above the threshold.") |
|
return |
|
|
|
for item in items: |
|
if "--" in item.line_info: |
|
line_out = item.line_info.split("--", 1)[0] |
|
else: |
|
line_out = item.line_info |
|
print( |
|
f"{item.file}:{line_out}: " |
|
f"{item.amount:.2f}pt overfull (log line {item.log_line})" |
|
) |
|
if args.v and item.file != "UNKNOWN": |
|
for line in load_context(pathlib.Path(item.file), item.line_info): |
|
print(line) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |