291 lines
13 KiB
Python
291 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""check_paper.py - compile the LaTeX paper and check it the way a strict co-author would.
|
|
|
|
python check_paper.py paper/main.tex [--summary paper/data/summary.csv] [--max-pages 9] [--no-compile]
|
|
|
|
1. Compiles (latexmk -> pdflatex+bibtex -> tectonic, whichever exists) and extracts LaTeX errors.
|
|
2. Log: undefined citations/references, duplicate labels, missing files, overfull boxes > 10pt.
|
|
3. Source (main.tex + every \\input/\\include): \\cite keys missing from the .bib, uncited .bib entries,
|
|
\\ref targets without \\label, figures/tables never referenced, leftover TODO/XXX/??/placeholder text,
|
|
abstract length, bib entries without author/title/year.
|
|
4. Number provenance: every decimal number / percentage in the prose (tables generated by results.py
|
|
excluded) is looked up in summary.csv (mean, std, ci95, min, max; scaled x1 and x100, rounded to the
|
|
shown digits). Numbers not found are listed - each one must be traced to data, a cited paper, or removed.
|
|
5. Page count (pdfinfo or pymupdf), page on which the references start.
|
|
Prints a compact report and writes the full one to <tex dir>/check_report.md. Exit code 1 if errors.
|
|
"""
|
|
import csv, re, shutil, subprocess, sys
|
|
from pathlib import Path
|
|
|
|
CITE_RE = re.compile(r"\\(?:no)?cite[a-zA-Z]*\*?(?:\[[^\]]*\]){0,2}\{([^}]*)\}")
|
|
REF_RE = re.compile(r"\\(?:ref|cref|Cref|autoref|eqref|pageref|nameref|vref)\*?\{([^}]*)\}")
|
|
LABEL_RE = re.compile(r"\\label\{([^}]*)\}")
|
|
INPUT_RE = re.compile(r"\\(?:input|include|subfile)\{([^}]*)\}")
|
|
BIB_RE = re.compile(r"\\(?:bibliography|addbibresource)\{([^}]*)\}")
|
|
TODO_RE = re.compile(r"\bTODO\b|\bXXX\b|\bFIXME\b|\?\?\?|\[citation needed\]|lorem ipsum|\bTBD\b|<[A-Z][A-Z _-]{3,}>", re.I)
|
|
NUM_RE = re.compile(r"(?<![\w.\\{-])(\d+\.\d+|\d+(?=\s*\\?%))(\s*\\?%)?")
|
|
|
|
|
|
def strip_comments(t):
|
|
return re.sub(r"(?<!\\)%.*", "", t)
|
|
|
|
|
|
def gather(tex, seen=None):
|
|
"""Concatenated source of tex and its inputs: list of (path, text)."""
|
|
seen = seen or set()
|
|
p = tex if tex.suffix else tex.with_suffix(".tex")
|
|
if p in seen or not p.is_file():
|
|
return []
|
|
seen.add(p)
|
|
text = strip_comments(p.read_text(encoding="utf-8", errors="replace"))
|
|
out = [(p, text)]
|
|
for inc in INPUT_RE.findall(text):
|
|
q = (TEX_DIR / inc)
|
|
out += gather(q if q.suffix else q.with_suffix(".tex"), seen)
|
|
return out
|
|
|
|
|
|
def expand(p, depth=0):
|
|
"""Source of p with every \\input/\\include replaced by the included text (for abstract etc.)."""
|
|
p = p if p.suffix else p.with_suffix(".tex")
|
|
if depth > 10 or not p.is_file():
|
|
return ""
|
|
text = strip_comments(p.read_text(encoding="utf-8", errors="replace"))
|
|
return INPUT_RE.sub(lambda m: expand(TEX_DIR / m.group(1), depth + 1), text)
|
|
|
|
|
|
def run(cmd, cwd, timeout=300):
|
|
try:
|
|
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
return r.returncode, r.stdout + r.stderr
|
|
except subprocess.TimeoutExpired:
|
|
return 1, f"TIMEOUT: {' '.join(cmd)}"
|
|
|
|
|
|
def compile_tex(tex):
|
|
d, name = tex.parent, tex.stem
|
|
if shutil.which("latexmk"):
|
|
code, out = run(["latexmk", "-pdf", "-interaction=nonstopmode", "-halt-on-error", "-file-line-error", tex.name], d)
|
|
how = "latexmk"
|
|
elif shutil.which("pdflatex"):
|
|
how, out, code = "pdflatex+bibtex", "", 0
|
|
for cmd in (["pdflatex"], ["bibtex"], ["pdflatex"], ["pdflatex"]):
|
|
arg = name if cmd[0] == "bibtex" else tex.name
|
|
extra = [] if cmd[0] == "bibtex" else ["-interaction=nonstopmode", "-halt-on-error", "-file-line-error"]
|
|
c, o = run(cmd + extra + [arg], d)
|
|
out += o
|
|
if c and cmd[0] != "bibtex":
|
|
code = c; break
|
|
elif shutil.which("tectonic"):
|
|
code, out = run(["tectonic", "--keep-logs", tex.name], d)
|
|
how = "tectonic"
|
|
else:
|
|
return None, "no LaTeX found: install TeX Live (latexmk, pdflatex, bibtex) or tectonic", []
|
|
log = d / f"{name}.log"
|
|
logtext = log.read_text(encoding="utf-8", errors="replace") if log.is_file() else out
|
|
errors = []
|
|
lines = logtext.splitlines()
|
|
for i, l in enumerate(lines):
|
|
if l.startswith("!") or re.match(r"^[^:\s]+\.tex:\d+: ", l):
|
|
errors.append(" | ".join(x.strip() for x in lines[i:i + 3] if x.strip())[:300])
|
|
if code and not errors:
|
|
errors.append(out.strip().splitlines()[-1][:300] if out.strip() else f"{how} failed")
|
|
return how, logtext, errors[:15]
|
|
|
|
|
|
def log_warnings(logtext):
|
|
w = []
|
|
w += [f"undefined citation: {k}" for k in sorted(set(re.findall(r"Citation [`']([^']+)' (?:on page \d+ )?undefined", logtext)))]
|
|
w += [f"undefined reference: {k}" for k in sorted(set(re.findall(r"Reference [`']([^']+)' on page \d+ undefined", logtext)))]
|
|
w += [f"label multiply defined: {k}" for k in sorted(set(re.findall(r"Label [`']([^']+)' multiply defined", logtext)))]
|
|
w += [f"missing file: {k}" for k in sorted(set(re.findall(r"File [`']([^']+)' not found", logtext)))]
|
|
for m in re.finditer(r"Overfull \\hbox \((\d+\.?\d*)pt too wide\)[^\n]*lines? (\d+)", logtext):
|
|
if float(m.group(1)) > 10:
|
|
w.append(f"overfull hbox {m.group(1)}pt near line {m.group(2)}")
|
|
if "Rerun to get" in logtext[-3000:]:
|
|
w.append("LaTeX asks for a rerun (cross-references may be stale)")
|
|
return w
|
|
|
|
|
|
def bib_entries(paths):
|
|
entries = {}
|
|
for b in paths:
|
|
if not b.is_file():
|
|
continue
|
|
text = b.read_text(encoding="utf-8", errors="replace")
|
|
for m in re.finditer(r"@(\w+)\s*\{\s*([^,\s]+)\s*,(.*?)(?=\n@|\Z)", text, re.S):
|
|
if m.group(1).lower() in ("string", "comment", "preamble"):
|
|
continue
|
|
body = m.group(3).lower()
|
|
entries[m.group(2)] = {"type": m.group(1).lower(),
|
|
"missing": [f for f in ("author", "title", "year") if not re.search(rf"\b{f}\s*=", body)]}
|
|
return entries
|
|
|
|
|
|
def summary_numbers(path):
|
|
vals = []
|
|
if not path or not Path(path).is_file():
|
|
return None
|
|
with open(path, newline="") as f:
|
|
for row in csv.DictReader(f):
|
|
for k in ("mean", "std", "ci95", "min", "max"):
|
|
try:
|
|
v = float(row.get(k, ""))
|
|
except ValueError:
|
|
continue
|
|
vals += [v, v * 100]
|
|
# also plotted numbers next to figures
|
|
return vals
|
|
|
|
|
|
def number_found(txt, vals, extra):
|
|
if txt in extra:
|
|
return True
|
|
digits = len(txt.split(".")[1]) if "." in txt else 0
|
|
x = float(txt)
|
|
tol = 0.5 * 10 ** (-digits) + 1e-9
|
|
return any(abs(v - x) <= tol for v in vals)
|
|
|
|
|
|
def prose_numbers(files):
|
|
out = []
|
|
for p, text in files:
|
|
if "generated by results.py" in text:
|
|
continue
|
|
body = re.sub(r"\\begin\{(table\*?|tabular|equation\*?|align\*?)\}.*?\\end\{\1\}", " ", text, flags=re.S)
|
|
body = re.sub(r"\$[^$]*\$", lambda m: m.group(0) if re.search(r"\d\.\d", m.group(0)) else " ", body)
|
|
body = re.sub(r"\\(?:includegraphics|vspace|hspace|setlength|resizebox|scalebox)\*?(\[[^\]]*\])?\{[^}]*\}", " ", body)
|
|
body = re.sub(r"\[[^\]]*(?:width|height|scale)=[^\]]*\]", " ", body)
|
|
body = re.sub(r"\\documentclass\[[^\]]*\]|\\usepackage\[[^\]]*\]", " ", body)
|
|
for i, line in enumerate(body.splitlines(), 1):
|
|
for m in NUM_RE.finditer(line):
|
|
num = m.group(1)
|
|
if not m.group(2) and "." not in num:
|
|
continue
|
|
if re.fullmatch(r"(19|20)\d\d", num):
|
|
continue
|
|
out.append((p.name, i, num + ("%" if m.group(2) else ""), line.strip()[:110]))
|
|
return out
|
|
|
|
|
|
def pdf_info(pdf):
|
|
pages, ref_page = None, None
|
|
if shutil.which("pdfinfo"):
|
|
c, o = run(["pdfinfo", str(pdf)], pdf.parent, 30)
|
|
m = re.search(r"Pages:\s+(\d+)", o)
|
|
pages = int(m.group(1)) if m else None
|
|
if shutil.which("pdftotext") and pages:
|
|
for pg in range(1, pages + 1):
|
|
c, o = run(["pdftotext", "-f", str(pg), "-l", str(pg), str(pdf), "-"], pdf.parent, 30)
|
|
if re.search(r"(?m)^\s*(References|Bibliography)\s*$", o):
|
|
ref_page = pg; break
|
|
if pages is None:
|
|
try:
|
|
import pymupdf as fitz
|
|
except ImportError:
|
|
try:
|
|
import fitz
|
|
except ImportError:
|
|
return None, None
|
|
with fitz.open(pdf) as doc:
|
|
pages = doc.page_count
|
|
for i, page in enumerate(doc):
|
|
if re.search(r"(?m)^\s*(References|Bibliography)\s*$", page.get_text()):
|
|
ref_page = i + 1; break
|
|
return pages, ref_page
|
|
|
|
|
|
def main(argv):
|
|
global TEX_DIR
|
|
if not argv or argv[0] in ("-h", "--help"):
|
|
print(__doc__); return 0
|
|
tex = Path(argv[0]).resolve()
|
|
TEX_DIR = tex.parent
|
|
opt = lambda k, d=None: argv[argv.index(k) + 1] if k in argv else d
|
|
summary = opt("--summary", str(TEX_DIR / "data" / "summary.csv"))
|
|
max_pages = int(opt("--max-pages", 0) or 0)
|
|
report, errors, warns = [], [], []
|
|
|
|
if "--no-compile" not in argv:
|
|
how, logtext, errs = compile_tex(tex)
|
|
if how is None:
|
|
warns.append(logtext)
|
|
else:
|
|
report.append(f"compiled with {how}: {'FAILED' if errs else 'ok'}")
|
|
errors += [f"LaTeX: {e}" for e in errs]
|
|
warns += log_warnings(logtext)
|
|
|
|
files = gather(tex)
|
|
src = "\n".join(t for _, t in files)
|
|
cites = {k.strip() for g in CITE_RE.findall(src) for k in g.split(",") if k.strip()}
|
|
bibs = [TEX_DIR / (b.strip() if b.strip().endswith(".bib") else b.strip() + ".bib")
|
|
for g in BIB_RE.findall(src) for b in g.split(",")]
|
|
entries = bib_entries(bibs)
|
|
if not bibs:
|
|
warns.append("no \\bibliography{...} found")
|
|
for k in sorted(cites - set(entries)):
|
|
errors.append(f"cited but not in .bib: {k}")
|
|
uncited = sorted(set(entries) - cites)
|
|
if uncited:
|
|
warns.append(f"{len(uncited)} .bib entries never cited (fine with natbib, but check): {', '.join(uncited[:15])}")
|
|
for k, e in entries.items():
|
|
if e["missing"]:
|
|
warns.append(f"bib entry {k} lacks {', '.join(e['missing'])}")
|
|
labels = LABEL_RE.findall(src)
|
|
refs = {k.strip() for g in REF_RE.findall(src) for k in g.split(",")}
|
|
for k in sorted(refs - set(labels)):
|
|
errors.append(f"\\ref to undefined label: {k}")
|
|
for k in sorted(set(l for l in labels if l.startswith(("fig:", "tab:"))) - refs):
|
|
warns.append(f"{k} is never referenced in the text")
|
|
for p, text in files:
|
|
for i, line in enumerate(text.splitlines(), 1):
|
|
if TODO_RE.search(line):
|
|
errors.append(f"placeholder in {p.name}:{i}: {line.strip()[:100]}")
|
|
m = re.search(r"\\begin\{abstract\}(.*?)\\end\{abstract\}", expand(tex), re.S)
|
|
if m:
|
|
words = len(re.sub(r"\\[a-zA-Z]+|[{}$]", " ", m.group(1)).split())
|
|
report.append(f"abstract: {words} words")
|
|
if words > 250:
|
|
warns.append(f"abstract has {words} words (> 250)")
|
|
else:
|
|
warns.append("no abstract environment")
|
|
|
|
vals = summary_numbers(summary)
|
|
nums = prose_numbers(files)
|
|
if vals is None:
|
|
warns.append(f"no summary file {summary} - number provenance not checked (run results.py summary)")
|
|
else:
|
|
plotted = set()
|
|
for dcsv in (TEX_DIR / "figures").glob("*.data.csv"):
|
|
plotted |= set(re.findall(r"-?\d+\.\d+", dcsv.read_text()))
|
|
unknown = [n for n in nums if not number_found(n[2].rstrip("%"), vals, plotted)]
|
|
report.append(f"number provenance: {len(nums) - len(unknown)}/{len(nums)} decimal numbers in the prose match summary.csv")
|
|
for f, i, num, line in unknown[:25]:
|
|
warns.append(f"number {num} ({f}:{i}) not in summary.csv - from a cited paper (cite it) or a setting? else fix: {line}")
|
|
|
|
pdf = tex.with_suffix(".pdf")
|
|
if pdf.is_file():
|
|
pages, refp = pdf_info(pdf)
|
|
if pages:
|
|
body = refp if refp else pages # references start on that page: count it as text
|
|
report.append(f"pdf: {pages} pages; references start on page {refp or '?'} (main text ~{body} pages)")
|
|
if max_pages and body > max_pages:
|
|
errors.append(f"main text ~{body} pages > limit {max_pages}")
|
|
elif "--no-compile" not in argv:
|
|
errors.append("no PDF produced")
|
|
|
|
full = ["# check_paper report", ""] + report + ["", f"## Errors ({len(errors)})"] + [f"- {e}" for e in errors] + \
|
|
["", f"## Warnings ({len(warns)})"] + [f"- {w}" for w in warns]
|
|
(TEX_DIR / "check_report.md").write_text("\n".join(full) + "\n", encoding="utf-8")
|
|
print("\n".join(report))
|
|
print(f"\nERRORS ({len(errors)}):" + ("".join(f"\n - {e}" for e in errors[:20]) or " none"))
|
|
print(f"WARNINGS ({len(warns)}):" + ("".join(f"\n - {w[:220]}" for w in warns[:20]) or " none"))
|
|
if len(errors) > 20 or len(warns) > 20:
|
|
print(f"... full list in {TEX_DIR / 'check_report.md'}")
|
|
return 1 if errors else 0
|
|
|
|
|
|
TEX_DIR = Path(".")
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|