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,648 @@
1
+ """Conversion orchestration utilities for CLI and embedding integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping, Sequence
6
+ import copy
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+
13
+ from texsmith.adapters.latex.engines import (
14
+ EngineResult,
15
+ build_engine_command,
16
+ build_tex_env,
17
+ compute_features,
18
+ ensure_command_paths,
19
+ missing_dependencies,
20
+ resolve_engine,
21
+ run_engine_command,
22
+ )
23
+ from texsmith.adapters.latex.pyxindy import is_available as pyxindy_available
24
+ from texsmith.adapters.latex.tectonic import (
25
+ BiberAcquisitionError,
26
+ MakeglossariesAcquisitionError,
27
+ TectonicAcquisitionError,
28
+ select_biber_binary,
29
+ select_makeglossaries,
30
+ select_tectonic_binary,
31
+ )
32
+ from texsmith.adapters.markdown import split_front_matter
33
+ from texsmith.core.conversion.debug import ConversionError, ensure_emitter
34
+ from texsmith.core.conversion.inputs import (
35
+ InputKind,
36
+ UnsupportedInputError,
37
+ extract_front_matter_slots,
38
+ )
39
+ from texsmith.core.diagnostics import DiagnosticEmitter
40
+ from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
41
+
42
+ from .document import Document, DocumentSlots, TitleStrategy, front_matter_has_title
43
+ from .pipeline import ConversionBundle, RenderSettings, convert_documents
44
+ from .templates import TemplateRenderResult, TemplateSession, get_template
45
+
46
+
47
+ __all__ = [
48
+ "ConversionRequest",
49
+ "ConversionResponse",
50
+ "ConversionService",
51
+ "SlotAssignment",
52
+ "SplitInputsResult",
53
+ "classify_input_source",
54
+ ]
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class SlotAssignment:
59
+ """Directive mapping a document onto a template slot."""
60
+
61
+ slot: str
62
+ selector: str | None
63
+ include_document: bool
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class SplitInputsResult:
68
+ """Structured partitioning of input paths."""
69
+
70
+ documents: list[Path]
71
+ bibliography_files: list[Path]
72
+ front_matter: Mapping[str, Any] | None = None
73
+ front_matter_path: Path | None = None
74
+
75
+ def __iter__(self) -> Iterable[object]: # pragma: no cover - convenience iterator
76
+ yield self.documents
77
+ yield self.bibliography_files
78
+
79
+
80
+ @dataclass(slots=True)
81
+ class ConversionRequest:
82
+ """Immutable description of a conversion run."""
83
+
84
+ documents: Sequence[Path]
85
+ bibliography_files: Sequence[Path] = field(default_factory=list)
86
+ embed_fragments: bool = False
87
+ front_matter: Mapping[str, Any] | None = None
88
+ front_matter_path: Path | None = None
89
+ slot_assignments: Mapping[Path, Sequence[SlotAssignment]] = field(default_factory=dict)
90
+
91
+ selector: str = "article.md-content__inner"
92
+ full_document: bool = False
93
+ base_level: int = 0
94
+ strip_heading_all: bool = False
95
+ strip_heading_first_document: bool = False
96
+ promote_title: bool = True
97
+ suppress_title: bool = False
98
+ numbered: bool = True
99
+ markdown_extensions: Sequence[str] = field(default_factory=list)
100
+
101
+ parser: str | None = None
102
+ disable_fallback_converters: bool = False
103
+ copy_assets: bool = True
104
+ convert_assets: bool = False
105
+ hash_assets: bool = False
106
+ manifest: bool = False
107
+ persist_debug_html: bool = False
108
+ language: str | None = None
109
+ legacy_latex_accents: bool = False
110
+ diagrams_backend: str | None = None
111
+
112
+ template: str | None = None
113
+ render_dir: Path | None = None
114
+ template_options: Mapping[str, Any] = field(default_factory=dict)
115
+ enable_fragments: Sequence[str] = field(default_factory=tuple)
116
+ disable_fragments: Sequence[str] = field(default_factory=tuple)
117
+
118
+ emitter: DiagnosticEmitter | None = None
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class ConversionResponse:
123
+ """Captured outcome of :class:`ConversionService` execution."""
124
+
125
+ request: ConversionRequest
126
+ documents: list[Document]
127
+ bibliography_files: list[Path]
128
+ result: ConversionBundle | TemplateRenderResult
129
+ emitter: DiagnosticEmitter | None = None
130
+
131
+ @property
132
+ def is_template(self) -> bool:
133
+ """Return True when the response contains template output rather than raw fragments."""
134
+ return isinstance(self.result, TemplateRenderResult)
135
+
136
+ @property
137
+ def bundle(self) -> ConversionBundle:
138
+ """Expose the conversion bundle while guarding against template misuse."""
139
+ if isinstance(self.result, ConversionBundle):
140
+ return self.result
141
+ raise TypeError("ConversionResponse does not contain a ConversionBundle.")
142
+
143
+ @property
144
+ def render_result(self) -> TemplateRenderResult:
145
+ """Expose the template render result while guarding against bundle misuse."""
146
+ if isinstance(self.result, TemplateRenderResult):
147
+ return self.result
148
+ raise TypeError("ConversionResponse does not contain a TemplateRenderResult.")
149
+
150
+
151
+ @dataclass(slots=True)
152
+ class _PreparedBatch:
153
+ documents: list[Document]
154
+ document_map: dict[Path, Document]
155
+ emitter: DiagnosticEmitter
156
+ bibliography_files: list[Path]
157
+
158
+
159
+ class ConversionService:
160
+ """High-level façade that encapsulates document preparation and execution."""
161
+
162
+ def split_inputs(
163
+ self,
164
+ inputs: Iterable[Path],
165
+ extra_bibliography: Iterable[Path] = (),
166
+ ) -> SplitInputsResult:
167
+ """Separate document inputs, bibliography files, and optional front matter to keep downstream parsing deterministic."""
168
+ inline_bibliography: list[Path] = []
169
+ documents: list[Path] = []
170
+ front_matter: Mapping[str, Any] | None = None
171
+ front_matter_path: Path | None = None
172
+
173
+ for candidate in inputs:
174
+ suffix = candidate.suffix.lower()
175
+ if suffix in {".bib", ".bibtex"}:
176
+ inline_bibliography.append(candidate)
177
+ continue
178
+ if front_matter is None:
179
+ loaded_front_matter = _load_front_matter_file(candidate)
180
+ if loaded_front_matter is not _NOT_FRONT_MATTER:
181
+ front_matter = loaded_front_matter
182
+ front_matter_path = candidate
183
+ continue
184
+ documents.append(candidate)
185
+
186
+ bibliography_paths = _deduplicate_paths([*inline_bibliography, *extra_bibliography])
187
+ if not documents and front_matter_path is not None:
188
+ # Treat a lone front-matter file as an input document so templates can operate on
189
+ # YAML sources without requiring additional Markdown/HTML content.
190
+ documents.append(front_matter_path)
191
+ front_matter = None
192
+ front_matter_path = None
193
+
194
+ return SplitInputsResult(
195
+ documents=documents,
196
+ bibliography_files=bibliography_paths,
197
+ front_matter=front_matter,
198
+ front_matter_path=front_matter_path,
199
+ )
200
+
201
+ def prepare_documents(self, request: ConversionRequest) -> _PreparedBatch:
202
+ """Normalise input sources into :class:`Document` instances so conversion steps operate on consistent objects."""
203
+ emitter = ensure_emitter(request.emitter)
204
+ documents: list[Document] = []
205
+ mapping: dict[Path, Document] = {}
206
+ shared_front_matter = _normalise_front_matter(request.front_matter)
207
+
208
+ for index, path in enumerate(request.documents):
209
+ input_kind = classify_input_source(path)
210
+ extract_title = request.promote_title and index == 0 and not request.suppress_title
211
+ effective_strip = request.strip_heading_all or (
212
+ request.strip_heading_first_document and index == 0
213
+ )
214
+ strategy: TitleStrategy | None = None
215
+ if effective_strip:
216
+ strategy = TitleStrategy.DROP
217
+ extract_title = False
218
+ elif not extract_title:
219
+ strategy = TitleStrategy.KEEP
220
+ else:
221
+ strategy = None
222
+
223
+ if input_kind is InputKind.MARKDOWN:
224
+ document = Document.from_markdown(
225
+ path,
226
+ extensions=list(request.markdown_extensions),
227
+ base_level=request.base_level,
228
+ promote_title=extract_title,
229
+ strip_heading=effective_strip,
230
+ suppress_title=request.suppress_title,
231
+ title_strategy=strategy,
232
+ numbered=request.numbered,
233
+ emitter=emitter,
234
+ )
235
+ else:
236
+ document = Document.from_html(
237
+ path,
238
+ selector=request.selector,
239
+ base_level=request.base_level,
240
+ promote_title=extract_title,
241
+ strip_heading=effective_strip,
242
+ suppress_title=request.suppress_title,
243
+ title_strategy=strategy,
244
+ numbered=request.numbered,
245
+ full_document=request.full_document,
246
+ emitter=emitter,
247
+ )
248
+
249
+ documents.append(document)
250
+ mapping[path] = document
251
+
252
+ press_sources = _collect_press_sources(documents, shared_front_matter, request)
253
+ if len(press_sources) > 1:
254
+ raise ConversionError(
255
+ "Multiple sources declare press metadata; only one is allowed for a conversion: "
256
+ + ", ".join(press_sources)
257
+ )
258
+
259
+ _apply_shared_front_matter(documents, shared_front_matter)
260
+
261
+ for path, directives in request.slot_assignments.items():
262
+ document = mapping.get(path)
263
+ if document is None or not directives:
264
+ continue
265
+ for directive in directives:
266
+ document.assign_slot(
267
+ directive.slot,
268
+ selector=directive.selector,
269
+ include_document=directive.include_document,
270
+ )
271
+ bibliography = _deduplicate_paths(request.bibliography_files)
272
+ return _PreparedBatch(
273
+ documents=documents,
274
+ document_map=mapping,
275
+ emitter=emitter,
276
+ bibliography_files=bibliography,
277
+ )
278
+
279
+ def execute(
280
+ self,
281
+ request: ConversionRequest,
282
+ *,
283
+ prepared: _PreparedBatch | None = None,
284
+ ) -> ConversionResponse:
285
+ """Execute a conversion workflow and return a structured response, routing to template or raw conversion paths as needed."""
286
+ batch = prepared or self.prepare_documents(request)
287
+ settings = self._build_render_settings(request)
288
+ emitter = batch.emitter
289
+
290
+ if request.template is None:
291
+ bundle = convert_documents(
292
+ batch.documents,
293
+ output_dir=request.render_dir,
294
+ settings=settings,
295
+ emitter=emitter,
296
+ bibliography_files=batch.bibliography_files,
297
+ )
298
+ return ConversionResponse(
299
+ request=request,
300
+ documents=batch.documents,
301
+ bibliography_files=batch.bibliography_files,
302
+ result=bundle,
303
+ emitter=emitter,
304
+ )
305
+
306
+ session = self._initialise_template_session(
307
+ request.template,
308
+ settings=settings,
309
+ emitter=emitter,
310
+ )
311
+ overrides: dict[str, Any] = {}
312
+ if request.template_options:
313
+ overrides.update(_normalise_template_options(request.template_options))
314
+ fragments_override = _resolve_fragment_overrides(
315
+ session,
316
+ overrides.get("fragments"),
317
+ request.enable_fragments,
318
+ request.disable_fragments,
319
+ )
320
+ if fragments_override is not None:
321
+ overrides["fragments"] = fragments_override
322
+ if overrides:
323
+ session.update_options(overrides)
324
+ if batch.bibliography_files:
325
+ session.add_bibliography(*batch.bibliography_files)
326
+ for document in batch.documents:
327
+ session.add_document(document)
328
+
329
+ target_dir = (request.render_dir or Path("build")).resolve()
330
+ render_result = session.render(target_dir, embed_fragments=request.embed_fragments)
331
+ return ConversionResponse(
332
+ request=request,
333
+ documents=batch.documents,
334
+ bibliography_files=batch.bibliography_files,
335
+ result=render_result,
336
+ emitter=emitter,
337
+ )
338
+
339
+ def build_pdf(
340
+ self,
341
+ render_result: TemplateRenderResult,
342
+ *,
343
+ engine: str | None = "tectonic",
344
+ classic_output: bool = False,
345
+ isolate_cache: bool = False,
346
+ env: Mapping[str, str] | None = None,
347
+ console: Any | None = None,
348
+ verbosity: int = 0,
349
+ use_system_tectonic: bool = False,
350
+ ) -> EngineResult:
351
+ """Compile a rendered template into a PDF using the requested engine, selecting dependencies on demand."""
352
+ template_context = getattr(render_result, "template_context", None) or getattr(
353
+ render_result, "context", None
354
+ )
355
+ features = compute_features(
356
+ requires_shell_escape=render_result.requires_shell_escape,
357
+ bibliography=render_result.has_bibliography,
358
+ document_state=render_result.document_state,
359
+ template_context=template_context,
360
+ )
361
+ choice = resolve_engine(engine, render_result.template_engine)
362
+ tectonic_binary: Path | None = None
363
+ biber_binary: Path | None = None
364
+ makeglossaries_binary: Path | None = None
365
+ bundled_bin: Path | None = None
366
+ if choice.backend == "tectonic":
367
+ try:
368
+ selection = select_tectonic_binary(use_system_tectonic, console=console)
369
+ if features.bibliography and not use_system_tectonic:
370
+ biber_binary = select_biber_binary(console=console)
371
+ bundled_bin = biber_binary.parent
372
+ if features.has_glossary and not pyxindy_available():
373
+ glossaries = select_makeglossaries(console=console)
374
+ makeglossaries_binary = glossaries.path
375
+ if glossaries.source == "bundled":
376
+ bundled_bin = bundled_bin or glossaries.path.parent
377
+ except (
378
+ TectonicAcquisitionError,
379
+ BiberAcquisitionError,
380
+ MakeglossariesAcquisitionError,
381
+ ) as exc:
382
+ raise ConversionError(str(exc)) from exc
383
+ tectonic_binary = selection.path
384
+
385
+ available_bins: dict[str, Path] = {}
386
+ if biber_binary:
387
+ available_bins["biber"] = biber_binary
388
+ if makeglossaries_binary:
389
+ available_bins["makeglossaries"] = makeglossaries_binary
390
+
391
+ missing = missing_dependencies(
392
+ choice,
393
+ features,
394
+ use_system_tectonic=use_system_tectonic,
395
+ available_binaries=available_bins or None,
396
+ )
397
+ if missing:
398
+ formatted = ", ".join(sorted(missing))
399
+ raise ConversionError(f"Missing required LaTeX tools for '{choice.label}': {formatted}")
400
+
401
+ command_plan = ensure_command_paths(
402
+ build_engine_command(
403
+ choice,
404
+ features,
405
+ main_tex_path=render_result.main_tex_path,
406
+ tectonic_binary=tectonic_binary,
407
+ )
408
+ )
409
+ base_env = build_tex_env(
410
+ render_result.main_tex_path.parent,
411
+ isolate_cache=isolate_cache,
412
+ extra_path=bundled_bin,
413
+ biber_path=biber_binary,
414
+ )
415
+ merged_env = dict(base_env)
416
+ if env:
417
+ merged_env.update(env)
418
+
419
+ return run_engine_command(
420
+ command_plan,
421
+ backend=choice.backend,
422
+ workdir=render_result.main_tex_path.parent,
423
+ env=merged_env,
424
+ console=console,
425
+ verbosity=verbosity,
426
+ classic_output=classic_output,
427
+ features=features,
428
+ )
429
+
430
+ @staticmethod
431
+ def _build_render_settings(request: ConversionRequest) -> RenderSettings:
432
+ return RenderSettings(
433
+ parser=request.parser,
434
+ disable_fallback_converters=request.disable_fallback_converters,
435
+ copy_assets=request.copy_assets,
436
+ convert_assets=request.convert_assets,
437
+ hash_assets=request.hash_assets,
438
+ manifest=request.manifest,
439
+ persist_debug_html=request.persist_debug_html,
440
+ language=request.language,
441
+ legacy_latex_accents=request.legacy_latex_accents,
442
+ diagrams_backend=request.diagrams_backend,
443
+ )
444
+
445
+ @staticmethod
446
+ def _initialise_template_session(
447
+ template: str,
448
+ *,
449
+ settings: RenderSettings,
450
+ emitter: DiagnosticEmitter,
451
+ ) -> TemplateSession:
452
+ return get_template(
453
+ template,
454
+ settings=settings,
455
+ emitter=emitter,
456
+ )
457
+
458
+
459
+ _NOT_FRONT_MATTER = object()
460
+
461
+
462
+ def _load_front_matter_file(path: Path) -> Mapping[str, Any] | object:
463
+ """Return parsed front matter when the path looks like a metadata file."""
464
+ suffix = path.suffix.lower()
465
+ if suffix not in {".yml", ".yaml"}:
466
+ return _NOT_FRONT_MATTER
467
+ name_lower = path.name.lower()
468
+ if name_lower in {"mkdocs.yml", "mkdocs.yaml"}:
469
+ return _NOT_FRONT_MATTER
470
+ try:
471
+ payload = path.read_text(encoding="utf-8")
472
+ except OSError as exc:
473
+ raise ConversionError(f"Failed to read front matter source '{path}': {exc}") from exc
474
+
475
+ if not payload.strip():
476
+ return {}
477
+
478
+ if payload.lstrip().startswith("---"):
479
+ metadata, body = split_front_matter(payload)
480
+ if body.strip():
481
+ return _NOT_FRONT_MATTER
482
+ return metadata
483
+
484
+ try:
485
+ parsed = yaml.safe_load(payload)
486
+ except yaml.YAMLError as exc:
487
+ raise ConversionError(f"Invalid YAML front matter in '{path}': {exc}") from exc
488
+ return dict(parsed) if isinstance(parsed, Mapping) else _NOT_FRONT_MATTER
489
+
490
+
491
+ def _normalise_front_matter(data: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
492
+ if data is None:
493
+ return None
494
+ if not isinstance(data, Mapping):
495
+ raise ConversionError("Front matter must be a mapping when provided programmatically.")
496
+ return copy.deepcopy(dict(data))
497
+
498
+
499
+ def _normalise_template_options(options: Mapping[str, Any]) -> dict[str, Any]:
500
+ payload = copy.deepcopy(dict(options))
501
+ if _requires_press_normalisation(payload):
502
+ try:
503
+ normalise_press_metadata(payload)
504
+ except PressMetadataError as exc:
505
+ raise ConversionError(str(exc)) from exc
506
+ return payload
507
+
508
+
509
+ def _resolve_fragment_overrides(
510
+ runtime: TemplateSession,
511
+ override_fragments: Any,
512
+ enable: Sequence[str],
513
+ disable: Sequence[str],
514
+ ) -> list[str] | None:
515
+ """Compute the final fragment list after applying enable/disable toggles."""
516
+
517
+ def _clean_list(values: Sequence[str] | None) -> list[str]:
518
+ seen: set[str] = set()
519
+ cleaned: list[str] = []
520
+ if not values:
521
+ return cleaned
522
+ for entry in values:
523
+ name = str(entry).strip()
524
+ if not name or name in seen:
525
+ continue
526
+ seen.add(name)
527
+ cleaned.append(name)
528
+ return cleaned
529
+
530
+ base: list[str] = []
531
+ if isinstance(override_fragments, list):
532
+ base = _clean_list(override_fragments)
533
+ elif runtime.runtime and isinstance(getattr(runtime.runtime, "extras", None), Mapping):
534
+ base = _clean_list(runtime.runtime.extras.get("fragments"))
535
+
536
+ enable_list = _clean_list(enable)
537
+ disable_list = _clean_list(disable)
538
+
539
+ if not base and not enable_list and not disable_list:
540
+ return None
541
+
542
+ result = [entry for entry in base if entry not in disable_list]
543
+ for entry in enable_list:
544
+ if entry not in result:
545
+ result.append(entry)
546
+ return result
547
+
548
+
549
+ def _requires_press_normalisation(options: Mapping[str, Any]) -> bool:
550
+ """Return True when template options include press metadata."""
551
+ for key in options:
552
+ if not isinstance(key, str):
553
+ continue
554
+ if key == "press" or key.startswith("press."):
555
+ return True
556
+ if key in {"title", "subtitle", "date", "authors", "author", "language"}:
557
+ return True
558
+ return False
559
+
560
+
561
+ def _front_matter_declares_press(metadata: Mapping[str, Any] | None) -> bool:
562
+ if not isinstance(metadata, Mapping):
563
+ return False
564
+ press_section = metadata.get("press")
565
+ if isinstance(press_section, Mapping):
566
+ return True
567
+ return any(isinstance(key, str) and key.startswith("press.") for key in metadata)
568
+
569
+
570
+ def _merge_front_matter(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
571
+ merged: dict[str, Any] = copy.deepcopy(dict(base))
572
+ for key, value in override.items():
573
+ if key in merged and isinstance(merged[key], Mapping) and isinstance(value, Mapping):
574
+ merged[key] = _merge_front_matter(merged[key], value)
575
+ else:
576
+ merged[key] = copy.deepcopy(value)
577
+ return merged
578
+
579
+
580
+ def _apply_shared_front_matter(
581
+ documents: list[Document],
582
+ shared_front_matter: Mapping[str, Any] | None,
583
+ ) -> None:
584
+ if not shared_front_matter:
585
+ return
586
+ for document in documents:
587
+ merged = _merge_front_matter(shared_front_matter, document.front_matter)
588
+ normalised = dict(merged)
589
+ try:
590
+ normalise_press_metadata(normalised)
591
+ except PressMetadataError as exc:
592
+ raise ConversionError(str(exc)) from exc
593
+ document.set_front_matter(merged)
594
+ if (
595
+ document.options.title_strategy is TitleStrategy.PROMOTE_METADATA
596
+ and front_matter_has_title(merged)
597
+ ):
598
+ document.options.title_strategy = TitleStrategy.KEEP
599
+ document.slots = DocumentSlots()
600
+ base_mapping = extract_front_matter_slots(normalised)
601
+ if base_mapping:
602
+ document.slots.merge(DocumentSlots.from_mapping(base_mapping))
603
+
604
+
605
+ def _describe_front_matter_source(path: Path | None) -> str:
606
+ return str(path) if path is not None else "front matter"
607
+
608
+
609
+ def _collect_press_sources(
610
+ documents: list[Document],
611
+ shared_front_matter: Mapping[str, Any] | None,
612
+ request: ConversionRequest,
613
+ ) -> list[str]:
614
+ sources: list[str] = []
615
+ if shared_front_matter and _front_matter_declares_press(shared_front_matter):
616
+ sources.append(_describe_front_matter_source(request.front_matter_path))
617
+ for document in documents:
618
+ if _front_matter_declares_press(document.front_matter):
619
+ sources.append(str(document.source_path))
620
+ return sources
621
+
622
+
623
+ def classify_input_source(path: Path) -> InputKind:
624
+ """Determine the document kind based on filename suffix, rejecting unsupported types early."""
625
+ suffix = path.suffix.lower()
626
+ if suffix in {".md", ".markdown"}:
627
+ return InputKind.MARKDOWN
628
+ if suffix in {".yaml", ".yml"}:
629
+ if path.name.lower() in {"mkdocs.yml", "mkdocs.yaml"}:
630
+ raise UnsupportedInputError("MkDocs configuration files are not supported.")
631
+ return InputKind.MARKDOWN
632
+ if suffix in {".html", ".htm"}:
633
+ return InputKind.HTML
634
+ raise UnsupportedInputError(
635
+ f"Unsupported input file type '{suffix or '<none>'}'. "
636
+ "Provide a Markdown source (.md) or HTML document (.html)."
637
+ )
638
+
639
+
640
+ def _deduplicate_paths(values: Iterable[Path]) -> list[Path]:
641
+ seen: set[Path] = set()
642
+ result: list[Path] = []
643
+ for path in values:
644
+ if path in seen:
645
+ continue
646
+ seen.add(path)
647
+ result.append(path)
648
+ return result