jcode/skills/write-my-paper/check_paper.py

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:]))