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,780 @@
1
+ """Core orchestration logic for the conversion pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ import copy
7
+ import dataclasses
8
+ from dataclasses import dataclass, field
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from texsmith.adapters.docker import is_docker_available
14
+ from texsmith.adapters.latex.formatter import LaTeXFormatter
15
+ from texsmith.adapters.latex.renderer import LaTeXRenderer
16
+ from texsmith.adapters.transformers import has_converter, register_converter
17
+ from texsmith.core.bibliography.collection import BibliographyCollection
18
+ from texsmith.core.callouts import DEFAULT_CALLOUTS, merge_callouts, normalise_callouts
19
+ from texsmith.core.context import DocumentState
20
+ from texsmith.core.conversion_contexts import (
21
+ BinderContext,
22
+ DocumentContext,
23
+ GenerationStrategy,
24
+ SegmentContext,
25
+ )
26
+ from texsmith.core.exceptions import LatexRenderingError, TransformerExecutionError
27
+ from texsmith.core.fragments import collect_fragment_partials
28
+ from texsmith.core.templates import (
29
+ TemplateBinding,
30
+ TemplateError,
31
+ TemplateRuntime,
32
+ wrap_template_document,
33
+ )
34
+
35
+ from ..diagnostics import DiagnosticEmitter
36
+ from .debug import (
37
+ debug_enabled,
38
+ ensure_emitter,
39
+ format_user_friendly_render_error,
40
+ persist_debug_artifacts,
41
+ raise_conversion_error,
42
+ record_event,
43
+ )
44
+ from .templates import (
45
+ SlotFragment,
46
+ build_binder_context,
47
+ extract_slot_fragments,
48
+ )
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class ConversionResult:
53
+ """Artifacts produced during a document conversion."""
54
+
55
+ latex_output: str
56
+ tex_path: Path | None
57
+ template_engine: str | None
58
+ template_shell_escape: bool
59
+ language: str
60
+ has_bibliography: bool = False
61
+ slot_outputs: dict[str, str] = field(default_factory=dict)
62
+ default_slot: str = "mainmatter"
63
+ document_state: DocumentState | None = None
64
+ bibliography_path: Path | None = None
65
+ template_overrides: dict[str, Any] = field(default_factory=dict)
66
+ document_context: DocumentContext | None = None
67
+ binder_context: BinderContext | None = None
68
+ rule_descriptions: list[dict[str, Any]] = field(default_factory=list)
69
+ assets_map: dict[str, Path] = field(default_factory=dict)
70
+
71
+
72
+ _EMOJI_SPECIAL_MODES = {"artifact", "symbola", "color", "black", "twemoji"}
73
+ _CODE_ENGINES = {"minted", "listings", "verbatim", "pygments"}
74
+
75
+
76
+ def _coerce_emoji_mode(value: Any) -> str | None:
77
+ if not isinstance(value, str):
78
+ return None
79
+ candidate = value.strip()
80
+ if not candidate:
81
+ return None
82
+ lowered = candidate.lower()
83
+ return lowered if lowered in _EMOJI_SPECIAL_MODES else candidate
84
+
85
+
86
+ def _resolve_code_options(
87
+ binding: TemplateBinding,
88
+ overrides: Mapping[str, Any] | None,
89
+ ) -> dict[str, Any]:
90
+ """Return the effective code configuration merging defaults and overrides."""
91
+ default_options: dict[str, Any] = {}
92
+ instance = binding.instance
93
+ if instance is not None:
94
+ try:
95
+ defaults = instance.info.attribute_defaults()
96
+ except Exception:
97
+ defaults = {}
98
+ code_default = defaults.get("code")
99
+ if isinstance(code_default, Mapping):
100
+ default_options.update(code_default)
101
+
102
+ merged = dict(default_options)
103
+ override_sources: list[Any] = []
104
+ if overrides:
105
+ if "code" in overrides:
106
+ override_sources.append(overrides.get("code"))
107
+ press_section = overrides.get("press")
108
+ if (
109
+ isinstance(press_section, Mapping)
110
+ and "code" in press_section
111
+ and "code" not in overrides
112
+ ):
113
+ override_sources.append(press_section.get("code"))
114
+
115
+ for candidate in override_sources:
116
+ if isinstance(candidate, Mapping):
117
+ merged.update(candidate)
118
+ elif isinstance(candidate, str):
119
+ merged["engine"] = candidate
120
+
121
+ engine_value = str(merged.get("engine", "pygments") or "pygments").strip().lower()
122
+ merged["engine"] = engine_value if engine_value in _CODE_ENGINES else "pygments"
123
+ style_value = merged.get("style", "bw")
124
+ if isinstance(style_value, str):
125
+ style_candidate = style_value.strip()
126
+ else:
127
+ style_candidate = str(style_value).strip() if style_value is not None else ""
128
+ merged["style"] = style_candidate or "bw"
129
+ return merged
130
+
131
+
132
+ def _extract_emoji_mode(mapping: Mapping[str, Any] | None) -> str | None:
133
+ if not isinstance(mapping, Mapping):
134
+ return None
135
+ direct = _coerce_emoji_mode(mapping.get("emoji"))
136
+ if direct:
137
+ return direct
138
+ fonts_section = mapping.get("fonts")
139
+ if isinstance(fonts_section, Mapping):
140
+ direct_fonts = _coerce_emoji_mode(fonts_section.get("emoji"))
141
+ if direct_fonts:
142
+ return direct_fonts
143
+ press = mapping.get("press")
144
+ if isinstance(press, Mapping):
145
+ press_direct = _coerce_emoji_mode(press.get("emoji"))
146
+ if press_direct:
147
+ return press_direct
148
+ press_fonts = press.get("fonts")
149
+ if isinstance(press_fonts, Mapping):
150
+ return _coerce_emoji_mode(press_fonts.get("emoji"))
151
+ return None
152
+
153
+
154
+ def _resolve_active_fragments(
155
+ binding: TemplateBinding, overrides: Mapping[str, Any] | None
156
+ ) -> list[str]:
157
+ """Return the fragment list, respecting explicit overrides when provided."""
158
+ if isinstance(overrides, Mapping) and "fragments" in overrides:
159
+ override_payload = overrides.get("fragments")
160
+ if isinstance(override_payload, list):
161
+ return list(override_payload)
162
+ return []
163
+
164
+ runtime = binding.runtime
165
+ if runtime is not None:
166
+ fragments = runtime.extras.get("fragments") if runtime.extras else None
167
+ if isinstance(fragments, list):
168
+ return list(fragments)
169
+ return []
170
+
171
+
172
+ def _resolve_fragment_source_dir(
173
+ overrides: Mapping[str, Any] | None, binder_context: BinderContext
174
+ ) -> Path | None:
175
+ """Infer the base directory used to resolve fragment paths."""
176
+ candidates = []
177
+ press_section = overrides.get("press") if isinstance(overrides, Mapping) else None
178
+ for container in (overrides, press_section):
179
+ if not isinstance(container, Mapping):
180
+ continue
181
+ for key in ("_source_dir", "source_dir"):
182
+ raw_value = container.get(key)
183
+ if isinstance(raw_value, str) and raw_value.strip():
184
+ candidates.append(Path(raw_value))
185
+ if candidates:
186
+ return candidates[0]
187
+ if getattr(binder_context, "config", None) is not None:
188
+ try:
189
+ return binder_context.config.project_dir
190
+ except Exception:
191
+ return None
192
+ if binder_context.documents:
193
+ try:
194
+ return binder_context.documents[0].source_path.parent
195
+ except Exception:
196
+ return None
197
+ return None
198
+
199
+
200
+ def convert_document(
201
+ document: DocumentContext,
202
+ output_dir: Path,
203
+ parser: str | None,
204
+ disable_fallback_converters: bool,
205
+ copy_assets: bool,
206
+ convert_assets: bool,
207
+ hash_assets: bool,
208
+ manifest: bool,
209
+ template: str | None,
210
+ persist_debug_html: bool,
211
+ language: str | None,
212
+ slot_overrides: Mapping[str, str] | None,
213
+ bibliography_files: list[Path],
214
+ legacy_latex_accents: bool,
215
+ diagrams_backend: str | None = None,
216
+ *,
217
+ template_overrides: Mapping[str, Any] | None = None,
218
+ state: DocumentState | None = None,
219
+ template_runtime: TemplateRuntime | None = None,
220
+ wrap_document: bool = True,
221
+ emitter: DiagnosticEmitter | None = None,
222
+ preloaded_bibliography: BibliographyCollection | None = None,
223
+ seen_bibliography_issues: set[tuple[str, str | None, str | None]] | None = None,
224
+ ) -> ConversionResult:
225
+ """Orchestrate the full HTML-to-LaTeX conversion for a single document."""
226
+ emitter = ensure_emitter(emitter)
227
+ record_event(
228
+ emitter,
229
+ "convert_document",
230
+ {
231
+ "source": str(document.source_path),
232
+ "template": template,
233
+ "language": language,
234
+ "copy_assets": copy_assets,
235
+ "convert_assets": convert_assets,
236
+ },
237
+ )
238
+ strategy = GenerationStrategy(
239
+ copy_assets=copy_assets,
240
+ convert_assets=convert_assets,
241
+ hash_assets=hash_assets,
242
+ prefer_inputs=False,
243
+ persist_manifest=manifest,
244
+ )
245
+
246
+ output_dir = output_dir.resolve()
247
+
248
+ binder_context = build_binder_context(
249
+ document_context=document,
250
+ template=template,
251
+ template_runtime=template_runtime,
252
+ requested_language=language,
253
+ bibliography_files=bibliography_files,
254
+ slot_overrides=slot_overrides,
255
+ output_dir=output_dir,
256
+ strategy=strategy,
257
+ emitter=emitter,
258
+ legacy_latex_accents=legacy_latex_accents,
259
+ session_overrides=template_overrides,
260
+ preloaded_bibliography=preloaded_bibliography,
261
+ seen_bibliography_issues=seen_bibliography_issues,
262
+ )
263
+
264
+ renderer_kwargs: dict[str, Any] = {
265
+ "output_root": output_dir,
266
+ "copy_assets": strategy.copy_assets,
267
+ "convert_assets": strategy.convert_assets,
268
+ "hash_assets": strategy.hash_assets,
269
+ "parser": parser or "html.parser",
270
+ }
271
+
272
+ return _render_document(
273
+ document_context=document,
274
+ binder_context=binder_context,
275
+ renderer_kwargs=renderer_kwargs,
276
+ strategy=strategy,
277
+ disable_fallback_converters=disable_fallback_converters,
278
+ persist_debug_html=persist_debug_html,
279
+ emitter=emitter,
280
+ initial_state=state,
281
+ wrap_document=wrap_document,
282
+ legacy_latex_accents=legacy_latex_accents,
283
+ diagrams_backend=diagrams_backend,
284
+ )
285
+
286
+
287
+ def _render_document(
288
+ *,
289
+ document_context: DocumentContext,
290
+ binder_context: BinderContext,
291
+ renderer_kwargs: dict[str, Any],
292
+ strategy: GenerationStrategy,
293
+ disable_fallback_converters: bool,
294
+ persist_debug_html: bool,
295
+ emitter: DiagnosticEmitter,
296
+ initial_state: DocumentState | None,
297
+ wrap_document: bool,
298
+ legacy_latex_accents: bool,
299
+ diagrams_backend: str | None,
300
+ ) -> ConversionResult:
301
+ if persist_debug_html:
302
+ persist_debug_artifacts(
303
+ binder_context.output_dir,
304
+ document_context.source_path,
305
+ document_context.html,
306
+ )
307
+
308
+ binding = binder_context.template_binding
309
+ if binding is None: # pragma: no cover - defensive safeguard
310
+ raise RuntimeError("BinderContext is missing a template binding.")
311
+
312
+ effective_base_level = binding.base_level or 0
313
+ slot_base_levels = binding.slot_levels()
314
+
315
+ runtime_common = _build_runtime_common(
316
+ binding=binding,
317
+ binder_context=binder_context,
318
+ document_context=document_context,
319
+ strategy=strategy,
320
+ diagrams_backend=diagrams_backend,
321
+ emitter=emitter,
322
+ )
323
+
324
+ active_slot_requests = binder_context.slot_requests
325
+
326
+ parser_backend = str(renderer_kwargs.get("parser", "html.parser"))
327
+ slot_fragments, missing_slots = extract_slot_fragments(
328
+ document_context.html,
329
+ active_slot_requests,
330
+ binding.default_slot,
331
+ slot_definitions=binding.slots,
332
+ parser_backend=parser_backend,
333
+ )
334
+ for message in missing_slots:
335
+ emitter.warning(message)
336
+
337
+ manual_base_level = document_context.base_level
338
+ drop_title_flag = bool(document_context.drop_title)
339
+ if drop_title_flag and document_context.slot_requests and not binder_context.slot_requests:
340
+ drop_title_flag = False
341
+
342
+ fragment_offsets: dict[str, int] = {}
343
+ for fragment in slot_fragments:
344
+ levels = list(getattr(fragment, "heading_levels", []) or [])
345
+ if drop_title_flag and fragment.name == binding.default_slot and levels:
346
+ levels = levels[1:]
347
+ if not levels:
348
+ fragment_offsets[fragment.name] = 0
349
+ else:
350
+ fragment_offsets[fragment.name] = 1 - min(levels)
351
+
352
+ segment_registry: dict[str, list[SegmentContext]] = {}
353
+ for fragment in slot_fragments:
354
+ base_value = slot_base_levels.get(fragment.name, effective_base_level)
355
+ fragment_offset = fragment_offsets.get(fragment.name, 0)
356
+ base_offset = manual_base_level + fragment_offset
357
+ segment_registry.setdefault(fragment.name, []).append(
358
+ SegmentContext(
359
+ name=fragment.name,
360
+ html=fragment.html,
361
+ base_level=base_value + base_offset,
362
+ metadata=document_context.front_matter,
363
+ bibliography=binder_context.bibliography_map,
364
+ )
365
+ )
366
+
367
+ try:
368
+ render_result = _render_slot_fragments(
369
+ slot_fragments=slot_fragments,
370
+ binding=binding,
371
+ runtime_common=runtime_common,
372
+ slot_base_levels=slot_base_levels,
373
+ fragment_offsets=fragment_offsets,
374
+ manual_base_level=manual_base_level,
375
+ disable_fallback_converters=disable_fallback_converters,
376
+ renderer_kwargs=renderer_kwargs,
377
+ initial_state=initial_state,
378
+ drop_title_flag=drop_title_flag,
379
+ binder_context=binder_context,
380
+ legacy_latex_accents=legacy_latex_accents,
381
+ emitter=emitter,
382
+ )
383
+ except TemplateError as exc:
384
+ if debug_enabled(emitter):
385
+ raise
386
+ raise_conversion_error(emitter, str(exc), exc)
387
+ slot_outputs = render_result["slot_outputs"]
388
+ document_state = render_result["document_state"]
389
+ renderer = render_result["renderer"]
390
+
391
+ default_content = slot_outputs.get(binding.default_slot)
392
+ if default_content is None:
393
+ default_content = ""
394
+ slot_outputs[binding.default_slot] = default_content
395
+ latex_output = default_content
396
+
397
+ document_context.segments = segment_registry
398
+ for slot_name, segments in segment_registry.items():
399
+ binder_context.bound_segments.setdefault(slot_name, []).extend(segments)
400
+
401
+ citations = list(document_state.citations)
402
+ bibliography_output: Path | None = None
403
+ if (
404
+ citations
405
+ and binder_context.bibliography_collection is not None
406
+ and binder_context.bibliography_map
407
+ ):
408
+ try:
409
+ binder_context.output_dir.mkdir(parents=True, exist_ok=True)
410
+ bibliography_output = binder_context.output_dir / "texsmith-bibliography.bib"
411
+ binder_context.bibliography_collection.write_bibtex(
412
+ bibliography_output,
413
+ keys=citations,
414
+ )
415
+ except OSError as exc:
416
+ if debug_enabled(emitter):
417
+ raise
418
+ emitter.warning(f"Failed to write bibliography file: {exc}")
419
+ bibliography_output = None
420
+
421
+ tex_path: Path | None = None
422
+ if document_state is not None:
423
+ document_state.requires_shell_escape = (
424
+ document_state.requires_shell_escape or binding.requires_shell_escape
425
+ )
426
+ template_instance = binding.instance
427
+ if template_instance is not None and wrap_document:
428
+ try:
429
+ wrap_result = wrap_template_document(
430
+ template=template_instance,
431
+ default_slot=binding.default_slot,
432
+ slot_outputs=slot_outputs,
433
+ document_state=document_state,
434
+ template_overrides=(
435
+ binder_context.template_overrides if binder_context.template_overrides else None
436
+ ),
437
+ output_dir=binder_context.output_dir,
438
+ copy_assets=strategy.copy_assets,
439
+ output_name=f"{document_context.source_path.stem}.tex",
440
+ bibliography_path=bibliography_output,
441
+ emitter=emitter,
442
+ fragments=list(
443
+ binder_context.template_overrides.get(
444
+ "fragments", binding.runtime.extras.get("fragments", [])
445
+ )
446
+ ),
447
+ template_runtime=binding.runtime,
448
+ )
449
+ latex_output = wrap_result.latex_output
450
+ tex_path = wrap_result.output_path
451
+ except TemplateError as exc:
452
+ if debug_enabled(emitter):
453
+ raise
454
+ raise_conversion_error(emitter, str(exc), exc)
455
+ except OSError as exc:
456
+ if debug_enabled(emitter):
457
+ raise
458
+ raise_conversion_error(
459
+ emitter,
460
+ f"Failed to write LaTeX output to '{binder_context.output_dir}': {exc}",
461
+ exc,
462
+ )
463
+
464
+ rule_descriptions: list[dict[str, Any]] = []
465
+ if renderer is not None:
466
+ try:
467
+ rule_descriptions = list(renderer.describe_registered_rules())
468
+ except Exception:
469
+ rule_descriptions = []
470
+ asset_map: dict[str, Path] = {}
471
+ if renderer is not None:
472
+ try:
473
+ asset_map = {str(key): Path(path) for key, path in renderer.assets.items()}
474
+ except Exception:
475
+ asset_map = {}
476
+
477
+ return ConversionResult(
478
+ latex_output=latex_output,
479
+ tex_path=tex_path,
480
+ template_engine=binding.engine,
481
+ template_shell_escape=bool(
482
+ binding.requires_shell_escape
483
+ or (document_state and document_state.requires_shell_escape)
484
+ ),
485
+ language=binder_context.language,
486
+ has_bibliography=bool(bibliography_output),
487
+ slot_outputs=dict(slot_outputs),
488
+ default_slot=binding.default_slot,
489
+ document_state=document_state,
490
+ bibliography_path=bibliography_output,
491
+ template_overrides=dict(binder_context.template_overrides),
492
+ document_context=document_context,
493
+ binder_context=binder_context,
494
+ rule_descriptions=rule_descriptions,
495
+ assets_map=asset_map,
496
+ )
497
+
498
+
499
+ def _build_runtime_common(
500
+ *,
501
+ binding: TemplateBinding,
502
+ binder_context: BinderContext,
503
+ document_context: DocumentContext,
504
+ strategy: GenerationStrategy,
505
+ diagrams_backend: str | None,
506
+ emitter: DiagnosticEmitter,
507
+ ) -> dict[str, object]:
508
+ """Prepare immutable runtime metadata shared across fragment rendering."""
509
+ code_options = _resolve_code_options(binding, binder_context.template_overrides)
510
+
511
+ runtime_common: dict[str, object] = {
512
+ "numbered": document_context.numbered,
513
+ "source_dir": document_context.source_path.parent,
514
+ "document_path": document_context.source_path,
515
+ "copy_assets": strategy.copy_assets,
516
+ "convert_assets": strategy.convert_assets,
517
+ "hash_assets": strategy.hash_assets,
518
+ "language": binder_context.language,
519
+ "emitter": emitter,
520
+ }
521
+ template_callouts = binder_context.template_overrides.get("callouts")
522
+ runtime_common["callouts_definitions"] = normalise_callouts(
523
+ merge_callouts(
524
+ DEFAULT_CALLOUTS,
525
+ template_callouts if isinstance(template_callouts, Mapping) else None,
526
+ )
527
+ )
528
+ runtime_common["bibliography"] = binder_context.bibliography_map
529
+ runtime_common["bibliography_collection"] = binder_context.bibliography_collection
530
+ if binding.name is not None:
531
+ runtime_common["template"] = binding.name
532
+ runtime_common["code"] = code_options
533
+ runtime_common["diagrams_backend"] = diagrams_backend or "playwright"
534
+ mermaid_config = binder_context.template_overrides.get("mermaid_config") or (
535
+ binder_context.template_overrides.get("press") or {}
536
+ ).get("mermaid_config")
537
+ if not mermaid_config and binding.runtime and binding.runtime.extras:
538
+ mermaid_config = binding.runtime.extras.get("mermaid_config")
539
+ if mermaid_config:
540
+ runtime_common["mermaid_config"] = mermaid_config
541
+ if strategy.persist_manifest:
542
+ runtime_common["generate_manifest"] = True
543
+ emoji_mode = _extract_emoji_mode(binder_context.template_overrides)
544
+ if not emoji_mode:
545
+ emoji_mode = _extract_emoji_mode(document_context.front_matter)
546
+ if emoji_mode:
547
+ runtime_common["emoji_mode"] = emoji_mode
548
+ binder_context.template_overrides.setdefault("emoji", emoji_mode)
549
+ if emoji_mode != "artifact":
550
+ runtime_common.setdefault("emoji_command", r"\texsmithEmoji")
551
+
552
+ return runtime_common
553
+
554
+
555
+ def _render_slot_fragments(
556
+ *,
557
+ slot_fragments: list[SlotFragment],
558
+ binding: TemplateBinding,
559
+ runtime_common: dict[str, object],
560
+ slot_base_levels: Mapping[str, int],
561
+ fragment_offsets: Mapping[str, int],
562
+ manual_base_level: int,
563
+ disable_fallback_converters: bool,
564
+ renderer_kwargs: dict[str, Any],
565
+ initial_state: DocumentState | None,
566
+ drop_title_flag: bool,
567
+ binder_context: BinderContext,
568
+ legacy_latex_accents: bool,
569
+ emitter: DiagnosticEmitter,
570
+ ) -> dict[str, Any]:
571
+ """Render slot fragments, applying fallback converters when required."""
572
+ formatter = LaTeXFormatter()
573
+ formatter.legacy_latex_accents = legacy_latex_accents
574
+ formatter.default_code_engine = runtime_common.get("code", {}).get(
575
+ "engine", formatter.default_code_engine
576
+ )
577
+ style_override = runtime_common.get("code", {}).get("style", formatter.default_code_style)
578
+ if not isinstance(style_override, str):
579
+ style_override = str(style_override or "")
580
+ formatter.default_code_style = style_override.strip() or formatter.default_code_style
581
+ available_templates = formatter.template_names
582
+ partial_providers: dict[str, str] = dict.fromkeys(available_templates, "core")
583
+ fragment_names = _resolve_active_fragments(binding, binder_context.template_overrides)
584
+ fragment_source_dir = _resolve_fragment_source_dir(
585
+ binder_context.template_overrides, binder_context
586
+ )
587
+ required_partials: dict[str, set[str]] = {}
588
+ if fragment_names:
589
+ fragment_overrides, fragment_required, fragment_providers = collect_fragment_partials(
590
+ fragment_names,
591
+ source_dir=fragment_source_dir,
592
+ )
593
+ for key, override_path in fragment_overrides.items():
594
+ formatter.override_template(key, override_path)
595
+ partial_providers[key] = fragment_providers.get(key, "fragment")
596
+ for key, owners in fragment_required.items():
597
+ required_partials.setdefault(key, set()).update(owners)
598
+
599
+ binding.apply_formatter_overrides(formatter)
600
+ template_provider = binding.name or "template"
601
+ for key in binding.formatter_overrides:
602
+ partial_providers[key] = template_provider
603
+ if binding.required_partials:
604
+ for name in binding.required_partials:
605
+ required_partials.setdefault(name, set()).add(template_provider)
606
+
607
+ available_partials = set(available_templates)
608
+ missing_partials = [name for name in required_partials if name not in available_partials]
609
+ if missing_partials:
610
+ details = []
611
+ for name in sorted(missing_partials):
612
+ owners = ", ".join(sorted(required_partials.get(name, set()))) or "unknown providers"
613
+ details.append(f"partial '{name}' required by {owners}")
614
+ raise TemplateError(f"Missing {', '.join(details)}.")
615
+ runtime_common["partial_providers"] = partial_providers
616
+ renderer: LaTeXRenderer | None = None
617
+
618
+ def renderer_factory() -> LaTeXRenderer:
619
+ nonlocal renderer
620
+ if renderer is None:
621
+ renderer = LaTeXRenderer(
622
+ config=binder_context.config,
623
+ formatter=formatter,
624
+ **renderer_kwargs,
625
+ )
626
+ return renderer
627
+
628
+ if not disable_fallback_converters:
629
+ ensure_fallback_converters()
630
+
631
+ slot_outputs: dict[str, str] = {}
632
+ document_state: DocumentState | None = initial_state
633
+ for fragment in slot_fragments:
634
+ runtime_fragment = dict(runtime_common)
635
+ base_value = slot_base_levels.get(fragment.name, binding.base_level or 0)
636
+ fragment_offset = fragment_offsets.get(fragment.name, 0)
637
+ base_offset = manual_base_level + fragment_offset
638
+ runtime_fragment["base_level"] = base_value + base_offset
639
+ if fragment.name == "preface":
640
+ runtime_fragment["numbered"] = False
641
+ if drop_title_flag and fragment.name == binding.default_slot:
642
+ runtime_fragment["drop_title"] = True
643
+ drop_title_flag = False
644
+ fragment_output = ""
645
+ try:
646
+ fragment_output, document_state = render_with_fallback(
647
+ renderer_factory,
648
+ fragment.html,
649
+ runtime_fragment,
650
+ binder_context.bibliography_map,
651
+ state=document_state,
652
+ emitter=emitter,
653
+ )
654
+ except LatexRenderingError as exc:
655
+ if debug_enabled(emitter):
656
+ raise
657
+ message = format_user_friendly_render_error(exc)
658
+ raise_conversion_error(emitter, message, exc)
659
+ existing_fragment = slot_outputs.get(fragment.name, "")
660
+ slot_outputs[fragment.name] = f"{existing_fragment}{fragment_output}"
661
+
662
+ if document_state is None:
663
+ document_state = DocumentState(bibliography=dict(binder_context.bibliography_map))
664
+
665
+ return {
666
+ "slot_outputs": slot_outputs,
667
+ "document_state": document_state,
668
+ "renderer": renderer,
669
+ }
670
+
671
+
672
+ def copy_document_state(target: DocumentState, source: DocumentState) -> None:
673
+ """Synchronise ``target`` with a source ``DocumentState`` instance."""
674
+ for metadata_field in dataclasses.fields(DocumentState):
675
+ setattr(
676
+ target,
677
+ metadata_field.name,
678
+ copy.deepcopy(getattr(source, metadata_field.name)),
679
+ )
680
+
681
+
682
+ def render_with_fallback(
683
+ renderer_factory: Callable[[], LaTeXRenderer],
684
+ html: str,
685
+ runtime: dict[str, object],
686
+ bibliography: Mapping[str, dict[str, Any]] | None = None,
687
+ *,
688
+ state: DocumentState | None = None,
689
+ emitter: DiagnosticEmitter | None = None,
690
+ ) -> tuple[str, DocumentState]:
691
+ """Render HTML to LaTeX, retrying with fallback converters when available."""
692
+ emitter = ensure_emitter(emitter)
693
+ attempts = 0
694
+ bibliography_payload = dict(bibliography or {})
695
+ base_state = state
696
+
697
+ while True:
698
+ current_state = (
699
+ copy.deepcopy(base_state)
700
+ if base_state is not None
701
+ else DocumentState(bibliography=dict(bibliography_payload))
702
+ )
703
+
704
+ renderer = renderer_factory()
705
+ try:
706
+ output = renderer.render(
707
+ html,
708
+ runtime=runtime,
709
+ state=current_state,
710
+ emitter=emitter,
711
+ )
712
+ except LatexRenderingError as exc:
713
+ attempts += 1
714
+ if attempts >= 5 or not attempt_transformer_fallback(exc):
715
+ raise
716
+ continue
717
+
718
+ if base_state is not None:
719
+ copy_document_state(base_state, current_state)
720
+ return output, base_state
721
+
722
+ return output, current_state
723
+
724
+
725
+ def attempt_transformer_fallback(error: LatexRenderingError) -> bool:
726
+ """Register placeholder converters when known transformers are unavailable."""
727
+ cause = error.__cause__
728
+ if not isinstance(cause, TransformerExecutionError):
729
+ return False
730
+
731
+ message = str(cause).lower()
732
+ applied = False
733
+
734
+ if "drawio" in message:
735
+ return False
736
+ if "mermaid" in message:
737
+ return False
738
+ if ("fetch-image" in message or "fetch image" in message) and not has_converter("fetch-image"):
739
+ register_converter("fetch-image", _FallbackConverter("image"))
740
+ applied = True
741
+ return applied
742
+
743
+
744
+ def ensure_fallback_converters() -> None:
745
+ """Install placeholder converters for optional transformer dependencies."""
746
+ if is_docker_available():
747
+ return
748
+
749
+ if not has_converter("fetch-image"):
750
+ register_converter("fetch-image", _FallbackConverter("image"))
751
+
752
+
753
+ class _FallbackConverter:
754
+ def __init__(self, name: str):
755
+ self.name = name
756
+
757
+ def __call__(self, source: Path | str, *, output_dir: Path, **_: Any) -> Path:
758
+ output_dir = Path(output_dir)
759
+ output_dir.mkdir(parents=True, exist_ok=True)
760
+
761
+ original = str(source) if isinstance(source, Path) else source
762
+ digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:12]
763
+ suffix = Path(original).suffix or ".txt"
764
+ filename = f"{self.name}-{digest}.pdf"
765
+ target = output_dir / filename
766
+ target.write_text(
767
+ f"Placeholder PDF for {self.name} ({suffix})",
768
+ encoding="utf-8",
769
+ )
770
+ return target
771
+
772
+
773
+ __all__ = [
774
+ "ConversionResult",
775
+ "attempt_transformer_fallback",
776
+ "convert_document",
777
+ "copy_document_state",
778
+ "ensure_fallback_converters",
779
+ "render_with_fallback",
780
+ ]