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.
Files changed (145) hide show
  1. latextify/__init__.py +15 -0
  2. latextify/__main__.py +11 -0
  3. latextify/audit/__init__.py +20 -0
  4. latextify/audit/equations.py +433 -0
  5. latextify/citations/__init__.py +37 -0
  6. latextify/citations/authoryear_index.py +54 -0
  7. latextify/citations/bib.py +353 -0
  8. latextify/citations/bibmatch.py +57 -0
  9. latextify/citations/bibtex_in.py +212 -0
  10. latextify/citations/corrections.py +173 -0
  11. latextify/citations/crossref.py +342 -0
  12. latextify/citations/csl_json_in.py +60 -0
  13. latextify/citations/endnote.py +208 -0
  14. latextify/citations/endnote_xml_in.py +72 -0
  15. latextify/citations/fields.py +334 -0
  16. latextify/citations/mendeley.py +27 -0
  17. latextify/citations/merge.py +77 -0
  18. latextify/citations/nbib_in.py +143 -0
  19. latextify/citations/plaintext.py +622 -0
  20. latextify/citations/reconcile.py +245 -0
  21. latextify/citations/reflist_nondocx.py +94 -0
  22. latextify/citations/refs_import.py +72 -0
  23. latextify/citations/validate.py +303 -0
  24. latextify/citations/wordnative.py +211 -0
  25. latextify/citations/zotero.py +128 -0
  26. latextify/cli.py +517 -0
  27. latextify/cli_batch.py +226 -0
  28. latextify/cli_clean.py +50 -0
  29. latextify/cli_equations.py +92 -0
  30. latextify/cli_export.py +100 -0
  31. latextify/cli_review.py +114 -0
  32. latextify/compile/__init__.py +12 -0
  33. latextify/compile/logs.py +148 -0
  34. latextify/compile/pdf.py +42 -0
  35. latextify/compile/tectonic.py +383 -0
  36. latextify/emit/__init__.py +41 -0
  37. latextify/emit/alt_formats.py +458 -0
  38. latextify/emit/alt_formats_render.py +264 -0
  39. latextify/emit/anchors.py +272 -0
  40. latextify/emit/metadata.py +36 -0
  41. latextify/emit/project.py +971 -0
  42. latextify/emit/submission.py +312 -0
  43. latextify/figures/__init__.py +29 -0
  44. latextify/figures/convert.py +476 -0
  45. latextify/figures/crop.py +254 -0
  46. latextify/figures/extract.py +288 -0
  47. latextify/figures/override.py +186 -0
  48. latextify/gui/__init__.py +18 -0
  49. latextify/gui/demo.py +178 -0
  50. latextify/gui/downloads.py +140 -0
  51. latextify/gui/exporting.py +55 -0
  52. latextify/gui/folder_picker.py +47 -0
  53. latextify/gui/guard.py +131 -0
  54. latextify/gui/schemas.py +192 -0
  55. latextify/gui/server.py +921 -0
  56. latextify/gui/static/alt-export.js +73 -0
  57. latextify/gui/static/app.js +458 -0
  58. latextify/gui/static/clean.js +64 -0
  59. latextify/gui/static/export.js +90 -0
  60. latextify/gui/static/index.html +204 -0
  61. latextify/gui/static/results.js +88 -0
  62. latextify/gui/static/review.js +193 -0
  63. latextify/gui/static/style.css +182 -0
  64. latextify/gui/upload_utils.py +80 -0
  65. latextify/gui/uploads_routes.py +161 -0
  66. latextify/ingest/__init__.py +16 -0
  67. latextify/ingest/_xml.py +39 -0
  68. latextify/ingest/archive_guard.py +198 -0
  69. latextify/ingest/citation_sentinels.py +162 -0
  70. latextify/ingest/docx_clean.py +431 -0
  71. latextify/ingest/filters.py +1061 -0
  72. latextify/ingest/formats.py +105 -0
  73. latextify/ingest/frontmatter.py +70 -0
  74. latextify/ingest/metadata_guess.py +919 -0
  75. latextify/ingest/metadata_guess_nondocx.py +93 -0
  76. latextify/ingest/pandoc.py +242 -0
  77. latextify/ingest/portable_anchors.py +68 -0
  78. latextify/ingest/preflight.py +353 -0
  79. latextify/kit/README-OFFLINE.md +119 -0
  80. latextify/kit/__init__.py +10 -0
  81. latextify/kit/build.py +468 -0
  82. latextify/kit/install_template.py +209 -0
  83. latextify/model/__init__.py +76 -0
  84. latextify/model/_compat.py +53 -0
  85. latextify/model/body.py +52 -0
  86. latextify/model/compile.py +54 -0
  87. latextify/model/emit.py +127 -0
  88. latextify/model/equations.py +107 -0
  89. latextify/model/figure.py +146 -0
  90. latextify/model/meta.py +59 -0
  91. latextify/model/preflight.py +72 -0
  92. latextify/model/reconcile.py +74 -0
  93. latextify/model/refs.py +77 -0
  94. latextify/model/validate.py +123 -0
  95. latextify/report/__init__.py +13 -0
  96. latextify/report/render.py +344 -0
  97. latextify/templates/__init__.py +17 -0
  98. latextify/templates/authors.py +131 -0
  99. latextify/templates/journals/.gitkeep +0 -0
  100. latextify/templates/journals/achemso/manifest.yaml +91 -0
  101. latextify/templates/journals/achemso/metadata.tex.j2 +36 -0
  102. latextify/templates/journals/achemso/preamble.tex.j2 +26 -0
  103. latextify/templates/journals/aip-advances/manifest.yaml +10 -0
  104. latextify/templates/journals/aip-apl/manifest.yaml +12 -0
  105. latextify/templates/journals/aip-jap/manifest.yaml +10 -0
  106. latextify/templates/journals/aps-prapplied/manifest.yaml +4 -0
  107. latextify/templates/journals/aps-prl/manifest.yaml +6 -0
  108. latextify/templates/journals/aps-prx/manifest.yaml +4 -0
  109. latextify/templates/journals/aps-rmp/manifest.yaml +4 -0
  110. latextify/templates/journals/elsarticle/manifest.yaml +57 -0
  111. latextify/templates/journals/elsarticle/metadata.tex.j2 +36 -0
  112. latextify/templates/journals/elsarticle/preamble.tex.j2 +12 -0
  113. latextify/templates/journals/elsarticle/vendor/elsarticle.cls +1403 -0
  114. latextify/templates/journals/ieeetran/manifest.yaml +42 -0
  115. latextify/templates/journals/ieeetran/metadata.tex.j2 +20 -0
  116. latextify/templates/journals/ieeetran/preamble.tex.j2 +17 -0
  117. latextify/templates/journals/iopart/VENDOR_LICENSE.txt +63 -0
  118. latextify/templates/journals/iopart/manifest.yaml +73 -0
  119. latextify/templates/journals/iopart/metadata.tex.j2 +32 -0
  120. latextify/templates/journals/iopart/preamble.tex.j2 +20 -0
  121. latextify/templates/journals/iopart/vendor/.gitattributes +6 -0
  122. latextify/templates/journals/iopart/vendor/iopart.cls +1107 -0
  123. latextify/templates/journals/iopart/vendor/iopart10.clo +1 -0
  124. latextify/templates/journals/iopart/vendor/iopart12.clo +1 -0
  125. latextify/templates/journals/revtex4-2/manifest.yaml +57 -0
  126. latextify/templates/journals/revtex4-2/metadata.tex.j2 +22 -0
  127. latextify/templates/journals/revtex4-2/preamble.tex.j2 +17 -0
  128. latextify/templates/journals/sn-jnl/VENDOR_LICENSE.txt +30 -0
  129. latextify/templates/journals/sn-jnl/manifest.yaml +99 -0
  130. latextify/templates/journals/sn-jnl/metadata.tex.j2 +23 -0
  131. latextify/templates/journals/sn-jnl/preamble.tex.j2 +22 -0
  132. latextify/templates/journals/sn-jnl/vendor/.gitattributes +6 -0
  133. latextify/templates/journals/sn-jnl/vendor/sn-jnl.cls +1 -0
  134. latextify/templates/journals/sn-jnl/vendor/sn-mathphys-ay.bst +3208 -0
  135. latextify/templates/journals/sn-jnl/vendor/sn-mathphys-num.bst +3211 -0
  136. latextify/templates/journals/wiley/manifest.yaml +103 -0
  137. latextify/templates/journals/wiley/metadata.tex.j2 +23 -0
  138. latextify/templates/journals/wiley/preamble.tex.j2 +14 -0
  139. latextify/templates/loader.py +539 -0
  140. latextify-0.1.0.dist-info/METADATA +285 -0
  141. latextify-0.1.0.dist-info/RECORD +145 -0
  142. latextify-0.1.0.dist-info/WHEEL +4 -0
  143. latextify-0.1.0.dist-info/entry_points.txt +2 -0
  144. latextify-0.1.0.dist-info/licenses/LICENSE +202 -0
  145. 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