Spaces:
Running
Running
unknown
Claude Opus 5.5
Journal Formatter: footnotes, web references, books and reports, author marks, cleaner LaTeX
2eb3609 Download devtools/fmt_audit.py from Rasheed/Test_AI_writing: direct link, hf CLI and curl.
- Browser
- Download file 12.6 kB
-
https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/devtools/fmt_audit.py
- Command line
-
hf download hf://spaces/Rasheed/Test_AI_writing/devtools/fmt_audit.py
-
curl -L -o fmt_audit.py https://huggingface.co/spaces/Rasheed/Test_AI_writing/resolve/main/devtools/fmt_audit.py
12.6 kB
| """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("<m:oMath") < C.FACTS["equations"]: | |
| a.bad("equation lost") | |
| if len(d.inline_shapes) < C.FACTS["figures"]: | |
| a.bad("figure lost") | |
| if len(d.tables) < C.FACTS["tables"]: | |
| a.bad("table lost") | |
| for row in C.TABLE: | |
| for cell in row: | |
| if not any(cell == c.text.strip() for t in d.tables for r in t.rows for c in r.cells): | |
| a.bad(f"table cell lost: {cell!r}") | |
| break | |
| # 2. references: all there, each with its year, first author and DOI / URL | |
| ref_head = next((i for i, p in enumerate(d.paragraphs) if p.text.strip().lower().rstrip(".:") in | |
| ("references", "reference", "bibliography", "literature cited")), None) | |
| refs = [p.text for p in d.paragraphs[ref_head + 1:] if p.text.strip()] if ref_head is not None else [] | |
| if len(refs) != C.FACTS["refs"]: | |
| a.bad(f"{len(refs)} references in the output, {C.FACTS['refs']} in the manuscript") | |
| for key, num, ay in C.REFS: | |
| surname = re.split(r"[ ,]", key)[0] | |
| year = re.search(r"\d{4}", key).group() | |
| hit = [r for r in refs if surname in r and year in r] | |
| if not hit: | |
| a.bad(f"reference {key} not found (author + year)") | |
| continue | |
| for m in re.findall(r"10\.\d{4,}/[^\s,;]+|https?://\S+", ay): | |
| core = m.rstrip(".").replace("https://doi.org/", "") | |
| if core not in hit[0]: | |
| a.bad(f"reference {key}: DOI/URL lost ({core[:40]})") | |
| if re.search(r"\bAvailable\b(?!:| (?:at|from|online):)", hit[0], re.I): | |
| a.bad(f"reference {key}: dangling 'Available' ({hit[0][-60:]})") | |
| # 3. citations: right mode, every one resolves, every reference cited | |
| body = "\n".join(paras[:ref_head] if ref_head else paras) | |
| mode = spec["citations"]["mode"] | |
| numeric_cites = re.findall(r"\[(\d+(?:\s*[,–-]\s*\d+)*)\]", body) | |
| ay_cites = re.findall(r"\(([A-Z][A-Za-z\- ]+(?: et al\.)?(?:,| and| &)[^()]*?\d{4}[a-z]?)\)", body) | |
| if mode in ("numeric", "superscript"): | |
| if ay_cites: | |
| a.bad(f"author-year citations left in a numeric journal: {ay_cites[:2]}") | |
| cited = set() | |
| for c in numeric_cites: | |
| for part in re.split(r"\s*,\s*", c): | |
| ab = re.split(r"\s*[–-]\s*", part) | |
| lo, hi = int(ab[0]), int(ab[-1]) | |
| cited.update(range(lo, hi + 1)) | |
| sup = [r for p in d.paragraphs[:ref_head or len(d.paragraphs)] for r in p.runs | |
| if r.font.superscript and re.fullmatch(r"[\d,–\-]+", r.text.strip() or "x")] | |
| if mode == "superscript" and not sup: | |
| a.bad("superscript citations expected, none found") | |
| if mode == "numeric": | |
| if not numeric_cites: | |
| a.bad("no numeric citations in the text") | |
| if cited and (max(cited) > 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:cols [^>]*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() | |