"""Tester for the Journal Formatter: formats every test manuscript (devtools/fmt_corpus.py) into several journal styles and checks each Word / LaTeX output against the source and the journal's rules. python devtools/fmt_audit.py [--presets ieee_journal,elsevier] [--docs numeric,messy] [--latex] Prints one line per problem; ends with a summary. --latex also compiles every LaTeX project (texlive.net, test content only) and reports errors, undefined citations / references and overfull lines.""" import argparse import io import re import subprocess import sys import tempfile import zipfile from collections import Counter from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(Path(__file__).resolve().parent)) import os os.environ.setdefault("SKIP_MODEL_LOAD", "1") sys.stdout.reconfigure(encoding="utf-8") import docx # noqa: E402 import fmt_corpus as C # noqa: E402 from formatmatch.engine import FormatSource, build_spec, format_manuscript # noqa: E402 PRESETS = ["ieee_journal", "elsevier", "elsevier_harvard", "springer_journal", "mdpi", "emerald", "plos", "wiley"] PAGE = {"A4": (21.0, 29.7), "Letter": (21.59, 27.94), "letter": (21.59, 27.94)} W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" def norm(t): t = re.sub(r"\[[\d,\s–\-]+\]|\([A-Z][^()]*?\d{4}[a-z]?\)", " ", t or "") # citations (they are rewritten) t = t.replace("Fig.", "Figure").replace("–", "-").replace("—", "-") return re.findall(r"[a-z0-9]+", t.lower()) def source_paragraphs(kind): """The body text the output must still contain: abstract, every paragraph / list item, captions, footnote.""" out = [C.ABSTRACT, C.TABLE_CAPTION, C.FIG_CAPTION, "The authors thank the Centre for Energy Storage"] for k, t in C.BODY: if k in ("p", "ul", "ol"): out.append(re.sub(r"\{(c|f|t):[\d,]+\}|\{fn\}", " ", t)) return out class Audit: def __init__(self, label): self.label, self.problems = label, [] def bad(self, what): self.problems.append(what) print(f" ✗ [{self.label}] {what}", flush=True) def check_docx(a, data, spec, kind): d = docx.Document(io.BytesIO(data)) z = zipfile.ZipFile(io.BytesIO(data)) xml = z.read("word/document.xml").decode("utf-8") paras = [p.text for p in d.paragraphs] text = "\n".join(paras + [c.text for t in d.tables for r in t.rows for c in r.cells]) words = Counter(norm(text)) # 1. nothing lost for src in source_paragraphs(kind): w = norm(src) missing = [x for x in set(w) if words[x] == 0 and len(x) > 2] if len(missing) > max(1, len(set(w)) // 12): a.bad(f"text lost from “{src[:60]}…”: missing {missing[:6]}") foot = z.read("word/footnotes.xml").decode("utf-8") if "word/footnotes.xml" in z.namelist() else "" if "differential scanning calorimetry" not in foot: a.bad("footnote lost") if xml.count(" len(refs) or min(cited) < 1): a.bad(f"citation number out of range: {sorted(cited)} for {len(refs)} references") if cited and len(cited) < len(refs): a.bad(f"references never cited: {sorted(set(range(1, len(refs) + 1)) - cited)}") order = [] for c in numeric_cites: # every number of a group, in reading order for part in re.split(r"\s*,\s*", c): ab = re.split(r"\s*[–-]\s*", part) order += list(range(int(ab[0]), int(ab[-1]) + 1)) firsts = [x for i, x in enumerate(order) if x not in order[:i]] if firsts != sorted(firsts) and spec["references"]["style"] in ("ieee", "vancouver", "numeric", "ama"): a.bad(f"numbers not in order of first citation: {firsts}") else: if numeric_cites: a.bad(f"numeric citations left in an author-year journal: {numeric_cites[:3]}") if not ay_cites: a.bad("no author-year citations in the text") for c in ay_cites: for one in re.split(r";\s*", c): sur = re.match(r"([A-Z][A-Za-z\-]+)", one.strip()) yr = re.search(r"\d{4}", one) if sur and yr and not any(sur.group(1) in r and yr.group() in r for r in refs): a.bad(f"citation ({one.strip()}) has no reference") # 4. the journal's rules sec = d.sections[0] want = PAGE.get(spec["page"]["size"]) if want and (abs(sec.page_width.cm - want[0]) > 0.3 or abs(sec.page_height.cm - want[1]) > 0.3): a.bad(f"page {sec.page_width.cm:.1f}×{sec.page_height.cm:.1f} cm, journal wants {spec['page']['size']}") cols = re.findall(r']*w:num="(\d+)"', xml) if int(spec["page"].get("columns", 1)) > 1 and str(spec["page"]["columns"]) not in cols: a.bad(f"{spec['page']['columns']} columns expected, found {cols or ['1']}") fam = spec["font"]["family"] styles = z.read("word/styles.xml").decode("utf-8") if fam not in styles and fam not in xml: a.bad(f"body font {fam} not used") kw = next((p for p in paras if "liquid cooling" in p.lower() and "electric vehicle" in p.lower() and "lithium-ion battery" in p.lower() and len(p) < 220), "") if not kw: a.bad("keywords line lost") elif spec["keywords"]["separator"].strip() and spec["keywords"]["separator"].strip() not in kw: a.bad(f"keywords separator {spec['keywords']['separator']!r} not used: {kw[:80]}") fig_lab = spec["captions"]["figure_label"] caps = [p for p in paras if re.match(r"(Figure|Fig\.?)\s*1\b", p.strip(), re.I)] if not any(c.strip().lower().startswith(fig_lab.lower()) for c in caps): a.bad(f"figure caption does not use “{fig_lab}”: {caps[:1]}") # 5. debris for p in paras: if re.search(r"\{[cft]:|\{fn\}|\?\?|\s{2,}\S", p) and not p.startswith("\t"): a.bad(f"stray characters: {p[:80]!r}") break empties = sum(1 for p in d.paragraphs if not p.text.strip() and not p._p.xpath(".//w:drawing|.//m:oMath")) if empties > 4: a.bad(f"{empties} empty paragraphs") def check_latex(a, data, spec, compile_it): z = zipfile.ZipFile(io.BytesIO(data)) tex = z.read("main.tex").decode("utf-8") if "differential scanning calorimetry" not in tex: a.bad("LaTeX: footnote lost") if "\\footnote" not in tex: a.bad("LaTeX: no \\footnote") for m in re.findall(r"10\.\d{4,}/[^\s,;}]+|https?://[^\s}]+", tex): pass if "batteryuniversity.com" not in tex: a.bad("LaTeX: URL of the web reference lost") if re.search(r"\{c:|\{fn\}|\?\?", tex): a.bad("LaTeX: stray characters") if not compile_it: return files = [("main.tex", tex)] + [(n, z.read(n).decode("utf-8")) for n in z.namelist() if n.endswith((".bib", ".cls", ".sty", ".bst"))] tex2 = "\\PassOptionsToPackage{demo}{graphicx}\n" + tex # images are not sent: grey boxes instead files[0] = ("document.tex", tex2) # (the service compiles document.tex) with tempfile.TemporaryDirectory() as td: args = ["curl", "-s", "-L", "--max-time", "200", "-o", str(Path(td) / "out")] for n, body in files: p = Path(td) / n.replace("/", "_") p.write_text(body, encoding="utf-8", newline="\n") args += ["-F", f"filecontents[]=<{p}", "-F", f"filename[]={n}"] args += ["-F", "engine=pdflatex", "-F", "return=log", "https://texlive.net/cgi-bin/latexcgi"] subprocess.run(args, capture_output=True, timeout=260) log = (Path(td) / "out").read_bytes().decode("utf-8", "replace") if (Path(td) / "out").exists() else "" if not log: a.bad("LaTeX: compile service did not answer") return errs = re.findall(r"^! .*", log, re.M) if errs: a.bad(f"LaTeX: {len(errs)} error(s): {errs[:2]}") if re.search(r"Citation .* undefined", log): a.bad("LaTeX: undefined citations") if re.search(r"Reference .* undefined", log): a.bad("LaTeX: undefined references") over = [float(x) for x in re.findall(r"Overfull \\hbox \(([\d.]+)pt", log)] if [x for x in over if x > 5]: a.bad(f"LaTeX: {len([x for x in over if x > 5])} line(s) run into the margin (up to {max(over):.0f}pt)") if "Output written" not in log and not errs: a.bad("LaTeX: no PDF produced") def main(): ap = argparse.ArgumentParser() ap.add_argument("--presets", default=",".join(PRESETS)) ap.add_argument("--docs", default="numeric,authoryear,messy") ap.add_argument("--latex", action="store_true") a_ = ap.parse_args() total, failed = 0, Counter() for kind in a_.docs.split(","): data = io.BytesIO() C.build(kind).save(data) for preset in a_.presets.split(","): spec = build_spec(FormatSource(preset=preset)) label = f"{kind} → {preset}" print(f"• {label}", flush=True) a = Audit(label) try: r = format_manuscript(f"{kind}.docx", data.getvalue(), spec) except Exception as err: a.bad(f"FORMATTER CRASHED: {type(err).__name__}: {err}") failed[label] += 1 continue check_docx(a, r.docx, spec, kind) check_latex(a, r.latex_zip, spec, a_.latex) total += 1 failed[label] += len(a.problems) bad = {k: v for k, v in failed.items() if v} print(f"\n{total} outputs checked, {sum(bad.values())} problem(s) in {len(bad)} of them") if __name__ == "__main__": main()