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()