Spaces:
Running
Running
File size: 12,582 Bytes
2eb3609 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | """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()
|