Test_AI_writing / formatter_tab.py
unknown
Claude Opus 5.5
Faster and steadier: jobs survive silent connection drops, Journal Finder warm start, parallel lookups and caches, fast engine up to FAST_MAX_WORDS
1f241b9
Raw History Blame Contribute Delete
13.9 kB
"""Journal Formatter tab: arrange a manuscript into a journal's format (Word + an Overleaf-ready LaTeX project).
The work is done by the FormatMatch engine in ./formatmatch: it reads the manuscript (.docx, .pdf, LaTeX .tex / .zip
- an Overleaf "Download source" zip works -, Markdown), builds the target format from a journal name, a publisher
house style, the journal's Word template or its author guidelines, and writes the paper again in that format with
a compliance report. Equations, algorithms and complex tables are carried over untouched.
Overleaf: the LaTeX project is a zip with main.tex at its top level, refs.bib and figures/, using classes that
Overleaf's TeX Live has (IEEEtran, elsarticle, llncs, sn-jnl ...). The customer downloads it and uploads it to
Overleaf himself (New Project -> Upload Project); the page explains how.
"""
import html
import os
import re
import tempfile
import time
from pathlib import Path
import gradio as gr
from tools_common import download_row
TOOL = "Journal Formatter" # job key: a reconnecting page picks up its own running job
try:
from formatmatch.engine import (FormatSource, build_spec, fetch_guidelines_url, format_manuscript,
guidelines_text_from_file, learn_template)
from formatmatch.read_any import SUPPORTED
from formatmatch.spec import PRESETS, lookup_journal, parse_guidelines
AVAILABLE, WHY = True, ""
except Exception as _err: # the suite must start even if this tool cannot load
AVAILABLE, WHY = False, f"{type(_err).__name__}: {_err}"
PRESETS, SUPPORTED = {}, []
OUT_DIR = Path(tempfile.gettempdir()) / "cs_outputs"
AUTO = "Recognise from the journal name"
REF_STYLES = ["(from the journal)", "ieee", "apa", "harvard", "emerald-harvard", "vancouver", "ama", "chicago",
"springer-basic", "springer-vancouver", "lncs", "mdpi", "elsevier-numeric", "mla"]
CITE_MODES = ["(from the journal)", "numeric", "superscript", "author-year", "author-year-nocomma"]
MAX_MB = float(os.getenv("FORMATTER_MAX_MB", "40"))
def _preset_choices():
return [AUTO] + [PRESETS[k]["name"] for k in PRESETS]
def _preset_key(label):
return next((k for k in PRESETS if PRESETS[k]["name"] == label), None)
def overleaf_html(zip_path):
"""How to use the LaTeX project in Overleaf. The customer downloads the zip and uploads it there himself:
there is no button that sends it to Overleaf."""
if not zip_path or not os.path.exists(zip_path):
return ""
return ('<div class="cs-gauge-cap" style="text-align:left">🍃 <b>To use it in Overleaf:</b> download the LaTeX '
'project above, then in Overleaf choose <b>New Project → Upload Project</b> and select the .zip. It '
'opens with <code>main.tex</code>, the references in <code>refs.bib</code> and the figures in '
'<code>figures/</code>; compile with pdfLaTeX.</div>')
def _data(path):
p = Path(str(path))
if p.stat().st_size > MAX_MB * 1024 * 1024:
raise gr.Error(f"That file is larger than {MAX_MB:.0f} MB. Remove very large images or split the document.")
return p.name, p.read_bytes()
def format_doc(manuscript, journal, style, template, guidelines, guidelines_url, want_docx, want_latex, crossref,
ref_style, cite_mode, progress=gr.Progress()):
if not manuscript:
raise gr.Error("Upload your manuscript first.")
journal, guidelines, guidelines_url = (journal or "").strip(), (guidelines or "").strip(), (guidelines_url or "").strip()
if not (journal or template or guidelines or guidelines_url or _preset_key(style)):
raise gr.Error("Tell the tool the target format: type the journal's name, choose a publisher style, or "
"upload the journal's Word template.")
if not (want_docx or want_latex):
raise gr.Error("Choose at least one output: Word or LaTeX.")
name, data = _data(manuscript)
src, found = FormatSource(), []
progress(0.03, desc="Working out the target format")
if journal:
try:
src.journal = lookup_journal(journal)
if src.journal.get("publisher"):
found.append(f"{src.journal.get('title') or journal} — {src.journal['publisher']}")
except Exception as err: # the lookup is a convenience (Crossref), never required
print(f"[formatter] journal lookup failed: {err}", flush=True)
src.preset = _preset_key(style) or (src.journal or {}).get("preset")
if template:
try:
ov, prof, notes = learn_template(*_data(template))
src.template_overrides, src.profile = ov, prof
found += list(notes)[:6]
except Exception as err:
raise gr.Error(f"The template could not be read: {err}")
text = guidelines
if guidelines_url:
try:
text += "\n" + fetch_guidelines_url(guidelines_url)
except Exception as err:
found.append(f"The guidelines page could not be opened ({err}); paste its text instead.")
if text.strip():
src.guidelines = parse_guidelines(text)
found += list(src.guidelines.get("_found", []))[:6]
manual = {}
if ref_style and not ref_style.startswith("("):
manual["references"] = {"style": ref_style}
if cite_mode and not cite_mode.startswith("("):
manual["citations"] = {"mode": cite_mode}
src.manual = manual
if not (src.preset or src.template_overrides or src.guidelines):
raise gr.Error(f"“{journal}” was not recognised as a known publisher. Choose the closest publisher style, "
"or upload the journal's Word template or author guidelines.")
spec = build_spec(src)
try:
res = format_manuscript(name, data, spec, src.profile, bool(want_docx), bool(want_latex), bool(crossref),
progress=lambda f, m: progress(0.05 + 0.9 * min(1.0, f), desc=m))
except RuntimeError as err: # e.g. LaTeX input without pandoc on the server
raise gr.Error(str(err))
except Exception as err:
print(f"[formatter] failed on {name}: {type(err).__name__}: {err}", flush=True)
raise gr.Error(f"This manuscript could not be formatted ({type(err).__name__}). If it is a PDF, try the "
"Word or LaTeX source instead.")
job = Path(tempfile.mkdtemp(prefix="format_", dir=str(OUT_DIR)))
base = re.sub(r"[^A-Za-z0-9_-]+", "_", os.path.splitext(name)[0]).strip("_") or "manuscript"
paths = {}
for key, blob, fname in (("docx", res.docx, f"{base}_formatted.docx"), ("zip", res.latex_zip, f"{base}_latex.zip"),
("md", res.report_md.encode("utf-8"), f"{base}_format_report.md")):
if blob:
(job / fname).write_bytes(blob)
paths[key] = str(job / fname)
checks = res.report.checks
n_ok = sum(1 for _, ok, _ in checks if ok)
print(f"[formatter] {name} -> {spec.get('name', 'custom')}: {n_ok}/{len(checks)} checks, "
f"{len(res.report.changes)} changes, {len(res.report.warnings)} warnings", flush=True)
notes = "".join(f"<li>{html.escape(str(f))}</li>" for f in found[:8])
status = (f'<div class="cs-status cs-done">✅ <b>Formatted as {html.escape(str(spec.get("name", "custom format")))}'
f'</b> — {n_ok} of {len(checks)} checks passed, {len(res.report.changes)} changes made, '
f'{len(res.report.warnings)} point(s) need your attention.'
+ (f'<ul style="margin:6px 0 0 18px">{notes}</ul>' if notes else "") + "</div>")
buttons = download_row((paths.get("docx"), "Download Word document"), (paths.get("zip"), "Download LaTeX project"),
(paths.get("md"), "Download report"))
return (status, buttons, paths.get("docx"), paths.get("zip"), paths.get("md"),
overleaf_html(paths.get("zip")) if paths.get("zip") else "", res.report_md)
def build(ui=None):
"""The tab's interface (call inside `with gr.Tab(...)`). `ui` carries the suite's shared look: its animated
status panel (wait, run_live, job) so this tab shows the same logo animation and progress bar as the others;
without it (tests, stand-alone use) Gradio's own progress display is used."""
gr.HTML('<div class="cs-intro"><b>Journal Formatter.</b> Upload your manuscript and name the journal. You get '
'it back arranged in that journal\'s format as a Word file and as a LaTeX project you can upload to '
'Overleaf, with a report of what was changed. Equations, algorithms and tables are carried over '
'untouched.<div class="cs-flow"><span>1 · Upload the manuscript</span><span>2 · Name the journal</span>'
'<span>3 · Download Word / LaTeX for Overleaf</span></div></div>')
if not AVAILABLE:
gr.HTML(f'<div class="cs-status cs-err">⚠️ The formatter could not be loaded on this server '
f'({html.escape(WHY)}).</div>')
return
with gr.Row(equal_height=False):
with gr.Column(elem_classes="cs-card"):
ms = gr.File(label="Manuscript (.docx, .pdf, LaTeX .tex or .zip incl. an Overleaf source zip, .md)",
file_types=["." + e for e in SUPPORTED], type="filepath")
journal = gr.Textbox(label="Target journal or conference",
placeholder="e.g. IEEE Access, Expert Systems with Applications")
style = gr.Dropdown(_preset_choices(), value=AUTO, label="Publisher style",
info="Leave as it is when you typed the journal's name; choose one if the journal "
"is not recognised.")
with gr.Accordion("📄 I have the journal's template or author guidelines (optional, most accurate)",
open=False):
template = gr.File(label="Journal Word template (.docx / .dotx) or a published sample paper (.pdf)",
file_types=[".docx", ".dotx", ".dotm", ".doc", ".pdf"], type="filepath")
guidelines = gr.Textbox(label="Author guidelines (paste the text)", lines=4)
guidelines_url = gr.Textbox(label="…or the address of the guidelines page",
placeholder="https://…/author-guidelines")
with gr.Accordion("⚙️ More options (not needed)", open=False):
with gr.Row():
want_docx = gr.Checkbox(value=True, label="Word (.docx)")
want_latex = gr.Checkbox(value=True, label="LaTeX project for Overleaf (.zip)")
crossref = gr.Checkbox(value=False, label="Check references online (slower)",
info="Fills in missing volume, pages and DOI from Crossref")
with gr.Row():
ref_style = gr.Dropdown(REF_STYLES, value=REF_STYLES[0], label="Reference style")
cite_mode = gr.Dropdown(CITE_MODES, value=CITE_MODES[0], label="In-text citations")
go = gr.Button("Format my manuscript", variant="primary", size="lg")
with gr.Column(elem_classes="cs-card"):
status = gr.HTML('<div class="cs-status cs-idle">Upload a manuscript, name the journal and press '
'<b>Format my manuscript</b>.</div>')
dl = gr.HTML() # download buttons that carry the files themselves
f_docx = gr.File(label="Formatted Word document", interactive=False)
f_zip = gr.File(label="LaTeX project (.zip) for Overleaf", interactive=False)
overleaf = gr.HTML()
f_md = gr.File(label="Formatting report", interactive=False)
with gr.Accordion("Formatting report", open=False):
report = gr.Markdown()
inputs = [ms, journal, style, template, guidelines, guidelines_url, want_docx, want_latex, crossref, ref_style,
cite_mode]
outputs = [status, dl, f_docx, f_zip, f_md, overleaf, report]
if ui: # the suite's branded status panel (logo animation, bar)
def core(*args): # args = the inputs + the job's progress reporter
return format_doc(*args[:-1], progress=args[-1])
def run(*args, request: gr.Request = None):
*args, user = args # the signed-in user (or None) comes last
t0, last = time.time(), None
name = os.path.basename(str(args[0])) if args[0] else ""
try:
for frame in ui["run_live"](core, tuple(args), len(outputs), 0, user=user, request=request,
tool=TOOL):
last = frame
yield frame
except Exception as err: # shown to the customer by the status panel; log it too
ui["log"](user, request, "Journal Formatter", True, {"name": name}, time.time() - t0, "error",
str(getattr(err, "message", err)))
raise
out = last[2] or last[3] if last and len(last) > 3 else None # Word file, else the LaTeX zip
ui["log"](user, request, "Journal Formatter", True,
{"name": name, "output": out if isinstance(out, str) else None}, time.time() - t0, "ok")
go.click(lambda: ui["wait"], None, status, queue=False, **ui["hid"]).then(
run, inputs + [ui["user"]], outputs, **ui["job"](status))
else:
go.click(format_doc, inputs, outputs, api_name=False, show_progress="full",
concurrency_limit=int(os.getenv("FORMATTER_CONCURRENCY", "3")))