#!/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 /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"(? 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:]))