latextify 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- latextify/__init__.py +15 -0
- latextify/__main__.py +11 -0
- latextify/audit/__init__.py +20 -0
- latextify/audit/equations.py +433 -0
- latextify/citations/__init__.py +37 -0
- latextify/citations/authoryear_index.py +54 -0
- latextify/citations/bib.py +353 -0
- latextify/citations/bibmatch.py +57 -0
- latextify/citations/bibtex_in.py +212 -0
- latextify/citations/corrections.py +173 -0
- latextify/citations/crossref.py +342 -0
- latextify/citations/csl_json_in.py +60 -0
- latextify/citations/endnote.py +208 -0
- latextify/citations/endnote_xml_in.py +72 -0
- latextify/citations/fields.py +334 -0
- latextify/citations/mendeley.py +27 -0
- latextify/citations/merge.py +77 -0
- latextify/citations/nbib_in.py +143 -0
- latextify/citations/plaintext.py +622 -0
- latextify/citations/reconcile.py +245 -0
- latextify/citations/reflist_nondocx.py +94 -0
- latextify/citations/refs_import.py +72 -0
- latextify/citations/validate.py +303 -0
- latextify/citations/wordnative.py +211 -0
- latextify/citations/zotero.py +128 -0
- latextify/cli.py +517 -0
- latextify/cli_batch.py +226 -0
- latextify/cli_clean.py +50 -0
- latextify/cli_equations.py +92 -0
- latextify/cli_export.py +100 -0
- latextify/cli_review.py +114 -0
- latextify/compile/__init__.py +12 -0
- latextify/compile/logs.py +148 -0
- latextify/compile/pdf.py +42 -0
- latextify/compile/tectonic.py +383 -0
- latextify/emit/__init__.py +41 -0
- latextify/emit/alt_formats.py +458 -0
- latextify/emit/alt_formats_render.py +264 -0
- latextify/emit/anchors.py +272 -0
- latextify/emit/metadata.py +36 -0
- latextify/emit/project.py +971 -0
- latextify/emit/submission.py +312 -0
- latextify/figures/__init__.py +29 -0
- latextify/figures/convert.py +476 -0
- latextify/figures/crop.py +254 -0
- latextify/figures/extract.py +288 -0
- latextify/figures/override.py +186 -0
- latextify/gui/__init__.py +18 -0
- latextify/gui/demo.py +178 -0
- latextify/gui/downloads.py +140 -0
- latextify/gui/exporting.py +55 -0
- latextify/gui/folder_picker.py +47 -0
- latextify/gui/guard.py +131 -0
- latextify/gui/schemas.py +192 -0
- latextify/gui/server.py +921 -0
- latextify/gui/static/alt-export.js +73 -0
- latextify/gui/static/app.js +458 -0
- latextify/gui/static/clean.js +64 -0
- latextify/gui/static/export.js +90 -0
- latextify/gui/static/index.html +204 -0
- latextify/gui/static/results.js +88 -0
- latextify/gui/static/review.js +193 -0
- latextify/gui/static/style.css +182 -0
- latextify/gui/upload_utils.py +80 -0
- latextify/gui/uploads_routes.py +161 -0
- latextify/ingest/__init__.py +16 -0
- latextify/ingest/_xml.py +39 -0
- latextify/ingest/archive_guard.py +198 -0
- latextify/ingest/citation_sentinels.py +162 -0
- latextify/ingest/docx_clean.py +431 -0
- latextify/ingest/filters.py +1061 -0
- latextify/ingest/formats.py +105 -0
- latextify/ingest/frontmatter.py +70 -0
- latextify/ingest/metadata_guess.py +919 -0
- latextify/ingest/metadata_guess_nondocx.py +93 -0
- latextify/ingest/pandoc.py +242 -0
- latextify/ingest/portable_anchors.py +68 -0
- latextify/ingest/preflight.py +353 -0
- latextify/kit/README-OFFLINE.md +119 -0
- latextify/kit/__init__.py +10 -0
- latextify/kit/build.py +468 -0
- latextify/kit/install_template.py +209 -0
- latextify/model/__init__.py +76 -0
- latextify/model/_compat.py +53 -0
- latextify/model/body.py +52 -0
- latextify/model/compile.py +54 -0
- latextify/model/emit.py +127 -0
- latextify/model/equations.py +107 -0
- latextify/model/figure.py +146 -0
- latextify/model/meta.py +59 -0
- latextify/model/preflight.py +72 -0
- latextify/model/reconcile.py +74 -0
- latextify/model/refs.py +77 -0
- latextify/model/validate.py +123 -0
- latextify/report/__init__.py +13 -0
- latextify/report/render.py +344 -0
- latextify/templates/__init__.py +17 -0
- latextify/templates/authors.py +131 -0
- latextify/templates/journals/.gitkeep +0 -0
- latextify/templates/journals/achemso/manifest.yaml +91 -0
- latextify/templates/journals/achemso/metadata.tex.j2 +36 -0
- latextify/templates/journals/achemso/preamble.tex.j2 +26 -0
- latextify/templates/journals/aip-advances/manifest.yaml +10 -0
- latextify/templates/journals/aip-apl/manifest.yaml +12 -0
- latextify/templates/journals/aip-jap/manifest.yaml +10 -0
- latextify/templates/journals/aps-prapplied/manifest.yaml +4 -0
- latextify/templates/journals/aps-prl/manifest.yaml +6 -0
- latextify/templates/journals/aps-prx/manifest.yaml +4 -0
- latextify/templates/journals/aps-rmp/manifest.yaml +4 -0
- latextify/templates/journals/elsarticle/manifest.yaml +57 -0
- latextify/templates/journals/elsarticle/metadata.tex.j2 +36 -0
- latextify/templates/journals/elsarticle/preamble.tex.j2 +12 -0
- latextify/templates/journals/elsarticle/vendor/elsarticle.cls +1403 -0
- latextify/templates/journals/ieeetran/manifest.yaml +42 -0
- latextify/templates/journals/ieeetran/metadata.tex.j2 +20 -0
- latextify/templates/journals/ieeetran/preamble.tex.j2 +17 -0
- latextify/templates/journals/iopart/VENDOR_LICENSE.txt +63 -0
- latextify/templates/journals/iopart/manifest.yaml +73 -0
- latextify/templates/journals/iopart/metadata.tex.j2 +32 -0
- latextify/templates/journals/iopart/preamble.tex.j2 +20 -0
- latextify/templates/journals/iopart/vendor/.gitattributes +6 -0
- latextify/templates/journals/iopart/vendor/iopart.cls +1107 -0
- latextify/templates/journals/iopart/vendor/iopart10.clo +1 -0
- latextify/templates/journals/iopart/vendor/iopart12.clo +1 -0
- latextify/templates/journals/revtex4-2/manifest.yaml +57 -0
- latextify/templates/journals/revtex4-2/metadata.tex.j2 +22 -0
- latextify/templates/journals/revtex4-2/preamble.tex.j2 +17 -0
- latextify/templates/journals/sn-jnl/VENDOR_LICENSE.txt +30 -0
- latextify/templates/journals/sn-jnl/manifest.yaml +99 -0
- latextify/templates/journals/sn-jnl/metadata.tex.j2 +23 -0
- latextify/templates/journals/sn-jnl/preamble.tex.j2 +22 -0
- latextify/templates/journals/sn-jnl/vendor/.gitattributes +6 -0
- latextify/templates/journals/sn-jnl/vendor/sn-jnl.cls +1 -0
- latextify/templates/journals/sn-jnl/vendor/sn-mathphys-ay.bst +3208 -0
- latextify/templates/journals/sn-jnl/vendor/sn-mathphys-num.bst +3211 -0
- latextify/templates/journals/wiley/manifest.yaml +103 -0
- latextify/templates/journals/wiley/metadata.tex.j2 +23 -0
- latextify/templates/journals/wiley/preamble.tex.j2 +14 -0
- latextify/templates/loader.py +539 -0
- latextify-0.1.0.dist-info/METADATA +285 -0
- latextify-0.1.0.dist-info/RECORD +145 -0
- latextify-0.1.0.dist-info/WHEEL +4 -0
- latextify-0.1.0.dist-info/entry_points.txt +2 -0
- latextify-0.1.0.dist-info/licenses/LICENSE +202 -0
- latextify-0.1.0.dist-info/licenses/NOTICE +9 -0
latextify/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""LaTeXtify — convert Word manuscripts to journal-ready LaTeX projects.
|
|
2
|
+
|
|
3
|
+
Pipeline stages (each is a subpackage; see plans/LATEXTIFY_PLAN.md):
|
|
4
|
+
|
|
5
|
+
ingest -> preflight validation + pandoc conversion of the .docx body
|
|
6
|
+
model -> intermediate representation shared by all stages
|
|
7
|
+
citations -> field-code / plain-text extraction to BibTeX
|
|
8
|
+
figures -> embedded-image extraction and user-file overrides
|
|
9
|
+
templates -> journal registry (manifests + class files + Jinja2 templates)
|
|
10
|
+
emit -> writes the output LaTeX project (generated/manual split)
|
|
11
|
+
compile -> Tectonic PDF compilation + log parsing
|
|
12
|
+
report -> consolidated per-run conversion report
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
latextify/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Run the CLI as ``python -m latextify`` (alias for the ``latextify`` script).
|
|
2
|
+
|
|
3
|
+
Handy when the console script isn't on PATH -- e.g. the usage examples in
|
|
4
|
+
``examples/`` invoke ``sys.executable -m latextify`` so they work from any
|
|
5
|
+
interpreter that can import the package, no matter how it was installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from latextify.cli import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
main()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Equation audit tooling (plan item 23).
|
|
2
|
+
|
|
3
|
+
Word's equation editor (OMML) is the pipeline's highest-fidelity input --
|
|
4
|
+
plain math converts cleanly through pandoc -- but there is no way to render
|
|
5
|
+
a Word equation object without Word itself, so this package cannot produce a
|
|
6
|
+
literal side-by-side image diff. Instead it produces a *textual* side-by-side
|
|
7
|
+
comparison a human can scan quickly for an equation-heavy manuscript:
|
|
8
|
+
|
|
9
|
+
equations.py -- walk the source .docx's raw OMML (``word/document.xml``)
|
|
10
|
+
for the ground-truth equation count/order, pair each one
|
|
11
|
+
with pandoc's own converted LaTeX (reusing the same
|
|
12
|
+
docx -> JSON-AST pandoc call the body pipeline uses),
|
|
13
|
+
flag any count mismatch (an equation pandoc dropped,
|
|
14
|
+
merged, or invented), write ``equations_audit.md``, and
|
|
15
|
+
optionally compile a numbered ``audit.pdf`` via Tectonic
|
|
16
|
+
-- with isolated per-equation probing so one broken
|
|
17
|
+
conversion can never take down the whole audit.
|
|
18
|
+
|
|
19
|
+
CLI surface: ``latextify equations paper.docx [--output DIR] [--pdf]``.
|
|
20
|
+
"""
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""Extract Word equations, pair them with pandoc's converted LaTeX, and write
|
|
2
|
+
a textual side-by-side audit (plan item 23).
|
|
3
|
+
|
|
4
|
+
There is no way to render a Word equation object without Word, so the
|
|
5
|
+
"side-by-side render comparison" the plan calls for is textual instead of
|
|
6
|
+
visual: :func:`extract_equations` walks the source .docx's raw OMML
|
|
7
|
+
(``word/document.xml``) directly with lxml for the ground-truth equation
|
|
8
|
+
count and document order -- completely independent of pandoc, so a pandoc
|
|
9
|
+
regression shows up as a count mismatch rather than silently vanishing --
|
|
10
|
+
and separately asks pandoc for its own converted LaTeX (the same
|
|
11
|
+
docx -> JSON-AST call :mod:`latextify.ingest.pandoc` makes, but read for
|
|
12
|
+
``Math`` AST nodes directly rather than round-tripped through the LaTeX
|
|
13
|
+
writer, since that is the only way to get per-equation text instead of one
|
|
14
|
+
opaque body string). The two lists are paired by position; a mismatched
|
|
15
|
+
count is flagged on :attr:`~latextify.model.equations.EquationAuditResult.count_mismatch`
|
|
16
|
+
rather than guessed at silently (see :func:`extract_equations`).
|
|
17
|
+
|
|
18
|
+
:func:`write_equation_audit` is the public entry point: it writes
|
|
19
|
+
``equations_audit.md`` unconditionally, and -- when ``compile_pdf`` is
|
|
20
|
+
requested -- compiles a numbered ``audit.pdf`` via
|
|
21
|
+
:func:`latextify.compile.tectonic.compile_document`. Compilation uses a
|
|
22
|
+
two-tier strategy (see :func:`probe_compile_equations`) so one broken
|
|
23
|
+
equation (an unsupported OMML construct, an empty placeholder that upsets a
|
|
24
|
+
stricter package, ...) degrades to a visible "FAILED" block instead of
|
|
25
|
+
taking down the whole PDF.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import tempfile
|
|
32
|
+
import zipfile
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import panflute as pf
|
|
37
|
+
import pypandoc
|
|
38
|
+
from lxml import etree
|
|
39
|
+
|
|
40
|
+
from latextify.citations.bib import escape_latex
|
|
41
|
+
from latextify.compile.tectonic import compile_document
|
|
42
|
+
from latextify.ingest._xml import hardened_xml_parser
|
|
43
|
+
from latextify.ingest.archive_guard import validate_docx_archive
|
|
44
|
+
from latextify.model.equations import (
|
|
45
|
+
EquationAuditResult,
|
|
46
|
+
EquationCompileStatus,
|
|
47
|
+
EquationRecord,
|
|
48
|
+
EquationWriteResult,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
52
|
+
M = "http://schemas.openxmlformats.org/officeDocument/2006/math"
|
|
53
|
+
NS = {"w": W, "m": M}
|
|
54
|
+
|
|
55
|
+
_SNIPPET_MAX_LEN = 100
|
|
56
|
+
|
|
57
|
+
_PROBE_PREAMBLE = "\\documentclass{article}\n\\usepackage{amsmath}\n\\usepackage{amssymb}\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _qn(prefixed_tag: str) -> str:
|
|
61
|
+
"""Expand a `prefix:local` tag name into lxml's Clark notation."""
|
|
62
|
+
prefix, local = prefixed_tag.split(":")
|
|
63
|
+
return f"{{{NS[prefix]}}}{local}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _local(element) -> str:
|
|
67
|
+
"""The namespace-stripped local name of an element, or "" for non-elements."""
|
|
68
|
+
tag = element.tag
|
|
69
|
+
return etree.QName(tag).localname if isinstance(tag, str) else ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _snippet(text: str, max_len: int = _SNIPPET_MAX_LEN) -> str:
|
|
73
|
+
collapsed = " ".join(text.split())
|
|
74
|
+
if len(collapsed) <= max_len:
|
|
75
|
+
return collapsed
|
|
76
|
+
return collapsed[: max_len - 1].rstrip() + "…"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------- #
|
|
80
|
+
# Raw OMML extraction (ground truth, pandoc-independent)
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_document_root(docx_path: Path | str) -> etree._Element:
|
|
85
|
+
try:
|
|
86
|
+
archive = zipfile.ZipFile(docx_path)
|
|
87
|
+
except (zipfile.BadZipFile, OSError) as exc:
|
|
88
|
+
raise ValueError(f"{docx_path}: not a valid .docx ({exc})") from exc
|
|
89
|
+
with archive:
|
|
90
|
+
if "word/document.xml" not in archive.namelist():
|
|
91
|
+
raise ValueError(f"{docx_path}: not a valid .docx (missing word/document.xml)")
|
|
92
|
+
with archive.open("word/document.xml") as fh:
|
|
93
|
+
try:
|
|
94
|
+
return etree.parse(fh, parser=hardened_xml_parser()).getroot()
|
|
95
|
+
except etree.XMLSyntaxError as exc:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"{docx_path}: not a valid .docx "
|
|
98
|
+
f"(malformed XML in word/document.xml: {exc})"
|
|
99
|
+
) from exc
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _enclosing_paragraph(element: etree._Element) -> etree._Element | None:
|
|
103
|
+
"""Nearest ancestor w:p of `element`, however deeply the m: wrapper nests."""
|
|
104
|
+
for ancestor in element.iterancestors(_qn("w:p")):
|
|
105
|
+
return ancestor
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _paragraph_snippet_excluding_math(paragraph: etree._Element | None) -> str:
|
|
110
|
+
"""Containing paragraph's own text, with any oMath/oMathPara descendants
|
|
111
|
+
excluded so the snippet reads as prose, not a jumble of equation runs."""
|
|
112
|
+
if paragraph is None:
|
|
113
|
+
return ""
|
|
114
|
+
texts: list[str] = []
|
|
115
|
+
for t in paragraph.iter(_qn("w:t")):
|
|
116
|
+
if any(_local(a) in ("oMath", "oMathPara") for a in t.iterancestors()):
|
|
117
|
+
continue
|
|
118
|
+
texts.append(t.text or "")
|
|
119
|
+
return _snippet("".join(texts))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _walk_raw_equations(document_root: etree._Element) -> list[tuple[bool, str]]:
|
|
123
|
+
"""(display, paragraph_snippet) per ``m:oMath``, in document order.
|
|
124
|
+
|
|
125
|
+
``display`` is True when the element's direct parent is ``m:oMathPara``
|
|
126
|
+
(Word's wrapper for a display equation); everything else is inline.
|
|
127
|
+
"""
|
|
128
|
+
raw: list[tuple[bool, str]] = []
|
|
129
|
+
for elem in document_root.iter(_qn("m:oMath")):
|
|
130
|
+
parent = elem.getparent()
|
|
131
|
+
display = parent is not None and _local(parent) == "oMathPara"
|
|
132
|
+
snippet = _paragraph_snippet_excluding_math(_enclosing_paragraph(elem))
|
|
133
|
+
raw.append((display, snippet))
|
|
134
|
+
return raw
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# --------------------------------------------------------------------------- #
|
|
138
|
+
# pandoc's own converted LaTeX per equation
|
|
139
|
+
# --------------------------------------------------------------------------- #
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _pandoc_math_nodes(docx_path: Path | str) -> list[pf.Math]:
|
|
143
|
+
"""``Math`` AST nodes from the same docx->JSON-AST pandoc call the body
|
|
144
|
+
pipeline uses (see :mod:`latextify.ingest.pandoc`), in document order.
|
|
145
|
+
|
|
146
|
+
Read directly off the AST rather than round-tripped through pandoc's
|
|
147
|
+
LaTeX writer, since that is the only way to recover per-equation text
|
|
148
|
+
(the writer serializes the whole body to one opaque string).
|
|
149
|
+
"""
|
|
150
|
+
ast_json = pypandoc.convert_file(str(docx_path), to="json", format="docx")
|
|
151
|
+
doc = pf.load(io.StringIO(ast_json))
|
|
152
|
+
nodes: list[pf.Math] = []
|
|
153
|
+
|
|
154
|
+
def collect(elem: pf.Element, doc: pf.Doc) -> None:
|
|
155
|
+
if isinstance(elem, pf.Math):
|
|
156
|
+
nodes.append(elem)
|
|
157
|
+
|
|
158
|
+
doc.walk(collect)
|
|
159
|
+
return nodes
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------- #
|
|
163
|
+
# Extraction: pair raw OMML with pandoc's converted LaTeX
|
|
164
|
+
# --------------------------------------------------------------------------- #
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def extract_equations(docx_path: Path | str) -> EquationAuditResult:
|
|
168
|
+
"""Extract every equation in `docx_path`, paired with its converted LaTeX.
|
|
169
|
+
|
|
170
|
+
Raw OMML order (ground truth, independent of pandoc) is paired
|
|
171
|
+
positionally with pandoc's own ``Math`` AST node order. When the two
|
|
172
|
+
counts agree -- the overwhelmingly common case -- every
|
|
173
|
+
:class:`~latextify.model.equations.EquationRecord` carries both a real
|
|
174
|
+
paragraph snippet and real converted LaTeX.
|
|
175
|
+
|
|
176
|
+
When they disagree (:attr:`~latextify.model.equations.EquationAuditResult.count_mismatch`),
|
|
177
|
+
pairing is NOT guessed at silently: indices beyond the raw count get an
|
|
178
|
+
empty snippet (pandoc produced an equation with no raw-XML counterpart --
|
|
179
|
+
should not normally happen, but is not dropped), and indices beyond the
|
|
180
|
+
converted count get empty LaTeX (pandoc dropped or merged that equation
|
|
181
|
+
away). The caller (report/CLI rendering) surfaces the mismatch loudly
|
|
182
|
+
rather than pretending the pairing is trustworthy.
|
|
183
|
+
"""
|
|
184
|
+
# Equation-audit-only operation bypasses run_preflight, so bound archive
|
|
185
|
+
# resource use here too before decompressing any member.
|
|
186
|
+
validate_docx_archive(docx_path)
|
|
187
|
+
document_root = _read_document_root(docx_path)
|
|
188
|
+
raw = _walk_raw_equations(document_root)
|
|
189
|
+
math_nodes = _pandoc_math_nodes(docx_path)
|
|
190
|
+
|
|
191
|
+
raw_count = len(raw)
|
|
192
|
+
converted_count = len(math_nodes)
|
|
193
|
+
|
|
194
|
+
equations = []
|
|
195
|
+
for i in range(max(raw_count, converted_count)):
|
|
196
|
+
if i < raw_count:
|
|
197
|
+
display, snippet = raw[i]
|
|
198
|
+
else:
|
|
199
|
+
display = math_nodes[i].format == "DisplayMath"
|
|
200
|
+
snippet = ""
|
|
201
|
+
latex = math_nodes[i].text if i < converted_count else ""
|
|
202
|
+
equations.append(
|
|
203
|
+
EquationRecord(index=i, display=display, paragraph_snippet=snippet, latex=latex)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return EquationAuditResult(
|
|
207
|
+
equations=tuple(equations), raw_omml_count=raw_count, converted_count=converted_count
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --------------------------------------------------------------------------- #
|
|
212
|
+
# Probe-compile documents
|
|
213
|
+
# --------------------------------------------------------------------------- #
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _math_block(equation: EquationRecord) -> str:
|
|
217
|
+
"""Typeset one equation as real math (display or inline)."""
|
|
218
|
+
if equation.display:
|
|
219
|
+
return f"\\[\n{equation.latex}\n\\]"
|
|
220
|
+
return f"\\({equation.latex}\\)"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _verbatim_block(equation: EquationRecord) -> str:
|
|
224
|
+
"""A FAILED equation's raw LaTeX shown as literal text, never as math --
|
|
225
|
+
this is what keeps one broken conversion from taking the whole audit.pdf
|
|
226
|
+
down with it."""
|
|
227
|
+
return f"\\begin{{verbatim}}\n{equation.latex}\n\\end{{verbatim}}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _equation_heading(equation: EquationRecord, *, failed: bool = False) -> str:
|
|
231
|
+
kind = "display" if equation.display else "inline"
|
|
232
|
+
label = f"Equation {equation.index + 1} ({kind})"
|
|
233
|
+
if failed:
|
|
234
|
+
label += " --- FAILED"
|
|
235
|
+
snippet = escape_latex(equation.paragraph_snippet) or "(no surrounding text found)"
|
|
236
|
+
return (
|
|
237
|
+
f"\\par\\noindent\\textbf{{{escape_latex(label)}}}\\par\n"
|
|
238
|
+
f"\\noindent\\textit{{{snippet}}}\\par\\medskip\n"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _build_probe_document(
|
|
243
|
+
equations: tuple[EquationRecord, ...], failed_indices: frozenset[int]
|
|
244
|
+
) -> str:
|
|
245
|
+
"""One document with every equation, numbered and labeled with its
|
|
246
|
+
source snippet. Equations in `failed_indices` render as a verbatim
|
|
247
|
+
"FAILED" block (raw LaTeX shown as text) instead of real math, so the
|
|
248
|
+
document as a whole always compiles once the failing set is known."""
|
|
249
|
+
parts = [_PROBE_PREAMBLE, "\\begin{document}\n"]
|
|
250
|
+
if not equations:
|
|
251
|
+
parts.append("No equations found.\n")
|
|
252
|
+
for equation in equations:
|
|
253
|
+
failed = equation.index in failed_indices
|
|
254
|
+
parts.append(_equation_heading(equation, failed=failed))
|
|
255
|
+
parts.append((_verbatim_block if failed else _math_block)(equation))
|
|
256
|
+
parts.append("\n\n")
|
|
257
|
+
parts.append("\\end{document}\n")
|
|
258
|
+
return "".join(parts)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _build_single_equation_document(equation: EquationRecord) -> str:
|
|
262
|
+
return (
|
|
263
|
+
f"{_PROBE_PREAMBLE}\\begin{{document}}\n{_math_block(equation)}\n\\end{{document}}\n"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def probe_compile_equations(
|
|
268
|
+
equations: tuple[EquationRecord, ...],
|
|
269
|
+
*,
|
|
270
|
+
tectonic_path: Path | None = None,
|
|
271
|
+
timeout: float | None = None,
|
|
272
|
+
) -> tuple[EquationCompileStatus, ...]:
|
|
273
|
+
"""Compile each equation standalone to find out which ones are broken.
|
|
274
|
+
|
|
275
|
+
Only called after the combined all-equations document
|
|
276
|
+
(:func:`_build_probe_document` with no failed indices) has already
|
|
277
|
+
failed to compile once -- probing every equation individually is one
|
|
278
|
+
Tectonic invocation per equation, so it is worth paying only on that
|
|
279
|
+
slow path. A single bad equation's probe failing never stops the
|
|
280
|
+
others from being probed; each gets its own try/isolated compile.
|
|
281
|
+
"""
|
|
282
|
+
statuses = []
|
|
283
|
+
with tempfile.TemporaryDirectory(prefix="latextify-eqprobe-") as tmp:
|
|
284
|
+
tmp_dir = Path(tmp)
|
|
285
|
+
for equation in equations:
|
|
286
|
+
tex_path = tmp_dir / f"eq{equation.index}.tex"
|
|
287
|
+
tex_path.write_text(_build_single_equation_document(equation), encoding="utf-8")
|
|
288
|
+
kwargs = {"tectonic_path": tectonic_path}
|
|
289
|
+
if timeout is not None:
|
|
290
|
+
kwargs["timeout"] = timeout
|
|
291
|
+
result = compile_document(tex_path, **kwargs)
|
|
292
|
+
if result.success:
|
|
293
|
+
statuses.append(EquationCompileStatus(index=equation.index, ok=True))
|
|
294
|
+
else:
|
|
295
|
+
message = result.errors[0].message if result.errors else "compilation failed"
|
|
296
|
+
statuses.append(
|
|
297
|
+
EquationCompileStatus(index=equation.index, ok=False, message=message)
|
|
298
|
+
)
|
|
299
|
+
return tuple(statuses)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _compile_audit_pdf(
|
|
303
|
+
equations: tuple[EquationRecord, ...],
|
|
304
|
+
output_dir: Path,
|
|
305
|
+
*,
|
|
306
|
+
tectonic_path: Path | None = None,
|
|
307
|
+
timeout: float | None = None,
|
|
308
|
+
) -> tuple[Path | None, tuple[EquationCompileStatus, ...]]:
|
|
309
|
+
"""Two-tier compile: try every equation as real math first (one compile);
|
|
310
|
+
fall back to per-equation probing only on failure, then recompile the
|
|
311
|
+
final document with the identified offenders shown as text instead of
|
|
312
|
+
math. Returns the compiled PDF path (None if even the corrected document
|
|
313
|
+
failed) and the per-equation statuses (empty when the fast path succeeded
|
|
314
|
+
-- every equation is then implicitly OK)."""
|
|
315
|
+
tex_path = output_dir / "audit.tex"
|
|
316
|
+
kwargs = {"tectonic_path": tectonic_path}
|
|
317
|
+
if timeout is not None:
|
|
318
|
+
kwargs["timeout"] = timeout
|
|
319
|
+
|
|
320
|
+
tex_path.write_text(_build_probe_document(equations, frozenset()), encoding="utf-8")
|
|
321
|
+
result = compile_document(tex_path, **kwargs)
|
|
322
|
+
if result.success:
|
|
323
|
+
return result.pdf_path, ()
|
|
324
|
+
|
|
325
|
+
statuses = probe_compile_equations(equations, tectonic_path=tectonic_path, timeout=timeout)
|
|
326
|
+
failed_indices = frozenset(s.index for s in statuses if not s.ok)
|
|
327
|
+
|
|
328
|
+
tex_path.write_text(_build_probe_document(equations, failed_indices), encoding="utf-8")
|
|
329
|
+
result = compile_document(tex_path, **kwargs)
|
|
330
|
+
return (result.pdf_path if result.success else None), statuses
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# --------------------------------------------------------------------------- #
|
|
334
|
+
# Markdown rendering
|
|
335
|
+
# --------------------------------------------------------------------------- #
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def render_audit_markdown(
|
|
339
|
+
docx_path: Path | str,
|
|
340
|
+
result: EquationAuditResult,
|
|
341
|
+
*,
|
|
342
|
+
compile_statuses: tuple[EquationCompileStatus, ...] = (),
|
|
343
|
+
) -> str:
|
|
344
|
+
"""Render `result` (and optional compile statuses) as markdown."""
|
|
345
|
+
status_by_index = {s.index: s for s in compile_statuses}
|
|
346
|
+
lines: list[str] = [
|
|
347
|
+
f"# Equation Audit — {Path(docx_path).name}\n",
|
|
348
|
+
f"Generated: {datetime.now().isoformat(timespec='seconds')}\n",
|
|
349
|
+
f"**Raw OMML equations found:** {result.raw_omml_count}\n",
|
|
350
|
+
f"**Pandoc-converted equations:** {result.converted_count}\n",
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
if result.count_mismatch:
|
|
354
|
+
lines.append(
|
|
355
|
+
"\n**MISMATCH:** the raw OMML count and the pandoc-converted count "
|
|
356
|
+
"disagree -- pandoc likely dropped, merged, or invented an equation. "
|
|
357
|
+
"Pairing below is best-effort by document position; verify each "
|
|
358
|
+
"entry against the source .docx.\n"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
lines.append("\n## Equations\n")
|
|
362
|
+
if not result.equations:
|
|
363
|
+
lines.append("_None found._\n")
|
|
364
|
+
return "".join(lines)
|
|
365
|
+
|
|
366
|
+
for equation in result.equations:
|
|
367
|
+
kind = "display" if equation.display else "inline"
|
|
368
|
+
status = status_by_index.get(equation.index)
|
|
369
|
+
heading = f"### Equation {equation.index + 1} ({kind})"
|
|
370
|
+
if status is not None and not status.ok:
|
|
371
|
+
heading += " — FAILED"
|
|
372
|
+
lines.append(f"\n{heading}\n")
|
|
373
|
+
|
|
374
|
+
snippet = equation.paragraph_snippet or "_(no surrounding text found)_"
|
|
375
|
+
lines.append(f"**Source paragraph:** {snippet}\n")
|
|
376
|
+
|
|
377
|
+
latex = equation.latex
|
|
378
|
+
if not latex:
|
|
379
|
+
lines.append("**Converted LaTeX:** _(empty)_\n")
|
|
380
|
+
else:
|
|
381
|
+
label = "raw, did not compile" if status is not None and not status.ok else "verbatim"
|
|
382
|
+
lines.append(f"**Converted LaTeX** ({label}):\n")
|
|
383
|
+
lines.append(f"```latex\n{latex}\n```\n")
|
|
384
|
+
|
|
385
|
+
if status is not None and not status.ok and status.message:
|
|
386
|
+
lines.append(f"**Compile error:** {status.message}\n")
|
|
387
|
+
|
|
388
|
+
return "".join(lines)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --------------------------------------------------------------------------- #
|
|
392
|
+
# Public entry point
|
|
393
|
+
# --------------------------------------------------------------------------- #
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def write_equation_audit(
|
|
397
|
+
docx_path: Path | str,
|
|
398
|
+
output_dir: Path | str,
|
|
399
|
+
*,
|
|
400
|
+
compile_pdf: bool = False,
|
|
401
|
+
tectonic_path: Path | None = None,
|
|
402
|
+
timeout: float | None = None,
|
|
403
|
+
) -> EquationWriteResult:
|
|
404
|
+
"""Extract equations from `docx_path` and write the audit artifacts.
|
|
405
|
+
|
|
406
|
+
Always writes ``output_dir/equations_audit.md``. When `compile_pdf` is
|
|
407
|
+
True, also compiles ``output_dir/audit.pdf`` via Tectonic using the
|
|
408
|
+
two-tier strategy in :func:`_compile_audit_pdf` -- a broken equation
|
|
409
|
+
degrades to a visible "FAILED" block rather than failing the whole
|
|
410
|
+
audit; `output_dir` is created if missing.
|
|
411
|
+
"""
|
|
412
|
+
output_dir = Path(output_dir)
|
|
413
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
414
|
+
|
|
415
|
+
result = extract_equations(docx_path)
|
|
416
|
+
|
|
417
|
+
compile_statuses: tuple[EquationCompileStatus, ...] = ()
|
|
418
|
+
audit_pdf_path: Path | None = None
|
|
419
|
+
if compile_pdf:
|
|
420
|
+
audit_pdf_path, compile_statuses = _compile_audit_pdf(
|
|
421
|
+
result.equations, output_dir, tectonic_path=tectonic_path, timeout=timeout
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
markdown = render_audit_markdown(docx_path, result, compile_statuses=compile_statuses)
|
|
425
|
+
audit_md_path = output_dir / "equations_audit.md"
|
|
426
|
+
audit_md_path.write_text(markdown, encoding="utf-8")
|
|
427
|
+
|
|
428
|
+
return EquationWriteResult(
|
|
429
|
+
audit_md_path=audit_md_path,
|
|
430
|
+
audit_pdf_path=audit_pdf_path,
|
|
431
|
+
result=result,
|
|
432
|
+
compile_statuses=compile_statuses,
|
|
433
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Citation extraction: docx field codes / plain text -> RefEntry list + .bib.
|
|
2
|
+
|
|
3
|
+
Citations are read directly from word/document.xml field codes, NOT from
|
|
4
|
+
pandoc output, because field codes carry full structured data.
|
|
5
|
+
|
|
6
|
+
Modules (plan items 7, 13; 14 still planned):
|
|
7
|
+
fields.py -- walk w:fldChar/w:instrText runs, reassemble complex
|
|
8
|
+
fields split across runs, classify by ADDIN/CITATION
|
|
9
|
+
marker, dispatch to the source-specific parser below
|
|
10
|
+
zotero.py -- ADDIN ZOTERO_ITEM CSL_CITATION {json} -> RefEntry
|
|
11
|
+
mendeley.py -- ADDIN CSL_CITATION {json} -> RefEntry
|
|
12
|
+
endnote.py -- ADDIN EN.CITE <EndNote><Cite> XML -> RefEntry
|
|
13
|
+
wordnative.py -- CITATION <Tag> field + customXml/item*.xml b:Sources
|
|
14
|
+
-> RefEntry
|
|
15
|
+
bib.py -- RefEntry -> BibTeX emission, stable citation keys
|
|
16
|
+
|
|
17
|
+
Still planned (plan item 14):
|
|
18
|
+
plaintext.py -- [12] / (Smith, 2020) markers + typed reference list,
|
|
19
|
+
Crossref matching with confidence scores
|
|
20
|
+
crossref.py -- api.crossref.org client (query.bibliographic)
|
|
21
|
+
reconcile.py -- merge sources, dedupe, confidence report entries
|
|
22
|
+
|
|
23
|
+
Also implemented (plan item 21):
|
|
24
|
+
merge.py -- cross-document reference merging for supplementary
|
|
25
|
+
material: dedupes a second document's RefEntry list
|
|
26
|
+
against an already-emitted document's, reusing
|
|
27
|
+
fields.dedup_identity
|
|
28
|
+
|
|
29
|
+
Bibliography FILE intake (plan item 10, GUI Options + Formats plan) --
|
|
30
|
+
distinct from the field-code extractors above, these parse a whole
|
|
31
|
+
reference-manager export handed in as ``references_bib_path``:
|
|
32
|
+
bibtex_in.py -- .bib -> RefEntry
|
|
33
|
+
csl_json_in.py -- CSL-JSON (Zotero export) -> RefEntry
|
|
34
|
+
endnote_xml_in.py -- EndNote XML library export -> RefEntry
|
|
35
|
+
nbib_in.py -- PubMed MEDLINE .nbib -> RefEntry
|
|
36
|
+
refs_import.py -- extension dispatch across all of the above
|
|
37
|
+
"""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Author-year lookup index for the plain-text citation reconstruction path.
|
|
2
|
+
|
|
3
|
+
Split out of :mod:`latextify.citations.plaintext` (which sits at its own
|
|
4
|
+
line-count ratchet pin, ``tests/test_repo_integrity.py``) since this is a
|
|
5
|
+
self-contained unit: build a ``(surname, year) -> [key, ...]`` index over a
|
|
6
|
+
reconstructed reference list, for :func:`~latextify.citations.plaintext.link_body_markers`
|
|
7
|
+
to resolve ``(Smith et al., 2020)``-style in-text markers against.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ..model.refs import RefEntry
|
|
15
|
+
|
|
16
|
+
# Leading initials of a Western author name at a raw citation's start, e.g.
|
|
17
|
+
# "J. E. " in "J. E. Davies, O. Hellwig, ...", so the surname after them can be
|
|
18
|
+
# picked out.
|
|
19
|
+
_RAW_INITIALS_RE = re.compile(r"^(?:[A-Z]\.[\s]*)+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _raw_leading_surname(title: str) -> str | None:
|
|
23
|
+
"""Leading author surname parsed from a raw-text reference's title.
|
|
24
|
+
|
|
25
|
+
A Crossref-unmatched entry keeps the whole typed citation in its ``.title``
|
|
26
|
+
("J. E. Davies, O. Hellwig, ... (2004).") with no structured author, so the
|
|
27
|
+
author-year index would otherwise never point at it. Pull the first author's
|
|
28
|
+
surname ("davies") after any leading initials. Returns ``None`` when the head
|
|
29
|
+
(text before the first comma) does not look like an author name -- e.g. a
|
|
30
|
+
"See Supplemental Material ..." note -- so junk is not indexed.
|
|
31
|
+
"""
|
|
32
|
+
head = title.split(",", 1)[0].strip()
|
|
33
|
+
head = _RAW_INITIALS_RE.sub("", head).strip()
|
|
34
|
+
if not head:
|
|
35
|
+
return None
|
|
36
|
+
first = head.split()[0].strip(".'`-").lower()
|
|
37
|
+
return first if first.isalpha() and len(first) >= 2 else None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_author_year_index(entries: list[RefEntry]) -> dict[tuple[str, str], list[str]]:
|
|
41
|
+
index: dict[tuple[str, str], list[str]] = {}
|
|
42
|
+
for entry in entries:
|
|
43
|
+
if not entry.year:
|
|
44
|
+
continue
|
|
45
|
+
if entry.authors:
|
|
46
|
+
first = entry.authors[0]
|
|
47
|
+
surname = (first.family or first.literal).strip().lower()
|
|
48
|
+
else:
|
|
49
|
+
# Raw-text (Crossref-unmatched) entry: surname lives in the title.
|
|
50
|
+
surname = _raw_leading_surname(entry.title or "") or ""
|
|
51
|
+
if not surname:
|
|
52
|
+
continue
|
|
53
|
+
index.setdefault((surname, entry.year), []).append(entry.key)
|
|
54
|
+
return index
|