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,578 @@
1
+ """Template rendering utilities that aggregate conversion fragments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, MutableMapping, Sequence
6
+ import copy
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from texsmith.adapters.latex.latexmk import build_latexmkrc_content
12
+ from texsmith.fonts import FallbackManager, FontCache, FontPipelineLogger
13
+ from texsmith.fonts.fallback import merge_fallback_summaries
14
+ from texsmith.fonts.scripts import fallback_summary_to_usage, merge_script_usage
15
+
16
+ from ..context import DocumentState
17
+ from ..diagnostics import DiagnosticEmitter
18
+ from ..templates import TemplateError, TemplateRuntime, wrap_template_document
19
+ from ..templates.context_usage import summarise_context_usage
20
+ from .debug import debug_enabled, ensure_emitter, raise_conversion_error, record_event
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class TemplateFragment:
25
+ """Contract describing the artefacts required by TemplateRenderer."""
26
+
27
+ stem: str
28
+ latex: str
29
+ default_slot: str
30
+ slot_outputs: Mapping[str, str]
31
+ slot_includes: set[str] = field(default_factory=set)
32
+ document_state: DocumentState | None = None
33
+ bibliography_path: Path | None = None
34
+ template_engine: str | None = None
35
+ requires_shell_escape: bool = False
36
+ template_overrides: Mapping[str, Any] | None = None
37
+ output_path: Path | None = None
38
+ front_matter: Mapping[str, Any] | None = None
39
+ source_path: Path | None = None
40
+ rule_descriptions: list[dict[str, Any]] | None = None
41
+ assets: dict[str, Path] | None = None
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class TemplateRendererResult:
46
+ """Structured artefacts produced after aggregating slot content."""
47
+
48
+ main_tex_path: Path
49
+ fragment_paths: list[Path]
50
+ template_context: dict[str, Any]
51
+ slot_content: dict[str, str]
52
+ document_state: DocumentState
53
+ bibliography_path: Path | None
54
+ template_engine: str | None
55
+ requires_shell_escape: bool
56
+ template_overrides: dict[str, Any] = field(default_factory=dict)
57
+ rule_descriptions: list[dict[str, Any]] = field(default_factory=list)
58
+ asset_paths: list[Path] = field(default_factory=list)
59
+ asset_sources: list[Path] = field(default_factory=list)
60
+ asset_map: dict[str, Path] = field(default_factory=dict)
61
+ context_attributes: list[dict[str, Any]] = field(default_factory=list)
62
+
63
+
64
+ _SOFT_OVERRIDE_KEYS = {"press._source_dir", "press._source_path", "_source_dir", "_source_path"}
65
+
66
+
67
+ class FragmentOverrideError(TemplateError):
68
+ """Raised when fragment template overrides disagree."""
69
+
70
+
71
+ def _merge_overrides(
72
+ target: MutableMapping[str, Any],
73
+ source: Mapping[str, Any],
74
+ *,
75
+ namespace: str = "",
76
+ ) -> None:
77
+ for key, value in source.items():
78
+ if key not in target:
79
+ target[key] = copy.deepcopy(value)
80
+ continue
81
+
82
+ existing = target[key]
83
+ if isinstance(existing, MutableMapping) and isinstance(value, Mapping):
84
+ _merge_overrides(
85
+ existing,
86
+ value,
87
+ namespace=f"{namespace}.{key}" if namespace else key,
88
+ )
89
+ continue
90
+
91
+ if existing == value:
92
+ continue
93
+
94
+ conflict_path = f"{namespace}.{key}" if namespace else key
95
+ if conflict_path in _SOFT_OVERRIDE_KEYS:
96
+ continue
97
+ raise FragmentOverrideError(
98
+ f"Conflicting template override for '{conflict_path}': {existing!r} vs {value!r}"
99
+ )
100
+
101
+
102
+ def _validate_slots(runtime: TemplateRuntime, aggregated_slots: Mapping[str, Any]) -> None:
103
+ """Ensure that fragments only target declared template slots."""
104
+ declared = set(runtime.slots.keys()) | {runtime.default_slot}
105
+ unknown = sorted(slot for slot in aggregated_slots if slot not in declared)
106
+ if unknown:
107
+ allowed = ", ".join(sorted(declared))
108
+ raise TemplateError(
109
+ f"Fragments target unknown slot(s) {', '.join(unknown)} for template '{runtime.name}'. "
110
+ f"Declared slots: {allowed or '(none)'}."
111
+ )
112
+
113
+
114
+ class TemplateRenderer:
115
+ """Aggregate conversion fragments and wrap them with a template."""
116
+
117
+ def __init__(
118
+ self,
119
+ runtime: TemplateRuntime,
120
+ *,
121
+ emitter: DiagnosticEmitter | None = None,
122
+ ) -> None:
123
+ self.runtime = runtime
124
+ self.emitter = ensure_emitter(emitter)
125
+
126
+ def _write_latexmkrc(
127
+ self,
128
+ *,
129
+ output_dir: Path,
130
+ main_tex_path: Path,
131
+ template_engine: str | None,
132
+ requires_shell_escape: bool,
133
+ document_state: DocumentState,
134
+ template_context: Mapping[str, Any] | None,
135
+ bibliography_present: bool,
136
+ ) -> Path | None:
137
+ latexmkrc_path = output_dir / ".latexmkrc"
138
+ if latexmkrc_path.exists():
139
+ return latexmkrc_path
140
+
141
+ index_engine: str | None = None
142
+ if template_context:
143
+ raw_engine = template_context.get("index_engine")
144
+ if isinstance(raw_engine, str):
145
+ candidate = raw_engine.strip()
146
+ if candidate:
147
+ index_engine = candidate
148
+
149
+ has_index = bool(
150
+ getattr(document_state, "has_index_entries", False)
151
+ or getattr(document_state, "index_entries", [])
152
+ )
153
+ has_glossary = bool(
154
+ getattr(document_state, "acronyms", {}) or getattr(document_state, "glossary", {})
155
+ )
156
+
157
+ content = build_latexmkrc_content(
158
+ root_filename=main_tex_path.stem,
159
+ engine=template_engine,
160
+ requires_shell_escape=requires_shell_escape,
161
+ bibliography=bibliography_present,
162
+ index_engine=index_engine,
163
+ has_index=has_index,
164
+ has_glossary=has_glossary,
165
+ )
166
+ try:
167
+ latexmkrc_path.write_text(content, encoding="utf-8")
168
+ except OSError as exc:
169
+ self.emitter.warning(f"Failed to write latexmkrc: {exc}")
170
+ return None
171
+ return latexmkrc_path
172
+
173
+ def render(
174
+ self,
175
+ fragments: Sequence[TemplateFragment],
176
+ *,
177
+ output_dir: Path,
178
+ overrides: Mapping[str, Any] | None = None,
179
+ copy_assets: bool = True,
180
+ embed_fragments: bool = True,
181
+ ) -> TemplateRendererResult:
182
+ if not fragments:
183
+ raise TemplateError("No fragments available for template rendering.")
184
+
185
+ output_dir = output_dir.resolve()
186
+ output_dir.mkdir(parents=True, exist_ok=True)
187
+
188
+ aggregated_slots: dict[str, list[str]] = {}
189
+ default_slot = self.runtime.default_slot
190
+ aggregated_slots.setdefault(default_slot, [])
191
+
192
+ template_overrides: dict[str, Any] | None = None
193
+
194
+ shared_state: DocumentState | None = None
195
+ aggregated_script_usage: list[dict[str, Any]] = []
196
+ aggregated_fallback_summary: list[dict[str, Any]] = []
197
+ bibliography_path: Path | None = None
198
+ template_engine: str | None = None
199
+ requires_shell_escape = bool(self.runtime.requires_shell_escape)
200
+ rule_descriptions: list[dict[str, Any]] = []
201
+
202
+ document_metadata: list[dict[str, Any]] = []
203
+ asset_paths: set[Path] = set()
204
+ asset_sources: set[Path] = set()
205
+ asset_map: dict[str, Path] = {}
206
+
207
+ for fragment in fragments:
208
+ fragment_default_slot = fragment.default_slot or default_slot
209
+ slot_outputs = dict(fragment.slot_outputs)
210
+ if fragment_default_slot not in slot_outputs:
211
+ slot_outputs[fragment_default_slot] = fragment.latex
212
+ fragment_record: dict[str, Any] = {
213
+ "stem": fragment.stem,
214
+ "default_slot": fragment_default_slot,
215
+ "slot_outputs": dict(slot_outputs),
216
+ "index": len(document_metadata),
217
+ }
218
+ if fragment.front_matter:
219
+ fragment_record["front_matter"] = copy.deepcopy(dict(fragment.front_matter))
220
+ if fragment.source_path:
221
+ fragment_record["source_path"] = str(fragment.source_path)
222
+ document_metadata.append(fragment_record)
223
+ if fragment.assets:
224
+ for key, path in fragment.assets.items():
225
+ target_path = Path(path).resolve()
226
+ asset_map.setdefault(key, target_path)
227
+ asset_paths.add(target_path)
228
+ source_candidate = Path(key)
229
+ if source_candidate.exists():
230
+ asset_sources.add(source_candidate.resolve())
231
+
232
+ for slot_name, latex in slot_outputs.items():
233
+ if not latex:
234
+ continue
235
+ aggregated_slots.setdefault(slot_name, []).append(latex)
236
+
237
+ slot_inclusions = set(fragment.slot_includes or set())
238
+ if slot_inclusions:
239
+ for slot_name in slot_inclusions:
240
+ aggregated_slots.setdefault(slot_name, [])
241
+ for slot_name in slot_inclusions:
242
+ if slot_name == fragment_default_slot:
243
+ continue
244
+ if fragment.latex:
245
+ aggregated_slots[slot_name].append(fragment.latex)
246
+ if (
247
+ fragment_default_slot in slot_inclusions
248
+ and fragment_default_slot not in slot_outputs
249
+ and fragment.latex
250
+ ):
251
+ aggregated_slots[fragment_default_slot].append(fragment.latex)
252
+
253
+ fragment_overrides = fragment.template_overrides
254
+ if fragment_overrides:
255
+ if template_overrides is None:
256
+ template_overrides = dict(fragment_overrides)
257
+ else:
258
+ _merge_overrides(template_overrides, fragment_overrides)
259
+
260
+ if fragment.document_state is not None:
261
+ if shared_state is None:
262
+ shared_state = fragment.document_state
263
+ else:
264
+ # Preserve the last state for other fields while merging script usage.
265
+ pass
266
+ aggregated_script_usage = merge_script_usage(
267
+ aggregated_script_usage, getattr(fragment.document_state, "script_usage", [])
268
+ )
269
+ aggregated_fallback_summary = merge_fallback_summaries(
270
+ aggregated_fallback_summary,
271
+ getattr(fragment.document_state, "fallback_summary", []),
272
+ )
273
+ if fragment.bibliography_path is not None:
274
+ bibliography_path = fragment.bibliography_path
275
+ if template_engine is None and fragment.template_engine is not None:
276
+ template_engine = fragment.template_engine
277
+ requires_shell_escape = requires_shell_escape or fragment.requires_shell_escape
278
+ if fragment.rule_descriptions and not rule_descriptions:
279
+ rule_descriptions = list(fragment.rule_descriptions)
280
+
281
+ if shared_state is None:
282
+ shared_state = DocumentState()
283
+ if aggregated_script_usage:
284
+ shared_state.script_usage = merge_script_usage(
285
+ getattr(shared_state, "script_usage", []), aggregated_script_usage
286
+ )
287
+ if aggregated_fallback_summary:
288
+ shared_state.fallback_summary = merge_fallback_summaries(
289
+ getattr(shared_state, "fallback_summary", []), aggregated_fallback_summary
290
+ )
291
+
292
+ slot_content: dict[str, str] = {}
293
+ render_slot_content: dict[str, str] = {
294
+ slot: "\n\n".join(chunks for chunks in content if chunks)
295
+ for slot, content in aggregated_slots.items()
296
+ }
297
+
298
+ written_fragment_paths: list[Path] = []
299
+
300
+ slot_output_overrides: dict[str, str] | None = None
301
+
302
+ if embed_fragments:
303
+ slot_content = dict(render_slot_content)
304
+ else:
305
+ slot_inputs: dict[str, list[str]] = {}
306
+ for fragment in fragments:
307
+ fragment_default_slot = fragment.default_slot or default_slot
308
+ slot_outputs = dict(fragment.slot_outputs)
309
+ if fragment_default_slot not in slot_outputs:
310
+ slot_outputs[fragment_default_slot] = fragment.latex
311
+
312
+ for slot_name, latex in slot_outputs.items():
313
+ if not latex:
314
+ continue
315
+ if slot_name == default_slot or slot_name == fragment.stem:
316
+ filename = f"{fragment.stem}.tex"
317
+ else:
318
+ filename = f"{fragment.stem}.{slot_name}.tex"
319
+ target_path = output_dir / filename
320
+ try:
321
+ target_path.write_text(latex, encoding="utf-8")
322
+ written_fragment_paths.append(target_path)
323
+ slot_inputs.setdefault(slot_name, []).append(
324
+ f"\\input{{{target_path.name}}}"
325
+ )
326
+ except OSError as exc:
327
+ raise TemplateError(
328
+ f"Failed to write fragment '{target_path}': {exc}"
329
+ ) from exc
330
+
331
+ slot_inclusions = set(fragment.slot_includes or set())
332
+ if slot_inclusions:
333
+ for slot_name in slot_inclusions:
334
+ if (
335
+ slot_name == fragment_default_slot
336
+ and fragment_default_slot not in slot_outputs
337
+ ):
338
+ latex = fragment.latex
339
+ if latex:
340
+ filename = f"{fragment.stem}.tex"
341
+ target_path = output_dir / filename
342
+ try:
343
+ target_path.write_text(latex, encoding="utf-8")
344
+ written_fragment_paths.append(target_path)
345
+ except OSError as exc:
346
+ raise TemplateError(
347
+ f"Failed to write fragment '{target_path}': {exc}"
348
+ ) from exc
349
+ slot_inputs.setdefault(slot_name, []).append(
350
+ f"\\input{{{target_path.name}}}"
351
+ )
352
+
353
+ slot_content = {
354
+ slot: "\n".join(entries for entries in slot_inputs.get(slot, []))
355
+ for slot in aggregated_slots
356
+ }
357
+ slot_output_overrides = dict(slot_content)
358
+ aggregated_slots.clear()
359
+ aggregated_slots.update(slot_inputs)
360
+
361
+ _validate_slots(self.runtime, render_slot_content)
362
+
363
+ # Compute fallback summary across all slot content for font fragments.
364
+ concatenated_text = "".join(render_slot_content.values())
365
+ if concatenated_text:
366
+ try:
367
+ raw_summary = FallbackManager(
368
+ cache=FontCache(), logger=FontPipelineLogger()
369
+ ).scan_text(concatenated_text)
370
+ if raw_summary:
371
+ aggregated_fallback_summary = merge_fallback_summaries(
372
+ aggregated_fallback_summary, raw_summary
373
+ )
374
+ usage = fallback_summary_to_usage(raw_summary)
375
+ if usage:
376
+ aggregated_script_usage = merge_script_usage(aggregated_script_usage, usage)
377
+ shared_state.script_usage = merge_script_usage(
378
+ getattr(shared_state, "script_usage", []), aggregated_script_usage
379
+ )
380
+ shared_state.fallback_summary = merge_fallback_summaries(
381
+ getattr(shared_state, "fallback_summary", []),
382
+ aggregated_fallback_summary,
383
+ )
384
+ except Exception:
385
+ # Best-effort: fallback detection should never block rendering.
386
+ pass
387
+
388
+ template_instance = self.runtime.instance
389
+ if template_instance is None: # pragma: no cover - defensive path
390
+ raise TemplateError("Template runtime is missing an instance implementation.")
391
+
392
+ if template_overrides is None:
393
+ template_overrides = {}
394
+ if overrides:
395
+ override_dict = dict(overrides)
396
+ _merge_overrides(template_overrides, override_dict)
397
+
398
+ if document_metadata:
399
+ if template_overrides is None:
400
+ template_overrides = {}
401
+ template_overrides["documents"] = document_metadata
402
+
403
+ if template_overrides:
404
+ record_event(
405
+ self.emitter,
406
+ "template_overrides",
407
+ {"values": dict(template_overrides)},
408
+ )
409
+ if aggregated_script_usage:
410
+ template_overrides = template_overrides or {}
411
+ template_overrides.setdefault("fonts", {})
412
+ if isinstance(template_overrides["fonts"], dict):
413
+ template_overrides["fonts"].setdefault("script_usage", aggregated_script_usage)
414
+
415
+ def _iter_strings(value: Any) -> list[str]:
416
+ if isinstance(value, str):
417
+ return [value]
418
+ if isinstance(value, Mapping):
419
+ collected: list[str] = []
420
+ for nested in value.values():
421
+ collected.extend(_iter_strings(nested))
422
+ return collected
423
+ if isinstance(value, (list, tuple, set)):
424
+ collected: list[str] = []
425
+ for nested in value:
426
+ collected.extend(_iter_strings(nested))
427
+ return collected
428
+ return []
429
+
430
+ metadata_strings: list[str] = []
431
+ for record in document_metadata:
432
+ front_matter = record.get("front_matter")
433
+ if isinstance(front_matter, Mapping):
434
+ metadata_strings.extend(_iter_strings(front_matter))
435
+ if template_overrides:
436
+ metadata_strings.extend(_iter_strings(template_overrides))
437
+
438
+ metadata_blob = " ".join(part for part in metadata_strings if part.strip())
439
+ if metadata_blob:
440
+ try:
441
+ metadata_raw = FallbackManager(
442
+ cache=FontCache(), logger=FontPipelineLogger()
443
+ ).scan_text(metadata_blob)
444
+ except Exception:
445
+ metadata_raw = []
446
+ if metadata_raw:
447
+ aggregated_fallback_summary = merge_fallback_summaries(
448
+ aggregated_fallback_summary, metadata_raw
449
+ )
450
+ metadata_usage = fallback_summary_to_usage(metadata_raw)
451
+ if metadata_usage:
452
+ aggregated_script_usage = merge_script_usage(
453
+ aggregated_script_usage, metadata_usage
454
+ )
455
+ shared_state.script_usage = merge_script_usage(
456
+ getattr(shared_state, "script_usage", []), aggregated_script_usage
457
+ )
458
+ shared_state.fallback_summary = merge_fallback_summaries(
459
+ getattr(shared_state, "fallback_summary", []), aggregated_fallback_summary
460
+ )
461
+
462
+ if aggregated_script_usage or aggregated_fallback_summary:
463
+ template_overrides = template_overrides or {}
464
+ template_overrides.setdefault("fonts", {})
465
+ if isinstance(template_overrides["fonts"], dict):
466
+ if aggregated_script_usage:
467
+ existing_usage = template_overrides["fonts"].get("script_usage", [])
468
+ template_overrides["fonts"]["script_usage"] = merge_script_usage(
469
+ existing_usage if isinstance(existing_usage, list) else [],
470
+ aggregated_script_usage,
471
+ )
472
+ if aggregated_fallback_summary:
473
+ existing_fallback = template_overrides["fonts"].get("fallback_summary", [])
474
+ template_overrides["fonts"]["fallback_summary"] = merge_fallback_summaries(
475
+ existing_fallback if isinstance(existing_fallback, list) else [],
476
+ aggregated_fallback_summary,
477
+ )
478
+
479
+ main_name = self._resolve_main_name(fragments)
480
+ try:
481
+ wrap_result = wrap_template_document(
482
+ template=template_instance,
483
+ default_slot=default_slot,
484
+ slot_outputs=render_slot_content,
485
+ slot_output_overrides=slot_output_overrides,
486
+ document_state=shared_state,
487
+ template_overrides=template_overrides if template_overrides else None,
488
+ output_dir=output_dir,
489
+ copy_assets=copy_assets,
490
+ output_name=main_name,
491
+ bibliography_path=bibliography_path,
492
+ emitter=self.emitter,
493
+ fragments=list(
494
+ template_overrides.get("fragments", self.runtime.extras.get("fragments", []))
495
+ ),
496
+ template_runtime=self.runtime,
497
+ )
498
+ except TemplateError as exc:
499
+ if debug_enabled(self.emitter):
500
+ raise
501
+ raise_conversion_error(self.emitter, str(exc), exc)
502
+
503
+ template_context = wrap_result.template_context or {}
504
+ main_tex_path = wrap_result.output_path or (output_dir / main_name)
505
+
506
+ fragment_paths: list[Path] = [
507
+ Path(fragment.output_path) for fragment in fragments if fragment.output_path
508
+ ]
509
+ fragment_paths.extend(written_fragment_paths)
510
+ asset_paths.update(wrap_result.asset_paths or [])
511
+ for source, destination in getattr(wrap_result, "asset_pairs", []):
512
+ resolved_dest = Path(destination).resolve()
513
+ asset_paths.add(resolved_dest)
514
+ asset_sources.add(Path(source).resolve())
515
+ asset_map.setdefault(str(source), resolved_dest)
516
+
517
+ context_engine: str | None = None
518
+ if template_context:
519
+ raw_engine = template_context.get("latex_engine")
520
+ if isinstance(raw_engine, str):
521
+ stripped = raw_engine.strip()
522
+ if stripped:
523
+ context_engine = stripped
524
+
525
+ template_engine = self.runtime.engine
526
+
527
+ if context_engine and context_engine.lower() != "pdflatex":
528
+ template_engine = context_engine
529
+ if template_engine is None:
530
+ template_engine = context_engine or "pdflatex"
531
+
532
+ template_context.setdefault("latex_engine", template_engine)
533
+
534
+ self._write_latexmkrc(
535
+ output_dir=output_dir,
536
+ main_tex_path=main_tex_path,
537
+ template_engine=template_engine,
538
+ requires_shell_escape=requires_shell_escape,
539
+ document_state=shared_state,
540
+ template_context=template_context,
541
+ bibliography_present=bool(bibliography_path),
542
+ )
543
+
544
+ asset_path_list = sorted({path.resolve() for path in asset_paths})
545
+ asset_source_list = sorted({path.resolve() for path in asset_sources})
546
+
547
+ context_attributes = summarise_context_usage(
548
+ template_instance,
549
+ template_context,
550
+ fragment_names=wrap_result.rendered_fragments,
551
+ overrides=template_overrides,
552
+ )
553
+
554
+ return TemplateRendererResult(
555
+ main_tex_path=main_tex_path,
556
+ fragment_paths=fragment_paths,
557
+ template_context=template_context or {},
558
+ slot_content=slot_output_overrides or slot_content,
559
+ document_state=shared_state,
560
+ bibliography_path=bibliography_path,
561
+ template_engine=template_engine,
562
+ requires_shell_escape=requires_shell_escape,
563
+ template_overrides=template_overrides,
564
+ rule_descriptions=rule_descriptions,
565
+ asset_paths=asset_path_list,
566
+ asset_sources=asset_source_list,
567
+ asset_map=asset_map,
568
+ context_attributes=context_attributes,
569
+ )
570
+
571
+ @staticmethod
572
+ def _resolve_main_name(fragments: Sequence[TemplateFragment]) -> str:
573
+ if len(fragments) == 1:
574
+ return f"{fragments[0].stem}.tex"
575
+ return "main.tex"
576
+
577
+
578
+ __all__ = ["TemplateFragment", "TemplateRenderer", "TemplateRendererResult"]