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,347 @@
1
+ """Runtime helpers for binding LaTeX templates to rendered documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from .base import WrappableTemplate
11
+ from .loader import load_template
12
+ from .manifest import (
13
+ DEFAULT_TEMPLATE_LANGUAGE,
14
+ TemplateError,
15
+ TemplateSlot,
16
+ _BABEL_LANGUAGE_ALIASES,
17
+ )
18
+
19
+ from texsmith.core.fragments import FRAGMENT_REGISTRY
20
+
21
+ if TYPE_CHECKING:
22
+ from texsmith.adapters.latex.formatter import LaTeXFormatter
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class TemplateRuntime:
27
+ """Resolved template metadata reused across conversions."""
28
+
29
+ instance: WrappableTemplate
30
+ name: str
31
+ engine: str | None
32
+ requires_shell_escape: bool
33
+ slots: dict[str, TemplateSlot]
34
+ default_slot: str
35
+ formatter_overrides: dict[str, Path]
36
+ base_level: int | None
37
+ required_partials: set[str] = field(default_factory=set)
38
+ extras: dict[str, Any] = field(default_factory=dict)
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class TemplateBinding:
43
+ """Binding between slot requests and a LaTeX template."""
44
+
45
+ runtime: TemplateRuntime | None
46
+ instance: WrappableTemplate | None
47
+ name: str | None
48
+ engine: str | None
49
+ requires_shell_escape: bool
50
+ formatter_overrides: dict[str, Path]
51
+ slots: dict[str, TemplateSlot]
52
+ default_slot: str
53
+ base_level: int | None
54
+ required_partials: set[str] = field(default_factory=set)
55
+
56
+ def slot_levels(self, *, offset: int = 0) -> dict[str, int]:
57
+ """Return the resolved base level for each slot."""
58
+ base = (self.base_level or 0) + offset
59
+ return {name: slot.resolve_level(base) for name, slot in self.slots.items()}
60
+
61
+ def apply_formatter_overrides(self, formatter: "LaTeXFormatter") -> None:
62
+ """Apply template-provided overrides to a formatter."""
63
+ for key, override_path in self.formatter_overrides.items():
64
+ formatter.override_template(key, override_path)
65
+
66
+
67
+ def coerce_base_level(value: Any, *, allow_none: bool = True) -> int | None:
68
+ """Normalise base-level metadata to an integer or ``None``."""
69
+ if value is None:
70
+ if allow_none:
71
+ return None
72
+ raise TemplateError("Base level value is missing.")
73
+
74
+ if isinstance(value, bool):
75
+ raise TemplateError("Base level must be an integer, booleans are not supported.")
76
+
77
+ if isinstance(value, (int, float)):
78
+ return int(value)
79
+
80
+ if isinstance(value, str):
81
+ candidate = value.strip().lower()
82
+ if not candidate:
83
+ if allow_none:
84
+ return None
85
+ raise TemplateError("Base level value cannot be empty.")
86
+ alias_map = {
87
+ "part": -1,
88
+ "chapter": 0,
89
+ "section": 1,
90
+ "subsection": 2,
91
+ }
92
+ if candidate in alias_map:
93
+ return alias_map[candidate]
94
+ try:
95
+ return int(candidate)
96
+ except ValueError as exc: # pragma: no cover - defensive
97
+ raise TemplateError(
98
+ f"Invalid base level '{value}'. Expected an integer value or one of "
99
+ f"{', '.join(alias_map)}."
100
+ ) from exc
101
+
102
+ raise TemplateError(
103
+ f"Base level should be provided as an integer value, got type '{type(value).__name__}'."
104
+ )
105
+
106
+
107
+ def extract_base_level_override(overrides: Mapping[str, Any] | None) -> Any:
108
+ """Extract a base level override from template metadata overrides."""
109
+ if not overrides:
110
+ return None
111
+
112
+ press_section = overrides.get("press")
113
+ direct_candidate = overrides.get("base_level")
114
+ if direct_candidate is not None:
115
+ return direct_candidate
116
+ if isinstance(press_section, Mapping):
117
+ return press_section.get("base_level")
118
+ return None
119
+
120
+
121
+ def build_template_overrides(front_matter: Mapping[str, Any] | None) -> dict[str, Any]:
122
+ """Build template overrides from front matter while preserving metadata."""
123
+ if not front_matter or not isinstance(front_matter, Mapping):
124
+ return {}
125
+
126
+ overrides = dict(front_matter)
127
+ press_section = overrides.get("press")
128
+ if isinstance(press_section, Mapping):
129
+ overrides["press"] = dict(press_section)
130
+
131
+ fragments = overrides.get("fragments")
132
+ if fragments is None and isinstance(press_section, Mapping):
133
+ fragments = press_section.get("fragments")
134
+ if isinstance(fragments, list):
135
+ overrides["fragments"] = list(fragments)
136
+
137
+ callouts_section = overrides.get("callouts")
138
+ if callouts_section is None and isinstance(press_section, Mapping):
139
+ callouts_section = press_section.get("callouts")
140
+ if isinstance(callouts_section, Mapping):
141
+ overrides["callouts"] = dict(callouts_section)
142
+
143
+ callouts_style = overrides.get("callouts_style")
144
+ if callouts_style is None and isinstance(press_section, Mapping):
145
+ callouts_style = press_section.get("callouts_style")
146
+ if callouts_style is not None:
147
+ overrides["callout_style"] = callouts_style
148
+
149
+ base_override = overrides.get("base_level")
150
+ if base_override is None and isinstance(press_section, Mapping):
151
+ base_override = press_section.get("base_level")
152
+ if base_override is not None:
153
+ try:
154
+ overrides["base_level"] = coerce_base_level(base_override)
155
+ except TemplateError:
156
+ overrides["base_level"] = base_override
157
+
158
+ return overrides
159
+
160
+
161
+ def extract_language_from_front_matter(
162
+ front_matter: Mapping[str, Any] | None,
163
+ ) -> str | None:
164
+ """Inspect front matter for language hints."""
165
+ if not isinstance(front_matter, Mapping):
166
+ return None
167
+
168
+ for key in ("language", "lang"):
169
+ value = front_matter.get(key)
170
+ if isinstance(value, str):
171
+ stripped = value.strip()
172
+ if stripped:
173
+ return stripped
174
+
175
+ press_entry = front_matter.get("press")
176
+ if isinstance(press_entry, Mapping):
177
+ for key in ("language", "lang"):
178
+ value = press_entry.get(key)
179
+ if isinstance(value, str):
180
+ stripped = value.strip()
181
+ if stripped:
182
+ return stripped
183
+ return None
184
+
185
+
186
+ def normalise_template_language(value: str | None) -> str | None:
187
+ """Normalise language codes and map them through babel aliases when available."""
188
+ if value is None:
189
+ return None
190
+
191
+ stripped = value.strip()
192
+ if not stripped:
193
+ return None
194
+
195
+ lowered = stripped.lower().replace("_", "-")
196
+ alias = _BABEL_LANGUAGE_ALIASES.get(lowered)
197
+ if alias:
198
+ return alias
199
+
200
+ primary = lowered.split("-", 1)[0]
201
+ alias = _BABEL_LANGUAGE_ALIASES.get(primary)
202
+ if alias:
203
+ return alias
204
+
205
+ if lowered.isalpha():
206
+ return lowered
207
+
208
+ return None
209
+
210
+
211
+ def resolve_template_language(
212
+ explicit: str | None,
213
+ front_matter: Mapping[str, Any] | None,
214
+ ) -> str:
215
+ """Resolve the effective template language from CLI and front matter inputs."""
216
+ candidates = (
217
+ normalise_template_language(explicit),
218
+ normalise_template_language(extract_language_from_front_matter(front_matter)),
219
+ )
220
+
221
+ for candidate in candidates:
222
+ if candidate:
223
+ return candidate
224
+
225
+ return DEFAULT_TEMPLATE_LANGUAGE
226
+
227
+
228
+ def load_template_runtime(template: str) -> TemplateRuntime:
229
+ """Resolve template metadata for repeated conversions."""
230
+ template_instance = load_template(template)
231
+
232
+ template_base = coerce_base_level(
233
+ template_instance.info.get_attribute_default("base_level"),
234
+ )
235
+
236
+ slots, default_slot = template_instance.info.resolve_slots()
237
+ formatter_overrides = dict(template_instance.iter_formatter_overrides())
238
+ extras_payload = getattr(template_instance, "extras", {}) or {}
239
+ extras = {key: value for key, value in extras_payload.items()}
240
+ declared_fragments = (
241
+ list(template_instance.info.fragments) if template_instance.info.fragments is not None else None
242
+ )
243
+ extras.setdefault(
244
+ "fragments",
245
+ declared_fragments if declared_fragments is not None else [],
246
+ )
247
+
248
+ return TemplateRuntime(
249
+ instance=template_instance,
250
+ name=template_instance.info.name,
251
+ engine=template_instance.info.engine,
252
+ requires_shell_escape=bool(template_instance.info.shell_escape),
253
+ slots=slots,
254
+ default_slot=default_slot,
255
+ formatter_overrides=formatter_overrides,
256
+ base_level=template_base,
257
+ required_partials=set(template_instance.info.required_partials or []),
258
+ extras=extras,
259
+ )
260
+
261
+
262
+ def resolve_template_binding(
263
+ *,
264
+ template: str | None,
265
+ template_runtime: TemplateRuntime | None,
266
+ template_overrides: Mapping[str, Any],
267
+ slot_requests: Mapping[str, str],
268
+ warn: Callable[[str], None] | None = None,
269
+ ) -> tuple[TemplateBinding, dict[str, str]]:
270
+ """Resolve template runtime metadata and apply slot overrides."""
271
+ runtime = template_runtime
272
+ if runtime is None and template:
273
+ runtime = load_template_runtime(template)
274
+
275
+ if runtime is not None:
276
+ # Adjust base level for book parts when requested via overrides.
277
+ binding_base_level = runtime.base_level
278
+ part_flag = None
279
+ press_section = template_overrides.get("press")
280
+ if isinstance(template_overrides.get("part"), bool):
281
+ part_flag = template_overrides.get("part")
282
+ elif isinstance(press_section, Mapping) and isinstance(press_section.get("part"), bool):
283
+ part_flag = press_section.get("part")
284
+ if part_flag and runtime.name == "book":
285
+ binding_base_level = coerce_base_level("part")
286
+
287
+ binding = TemplateBinding(
288
+ runtime=runtime,
289
+ instance=runtime.instance,
290
+ name=runtime.name,
291
+ engine=runtime.engine,
292
+ requires_shell_escape=runtime.requires_shell_escape,
293
+ formatter_overrides=dict(runtime.formatter_overrides),
294
+ slots=runtime.slots,
295
+ default_slot=runtime.default_slot,
296
+ base_level=binding_base_level,
297
+ required_partials=set(runtime.required_partials),
298
+ )
299
+ else:
300
+ binding = TemplateBinding(
301
+ runtime=None,
302
+ instance=None,
303
+ name=None,
304
+ engine=None,
305
+ requires_shell_escape=False,
306
+ formatter_overrides={},
307
+ slots={"mainmatter": TemplateSlot(default=True)},
308
+ default_slot="mainmatter",
309
+ base_level=None,
310
+ required_partials=set(),
311
+ )
312
+
313
+ base_override = coerce_base_level(extract_base_level_override(template_overrides))
314
+ if base_override is not None:
315
+ binding.base_level = base_override
316
+
317
+ filtered: dict[str, str] = {}
318
+ for slot_name, selector in slot_requests.items():
319
+ if slot_name not in binding.slots:
320
+ if warn is not None:
321
+ template_hint = f"template '{binding.name}'" if binding.name else "the template"
322
+ warn(
323
+ f"slot '{slot_name}' is not defined by {template_hint}; "
324
+ f"content will remain in '{binding.default_slot}'."
325
+ )
326
+ continue
327
+ if binding.runtime is None:
328
+ if warn is not None:
329
+ warn(f"slot '{slot_name}' was requested but no template is selected; ignoring.")
330
+ continue
331
+ filtered[slot_name] = selector
332
+
333
+ return binding, filtered
334
+
335
+
336
+ __all__ = [
337
+ "TemplateBinding",
338
+ "TemplateRuntime",
339
+ "build_template_overrides",
340
+ "coerce_base_level",
341
+ "extract_base_level_override",
342
+ "extract_language_from_front_matter",
343
+ "load_template_runtime",
344
+ "normalise_template_language",
345
+ "resolve_template_binding",
346
+ "resolve_template_language",
347
+ ]
@@ -0,0 +1,14 @@
1
+ """Utilities for post-processing generated LaTeX artefacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ def squash_blank_lines(text: str) -> str:
9
+ """Trim trailing whitespace and collapse runs of blank lines."""
10
+ trimmed = re.sub(r"[ \t]+(?=\r?\n|$)", "", text)
11
+ return re.sub(r"\n{3,}", "\n\n", trimmed)
12
+
13
+
14
+ __all__ = ["squash_blank_lines"]
@@ -0,0 +1,340 @@
1
+ """Shared helpers for wrapping LaTeX bodies with template metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any
10
+
11
+ from jinja2 import meta
12
+
13
+ from texsmith.core.callouts import DEFAULT_CALLOUTS, merge_callouts, normalise_callouts
14
+ from texsmith.core.fragments import (
15
+ FRAGMENT_REGISTRY,
16
+ inject_fragment_attributes,
17
+ render_fragments,
18
+ )
19
+ from texsmith.core.templates import TemplateRuntime
20
+ from texsmith.core.templates.manifest import TemplateError
21
+
22
+ from texsmith.core.conversion.debug import ensure_emitter
23
+ from texsmith.core.diagnostics import DiagnosticEmitter
24
+ from texsmith.fonts.scripts import render_script_macros
25
+ from ..context import DocumentState
26
+ from .base import WrappableTemplate
27
+ from .loader import copy_template_assets
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class TemplateWrapResult:
32
+ """Result artefacts produced after wrapping LaTeX with a template."""
33
+
34
+ latex_output: str
35
+ template_context: dict[str, Any]
36
+ output_path: Path | None
37
+ asset_paths: list[Path] = field(default_factory=list)
38
+ asset_pairs: list[tuple[Path, Path]] = field(default_factory=list)
39
+ rendered_fragments: list[str] = field(default_factory=list)
40
+
41
+
42
+ def wrap_template_document(
43
+ *,
44
+ template: WrappableTemplate,
45
+ default_slot: str,
46
+ slot_outputs: Mapping[str, str],
47
+ slot_output_overrides: Mapping[str, str] | None = None,
48
+ document_state: DocumentState,
49
+ template_overrides: Mapping[str, Any] | None,
50
+ output_dir: Path,
51
+ copy_assets: bool = True,
52
+ output_name: str | None = None,
53
+ bibliography_path: Path | None = None,
54
+ emitter: DiagnosticEmitter | None = None,
55
+ fragments: list[str] | None = None,
56
+ template_runtime: TemplateRuntime | None = None,
57
+ ) -> TemplateWrapResult:
58
+ """Wrap LaTeX content using a template and optional asset copying."""
59
+ output_dir = Path(output_dir).resolve()
60
+ resolved_slots = {name: value for name, value in slot_outputs.items()}
61
+ override_slots = (
62
+ {name: value for name, value in slot_output_overrides.items()}
63
+ if slot_output_overrides
64
+ else None
65
+ )
66
+ def _process_slot(value: Any) -> Any:
67
+ return value
68
+
69
+ main_slot_content = _process_slot(resolved_slots.get(default_slot, ""))
70
+ resolved_slots[default_slot] = main_slot_content
71
+ resolved_slots.setdefault(default_slot, main_slot_content)
72
+ processed_override_slots: dict[str, Any] | None = None
73
+ if override_slots is not None:
74
+ processed_override_slots = {
75
+ name: _process_slot(value) for name, value in override_slots.items()
76
+ }
77
+ processed_override_slots.setdefault(
78
+ default_slot, resolved_slots.get(default_slot, "")
79
+ )
80
+
81
+ overrides_payload = dict(template_overrides) if template_overrides else None
82
+ source_dir = None
83
+ overrides_press = overrides_payload.get("press") if overrides_payload else None
84
+ if isinstance(overrides_payload, Mapping):
85
+ raw_source_dir = overrides_payload.get("_source_dir") or overrides_payload.get("source_dir")
86
+ if isinstance(raw_source_dir, (str, Path)) and str(raw_source_dir):
87
+ source_dir = Path(raw_source_dir)
88
+ if source_dir is None and isinstance(overrides_press, Mapping):
89
+ source_dir_raw = overrides_press.get("_source_dir") or overrides_press.get("source_dir")
90
+ if source_dir_raw:
91
+ source_dir = Path(source_dir_raw)
92
+ fragment_names = list(fragments or [])
93
+ if not fragment_names:
94
+ if template_runtime is not None:
95
+ fragment_names = list(template_runtime.extras.get("fragments") or [])
96
+ else:
97
+ manifest_fragments = getattr(template.info, "fragments", None)
98
+ fragment_names = list(manifest_fragments or [])
99
+
100
+ template_context = template.prepare_context(
101
+ main_slot_content,
102
+ overrides=overrides_payload,
103
+ )
104
+ if isinstance(overrides_press, Mapping):
105
+ template_context.setdefault("press", overrides_press)
106
+ root_name: str | None = None
107
+ if output_name:
108
+ root_name = Path(output_name).stem
109
+ if root_name:
110
+ template_context.setdefault("root_filename", root_name)
111
+
112
+ for slot_name, raw_content in resolved_slots.items():
113
+ if slot_name == default_slot:
114
+ continue
115
+ processed_content = _process_slot(raw_content)
116
+ resolved_slots[slot_name] = processed_content
117
+ template_context[slot_name] = processed_content
118
+
119
+ template_context["index_entries"] = document_state.has_index_entries
120
+ index_terms = list(dict.fromkeys(getattr(document_state, "index_entries", [])))
121
+ template_context["has_index"] = bool(index_terms)
122
+ template_context["index_terms"] = [tuple(term) for term in index_terms]
123
+
124
+ registry_entries = index_terms
125
+ try: # pragma: no cover - optional dependency
126
+ from texsmith.index import get_registry
127
+ except ModuleNotFoundError:
128
+ template_context["index_registry"] = [tuple(term) for term in registry_entries]
129
+ else:
130
+ snapshot = sorted(get_registry().snapshot())
131
+ template_context["index_registry"] = [tuple(term) for term in snapshot]
132
+ template_context["acronyms"] = document_state.acronyms.copy()
133
+ template_context["citations"] = list(document_state.citations)
134
+ template_context["bibliography_entries"] = document_state.bibliography
135
+
136
+ fragment_attributes: dict[str, Any] = {}
137
+ if fragment_names:
138
+ fragment_attributes = inject_fragment_attributes(
139
+ fragment_names,
140
+ context=template_context,
141
+ overrides=overrides_payload,
142
+ source_dir=source_dir,
143
+ declared_attribute_owners=(
144
+ template.info.attribute_owners() if hasattr(template, "info") else {}
145
+ ),
146
+ )
147
+
148
+ code_section = template_context.get("code")
149
+ code_engine = None
150
+ code_style = "bw"
151
+ if isinstance(code_section, Mapping):
152
+ raw_engine = code_section.get("engine")
153
+ code_engine = raw_engine if isinstance(raw_engine, str) else None
154
+ raw_style = code_section.get("style")
155
+ if isinstance(raw_style, str) and raw_style.strip():
156
+ code_style = raw_style.strip()
157
+ elif isinstance(code_section, str):
158
+ code_engine = code_section
159
+ code_engine = (code_engine or "pygments").strip().lower()
160
+ template_context["code_engine"] = code_engine or "pygments"
161
+ template_context.setdefault("code_style", code_style)
162
+ if "code" not in template_context:
163
+ template_context["code"] = {
164
+ "engine": template_context["code_engine"],
165
+ "style": template_context["code_style"],
166
+ }
167
+ elif isinstance(template_context["code"], dict):
168
+ template_context["code"].setdefault("style", template_context["code_style"])
169
+ if code_engine == "pygments" and getattr(document_state, "pygments_styles", {}):
170
+ styles = getattr(document_state, "pygments_styles", {})
171
+ template_context["pygments_style_defs"] = "\n".join(styles.values())
172
+
173
+ template_context["requires_shell_escape"] = bool(
174
+ template_context.get("requires_shell_escape", False)
175
+ or getattr(document_state, "requires_shell_escape", False)
176
+ or (template_runtime.requires_shell_escape if template_runtime else False)
177
+ or code_engine == "minted"
178
+ )
179
+ if template_runtime and template_runtime.engine:
180
+ template_context.setdefault("latex_engine", template_runtime.engine)
181
+
182
+ emitter_obj = ensure_emitter(emitter)
183
+
184
+ if document_state.citations and bibliography_path is not None:
185
+ template_context["bibliography"] = bibliography_path.stem
186
+ template_context["bibliography_resource"] = bibliography_path.name
187
+ template_context.setdefault("bibliography_style", "numeric")
188
+
189
+ template_context["ts_uses_callouts"] = bool(getattr(document_state, "callouts_used", False))
190
+
191
+ # Render fragments and inject declarations into template variables.
192
+ requested_fragments = list(fragment_names)
193
+ callout_overrides = overrides_payload.get("callouts") if overrides_payload else None
194
+ callouts_defs = normalise_callouts(
195
+ merge_callouts(
196
+ DEFAULT_CALLOUTS, callout_overrides if isinstance(callout_overrides, Mapping) else None
197
+ )
198
+ )
199
+ template_context.setdefault("callouts_definitions", callouts_defs)
200
+ variable_injections: dict[str, list[str]] = {}
201
+ fragment_providers: dict[str, list[str]] = {}
202
+ declared_slots, _default_slot = template.info.resolve_slots()
203
+ declared_slot_names = set(declared_slots.keys())
204
+ declared_vars = _discover_template_variables(template)
205
+ rendered_fragments: set[str] = set()
206
+ if fragment_names:
207
+ fragment_context: dict[str, Any] = template_context
208
+
209
+ def _reassert_effective_emoji_mode(target: dict[str, Any]) -> None:
210
+ effective_mode = target.get("_texsmith_effective_emoji_mode")
211
+ if not effective_mode:
212
+ return
213
+ target["emoji"] = effective_mode
214
+ target["emoji_mode"] = effective_mode
215
+ fonts_section = target.get("fonts")
216
+ if isinstance(fonts_section, Mapping):
217
+ if isinstance(fonts_section, dict):
218
+ fonts_section["emoji"] = effective_mode
219
+ else:
220
+ updated_fonts = dict(fonts_section)
221
+ updated_fonts["emoji"] = effective_mode
222
+ target["fonts"] = updated_fonts
223
+
224
+ if overrides_payload:
225
+ for key, value in overrides_payload.items():
226
+ if key in fragment_attributes:
227
+ continue
228
+ fragment_context.setdefault(key, value)
229
+ press_section = overrides_payload.get("press")
230
+ if isinstance(press_section, Mapping):
231
+ for key, value in press_section.items():
232
+ fragment_context.setdefault(key, value)
233
+ _reassert_effective_emoji_mode(fragment_context)
234
+ else:
235
+ _reassert_effective_emoji_mode(fragment_context)
236
+ fragment_result = render_fragments(
237
+ fragment_names,
238
+ context=fragment_context,
239
+ output_dir=output_dir,
240
+ source_dir=source_dir,
241
+ overrides=overrides_payload,
242
+ declared_slots=declared_slot_names,
243
+ declared_variables=declared_vars,
244
+ template_name=template.info.name,
245
+ )
246
+ variable_injections = fragment_result.variable_injections
247
+ fragment_providers = fragment_result.providers
248
+ for provider_list in fragment_providers.values():
249
+ rendered_fragments.update(provider_list)
250
+ template_context["requested_fragments"] = requested_fragments
251
+ template_context["fragments"] = sorted(rendered_fragments)
252
+
253
+ if variable_injections:
254
+ for variable_name, injections in variable_injections.items():
255
+ base = template_context.get(variable_name, "")
256
+ parts: list[str] = [base] if base else []
257
+ parts.extend(injections)
258
+ template_context[variable_name] = "\n".join(part for part in parts if part)
259
+
260
+ script_macros = render_script_macros(getattr(document_state, "script_usage", []))
261
+ if script_macros:
262
+ existing_extra = template_context.get("extra_packages", "")
263
+ template_context["extra_packages"] = "\n".join(
264
+ part for part in (existing_extra, script_macros) if part
265
+ )
266
+ template_context["script_macros"] = script_macros
267
+
268
+ final_slots = processed_override_slots if processed_override_slots is not None else resolved_slots
269
+ for slot_name, value in final_slots.items():
270
+ template_context[slot_name] = value
271
+ main_slot_content = final_slots.get(default_slot, main_slot_content)
272
+
273
+ # Append pdfLaTeX-specific packages when not using LuaLaTeX.
274
+ extra_lines = [line for line in template_context.get("extra_packages", "").splitlines() if line]
275
+ engine = str(template_context.get("latex_engine") or "").strip().lower()
276
+ if engine and engine != "lualatex":
277
+ for package in template_context.get("pdflatex_extra_packages") or []:
278
+ if package:
279
+ extra_lines.append(f"\\usepackage{{{package}}}")
280
+ template_context["extra_packages"] = "\n".join(extra_lines)
281
+
282
+ if document_state.citations and bibliography_path is not None:
283
+ template_context["bibliography"] = bibliography_path.stem
284
+ template_context["bibliography_resource"] = bibliography_path.name
285
+ template_context.setdefault("bibliography_style", "plain")
286
+
287
+ latex_output = template.wrap_document(
288
+ main_slot_content,
289
+ context=template_context,
290
+ )
291
+ latex_output = _squash_blank_lines(latex_output)
292
+
293
+ asset_paths: list[Path] = []
294
+ asset_pairs: list[tuple[Path, Path]] = []
295
+ if copy_assets:
296
+ declared_assets = list(template.iter_assets())
297
+ asset_paths = copy_template_assets(
298
+ template,
299
+ output_dir,
300
+ context=template_context,
301
+ overrides=overrides_payload,
302
+ assets=declared_assets,
303
+ )
304
+ asset_pairs = [(entry.source, dest) for entry, dest in zip(declared_assets, asset_paths)]
305
+
306
+ output_path: Path | None = None
307
+ if output_name:
308
+ output_dir.mkdir(parents=True, exist_ok=True)
309
+ output_path = output_dir / output_name
310
+ output_path.write_text(latex_output, encoding="utf-8")
311
+ template_context.setdefault("root_filename", output_path.stem)
312
+
313
+ return TemplateWrapResult(
314
+ latex_output=latex_output,
315
+ template_context=template_context,
316
+ output_path=output_path,
317
+ asset_paths=asset_paths,
318
+ asset_pairs=asset_pairs,
319
+ rendered_fragments=sorted(rendered_fragments),
320
+ )
321
+
322
+
323
+ def _discover_template_variables(template: WrappableTemplate) -> set[str] | None:
324
+ """Return undeclared variables referenced by the template entrypoint."""
325
+ env = template.environment
326
+ try:
327
+ source, _, _ = env.loader.get_source(env, template.info.entrypoint)
328
+ except Exception:
329
+ return None
330
+ parsed = env.parse(source)
331
+ return set(meta.find_undeclared_variables(parsed))
332
+
333
+
334
+ def _squash_blank_lines(text: str) -> str:
335
+ """Normalise LaTeX output by trimming trailing whitespace and blank lines."""
336
+ trimmed = re.sub(r"[ \t]+(?=\r?\n|$)", "", text)
337
+ return re.sub(r"\n{3,}", "\n\n", trimmed)
338
+
339
+
340
+ __all__ = ["TemplateWrapResult", "wrap_template_document"]