texsmith 0.0.2.dev0__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 (252) hide show
  1. texsmith/__init__.py +107 -0
  2. texsmith/_alias.py +59 -0
  3. texsmith/adapters/__init__.py +6 -0
  4. texsmith/adapters/docker.py +258 -0
  5. texsmith/adapters/handlers/__init__.py +3 -0
  6. texsmith/adapters/handlers/_assets.py +363 -0
  7. texsmith/adapters/handlers/_helpers.py +62 -0
  8. texsmith/adapters/handlers/_mermaid.py +122 -0
  9. texsmith/adapters/handlers/admonitions.py +267 -0
  10. texsmith/adapters/handlers/basic.py +228 -0
  11. texsmith/adapters/handlers/blocks.py +851 -0
  12. texsmith/adapters/handlers/code.py +309 -0
  13. texsmith/adapters/handlers/inline.py +937 -0
  14. texsmith/adapters/handlers/links.py +239 -0
  15. texsmith/adapters/handlers/media.py +380 -0
  16. texsmith/adapters/latex/__init__.py +10 -0
  17. texsmith/adapters/latex/engines/__init__.py +726 -0
  18. texsmith/adapters/latex/engines/latex/__init__.py +26 -0
  19. texsmith/adapters/latex/engines/latex/log.py +720 -0
  20. texsmith/adapters/latex/engines/latex/runner.py +28 -0
  21. texsmith/adapters/latex/engines/tectonic/__init__.py +38 -0
  22. texsmith/adapters/latex/formatter.py +297 -0
  23. texsmith/adapters/latex/latexmk.py +148 -0
  24. texsmith/adapters/latex/partials/acronym.tex +1 -0
  25. texsmith/adapters/latex/partials/add.tex +1 -0
  26. texsmith/adapters/latex/partials/addition.tex +1 -0
  27. texsmith/adapters/latex/partials/blockquote.tex +3 -0
  28. texsmith/adapters/latex/partials/callout.tex +3 -0
  29. texsmith/adapters/latex/partials/choices.tex +5 -0
  30. texsmith/adapters/latex/partials/citation.tex +1 -0
  31. texsmith/adapters/latex/partials/codeblock.tex +7 -0
  32. texsmith/adapters/latex/partials/codeblock_listings.tex +5 -0
  33. texsmith/adapters/latex/partials/codeblock_pygments.tex +5 -0
  34. texsmith/adapters/latex/partials/codeblock_verbatim.tex +12 -0
  35. texsmith/adapters/latex/partials/codeinline.tex +1 -0
  36. texsmith/adapters/latex/partials/codeinlinett.tex +1 -0
  37. texsmith/adapters/latex/partials/comment.tex +1 -0
  38. texsmith/adapters/latex/partials/del.tex +1 -0
  39. texsmith/adapters/latex/partials/deletion.tex +1 -0
  40. texsmith/adapters/latex/partials/description_list.tex +5 -0
  41. texsmith/adapters/latex/partials/enquote.tex +1 -0
  42. texsmith/adapters/latex/partials/epigraph.tex +1 -0
  43. texsmith/adapters/latex/partials/exercises_solutions.tex +7 -0
  44. texsmith/adapters/latex/partials/figure.tex +33 -0
  45. texsmith/adapters/latex/partials/figure_tcolorbox.tex +15 -0
  46. texsmith/adapters/latex/partials/footnote.tex +3 -0
  47. texsmith/adapters/latex/partials/glossary.tex +1 -0
  48. texsmith/adapters/latex/partials/heading.tex +14 -0
  49. texsmith/adapters/latex/partials/highlight.tex +25 -0
  50. texsmith/adapters/latex/partials/horizontal_rule.tex +1 -0
  51. texsmith/adapters/latex/partials/href.tex +1 -0
  52. texsmith/adapters/latex/partials/icon.tex +1 -0
  53. texsmith/adapters/latex/partials/include.tex +1 -0
  54. texsmith/adapters/latex/partials/index.tex +1 -0
  55. texsmith/adapters/latex/partials/italic.tex +1 -0
  56. texsmith/adapters/latex/partials/keystroke.tex +46 -0
  57. texsmith/adapters/latex/partials/label.tex +1 -0
  58. texsmith/adapters/latex/partials/list_acronyms.tex +5 -0
  59. texsmith/adapters/latex/partials/list_glossary.tex +8 -0
  60. texsmith/adapters/latex/partials/multicolumn.tex +3 -0
  61. texsmith/adapters/latex/partials/ordered_list.tex +5 -0
  62. texsmith/adapters/latex/partials/pagestyle.tex +1 -0
  63. texsmith/adapters/latex/partials/ref.tex +1 -0
  64. texsmith/adapters/latex/partials/regex.tex +1 -0
  65. texsmith/adapters/latex/partials/smallcaps.tex +1 -0
  66. texsmith/adapters/latex/partials/strikethrough.tex +1 -0
  67. texsmith/adapters/latex/partials/strong.tex +1 -0
  68. texsmith/adapters/latex/partials/subscript.tex +1 -0
  69. texsmith/adapters/latex/partials/substitution.tex +1 -0
  70. texsmith/adapters/latex/partials/superscript.tex +1 -0
  71. texsmith/adapters/latex/partials/tabbed.tex +3 -0
  72. texsmith/adapters/latex/partials/table.tex +48 -0
  73. texsmith/adapters/latex/partials/underline.tex +1 -0
  74. texsmith/adapters/latex/partials/unordered_list.tex +5 -0
  75. texsmith/adapters/latex/partials/url.tex +1 -0
  76. texsmith/adapters/latex/pygments.py +98 -0
  77. texsmith/adapters/latex/pyxindy.py +64 -0
  78. texsmith/adapters/latex/renderer.py +220 -0
  79. texsmith/adapters/latex/tectonic.py +366 -0
  80. texsmith/adapters/latex/utils.py +86 -0
  81. texsmith/adapters/markdown/__init__.py +342 -0
  82. texsmith/adapters/plugins/__init__.py +8 -0
  83. texsmith/adapters/plugins/material.py +174 -0
  84. texsmith/adapters/plugins/snippet.py +1734 -0
  85. texsmith/adapters/transformers/__init__.py +122 -0
  86. texsmith/adapters/transformers/base.py +140 -0
  87. texsmith/adapters/transformers/strategies.py +1456 -0
  88. texsmith/adapters/transformers/utils.py +59 -0
  89. texsmith/api/__init__.py +80 -0
  90. texsmith/api/_utils.py +56 -0
  91. texsmith/api/document.py +645 -0
  92. texsmith/api/pipeline.py +229 -0
  93. texsmith/api/service.py +648 -0
  94. texsmith/api/templates.py +287 -0
  95. texsmith/core/__init__.py +6 -0
  96. texsmith/core/bibliography/__init__.py +57 -0
  97. texsmith/core/bibliography/collection.py +354 -0
  98. texsmith/core/bibliography/doi.py +194 -0
  99. texsmith/core/bibliography/issues.py +15 -0
  100. texsmith/core/bibliography/parsing.py +45 -0
  101. texsmith/core/callouts.py +99 -0
  102. texsmith/core/config.py +191 -0
  103. texsmith/core/context.py +242 -0
  104. texsmith/core/conversion/__init__.py +103 -0
  105. texsmith/core/conversion/core.py +780 -0
  106. texsmith/core/conversion/debug.py +87 -0
  107. texsmith/core/conversion/inputs.py +474 -0
  108. texsmith/core/conversion/renderer.py +578 -0
  109. texsmith/core/conversion/templates.py +646 -0
  110. texsmith/core/conversion_contexts.py +95 -0
  111. texsmith/core/diagnostics.py +118 -0
  112. texsmith/core/exceptions.py +41 -0
  113. texsmith/core/fonts/__init__.py +3 -0
  114. texsmith/core/fragments/__init__.py +716 -0
  115. texsmith/core/fragments/base.py +117 -0
  116. texsmith/core/metadata.py +280 -0
  117. texsmith/core/mustache.py +86 -0
  118. texsmith/core/partials.py +20 -0
  119. texsmith/core/rules.py +394 -0
  120. texsmith/core/templates/__init__.py +58 -0
  121. texsmith/core/templates/base.py +343 -0
  122. texsmith/core/templates/builtins.py +68 -0
  123. texsmith/core/templates/context_usage.py +137 -0
  124. texsmith/core/templates/loader.py +303 -0
  125. texsmith/core/templates/manifest.py +861 -0
  126. texsmith/core/templates/runtime.py +347 -0
  127. texsmith/core/templates/text.py +14 -0
  128. texsmith/core/templates/wrapper.py +340 -0
  129. texsmith/core/user_dir.py +179 -0
  130. texsmith/devtools.py +28 -0
  131. texsmith/extensions/__init__.py +162 -0
  132. texsmith/extensions/index/__init__.py +21 -0
  133. texsmith/extensions/index/markdown.py +94 -0
  134. texsmith/extensions/index/mkdocs_plugin.py +136 -0
  135. texsmith/extensions/index/registry.py +57 -0
  136. texsmith/extensions/index/renderer.py +183 -0
  137. texsmith/extensions/index/templates/index.tex +1 -0
  138. texsmith/extensions/latex_raw.py +115 -0
  139. texsmith/extensions/latex_text.py +117 -0
  140. texsmith/extensions/mermaid.py +267 -0
  141. texsmith/extensions/missing_footnotes.py +125 -0
  142. texsmith/extensions/multi_citations.py +54 -0
  143. texsmith/extensions/progressbar/__init__.py +9 -0
  144. texsmith/extensions/progressbar/markdown.py +212 -0
  145. texsmith/extensions/progressbar/renderer.py +117 -0
  146. texsmith/extensions/smallcaps.py +48 -0
  147. texsmith/extensions/texlogos/__init__.py +10 -0
  148. texsmith/extensions/texlogos/markdown.py +236 -0
  149. texsmith/extensions/texlogos/renderer.py +81 -0
  150. texsmith/extensions/texlogos/specs.py +66 -0
  151. texsmith/fonts/__init__.py +57 -0
  152. texsmith/fonts/cache.py +46 -0
  153. texsmith/fonts/constants.py +60 -0
  154. texsmith/fonts/coverage.py +275 -0
  155. texsmith/fonts/downloader.py +103 -0
  156. texsmith/fonts/fallback.py +438 -0
  157. texsmith/fonts/html_scripts.py +168 -0
  158. texsmith/fonts/logging.py +127 -0
  159. texsmith/fonts/pipeline.py +338 -0
  160. texsmith/fonts/scripts.py +493 -0
  161. texsmith/fonts/ucharclasses.py +164 -0
  162. texsmith/fragments/__init__.py +11 -0
  163. texsmith/fragments/bibliography/__init__.py +65 -0
  164. texsmith/fragments/bibliography/fragment.toml +3 -0
  165. texsmith/fragments/bibliography/ts-bibliography-backmatter.jinja.tex +35 -0
  166. texsmith/fragments/bibliography/ts-bibliography.jinja.tex +10 -0
  167. texsmith/fragments/callouts/__init__.py +88 -0
  168. texsmith/fragments/callouts/fragment.toml +3 -0
  169. texsmith/fragments/callouts/ts-callouts.jinja.sty +129 -0
  170. texsmith/fragments/code/__init__.py +82 -0
  171. texsmith/fragments/code/fragment.toml +3 -0
  172. texsmith/fragments/code/ts-code.jinja.sty +140 -0
  173. texsmith/fragments/extra/__init__.py +180 -0
  174. texsmith/fragments/extra/fragment.toml +3 -0
  175. texsmith/fragments/extra/ts-extra.jinja.tex +13 -0
  176. texsmith/fragments/fonts/__init__.py +880 -0
  177. texsmith/fragments/fonts/fragment.toml +3 -0
  178. texsmith/fragments/fonts/ts-fonts.jinja.sty +292 -0
  179. texsmith/fragments/frame/__init__.py +170 -0
  180. texsmith/fragments/frame/fragment.toml +3 -0
  181. texsmith/fragments/frame/ts-frame.tex.jinja +49 -0
  182. texsmith/fragments/geometry/__init__.py +169 -0
  183. texsmith/fragments/geometry/fragment.toml +3 -0
  184. texsmith/fragments/geometry/paper.py +526 -0
  185. texsmith/fragments/geometry/ts_geometry.tex.jinja +53 -0
  186. texsmith/fragments/glossary/__init__.py +67 -0
  187. texsmith/fragments/glossary/fragment.toml +3 -0
  188. texsmith/fragments/glossary/ts-glossary-backmatter.jinja.tex +5 -0
  189. texsmith/fragments/glossary/ts-glossary.jinja.sty +28 -0
  190. texsmith/fragments/index/__init__.py +66 -0
  191. texsmith/fragments/index/fragment.toml +3 -0
  192. texsmith/fragments/index/ts-index-backmatter.jinja.tex +3 -0
  193. texsmith/fragments/index/ts-index.jinja.sty +12 -0
  194. texsmith/fragments/keystrokes/__init__.py +69 -0
  195. texsmith/fragments/keystrokes/fragment.toml +3 -0
  196. texsmith/fragments/keystrokes/ts-keystrokes.jinja.sty +20 -0
  197. texsmith/fragments/todolist/__init__.py +71 -0
  198. texsmith/fragments/todolist/fragment.toml +3 -0
  199. texsmith/fragments/todolist/ts-todolist.jinja.sty +21 -0
  200. texsmith/fragments/typesetting/__init__.py +214 -0
  201. texsmith/fragments/typesetting/fragment.toml +3 -0
  202. texsmith/fragments/typesetting/ts-typesetting.tex.jinja +81 -0
  203. texsmith/index.py +26 -0
  204. texsmith/plugins/__init__.py +14 -0
  205. texsmith/progressbar.py +8 -0
  206. texsmith/quotes.py +40 -0
  207. texsmith/smart_dashes.py +66 -0
  208. texsmith/templates/__init__.py +3 -0
  209. texsmith/templates/article/README.md +33 -0
  210. texsmith/templates/article/__init__.py +281 -0
  211. texsmith/templates/article/template/manifest.toml +125 -0
  212. texsmith/templates/article/template/mermaid-config.json +18 -0
  213. texsmith/templates/article/template/template.tex +74 -0
  214. texsmith/templates/book/README.md +26 -0
  215. texsmith/templates/book/__init__.py +75 -0
  216. texsmith/templates/book/overrides/codeblock.tex +7 -0
  217. texsmith/templates/book/overrides/codeinline.tex +1 -0
  218. texsmith/templates/book/template/fixtoc.sty +81 -0
  219. texsmith/templates/book/template/manifest.toml +198 -0
  220. texsmith/templates/book/template/template.tex +314 -0
  221. texsmith/templates/common/__init__.py +1 -0
  222. texsmith/templates/common/latexmkrc +46 -0
  223. texsmith/templates/letter/README.md +59 -0
  224. texsmith/templates/letter/__init__.py +495 -0
  225. texsmith/templates/letter/demo.md +31 -0
  226. texsmith/templates/letter/fonts/modernline bold.otf +0 -0
  227. texsmith/templates/letter/fonts/modernline.otf +0 -0
  228. texsmith/templates/letter/manifest.toml +197 -0
  229. texsmith/templates/letter/template/callouts.jinja.sty +290 -0
  230. texsmith/templates/letter/template/template.tex +123 -0
  231. texsmith/templates/snippet/README.md +21 -0
  232. texsmith/templates/snippet/__init__.py +80 -0
  233. texsmith/templates/snippet/template/manifest.toml +66 -0
  234. texsmith/templates/snippet/template/template.tex +47 -0
  235. texsmith/texlogos.py +15 -0
  236. texsmith/ui/__init__.py +6 -0
  237. texsmith/ui/cli/__init__.py +22 -0
  238. texsmith/ui/cli/_options.py +338 -0
  239. texsmith/ui/cli/app.py +65 -0
  240. texsmith/ui/cli/bibliography.py +300 -0
  241. texsmith/ui/cli/commands/__init__.py +14 -0
  242. texsmith/ui/cli/commands/render.py +1128 -0
  243. texsmith/ui/cli/commands/templates.py +397 -0
  244. texsmith/ui/cli/diagnostics.py +36 -0
  245. texsmith/ui/cli/presenter.py +663 -0
  246. texsmith/ui/cli/state.py +263 -0
  247. texsmith/ui/cli/utils.py +235 -0
  248. texsmith-0.0.2.dev0.dist-info/METADATA +187 -0
  249. texsmith-0.0.2.dev0.dist-info/RECORD +252 -0
  250. texsmith-0.0.2.dev0.dist-info/WHEEL +4 -0
  251. texsmith-0.0.2.dev0.dist-info/entry_points.txt +22 -0
  252. texsmith-0.0.2.dev0.dist-info/licenses/LICENSE.md +21 -0
@@ -0,0 +1,726 @@
1
+ """Pluggable LaTeX engine helpers (latexmk, Tectonic, log parsing)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass
7
+ import io
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import subprocess
12
+ from typing import Any, Literal
13
+
14
+ from rich.console import Console
15
+
16
+ from texsmith.core.context import DocumentState
17
+ from texsmith.core.user_dir import get_user_dir
18
+
19
+ from ..latexmk import (
20
+ LatexmkEngine,
21
+ build_engine_command as build_pdflatex_command,
22
+ latexmk_pdf_flag,
23
+ normalise_engine_command,
24
+ normalise_index_engine,
25
+ )
26
+ from ..pyxindy import (
27
+ glossary_command_tokens as pyxindy_glossary_tokens,
28
+ index_command_tokens as pyxindy_index_tokens,
29
+ is_available as pyxindy_available,
30
+ )
31
+ from ..tectonic import select_makeglossaries
32
+ from .latex import (
33
+ LatexLogParser,
34
+ LatexMessage,
35
+ LatexMessageSeverity,
36
+ LatexStreamResult,
37
+ parse_latex_log,
38
+ run_latex_engine,
39
+ )
40
+ from .tectonic import run_tectonic_engine
41
+
42
+
43
+ EngineBackend = Literal["tectonic", "latexmk"]
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class EngineChoice:
48
+ """Resolved backend and latexmk program selection."""
49
+
50
+ backend: EngineBackend
51
+ latexmk_engine: str | None
52
+
53
+ @property
54
+ def label(self) -> str:
55
+ return "tectonic" if self.backend == "tectonic" else "latexmk"
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class EngineFeatures:
60
+ """Feature flags that influence engine configuration."""
61
+
62
+ requires_shell_escape: bool
63
+ bibliography: bool
64
+ has_index: bool
65
+ has_glossary: bool
66
+ index_engine: str | None = None
67
+
68
+
69
+ @dataclass(slots=True)
70
+ class EngineCommand:
71
+ """Executable command plus metadata."""
72
+
73
+ argv: list[str]
74
+ log_path: Path
75
+ pdf_path: Path
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class EngineResult:
80
+ """Outcome of a LaTeX build run."""
81
+
82
+ returncode: int
83
+ messages: list[LatexMessage]
84
+ command: list[str]
85
+ log_path: Path
86
+ pdf_path: Path
87
+
88
+
89
+ _TECTONIC_RERUN_TOKENS = (
90
+ "rerun to get cross-references right",
91
+ "rerun to get cross references right",
92
+ "label(s) may have changed",
93
+ "there were undefined references",
94
+ "there were undefined citations",
95
+ "package rerunfilecheck warning",
96
+ "please rerun",
97
+ )
98
+
99
+
100
+ def resolve_engine(preference: str | None, template_engine: str | None) -> EngineChoice:
101
+ """Resolve the engine backend and latexmk program to use."""
102
+ candidate = preference.strip().lower() if preference else ""
103
+
104
+ if candidate == "tectonic":
105
+ return EngineChoice(backend="tectonic", latexmk_engine=None)
106
+
107
+ if candidate in {"xelatex", "lualatex", "pdflatex"}:
108
+ return EngineChoice(backend="latexmk", latexmk_engine=candidate)
109
+
110
+ engine_value = template_engine if candidate == "template" else preference or template_engine
111
+
112
+ return EngineChoice(backend="latexmk", latexmk_engine=engine_value)
113
+
114
+
115
+ def compute_features(
116
+ *,
117
+ requires_shell_escape: bool,
118
+ bibliography: bool,
119
+ document_state: DocumentState | None,
120
+ template_context: Mapping[str, Any] | None,
121
+ ) -> EngineFeatures:
122
+ """Derive engine feature flags from render metadata."""
123
+ has_index = bool(
124
+ getattr(document_state, "has_index_entries", False)
125
+ or getattr(document_state, "index_entries", [])
126
+ )
127
+ has_glossary = bool(
128
+ getattr(document_state, "acronyms", {}) or getattr(document_state, "glossary", {})
129
+ )
130
+
131
+ index_engine: str | None = None
132
+ if template_context:
133
+ raw_engine = template_context.get("index_engine")
134
+ if isinstance(raw_engine, str):
135
+ candidate = raw_engine.strip()
136
+ if candidate:
137
+ index_engine = candidate
138
+
139
+ return EngineFeatures(
140
+ requires_shell_escape=requires_shell_escape,
141
+ bibliography=bibliography,
142
+ has_index=has_index,
143
+ has_glossary=has_glossary,
144
+ index_engine=index_engine,
145
+ )
146
+
147
+
148
+ def missing_dependencies(
149
+ choice: EngineChoice,
150
+ features: EngineFeatures,
151
+ *,
152
+ use_system_tectonic: bool = False,
153
+ available_binaries: Mapping[str, str | Path] | None = None,
154
+ ) -> list[str]:
155
+ """Return missing executables required by the selected engine."""
156
+ missing: list[str] = []
157
+
158
+ def _check(binary: str) -> None:
159
+ if available_binaries and binary in available_binaries:
160
+ candidate = Path(available_binaries[binary])
161
+ if candidate.exists():
162
+ return
163
+ if shutil.which(binary) is None:
164
+ missing.append(binary)
165
+
166
+ if choice.backend == "tectonic":
167
+ if use_system_tectonic:
168
+ _check("tectonic")
169
+ else:
170
+ _check("latexmk")
171
+ engine_config = normalise_engine_command(
172
+ choice.latexmk_engine, shell_escape=features.requires_shell_escape
173
+ )
174
+ if engine_config.command:
175
+ _check(engine_config.command[0])
176
+
177
+ if features.bibliography:
178
+ _check("biber")
179
+ if features.has_index:
180
+ index_engine = normalise_index_engine(features.index_engine)
181
+ if index_engine == "pyxindy":
182
+ if not pyxindy_available():
183
+ missing.append("pyxindy")
184
+ else:
185
+ _check("texindy" if index_engine == "texindy" else "makeindex")
186
+ if features.has_glossary and not pyxindy_available():
187
+ _check("makeglossaries")
188
+
189
+ return missing
190
+
191
+
192
+ def build_engine_command(
193
+ choice: EngineChoice,
194
+ features: EngineFeatures,
195
+ *,
196
+ main_tex_path: Path,
197
+ tectonic_binary: str | Path | None = None,
198
+ ) -> EngineCommand:
199
+ """Construct the command to compile the LaTeX document."""
200
+ if choice.backend == "tectonic":
201
+ if tectonic_binary is None:
202
+ binary = "tectonic"
203
+ else:
204
+ binary_path = Path(tectonic_binary)
205
+ if not binary_path.drive and binary_path.anchor in {"/", "\\"}:
206
+ binary = binary_path.as_posix()
207
+ else:
208
+ binary = os.fspath(binary_path)
209
+ argv = [
210
+ binary,
211
+ "-X",
212
+ "compile",
213
+ main_tex_path.name,
214
+ "--keep-logs",
215
+ "--keep-intermediates",
216
+ "--synctex",
217
+ "--outdir",
218
+ ".",
219
+ ]
220
+ log_path = main_tex_path.with_suffix(".log")
221
+ pdf_path = main_tex_path.with_suffix(".pdf")
222
+ return EngineCommand(argv=argv, log_path=log_path, pdf_path=pdf_path)
223
+
224
+ engine_config = normalise_engine_command(
225
+ choice.latexmk_engine, shell_escape=features.requires_shell_escape
226
+ )
227
+ command = [
228
+ "latexmk",
229
+ latexmk_pdf_flag(engine_config.pdf_mode),
230
+ "-interaction=nonstopmode",
231
+ "-halt-on-error",
232
+ "-file-line-error",
233
+ f"-pdflatex={build_pdflatex_command(engine_config)}",
234
+ main_tex_path.name,
235
+ ]
236
+ if features.bibliography:
237
+ command.insert(2, "-bibtex")
238
+
239
+ log_path = main_tex_path.with_suffix(".log")
240
+ pdf_path = main_tex_path.with_suffix(".pdf")
241
+ return EngineCommand(argv=command, log_path=log_path, pdf_path=pdf_path)
242
+
243
+
244
+ def _looks_like_path(binary: str) -> bool:
245
+ """Return True when ``binary`` already encodes a filesystem path."""
246
+ if Path(binary).is_absolute():
247
+ return True
248
+ separators = [os.sep]
249
+ if os.altsep:
250
+ separators.append(os.altsep)
251
+ return any(sep and sep in binary for sep in separators)
252
+
253
+
254
+ def ensure_command_paths(command: EngineCommand) -> EngineCommand:
255
+ """Resolve the primary binary path for the generated command."""
256
+ argv = list(command.argv)
257
+ if argv:
258
+ binary = str(argv[0])
259
+ if not _looks_like_path(binary):
260
+ resolved = shutil.which(binary)
261
+ if resolved:
262
+ argv[0] = resolved
263
+ return EngineCommand(argv=argv, log_path=command.log_path, pdf_path=command.pdf_path)
264
+
265
+
266
+ def build_tex_env(
267
+ render_dir: Path,
268
+ *,
269
+ isolate_cache: bool,
270
+ extra_path: Path | None = None,
271
+ biber_path: Path | None = None,
272
+ ) -> dict[str, str]:
273
+ """Construct TeX cache environment variables (shared by all engines)."""
274
+ if isolate_cache:
275
+ tex_cache_root = (render_dir / ".texmf-cache").resolve()
276
+ else:
277
+ tex_cache_root = get_user_dir().cache_dir("texmf")
278
+
279
+ tex_cache_root.mkdir(parents=True, exist_ok=True)
280
+ env = os.environ.copy()
281
+
282
+ texmf_home = tex_cache_root / "texmf-home"
283
+ texmf_var = tex_cache_root / "texmf-var"
284
+ luatex_cache = tex_cache_root / "luatex-cache"
285
+
286
+ texmf_cache = tex_cache_root / "texmf-cache"
287
+ texmf_config = tex_cache_root / "texmf-config"
288
+ xdg_cache = tex_cache_root / "xdg-cache"
289
+ tectonic_cache = tex_cache_root / "tectonic-cache"
290
+
291
+ for cache_path in (
292
+ texmf_home,
293
+ texmf_var,
294
+ texmf_config,
295
+ luatex_cache,
296
+ texmf_cache,
297
+ xdg_cache,
298
+ tectonic_cache,
299
+ ):
300
+ cache_path.mkdir(parents=True, exist_ok=True)
301
+
302
+ if extra_path:
303
+ env["PATH"] = f"{extra_path}{os.pathsep}{env.get('PATH', '')}"
304
+
305
+ env["TEXMFHOME"] = str(texmf_home)
306
+ env["TEXMFVAR"] = str(texmf_var)
307
+ env["TEXMFCONFIG"] = str(texmf_config)
308
+ env["LUATEXCACHE"] = str(luatex_cache)
309
+ env["LUAOTFLOAD_CACHE"] = str(luatex_cache)
310
+ env["TEXMFCACHE"] = str(texmf_cache)
311
+ env.setdefault("XDG_CACHE_HOME", str(xdg_cache))
312
+ env["TECTONIC_CACHE_DIR"] = str(tectonic_cache)
313
+ if biber_path:
314
+ env["BIBER"] = str(biber_path)
315
+ return env
316
+
317
+
318
+ def run_engine_command(
319
+ command: EngineCommand,
320
+ *,
321
+ backend: EngineBackend,
322
+ workdir: Path,
323
+ env: Mapping[str, str],
324
+ console: Console | None,
325
+ verbosity: int = 0,
326
+ classic_output: bool = False,
327
+ features: EngineFeatures | None = None,
328
+ rerun_limit: int = 5,
329
+ ) -> EngineResult:
330
+ """Execute the engine command, streaming logs when requested."""
331
+ argv = command.argv
332
+ log_path = command.log_path
333
+ console = console or Console(file=io.StringIO())
334
+
335
+ if backend == "tectonic" and features is not None:
336
+ return _run_tectonic_build(
337
+ command,
338
+ features,
339
+ workdir=workdir,
340
+ env=env,
341
+ console=console,
342
+ classic_output=classic_output,
343
+ rerun_limit=rerun_limit,
344
+ )
345
+
346
+ if classic_output:
347
+ process = subprocess.run(
348
+ argv,
349
+ check=False,
350
+ capture_output=True,
351
+ text=True,
352
+ cwd=workdir,
353
+ env=dict(env),
354
+ )
355
+ if process.stdout:
356
+ console.print(process.stdout.rstrip())
357
+ if process.stderr:
358
+ console.print(process.stderr.rstrip())
359
+ messages: list[LatexMessage] = []
360
+ if process.returncode != 0:
361
+ messages = parse_latex_log(log_path)
362
+ return EngineResult(
363
+ returncode=process.returncode,
364
+ messages=messages,
365
+ command=argv,
366
+ log_path=log_path,
367
+ pdf_path=command.pdf_path,
368
+ )
369
+
370
+ if backend == "latexmk":
371
+ result: LatexStreamResult = run_latex_engine(
372
+ argv,
373
+ workdir=workdir,
374
+ env=env,
375
+ console=console,
376
+ verbosity=verbosity,
377
+ )
378
+ else:
379
+ result = run_tectonic_engine(
380
+ argv,
381
+ workdir=workdir,
382
+ env=env,
383
+ console=console,
384
+ )
385
+ return _stream_result_to_engine_result(result, command)
386
+
387
+
388
+ def _stream_result_to_engine_result(
389
+ result: LatexStreamResult, command: EngineCommand
390
+ ) -> EngineResult:
391
+ log_path = command.log_path
392
+ messages = result.messages if result.returncode != 0 else result.messages or []
393
+ if result.returncode != 0 and not messages:
394
+ messages = parse_latex_log(log_path)
395
+ return EngineResult(
396
+ returncode=result.returncode,
397
+ messages=messages,
398
+ command=command.argv,
399
+ log_path=log_path,
400
+ pdf_path=command.pdf_path,
401
+ )
402
+
403
+
404
+ @dataclass(slots=True)
405
+ class _AuxiliaryToolRun:
406
+ returncode: int
407
+ stdout: str
408
+ stderr: str
409
+
410
+
411
+ def _invoke_auxiliary_tool(
412
+ label: str,
413
+ argv: Sequence[str],
414
+ *,
415
+ workdir: Path,
416
+ env: Mapping[str, str],
417
+ console: Console,
418
+ ) -> _AuxiliaryToolRun:
419
+ console.print(f"[cyan]Running {label}…[/]")
420
+ try:
421
+ process = subprocess.run(
422
+ list(argv),
423
+ check=False,
424
+ capture_output=True,
425
+ text=True,
426
+ cwd=workdir,
427
+ env=dict(env),
428
+ )
429
+ stdout = process.stdout or ""
430
+ stderr = process.stderr or ""
431
+ except OSError as exc:
432
+ stdout = ""
433
+ stderr = str(exc)
434
+ console.print(stderr)
435
+ return _AuxiliaryToolRun(returncode=1, stdout=stdout, stderr=stderr)
436
+
437
+ if stdout:
438
+ console.print(stdout.rstrip())
439
+ if stderr:
440
+ console.print(stderr.rstrip())
441
+ return _AuxiliaryToolRun(
442
+ returncode=process.returncode,
443
+ stdout=stdout,
444
+ stderr=stderr,
445
+ )
446
+
447
+
448
+ def _tool_failure_result(
449
+ tool: str,
450
+ run: _AuxiliaryToolRun,
451
+ *,
452
+ command: EngineCommand,
453
+ ) -> EngineResult:
454
+ summary = f"{tool} failed with status {run.returncode}"
455
+ details = [segment for segment in (run.stdout.strip(), run.stderr.strip()) if segment]
456
+ message = LatexMessage(
457
+ severity=LatexMessageSeverity.ERROR,
458
+ summary=summary,
459
+ details=details,
460
+ )
461
+ return EngineResult(
462
+ returncode=run.returncode or 1,
463
+ messages=[message],
464
+ command=command.argv,
465
+ log_path=command.log_path,
466
+ pdf_path=command.pdf_path,
467
+ )
468
+
469
+
470
+ def _run_tectonic_build(
471
+ command: EngineCommand,
472
+ features: EngineFeatures,
473
+ *,
474
+ workdir: Path,
475
+ env: Mapping[str, str],
476
+ console: Console,
477
+ classic_output: bool,
478
+ rerun_limit: int,
479
+ ) -> EngineResult:
480
+ job_stem = command.log_path.with_suffix("").name
481
+ index_engine = normalise_index_engine(features.index_engine) if features.has_index else None
482
+ rerun_target = max(1, rerun_limit)
483
+ ran_biber = False
484
+ ran_index = False
485
+ ran_glossary = False
486
+
487
+ last_result: LatexStreamResult | None = None
488
+
489
+ for pass_number in range(1, rerun_target + 1):
490
+ result = _run_single_tectonic_pass(
491
+ command,
492
+ workdir=workdir,
493
+ env=env,
494
+ console=console,
495
+ classic_output=classic_output,
496
+ )
497
+ last_result = result
498
+ if result.returncode != 0:
499
+ return _stream_result_to_engine_result(result, command)
500
+
501
+ forced_rerun = False
502
+
503
+ if features.bibliography and not ran_biber:
504
+ ran, failure = _maybe_run_biber(
505
+ job_stem,
506
+ workdir=workdir,
507
+ env=env,
508
+ console=console,
509
+ command=command,
510
+ )
511
+ if failure is not None:
512
+ return failure
513
+ if ran:
514
+ ran_biber = True
515
+ forced_rerun = True
516
+
517
+ if features.has_index and not ran_index and index_engine:
518
+ ran, failure = _maybe_run_index(
519
+ job_stem,
520
+ engine_name=index_engine,
521
+ workdir=workdir,
522
+ env=env,
523
+ console=console,
524
+ command=command,
525
+ )
526
+ if failure is not None:
527
+ return failure
528
+ if ran:
529
+ ran_index = True
530
+ forced_rerun = True
531
+
532
+ if features.has_glossary and not ran_glossary:
533
+ ran, failure = _maybe_run_glossaries(
534
+ job_stem,
535
+ workdir=workdir,
536
+ env=env,
537
+ console=console,
538
+ command=command,
539
+ )
540
+ if failure is not None:
541
+ return failure
542
+ if ran:
543
+ ran_glossary = True
544
+ forced_rerun = True
545
+
546
+ needs_rerun = forced_rerun or _log_requests_rerun(command.log_path)
547
+ if not needs_rerun:
548
+ break
549
+
550
+ if pass_number == rerun_target:
551
+ message = LatexMessage(
552
+ severity=LatexMessageSeverity.ERROR,
553
+ summary=f"Tectonic did not resolve references after {rerun_target} passes",
554
+ )
555
+ return EngineResult(
556
+ returncode=1,
557
+ messages=[message],
558
+ command=command.argv,
559
+ log_path=command.log_path,
560
+ pdf_path=command.pdf_path,
561
+ )
562
+
563
+ assert last_result is not None
564
+ return _stream_result_to_engine_result(last_result, command)
565
+
566
+
567
+ def _run_single_tectonic_pass(
568
+ command: EngineCommand,
569
+ *,
570
+ workdir: Path,
571
+ env: Mapping[str, str],
572
+ console: Console,
573
+ classic_output: bool,
574
+ ) -> LatexStreamResult:
575
+ if classic_output:
576
+ process = subprocess.run(
577
+ command.argv,
578
+ check=False,
579
+ capture_output=True,
580
+ text=True,
581
+ cwd=workdir,
582
+ env=dict(env),
583
+ )
584
+ if process.stdout:
585
+ console.print(process.stdout.rstrip())
586
+ if process.stderr:
587
+ console.print(process.stderr.rstrip())
588
+ return LatexStreamResult(returncode=process.returncode, messages=[])
589
+ return run_tectonic_engine(
590
+ command.argv,
591
+ workdir=workdir,
592
+ env=env,
593
+ console=console,
594
+ )
595
+
596
+
597
+ def _maybe_run_biber(
598
+ job_stem: str,
599
+ *,
600
+ workdir: Path,
601
+ env: Mapping[str, str],
602
+ console: Console,
603
+ command: EngineCommand,
604
+ ) -> tuple[bool, EngineResult | None]:
605
+ bcf_path = workdir / f"{job_stem}.bcf"
606
+ if not bcf_path.exists():
607
+ return False, None
608
+ run = _invoke_auxiliary_tool(
609
+ "biber",
610
+ ["biber", job_stem],
611
+ workdir=workdir,
612
+ env=env,
613
+ console=console,
614
+ )
615
+ if run.returncode != 0:
616
+ return True, _tool_failure_result("biber", run, command=command)
617
+ return True, None
618
+
619
+
620
+ def _maybe_run_index(
621
+ job_stem: str,
622
+ *,
623
+ engine_name: str,
624
+ workdir: Path,
625
+ env: Mapping[str, str],
626
+ console: Console,
627
+ command: EngineCommand,
628
+ ) -> tuple[bool, EngineResult | None]:
629
+ index_path = workdir / f"{job_stem}.idx"
630
+ if not index_path.exists():
631
+ return False, None
632
+ argv = [*_index_command_tokens(engine_name), index_path.name]
633
+ run = _invoke_auxiliary_tool(
634
+ argv[0],
635
+ argv,
636
+ workdir=workdir,
637
+ env=env,
638
+ console=console,
639
+ )
640
+ if run.returncode != 0:
641
+ return True, _tool_failure_result(argv[0], run, command=command)
642
+ return True, None
643
+
644
+
645
+ def _maybe_run_glossaries(
646
+ job_stem: str,
647
+ *,
648
+ workdir: Path,
649
+ env: Mapping[str, str],
650
+ console: Console,
651
+ command: EngineCommand,
652
+ ) -> tuple[bool, EngineResult | None]:
653
+ glo_path = workdir / f"{job_stem}.glo"
654
+ acn_path = workdir / f"{job_stem}.acn"
655
+ if not glo_path.exists() and not acn_path.exists():
656
+ return False, None
657
+ argv = [*_glossaries_command_tokens(), job_stem]
658
+ run = _invoke_auxiliary_tool(
659
+ argv[0],
660
+ argv,
661
+ workdir=workdir,
662
+ env=env,
663
+ console=console,
664
+ )
665
+ if run.returncode != 0:
666
+ return True, _tool_failure_result(argv[0], run, command=command)
667
+ return True, None
668
+
669
+
670
+ def _index_command_tokens(engine_name: str) -> list[str]:
671
+ """Select an index command based on the requested engine."""
672
+ if engine_name == "pyxindy":
673
+ return pyxindy_index_tokens()
674
+ if engine_name == "texindy":
675
+ return ["texindy"]
676
+ return [engine_name]
677
+
678
+
679
+ def _glossaries_command_tokens() -> list[str]:
680
+ """Return the preferred makeglossaries command."""
681
+ if pyxindy_available():
682
+ return pyxindy_glossary_tokens()
683
+
684
+ helper: Path | None = None
685
+ try:
686
+ helper = select_makeglossaries(console=None).path
687
+ except Exception:
688
+ helper = None
689
+
690
+ if helper and helper.exists():
691
+ return [str(helper)]
692
+ return ["makeglossaries"]
693
+
694
+
695
+ def _log_requests_rerun(log_path: Path) -> bool:
696
+ try:
697
+ with log_path.open("r", encoding="utf-8", errors="ignore") as handle:
698
+ for line in handle:
699
+ lower = line.lower()
700
+ if "rerunfilecheck" in lower:
701
+ continue
702
+ if any(token in lower for token in _TECTONIC_RERUN_TOKENS):
703
+ return True
704
+ except FileNotFoundError:
705
+ return False
706
+ return False
707
+
708
+
709
+ __all__ = [
710
+ "EngineChoice",
711
+ "EngineCommand",
712
+ "EngineFeatures",
713
+ "EngineResult",
714
+ "LatexLogParser",
715
+ "LatexMessage",
716
+ "LatexMessageSeverity",
717
+ "LatexStreamResult",
718
+ "build_engine_command",
719
+ "build_tex_env",
720
+ "compute_features",
721
+ "ensure_command_paths",
722
+ "missing_dependencies",
723
+ "parse_latex_log",
724
+ "resolve_engine",
725
+ "run_engine_command",
726
+ ]