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,1128 @@
1
+ """Implementation of the primary ``texsmith`` CLI command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ from collections.abc import Iterable, Mapping
7
+ import contextlib
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ from typing import Annotated, Any
15
+
16
+ import click
17
+ from click.core import ParameterSource
18
+ import typer
19
+
20
+ from texsmith.adapters.latex.engines import (
21
+ EngineResult,
22
+ build_engine_command as build_latex_engine_command,
23
+ build_tex_env,
24
+ compute_features,
25
+ ensure_command_paths,
26
+ missing_dependencies,
27
+ parse_latex_log,
28
+ resolve_engine,
29
+ run_engine_command,
30
+ )
31
+ from texsmith.adapters.latex.pyxindy import is_available as pyxindy_available
32
+ from texsmith.adapters.latex.tectonic import (
33
+ BiberAcquisitionError,
34
+ MakeglossariesAcquisitionError,
35
+ TectonicAcquisitionError,
36
+ select_biber_binary,
37
+ select_makeglossaries,
38
+ select_tectonic_binary,
39
+ )
40
+ from texsmith.adapters.markdown import (
41
+ DEFAULT_MARKDOWN_EXTENSIONS,
42
+ resolve_markdown_extensions,
43
+ split_front_matter,
44
+ )
45
+ from texsmith.api.service import ConversionRequest, ConversionService
46
+ from texsmith.core.bibliography import BibliographyCollection
47
+ from texsmith.core.conversion.debug import ConversionError
48
+ from texsmith.core.conversion.inputs import UnsupportedInputError
49
+ from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
50
+ from texsmith.core.templates import TemplateError
51
+ from texsmith.core.templates.runtime import coerce_base_level
52
+ from texsmith.fonts.html_scripts import wrap_scripts_in_html
53
+
54
+ from .._options import (
55
+ DIAGNOSTICS_PANEL,
56
+ OUTPUT_PANEL,
57
+ BaseLevelOption,
58
+ ConvertAssetsOption,
59
+ DebugHtmlOption,
60
+ DebugRulesOption,
61
+ DisableFallbackOption,
62
+ DisableFragmentOption,
63
+ DisableMarkdownExtensionsOption,
64
+ EnableFragmentOption,
65
+ FontsInfoOption,
66
+ FullDocumentOption,
67
+ HashAssetsOption,
68
+ HtmlOnlyOption,
69
+ InputPathArgument,
70
+ LanguageOption,
71
+ MakefileDepsOption,
72
+ ManifestOptionWithShort,
73
+ MarkdownExtensionsOption,
74
+ NoCopyAssetsOption,
75
+ NoPromoteTitleOption,
76
+ NoTitleOption,
77
+ OpenLogOption,
78
+ OutputPathOption,
79
+ ParserOption,
80
+ SelectorOption,
81
+ SlotsOption,
82
+ StripHeadingOption,
83
+ TemplateAttributeOption,
84
+ TemplateInfoOption,
85
+ TemplateOption,
86
+ )
87
+ from ..bibliography import print_bibliography_overview
88
+ from ..commands.templates import list_templates, scaffold_template, show_template_info
89
+ from ..diagnostics import CliEmitter
90
+ from ..presenter import (
91
+ consume_event_diagnostics,
92
+ present_build_summary,
93
+ present_context_attributes,
94
+ present_conversion_summary,
95
+ present_fonts_info,
96
+ present_html_summary,
97
+ present_latex_failure,
98
+ present_rule_descriptions,
99
+ )
100
+ from ..state import debug_enabled, emit_error, set_cli_state
101
+ from ..utils import determine_output_target, organise_slot_overrides, write_output_file
102
+
103
+
104
+ _SERVICE = ConversionService()
105
+
106
+
107
+ _MARKDOWN_SUFFIXES = {
108
+ ".md",
109
+ ".markdown",
110
+ ".mdown",
111
+ ".mkd",
112
+ ".mkdown",
113
+ ".mdtxt",
114
+ ".text",
115
+ ".yml",
116
+ ".yaml",
117
+ }
118
+
119
+
120
+ def _cleanup_temp_input(path: Path) -> None:
121
+ """Remove the temporary input file if it exists.
122
+
123
+ This cleanup step prevents filesystem clutter when the user pipes content
124
+ via stdin, ensuring that temporary files don't accumulate over time.
125
+ """
126
+ with contextlib.suppress(OSError):
127
+ path.unlink(missing_ok=True)
128
+
129
+
130
+ def _read_stdin_document() -> Path | None:
131
+ """Write stdin content to a temporary Markdown file when piped."""
132
+ stream = sys.stdin
133
+ if stream is None or stream.closed:
134
+ return None
135
+ try:
136
+ if stream.isatty():
137
+ return None
138
+ except (AttributeError, ValueError):
139
+ return None
140
+
141
+ payload = stream.read()
142
+ if not payload:
143
+ return None
144
+
145
+ with tempfile.NamedTemporaryFile(
146
+ mode="w",
147
+ suffix=".md",
148
+ prefix="texsmith-stdin-",
149
+ encoding="utf-8",
150
+ delete=False,
151
+ ) as handle:
152
+ handle.write(payload)
153
+ temp_path = Path(handle.name)
154
+ atexit.register(_cleanup_temp_input, temp_path)
155
+ return temp_path
156
+
157
+
158
+ def _load_front_matter(path: Path) -> Mapping[str, Any] | None:
159
+ """Return parsed Markdown front matter when available."""
160
+ suffix = path.suffix.lower()
161
+ if suffix not in _MARKDOWN_SUFFIXES:
162
+ return None
163
+ try:
164
+ metadata, _ = split_front_matter(path.read_text(encoding="utf-8"))
165
+ except OSError:
166
+ return None
167
+ return metadata if isinstance(metadata, dict) else {}
168
+
169
+
170
+ def _extract_press_template(metadata: Mapping[str, Any] | None) -> str | None:
171
+ """Extract the template identifier declared in front matter."""
172
+ if not isinstance(metadata, Mapping):
173
+ return None
174
+
175
+ payload = dict(metadata)
176
+ with contextlib.suppress(PressMetadataError):
177
+ normalise_press_metadata(payload)
178
+
179
+ template_value = payload.get("template")
180
+ if isinstance(template_value, str) and (candidate := template_value.strip()):
181
+ return candidate
182
+ return None
183
+
184
+
185
+ def _format_path_for_event(path: Path) -> str:
186
+ try:
187
+ resolved = path.resolve()
188
+ except OSError:
189
+ resolved = path
190
+ try:
191
+ return str(resolved.relative_to(Path.cwd()))
192
+ except ValueError:
193
+ return str(resolved)
194
+
195
+
196
+ def _relativize_path(path: Path, base: Path) -> Path:
197
+ """Return a path relative to ``base`` when possible."""
198
+ try:
199
+ return path.resolve().relative_to(base)
200
+ except ValueError:
201
+ try:
202
+ return Path(os.path.relpath(path, base))
203
+ except ValueError:
204
+ return path.resolve()
205
+
206
+
207
+ def _escape_make_path(path: Path) -> str:
208
+ """Escape whitespace and backslashes for Makefile dependency entries."""
209
+ raw = path.as_posix()
210
+ raw = raw.replace("\\", "\\\\")
211
+ return raw.replace(" ", "\\ ")
212
+
213
+
214
+ def _write_makefile_deps(target: Path, dependencies: Iterable[Path]) -> Path:
215
+ """Write a Makefile-compatible .d file for the given target."""
216
+ base = Path.cwd()
217
+ resolved_target = target.resolve()
218
+ dep_path = resolved_target.with_suffix(resolved_target.suffix + ".d")
219
+ dep_path.parent.mkdir(parents=True, exist_ok=True)
220
+
221
+ seen: set[Path] = set()
222
+ normalised: list[Path] = []
223
+ for dep in dependencies:
224
+ if not dep:
225
+ continue
226
+ try:
227
+ resolved = Path(dep).resolve()
228
+ except OSError:
229
+ continue
230
+ if resolved == resolved_target or resolved in seen:
231
+ continue
232
+ if not resolved.exists():
233
+ continue
234
+ seen.add(resolved)
235
+ normalised.append(resolved)
236
+
237
+ normalised = sorted(normalised, key=lambda path: path.as_posix())
238
+ rel_target = _escape_make_path(_relativize_path(resolved_target, base))
239
+ rel_deps = [_escape_make_path(_relativize_path(dep, base)) for dep in normalised]
240
+ content = f"{rel_target}: {' '.join(rel_deps)}\n" if rel_deps else f"{rel_target}:\n"
241
+ dep_path.write_text(content, encoding="utf-8")
242
+ return dep_path
243
+
244
+
245
+ def _coerce_attribute_value(raw: str) -> Any:
246
+ """Infer the type of a template attribute value from its string representation.
247
+
248
+ This bridges the gap between string-only CLI arguments and the typed configuration
249
+ expected by templates, allowing users to pass booleans and numbers naturally.
250
+ """
251
+ candidate = raw.strip()
252
+ lowered = candidate.lower()
253
+ if lowered in {"true", "false"}:
254
+ return lowered == "true"
255
+ try:
256
+ if candidate.startswith(("0x", "0X")):
257
+ return int(candidate, 16)
258
+ return int(candidate)
259
+ except ValueError:
260
+ pass
261
+ try:
262
+ return float(candidate)
263
+ except ValueError:
264
+ pass
265
+ return candidate
266
+
267
+
268
+ def _assign_nested_value(target: dict[str, Any], path: list[str], value: Any) -> None:
269
+ """Set a value in a nested dictionary structure using a list of keys.
270
+
271
+ This enables dot-notation configuration (e.g. `theme.color=red`) for complex
272
+ template settings, allowing deep overrides from the flat CLI interface.
273
+ """
274
+ cursor = target
275
+ for key in path[:-1]:
276
+ if key not in cursor:
277
+ cursor[key] = {}
278
+ elif not isinstance(cursor[key], dict):
279
+ raise typer.BadParameter(
280
+ f"Invalid attribute override for '{'.'.join(path)}', "
281
+ f"'{key}' is already assigned to a non-mapping value."
282
+ )
283
+ cursor = cursor[key] # type: ignore[assignment]
284
+ cursor[path[-1]] = value
285
+
286
+
287
+ def _parse_template_attributes(values: Iterable[str] | None) -> dict[str, Any]:
288
+ """Parse a list of key=value strings into a dictionary of attributes.
289
+
290
+ This transforms the flat list of CLI arguments into a structured configuration
291
+ dictionary that can be merged with the template's default settings.
292
+ """
293
+ overrides: dict[str, Any] = {}
294
+ if not values:
295
+ return overrides
296
+ for raw in values:
297
+ if not isinstance(raw, str) or not raw.strip():
298
+ continue
299
+ if "=" not in raw:
300
+ raise typer.BadParameter(f"Invalid attribute override '{raw}', expected key=value.")
301
+ key, value = raw.split("=", 1)
302
+ key = key.strip()
303
+ if not key:
304
+ raise typer.BadParameter(f"Invalid attribute override '{raw}', empty key.")
305
+ parts = [chunk for chunk in key.split(".") if chunk]
306
+ if not parts:
307
+ raise typer.BadParameter(f"Invalid attribute override '{raw}', empty key.")
308
+ coerced = _coerce_attribute_value(value)
309
+ if len(parts) == 1:
310
+ overrides[parts[0]] = coerced
311
+ else:
312
+ _assign_nested_value(overrides, parts, coerced)
313
+ return overrides
314
+
315
+
316
+ def _lookup_bool(mapping: Mapping[str, Any] | None, path: tuple[str, ...]) -> bool | None:
317
+ """Walk a mapping to resolve a boolean-like value."""
318
+ if not isinstance(mapping, Mapping):
319
+ return None
320
+ cursor: Any = mapping
321
+ for key in path:
322
+ if not isinstance(cursor, Mapping) or key not in cursor:
323
+ return None
324
+ cursor = cursor[key]
325
+ if isinstance(cursor, bool):
326
+ return cursor
327
+ if isinstance(cursor, (int, float)):
328
+ return bool(cursor)
329
+ if isinstance(cursor, str):
330
+ candidate = cursor.strip().lower()
331
+ if candidate in {"true", "yes", "on", "1"}:
332
+ return True
333
+ if candidate in {"false", "no", "off", "0"}:
334
+ return False
335
+ return None
336
+
337
+
338
+ def render(
339
+ list_extensions: Annotated[
340
+ bool,
341
+ typer.Option(
342
+ "--list-extensions",
343
+ help="List Markdown extensions enabled by default and exit.",
344
+ rich_help_panel=DIAGNOSTICS_PANEL,
345
+ ),
346
+ ] = False,
347
+ list_templates_flag: Annotated[
348
+ bool,
349
+ typer.Option(
350
+ "--list-templates",
351
+ help="List available templates (builtin, entry-point, and local) and exit.",
352
+ rich_help_panel=DIAGNOSTICS_PANEL,
353
+ ),
354
+ ] = False,
355
+ list_bibliography: Annotated[
356
+ bool,
357
+ typer.Option(
358
+ "--list-bibliography",
359
+ help="Print bibliography details from provided .bib files and exit.",
360
+ rich_help_panel=DIAGNOSTICS_PANEL,
361
+ ),
362
+ ] = False,
363
+ verbose: Annotated[
364
+ int,
365
+ typer.Option(
366
+ "--verbose",
367
+ "-v",
368
+ count=True,
369
+ help=("Increase CLI verbosity. Combine multiple times for additional diagnostics."),
370
+ rich_help_panel=DIAGNOSTICS_PANEL,
371
+ ),
372
+ ] = 0,
373
+ debug: Annotated[
374
+ bool,
375
+ typer.Option(
376
+ "--debug",
377
+ help="Show full tracebacks when an unexpected error occurs.",
378
+ rich_help_panel=DIAGNOSTICS_PANEL,
379
+ ),
380
+ ] = False,
381
+ debug_rules: DebugRulesOption = False,
382
+ inputs: InputPathArgument = None,
383
+ input_path: Annotated[
384
+ Path | None,
385
+ typer.Option(
386
+ "--input-path",
387
+ help="Internal helper used for programmatic invocation.",
388
+ hidden=True,
389
+ ),
390
+ ] = None,
391
+ output: OutputPathOption = None,
392
+ selector: SelectorOption = "article.md-content__inner",
393
+ full_document: FullDocumentOption = False,
394
+ base_level: BaseLevelOption = "0",
395
+ strip_heading: StripHeadingOption = False,
396
+ no_promote_title: NoPromoteTitleOption = False,
397
+ no_title: NoTitleOption = False,
398
+ parser: ParserOption = None,
399
+ disable_fallback_converters: DisableFallbackOption = False,
400
+ no_copy_assets: NoCopyAssetsOption = False,
401
+ convert_assets: ConvertAssetsOption = False,
402
+ hash_assets: HashAssetsOption = False,
403
+ diagrams_backend: Annotated[
404
+ str | None,
405
+ typer.Option(
406
+ "--diagrams-backend",
407
+ metavar="BACKEND",
408
+ help="Force the backend for diagram conversion (draw.io, mermaid): playwright, local, or docker (auto-default).",
409
+ case_sensitive=False,
410
+ ),
411
+ ] = None,
412
+ manifest: ManifestOptionWithShort = False,
413
+ make_deps: MakefileDepsOption = False,
414
+ template: TemplateOption = None,
415
+ embed_fragments: Annotated[
416
+ bool,
417
+ typer.Option(
418
+ "--embed",
419
+ help="Embed converted documents into the main document instead linking them with \\input.",
420
+ ),
421
+ ] = False,
422
+ enable_fragments: EnableFragmentOption = None,
423
+ disable_fragments: DisableFragmentOption = None,
424
+ template_attributes: TemplateAttributeOption = None,
425
+ debug_html: DebugHtmlOption = None,
426
+ classic_output: Annotated[
427
+ bool,
428
+ typer.Option(
429
+ "--classic-output",
430
+ help=("Display raw latexmk output without parsing."),
431
+ ),
432
+ ] = False,
433
+ html_only: HtmlOnlyOption = False,
434
+ build_pdf: Annotated[
435
+ bool,
436
+ typer.Option(
437
+ "-b",
438
+ "--build",
439
+ help="Invoke latexmk after rendering to compile the resulting LaTeX project.",
440
+ ),
441
+ ] = False,
442
+ engine: Annotated[
443
+ str,
444
+ typer.Option(
445
+ "-e",
446
+ "--engine",
447
+ help="LaTeX engine backend to use when building (tectonic, lualatex, xelatex).",
448
+ rich_help_panel=OUTPUT_PANEL,
449
+ ),
450
+ ] = "tectonic",
451
+ system_tectonic: Annotated[
452
+ bool,
453
+ typer.Option(
454
+ "-S",
455
+ "--system",
456
+ help="Use the system Tectonic binary instead of the bundled download.",
457
+ rich_help_panel=OUTPUT_PANEL,
458
+ ),
459
+ ] = False,
460
+ language: LanguageOption = None,
461
+ legacy_latex_accents: Annotated[
462
+ bool,
463
+ typer.Option(
464
+ "--legacy-latex-accents",
465
+ help=(
466
+ "Escape accented characters and ligatures with legacy LaTeX macros instead of "
467
+ "emitting Unicode glyphs (defaults to Unicode output)."
468
+ ),
469
+ ),
470
+ ] = False,
471
+ slots: SlotsOption = None,
472
+ markdown_extensions: MarkdownExtensionsOption = None,
473
+ disable_markdown_extensions: DisableMarkdownExtensionsOption = None,
474
+ open_log: OpenLogOption = False,
475
+ isolate_cache: Annotated[
476
+ bool,
477
+ typer.Option(
478
+ "--isolate",
479
+ help=(
480
+ "Use a per-render TeX cache inside the output directory instead of the shared "
481
+ "~/.cache/texsmith cache."
482
+ ),
483
+ rich_help_panel=OUTPUT_PANEL,
484
+ ),
485
+ ] = False,
486
+ template_info_flag: TemplateInfoOption = False,
487
+ template_scaffold: Annotated[
488
+ Path | None,
489
+ typer.Option(
490
+ "--template-scaffold",
491
+ metavar="DEST",
492
+ help="Copy the selected template into DEST and exit.",
493
+ ),
494
+ ] = None,
495
+ fonts_info: FontsInfoOption = False,
496
+ print_context: Annotated[
497
+ bool,
498
+ typer.Option(
499
+ "--print-context",
500
+ help="Print resolved template context emitters/consumers and exit.",
501
+ rich_help_panel=DIAGNOSTICS_PANEL,
502
+ ),
503
+ ] = False,
504
+ ) -> None:
505
+ """Convert MkDocs documents into LaTeX artefacts and optionally build PDFs."""
506
+
507
+ ctx = click.get_current_context(silent=True)
508
+ typer_ctx = ctx if isinstance(ctx, typer.Context) else None
509
+ state = set_cli_state(ctx=typer_ctx, verbosity=verbose, debug=debug)
510
+ if html_only:
511
+ build_pdf = False
512
+ template = None
513
+ template_info_flag = False
514
+ template_scaffold = None
515
+ if print_context:
516
+ build_pdf = False
517
+
518
+ if typer_ctx is not None and typer_ctx.resilient_parsing:
519
+ return
520
+
521
+ if list_extensions:
522
+ for extension in DEFAULT_MARKDOWN_EXTENSIONS:
523
+ typer.echo(extension)
524
+ raise typer.Exit()
525
+
526
+ if list_templates_flag:
527
+ list_templates()
528
+ raise typer.Exit()
529
+
530
+ verbosity_level = state.verbosity
531
+ if verbosity_level <= 0 and typer_ctx is not None and typer_ctx.parent is not None:
532
+ verbosity_level = int(typer_ctx.parent.params.get("verbose", 0) or 0)
533
+ if verbosity_level > 0:
534
+ state.verbosity = verbosity_level
535
+
536
+ document_paths = list(inputs or [])
537
+ if input_path is not None:
538
+ if document_paths:
539
+ raise typer.BadParameter("Provide either positional inputs or --input-path, not both.")
540
+ document_paths = [input_path]
541
+
542
+ if not document_paths:
543
+ stdin_document = _read_stdin_document()
544
+ if stdin_document is not None:
545
+ document_paths = [stdin_document]
546
+
547
+ try:
548
+ split_result = _SERVICE.split_inputs(document_paths)
549
+ except ConversionError as exc:
550
+ emit_error(str(exc), exception=exc)
551
+ raise typer.Exit(code=1) from exc
552
+
553
+ document_paths = split_result.documents
554
+ bibliography_files = split_result.bibliography_files
555
+ shared_front_matter = split_result.front_matter
556
+ shared_front_matter_path = split_result.front_matter_path
557
+
558
+ template_requested = template_info_flag or template_scaffold
559
+
560
+ if list_bibliography:
561
+ collection = BibliographyCollection()
562
+ if bibliography_files:
563
+ collection.load_files(bibliography_files)
564
+ print_bibliography_overview(collection)
565
+ raise typer.Exit()
566
+
567
+ if not document_paths:
568
+ if template_requested:
569
+ identifier = template or "article"
570
+ if template_info_flag:
571
+ show_template_info(identifier)
572
+ if template_scaffold:
573
+ scaffold_template(identifier, template_scaffold)
574
+ raise typer.Exit()
575
+ raise typer.BadParameter(
576
+ "Provide a Markdown (.md) or HTML (.html) source document or pipe content via stdin."
577
+ )
578
+
579
+ numbered = True
580
+ copy_assets = not no_copy_assets
581
+ primary_front_matter: Mapping[str, Any] | None = shared_front_matter
582
+ first_document = document_paths[0] if document_paths else None
583
+ if primary_front_matter is None and first_document is not None:
584
+ front_matter = _load_front_matter(first_document)
585
+ if front_matter:
586
+ primary_front_matter = front_matter
587
+
588
+ fm_payload: dict[str, Any] = {}
589
+ if isinstance(primary_front_matter, Mapping):
590
+ fm_payload = dict(primary_front_matter)
591
+ try:
592
+ normalise_press_metadata(fm_payload)
593
+ except PressMetadataError as exc:
594
+ raise typer.BadParameter(str(exc)) from exc
595
+
596
+ fm_numbered = _lookup_bool(fm_payload, ("numbered",))
597
+ if fm_numbered is not None:
598
+ numbered = fm_numbered
599
+
600
+ template_param_source = ctx.get_parameter_source("template") if ctx else None
601
+ no_promote_param_source = ctx.get_parameter_source("no_promote_title") if ctx else None
602
+ if (
603
+ template_param_source in {None, ParameterSource.DEFAULT}
604
+ and template is None
605
+ and primary_front_matter is not None
606
+ ):
607
+ metadata_template = _extract_press_template(primary_front_matter)
608
+ if metadata_template:
609
+ template = metadata_template
610
+
611
+ if template is None and (build_pdf or template_requested):
612
+ template = "article"
613
+
614
+ template_selected = bool(template)
615
+
616
+ if template_requested:
617
+ identifier = template or "article"
618
+ if template_info_flag:
619
+ show_template_info(identifier)
620
+ if template_scaffold:
621
+ scaffold_template(identifier, template_scaffold)
622
+ raise typer.Exit()
623
+
624
+ promote_title = not no_promote_title
625
+
626
+ attribute_overrides = _parse_template_attributes(template_attributes)
627
+ if attribute_overrides:
628
+ try:
629
+ normalise_press_metadata(attribute_overrides)
630
+ except PressMetadataError as exc:
631
+ raise typer.BadParameter(str(exc)) from exc
632
+ if attribute_overrides and not template_selected:
633
+ raise typer.BadParameter("--attribute can only be used together with --template.")
634
+ if engine:
635
+ attribute_overrides.setdefault("_texsmith_latex_engine", engine)
636
+ attr_numbered = _lookup_bool(attribute_overrides, ("numbered",))
637
+ if attr_numbered is not None:
638
+ numbered = attr_numbered
639
+ if attribute_overrides:
640
+ state.record_event("template_attributes", {"values": attribute_overrides})
641
+
642
+ output_param_source = ctx.get_parameter_source("output") if ctx else None
643
+ pdf_output_requested = bool(output and output.suffix.lower() == ".pdf")
644
+ if pdf_output_requested and not build_pdf:
645
+ typer.echo("Enabling --build to produce PDF output.")
646
+ build_pdf = True
647
+
648
+ if no_title:
649
+ promote_title = False
650
+
651
+ if strip_heading:
652
+ promote_title = False
653
+
654
+ if build_pdf and not template_selected:
655
+ template = "article"
656
+ template_selected = True
657
+ # In CI runners like act, prefer classic output to avoid streaming latexmk.
658
+ if os.environ.get("ACT") == "true":
659
+ classic_output = True
660
+
661
+ if classic_output and not build_pdf:
662
+ raise typer.BadParameter("--classic-output can only be used together with --build.")
663
+
664
+ if open_log and not build_pdf:
665
+ raise typer.BadParameter("--open-log can only be used together with --build.")
666
+ if make_deps and not build_pdf:
667
+ raise typer.BadParameter("--makefile-deps can only be used together with --build.")
668
+
669
+ if print_context and not template_selected:
670
+ raise typer.BadParameter(
671
+ "--print-context requires a template (front matter or --template)."
672
+ )
673
+
674
+ try:
675
+ _, slot_assignments = organise_slot_overrides(slots, document_paths)
676
+ except (typer.BadParameter, ValueError) as exc:
677
+ raise typer.BadParameter(str(exc)) from exc
678
+
679
+ state_slot_rows: list[dict[str, object]] = []
680
+ for doc_path, entries in slot_assignments.items():
681
+ for entry in entries:
682
+ state_slot_rows.append(
683
+ {
684
+ "document": _format_path_for_event(doc_path),
685
+ "slot": entry.slot,
686
+ "selector": entry.selector,
687
+ "include_document": entry.include_document,
688
+ }
689
+ )
690
+ if state_slot_rows:
691
+ state.record_event("slot_assignments", {"entries": state_slot_rows})
692
+
693
+ base_level_param_source = ctx.get_parameter_source("base_level") if ctx else None
694
+ base_level_value = base_level
695
+ if not template_selected and base_level_param_source in {
696
+ None,
697
+ ParameterSource.DEFAULT,
698
+ }:
699
+ base_level_value = "section"
700
+ try:
701
+ resolved_base_level = coerce_base_level(base_level_value, allow_none=False)
702
+ except TemplateError as exc:
703
+ raise typer.BadParameter(str(exc)) from exc
704
+
705
+ if not template_selected and no_promote_param_source in {
706
+ None,
707
+ ParameterSource.DEFAULT,
708
+ }:
709
+ promote_title = False
710
+
711
+ resolved_markdown_extensions = resolve_markdown_extensions(
712
+ markdown_extensions,
713
+ disable_markdown_extensions,
714
+ )
715
+ extension_line = f"Extensions: {', '.join(resolved_markdown_extensions) or '(none)'}"
716
+
717
+ def _flush_diagnostics() -> None:
718
+ lines: list[str] = []
719
+ if state.verbosity >= 1:
720
+ lines.append(extension_line)
721
+ lines.extend(consume_event_diagnostics(state))
722
+ for line in lines:
723
+ typer.echo(line)
724
+
725
+ debug_snapshot = debug_html if debug_html is not None else debug_enabled()
726
+
727
+ output_mode, output_target = determine_output_target(template_selected, document_paths, output)
728
+ resolved_output_target = output_target.resolve() if output_target is not None else None
729
+
730
+ temp_render_dir: Path | None = None
731
+ cleanup_render_dir = False
732
+ cleanup_render_dir_path: Path | None = None
733
+ final_pdf_target: Path | None = None
734
+
735
+ if template_selected:
736
+ if output_mode == "template-pdf":
737
+ temp_render_dir = Path(tempfile.mkdtemp(prefix="texsmith-")).resolve()
738
+ typer.echo(f"Using temporary output directory: {temp_render_dir}")
739
+ cleanup_render_dir = True
740
+ cleanup_render_dir_path = temp_render_dir
741
+ final_pdf_target = resolved_output_target
742
+ elif (
743
+ build_pdf
744
+ and output_mode == "template"
745
+ and output_param_source in {None, ParameterSource.DEFAULT}
746
+ ):
747
+ temp_render_dir = Path(tempfile.mkdtemp(prefix="texsmith-")).resolve()
748
+ typer.echo(f"Using temporary output directory: {temp_render_dir}")
749
+ cleanup_render_dir = True
750
+ cleanup_render_dir_path = temp_render_dir
751
+ primary_name = document_paths[0].stem if document_paths else "texsmith"
752
+ final_pdf_target = Path.cwd() / f"{primary_name}.pdf"
753
+
754
+ render_dir_path: Path | None = None
755
+ if template_selected:
756
+ render_dir_path = temp_render_dir or resolved_output_target
757
+ elif output_mode == "directory":
758
+ render_dir_path = resolved_output_target
759
+
760
+ if template_selected and render_dir_path is None:
761
+ raise typer.BadParameter("Unable to resolve template output directory.")
762
+
763
+ if not embed_fragments and template_selected and len(document_paths) == 1:
764
+ embed_fragments = True
765
+
766
+ emitter = CliEmitter(state=state, debug_enabled=debug_enabled())
767
+
768
+ request_render_dir = render_dir_path
769
+
770
+ request = ConversionRequest(
771
+ documents=document_paths,
772
+ bibliography_files=bibliography_files,
773
+ front_matter=shared_front_matter,
774
+ front_matter_path=shared_front_matter_path,
775
+ slot_assignments=slot_assignments,
776
+ selector=selector,
777
+ full_document=full_document,
778
+ base_level=resolved_base_level,
779
+ strip_heading_all=strip_heading if build_pdf else False,
780
+ strip_heading_first_document=False if build_pdf else strip_heading,
781
+ promote_title=promote_title,
782
+ suppress_title=no_title,
783
+ numbered=numbered,
784
+ markdown_extensions=resolved_markdown_extensions,
785
+ parser=parser,
786
+ disable_fallback_converters=disable_fallback_converters,
787
+ copy_assets=copy_assets,
788
+ convert_assets=convert_assets,
789
+ hash_assets=hash_assets,
790
+ manifest=manifest,
791
+ persist_debug_html=bool(debug_snapshot),
792
+ language=language,
793
+ legacy_latex_accents=legacy_latex_accents,
794
+ diagrams_backend=diagrams_backend.lower() if isinstance(diagrams_backend, str) else None,
795
+ template=template,
796
+ render_dir=request_render_dir,
797
+ template_options=attribute_overrides,
798
+ embed_fragments=embed_fragments,
799
+ enable_fragments=enable_fragments or [],
800
+ disable_fragments=disable_fragments or [],
801
+ emitter=emitter,
802
+ )
803
+ state.record_event(
804
+ "conversion_settings",
805
+ {
806
+ "parser": parser or "auto",
807
+ "copy_assets": copy_assets,
808
+ "convert_assets": convert_assets,
809
+ "hash_assets": hash_assets,
810
+ "manifest": manifest,
811
+ "fallback_converters_enabled": not disable_fallback_converters,
812
+ },
813
+ )
814
+
815
+ try:
816
+ prepared = _SERVICE.prepare_documents(request)
817
+ except UnsupportedInputError as exc:
818
+ emit_error(str(exc), exception=exc)
819
+ raise typer.Exit(code=1) from exc
820
+ except ConversionError as exc:
821
+ emit_error(str(exc), exception=exc)
822
+ raise typer.Exit(code=1) from exc
823
+
824
+ if html_only:
825
+ html_fragments = []
826
+ for doc in prepared.documents:
827
+ processed_html = doc.html
828
+ try:
829
+ processed_html, _usage, _summary = wrap_scripts_in_html(processed_html)
830
+ except Exception:
831
+ processed_html = doc.html
832
+ html_fragments.append((doc.source_path, processed_html))
833
+ if output_mode == "stdout":
834
+ typer.echo("\n\n".join(fragment for _, fragment in html_fragments))
835
+ _flush_diagnostics()
836
+ return
837
+
838
+ summary_paths: list[Path] = []
839
+ if output_mode == "file":
840
+ if resolved_output_target is None:
841
+ raise typer.BadParameter("Output path is required when writing HTML to a file.")
842
+ try:
843
+ write_output_file(resolved_output_target, html_fragments[0][1])
844
+ except OSError as exc:
845
+ emit_error(str(exc), exception=exc)
846
+ raise typer.Exit(code=1) from exc
847
+ summary_paths.append(resolved_output_target)
848
+ elif output_mode in {"directory", "template"}:
849
+ if resolved_output_target is None:
850
+ raise typer.BadParameter("Output directory is required when writing HTML files.")
851
+ resolved_output_target.mkdir(parents=True, exist_ok=True)
852
+ for source_path, payload in html_fragments:
853
+ target = resolved_output_target / f"{source_path.stem}.html"
854
+ try:
855
+ write_output_file(target, payload)
856
+ except OSError as exc:
857
+ emit_error(str(exc), exception=exc)
858
+ raise typer.Exit(code=1) from exc
859
+ summary_paths.append(target)
860
+ elif output_mode == "template-pdf":
861
+ raise typer.BadParameter("--html cannot be combined with a PDF output target.")
862
+ else:
863
+ raise RuntimeError(f"Unsupported output mode '{output_mode}' for HTML output.")
864
+
865
+ present_html_summary(
866
+ state=state,
867
+ output_mode=output_mode,
868
+ output_paths=summary_paths,
869
+ )
870
+ _flush_diagnostics()
871
+ return
872
+
873
+ engine_env_key = "TEXSMITH_SELECTED_ENGINE"
874
+ previous_engine_value = os.environ.get(engine_env_key)
875
+ if engine:
876
+ os.environ[engine_env_key] = engine
877
+ else:
878
+ os.environ.pop(engine_env_key, None)
879
+ try:
880
+ response = _SERVICE.execute(request, prepared=prepared)
881
+ except (TemplateError, ConversionError) as exc:
882
+ emit_error(str(exc), exception=exc)
883
+ raise typer.Exit(code=1) from exc
884
+ finally:
885
+ if previous_engine_value is None:
886
+ os.environ.pop(engine_env_key, None)
887
+ else:
888
+ os.environ[engine_env_key] = previous_engine_value
889
+
890
+ def _emit_rule_diagnostics() -> None:
891
+ if not debug_rules:
892
+ return
893
+ rules: list[dict[str, object]] = []
894
+ if response.is_template:
895
+ rules = getattr(response.render_result, "rule_descriptions", []) or []
896
+ else:
897
+ for fragment in response.bundle.fragments:
898
+ conversion = fragment.conversion
899
+ if conversion and conversion.rule_descriptions:
900
+ rules = conversion.rule_descriptions
901
+ break
902
+ if rules:
903
+ present_rule_descriptions(state, rules)
904
+
905
+ if not template_selected:
906
+ bundle = response.bundle
907
+
908
+ if output_mode == "stdout":
909
+ typer.echo(bundle.combined_output())
910
+ _emit_rule_diagnostics()
911
+ _flush_diagnostics()
912
+ return
913
+
914
+ if output_mode == "file":
915
+ if resolved_output_target is None:
916
+ raise typer.BadParameter("Output path is required when writing to a file.")
917
+ try:
918
+ write_output_file(resolved_output_target, bundle.combined_output())
919
+ except OSError as exc:
920
+ emit_error(str(exc), exception=exc)
921
+ raise typer.Exit(code=1) from exc
922
+ present_conversion_summary(
923
+ state=state,
924
+ output_mode=output_mode,
925
+ bundle=bundle,
926
+ output_path=resolved_output_target,
927
+ render_result=None,
928
+ )
929
+ _emit_rule_diagnostics()
930
+ _flush_diagnostics()
931
+ return
932
+
933
+ if output_mode == "directory":
934
+ present_conversion_summary(
935
+ state=state,
936
+ output_mode=output_mode,
937
+ bundle=bundle,
938
+ output_path=request.render_dir,
939
+ render_result=None,
940
+ )
941
+ _emit_rule_diagnostics()
942
+ _flush_diagnostics()
943
+ return
944
+
945
+ raise RuntimeError(f"Unsupported output mode '{output_mode}'.")
946
+
947
+ render_result = response.render_result
948
+
949
+ render_dir = render_result.main_tex_path.parent.resolve()
950
+
951
+ if not build_pdf:
952
+ present_conversion_summary(
953
+ state=state,
954
+ output_mode="template",
955
+ bundle=None,
956
+ output_path=render_dir,
957
+ render_result=render_result,
958
+ )
959
+ if print_context:
960
+ present_context_attributes(state=state, render_result=render_result)
961
+ if fonts_info:
962
+ present_fonts_info(state, render_result)
963
+ _emit_rule_diagnostics()
964
+ _flush_diagnostics()
965
+ return
966
+
967
+ engine_choice = resolve_engine(engine, render_result.template_engine)
968
+ template_context = getattr(render_result, "template_context", None) or getattr(
969
+ render_result, "context", None
970
+ )
971
+ use_system_tectonic = system_tectonic if engine_choice.backend == "tectonic" else False
972
+ features = compute_features(
973
+ requires_shell_escape=render_result.requires_shell_escape,
974
+ bibliography=render_result.has_bibliography,
975
+ document_state=render_result.document_state,
976
+ template_context=template_context,
977
+ )
978
+
979
+ tectonic_binary: Path | None = None
980
+ biber_binary: Path | None = None
981
+ makeglossaries_binary: Path | None = None
982
+ bundled_bin: Path | None = None
983
+ if engine_choice.backend == "tectonic":
984
+ try:
985
+ selection = select_tectonic_binary(use_system_tectonic, console=state.console)
986
+ if features.bibliography and not use_system_tectonic:
987
+ biber_binary = select_biber_binary(console=state.console)
988
+ bundled_bin = biber_binary.parent
989
+ if features.has_glossary and not pyxindy_available():
990
+ glossaries = select_makeglossaries(console=state.console)
991
+ makeglossaries_binary = glossaries.path
992
+ if glossaries.source == "bundled":
993
+ bundled_bin = bundled_bin or glossaries.path.parent
994
+ except (
995
+ TectonicAcquisitionError,
996
+ BiberAcquisitionError,
997
+ MakeglossariesAcquisitionError,
998
+ ) as exc:
999
+ emit_error(str(exc), exception=exc)
1000
+ raise typer.Exit(code=1) from exc
1001
+ tectonic_binary = selection.path
1002
+
1003
+ available_bins: dict[str, Path] = {}
1004
+ if biber_binary:
1005
+ available_bins["biber"] = biber_binary
1006
+ if makeglossaries_binary:
1007
+ available_bins["makeglossaries"] = makeglossaries_binary
1008
+
1009
+ missing_tools = missing_dependencies(
1010
+ engine_choice,
1011
+ features,
1012
+ use_system_tectonic=use_system_tectonic,
1013
+ available_binaries=available_bins or None,
1014
+ )
1015
+ if missing_tools:
1016
+ formatted = ", ".join(sorted(missing_tools))
1017
+ emit_error(f"Missing required tools for {engine_choice.label}: {formatted}")
1018
+ raise typer.Exit(code=1)
1019
+
1020
+ command_plan = ensure_command_paths(
1021
+ build_latex_engine_command(
1022
+ engine_choice,
1023
+ features,
1024
+ main_tex_path=render_result.main_tex_path,
1025
+ tectonic_binary=tectonic_binary,
1026
+ )
1027
+ )
1028
+ env = build_tex_env(
1029
+ render_dir,
1030
+ isolate_cache=isolate_cache,
1031
+ extra_path=bundled_bin,
1032
+ biber_path=biber_binary,
1033
+ )
1034
+
1035
+ state.console.print(f"[bold cyan]Running {engine_choice.label}…[/]")
1036
+
1037
+ run_engine = getattr(render, "run_engine_command", run_engine_command)
1038
+
1039
+ try:
1040
+ engine_result: EngineResult = run_engine(
1041
+ command_plan,
1042
+ backend=engine_choice.backend,
1043
+ workdir=render_dir,
1044
+ env=env,
1045
+ console=state.console,
1046
+ verbosity=state.verbosity,
1047
+ classic_output=classic_output,
1048
+ features=features,
1049
+ )
1050
+ except OSError as exc:
1051
+ if debug_enabled():
1052
+ raise
1053
+ emit_error(f"Failed to execute {engine_choice.label}: {exc}", exception=exc)
1054
+ raise typer.Exit(code=1) from exc
1055
+
1056
+ if engine_result.returncode != 0:
1057
+ messages = engine_result.messages or parse_latex_log(command_plan.log_path)
1058
+ present_latex_failure(
1059
+ state=state,
1060
+ log_path=command_plan.log_path,
1061
+ messages=messages,
1062
+ open_log=open_log,
1063
+ )
1064
+ emit_error(f"{engine_choice.label} exited with status {engine_result.returncode}")
1065
+ raise typer.Exit(code=engine_result.returncode)
1066
+
1067
+ pdf_path = command_plan.pdf_path
1068
+ final_pdf_path = pdf_path
1069
+ dep_file_path: Path | None = None
1070
+
1071
+ if final_pdf_target is not None:
1072
+ final_destination = final_pdf_target
1073
+ try:
1074
+ final_destination.parent.mkdir(parents=True, exist_ok=True)
1075
+ except OSError as exc:
1076
+ emit_error(
1077
+ f"Unable to create output directory '{final_destination.parent}': {exc}",
1078
+ exc,
1079
+ )
1080
+ raise typer.Exit(code=1) from exc
1081
+ try:
1082
+ shutil.copy2(pdf_path, final_destination)
1083
+ except OSError as exc:
1084
+ emit_error(f"Failed to write PDF to '{final_destination}': {exc}", exc)
1085
+ raise typer.Exit(code=1) from exc
1086
+ final_pdf_path = final_destination
1087
+
1088
+ if make_deps:
1089
+ dependency_paths: set[Path] = set()
1090
+ dependency_paths.update(document_paths)
1091
+ dependency_paths.update(bibliography_files)
1092
+ if shared_front_matter_path:
1093
+ dependency_paths.add(shared_front_matter_path)
1094
+ dependency_paths.add(render_result.main_tex_path)
1095
+ dependency_paths.update(render_result.fragment_paths)
1096
+ if render_result.bibliography_path:
1097
+ dependency_paths.add(render_result.bibliography_path)
1098
+ latexmkrc_candidate = render_dir / ".latexmkrc"
1099
+ if latexmkrc_candidate.exists():
1100
+ dependency_paths.add(latexmkrc_candidate)
1101
+ dependency_paths.update(getattr(render_result, "asset_paths", []))
1102
+ dependency_paths.update(getattr(render_result, "asset_sources", []))
1103
+ for key in getattr(render_result, "asset_map", {}) or {}:
1104
+ candidate_path = Path(key)
1105
+ if candidate_path.exists():
1106
+ dependency_paths.add(candidate_path)
1107
+ try:
1108
+ dep_file_path = _write_makefile_deps(final_pdf_path, dependency_paths)
1109
+ except OSError as exc:
1110
+ emit_error(f"Failed to write dependency file: {exc}", exc)
1111
+ raise typer.Exit(code=1) from exc
1112
+
1113
+ present_build_summary(state=state, render_result=render_result, pdf_path=final_pdf_path)
1114
+ if fonts_info:
1115
+ present_fonts_info(state, render_result)
1116
+ if dep_file_path is not None:
1117
+ state.console.print(f"[cyan]Dependencies written to[/] {dep_file_path}")
1118
+ _emit_rule_diagnostics()
1119
+ _flush_diagnostics()
1120
+
1121
+ if cleanup_render_dir and cleanup_render_dir_path is not None:
1122
+ shutil.rmtree(cleanup_render_dir_path, ignore_errors=True)
1123
+
1124
+
1125
+ # Expose runtime dependencies for test monkeypatching
1126
+ render.shutil = shutil # type: ignore[attr-defined]
1127
+ render.subprocess = subprocess # type: ignore[attr-defined]
1128
+ render.run_engine_command = run_engine_command # type: ignore[attr-defined]