File size: 7,882 Bytes
7ee2a84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""High-level API: build the target spec from all sources and format a manuscript."""
from __future__ import annotations

import io
import os
import re
import shutil
import subprocess
import tempfile
import zipfile
from dataclasses import dataclass, field
from typing import Optional

from .pipeline import Report, prepare
from .read_any import read_any
from .render_docx import render_docx
from .render_latex import render_latex
from .spec import DEFAULT_SPEC, deep_merge, get_preset, lookup_journal, parse_guidelines, parse_guidelines_llm
from .template import analyze_docx_template, analyze_pdf_sample


def dotx_to_docx(data: bytes) -> bytes:
    """Word templates (.dotx/.dotm) differ from .docx only in the main part's content type."""
    src = zipfile.ZipFile(io.BytesIO(data))
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as out:
        for item in src.infolist():
            content = src.read(item.filename)
            if item.filename == "[Content_Types].xml":
                content = re.sub(rb"application/vnd\.ms-word\.template\.macroEnabledTemplate\.main\+xml|"
                                 rb"application/vnd\.openxmlformats-officedocument\.wordprocessingml\.template\.main\+xml",
                                 b"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
                                 content)
            out.writestr(item, content)
    return buf.getvalue()


def guidelines_text_from_file(name: str, data: bytes) -> str:
    ext = name.rsplit(".", 1)[-1].lower()
    if ext == "pdf":
        import pymupdf
        return "\n".join(p.get_text() for p in pymupdf.open(stream=data, filetype="pdf"))
    if ext in ("docx", "dotx"):
        import docx
        d = docx.Document(io.BytesIO(dotx_to_docx(data) if ext == "dotx" else data))
        return "\n".join(p.text for p in d.paragraphs)
    if ext in ("html", "htm"):
        return html_to_text(data.decode("utf-8", "ignore"))
    return data.decode("utf-8", "ignore")


def html_to_text(html: str) -> str:
    html = re.sub(r"(?is)<(script|style|nav|footer|header)[^>]*>.*?</\1>", " ", html)
    html = re.sub(r"(?i)<br\s*/?>|</p>|</li>|</h\d>|</div>", "\n", html)
    text = re.sub(r"<[^>]+>", " ", html)
    import html as h
    text = h.unescape(text)
    return re.sub(r"[ \t]+", " ", re.sub(r"\n\s*\n+", "\n\n", text)).strip()


def fetch_guidelines_url(url: str, timeout=20) -> str:
    import requests
    r = requests.get(url, timeout=timeout, headers={"User-Agent": "Mozilla/5.0 (FormatMatch guideline reader)"})
    r.raise_for_status()
    ctype = r.headers.get("content-type", "")
    if "pdf" in ctype or url.lower().endswith(".pdf"):
        return guidelines_text_from_file("g.pdf", r.content)
    return html_to_text(r.text)


@dataclass
class FormatSource:
    preset: Optional[str] = None                 # preset key chosen/derived from journal name
    journal: Optional[dict] = None               # lookup_journal() result
    guidelines: Optional[dict] = None            # overrides from guidelines
    template_overrides: Optional[dict] = None    # overrides from template/sample
    profile: object = None                       # TemplateProfile (docx templates only)
    manual: dict = field(default_factory=dict)   # user edits in the UI
    found: list = field(default_factory=list)    # human-readable detection notes


def build_spec(src: FormatSource) -> dict:
    spec = get_preset(src.preset) if src.preset else DEFAULT_SPEC
    if src.guidelines:
        spec = deep_merge(spec, {k: v for k, v in src.guidelines.items() if not k.startswith("_")})
    if src.template_overrides:
        spec = deep_merge(spec, {k: v for k, v in src.template_overrides.items() if not k.startswith("_")})
    if src.manual:
        spec = deep_merge(spec, src.manual)
    if src.journal and src.journal.get("title"):
        spec["name"] = src.journal["title"] + (f" ({spec.get('name')})" if spec.get("name") else "")
    return spec


def learn_template(name: str, data: bytes):
    """Returns (overrides, profile_or_None, found_notes)."""
    ext = name.rsplit(".", 1)[-1].lower()
    if ext in ("dotx", "dotm"):
        data = dotx_to_docx(data)
        ext = "docx"
    if ext == "docx":
        ov, prof = analyze_docx_template(data)
        return ov, prof, list(prof.found)
    if ext == "pdf":
        ov = analyze_pdf_sample(data)
        return ov, None, ov.pop("_found", [])
    if ext in ("doc", "odt", "rtf"):
        from .read_any import _office_to_docx
        d2 = _office_to_docx(data, ext)
        ov, prof = analyze_docx_template(d2)
        return ov, prof, list(prof.found)
    raise RuntimeError("Template/sample must be .docx, .dotx, .doc or a published-paper .pdf")


@dataclass
class Result:
    docx: Optional[bytes]
    latex_zip: Optional[bytes]
    report: Report
    report_md: str
    spec: dict
    preview_pdf: Optional[bytes] = None


def format_manuscript(filename: str, data: bytes, spec: dict, profile=None, want_docx=True, want_latex=True,
                      crossref=False, progress=None, preview=False) -> Result:
    def prog(f, msg):
        if progress:
            progress(f, msg)

    prog(0.05, "Reading manuscript")
    doc = read_any(filename, data)
    prog(0.2, "Restructuring: headings, captions, citations, references")
    prepared, rep = prepare(doc, spec, crossref=crossref,
                            progress=(lambda f, m: prog(0.2 + 0.4 * f, m)) if crossref else None)
    docx_bytes = latex_bytes = None
    if want_docx:
        prog(0.65, "Writing Word document")
        docx_bytes, w = render_docx(prepared, spec, profile)
        for x in w:
            rep.warn(x)
    if want_latex:
        prog(0.8, "Writing LaTeX project")
        lw = []
        latex_bytes = render_latex(prepared, spec, rep.refs, lw)
        for x in lw:
            rep.warn(x)
    pdf = None
    if preview and docx_bytes:
        prog(0.9, "Rendering preview")
        pdf = docx_to_pdf(docx_bytes)
    prog(1.0, "Done")
    return Result(docx_bytes, latex_bytes, rep, report_markdown(rep, spec, filename), spec, pdf)


def docx_to_pdf(data: bytes) -> Optional[bytes]:
    soffice = shutil.which("soffice") or shutil.which("libreoffice")
    if not soffice:
        return None
    with tempfile.TemporaryDirectory() as td:
        src = os.path.join(td, "out.docx")
        open(src, "wb").write(data)
        try:
            subprocess.run([soffice, "--headless", "--convert-to", "pdf", "--outdir", td, src],
                           capture_output=True, timeout=180)
        except Exception:
            return None
        pdf = os.path.join(td, "out.pdf")
        return open(pdf, "rb").read() if os.path.exists(pdf) else None


def report_markdown(rep: Report, spec: dict, filename: str) -> str:
    L = [f"# Formatting report — {filename}", "", f"**Target format:** {spec.get('name', 'Custom')}", ""]
    L += ["## Compliance checks", "", "| Check | Status | Detail |", "|---|---|---|"]
    for name, ok, detail in rep.checks:
        L.append(f"| {name} | {'✅ Pass' if ok else '⚠️ Fix'} | {detail} |")
    L += ["", "## Changes made", ""] + [f"- {c}" for c in rep.changes]
    if rep.warnings:
        L += ["", "## Needs your attention", ""] + [f"- {w}" for w in rep.warnings]
    S = spec
    L += ["", "## Format applied", "",
          f"- Page: {S['page']['size']}, {S['page']['columns']} column(s), margins (T/B/L/R) {S['page']['margins_cm']} cm",
          f"- Body: {S['font']['family']} {S['font']['size']} pt, line spacing {S['font']['line_spacing']}",
          f"- Headings: {S['headings']['scheme']} numbering",
          f"- References: {S['references']['style']}; citations: {S['citations']['mode']}",
          "", "_Generated by FormatMatch — CS Software Solutions (cssoftwaresolutions.in)_"]
    return "\n".join(L)