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,861 @@
1
+ """Pydantic models describing LaTeX template manifests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ import copy
7
+ from pathlib import Path
8
+ import re
9
+ from typing import Any, Callable, Literal
10
+
11
+ try: # Python >=3.11
12
+ import tomllib # type: ignore[attr-defined]
13
+ except ModuleNotFoundError: # Python 3.10
14
+ import tomli as tomllib
15
+ from pydantic import (
16
+ BaseModel,
17
+ ConfigDict,
18
+ Field,
19
+ PrivateAttr,
20
+ ValidationError,
21
+ model_validator,
22
+ )
23
+
24
+ from bs4 import BeautifulSoup, NavigableString, Tag
25
+
26
+ from texsmith.adapters.latex.utils import escape_latex_chars
27
+ from texsmith.adapters.markdown import DEFAULT_MARKDOWN_EXTENSIONS, render_markdown
28
+ from texsmith.core.exceptions import LatexRenderingError
29
+ from texsmith.core.metadata import PressMetadataError, normalise_press_metadata
30
+ from texsmith.core.partials import normalise_partial_key
31
+
32
+
33
+ class TemplateError(LatexRenderingError):
34
+ """Raised when a LaTeX template cannot be loaded or rendered."""
35
+
36
+
37
+ DEFAULT_TEMPLATE_LANGUAGE = "english"
38
+
39
+ _BABEL_LANGUAGE_ALIASES = {
40
+ "ad": "catalan",
41
+ "ca": "catalan",
42
+ "cs": "czech",
43
+ "da": "danish",
44
+ "de": "ngerman",
45
+ "de-de": "ngerman",
46
+ "en": "english",
47
+ "en-gb": "british",
48
+ "en-us": "english",
49
+ "en-au": "australian",
50
+ "en-ca": "canadian",
51
+ "es": "spanish",
52
+ "es-es": "spanish",
53
+ "es-mx": "mexican",
54
+ "fi": "finnish",
55
+ "fr": "french",
56
+ "fr-fr": "french",
57
+ "fr-ca": "canadien",
58
+ "it": "italian",
59
+ "nl": "dutch",
60
+ "nb": "norwegian",
61
+ "nn": "nynorsk",
62
+ "pl": "polish",
63
+ "pt": "portuguese",
64
+ "pt-br": "brazilian",
65
+ "ro": "romanian",
66
+ "ru": "russian",
67
+ "sk": "slovak",
68
+ "sl": "slovene",
69
+ "sv": "swedish",
70
+ "tr": "turkish",
71
+ }
72
+
73
+ LATEX_HEADING_LEVELS: dict[str, int] = {
74
+ "part": -1,
75
+ "chapter": 0,
76
+ "section": 1,
77
+ "subsection": 2,
78
+ "subsubsection": 3,
79
+ "paragraph": 4,
80
+ "subparagraph": 5,
81
+ }
82
+
83
+
84
+ class CompatInfo(BaseModel):
85
+ """Compatibility constraints declared by the template."""
86
+
87
+ model_config = ConfigDict(extra="ignore")
88
+
89
+ texsmith: str | None = None
90
+
91
+
92
+ class TemplateAsset(BaseModel):
93
+ """Description of individual template assets."""
94
+
95
+ model_config = ConfigDict(extra="forbid")
96
+
97
+ source: str
98
+ template: bool = False
99
+ encoding: str | None = None
100
+
101
+
102
+ class TemplateSlot(BaseModel):
103
+ """Configuration describing how content is injected into a template slot."""
104
+
105
+ model_config = ConfigDict(extra="forbid")
106
+
107
+ base_level: int | None = None
108
+ depth: str | None = None
109
+ offset: int = 0
110
+ default: bool = False
111
+ strip_heading: bool = False
112
+ description: str | None = None
113
+
114
+ @model_validator(mode="after")
115
+ def _validate_depth(self) -> TemplateSlot:
116
+ if self.depth is not None and self.depth not in LATEX_HEADING_LEVELS:
117
+ raise ValueError(
118
+ f"Unsupported slot depth '{self.depth}', "
119
+ f"expected one of {', '.join(LATEX_HEADING_LEVELS)}."
120
+ )
121
+ return self
122
+
123
+ def resolve_level(self, fallback: int) -> int:
124
+ """Return the base level applied to rendered headings for this slot."""
125
+ base = fallback
126
+ if self.base_level is not None:
127
+ base = self.base_level
128
+ elif self.depth is not None:
129
+ base = fallback + LATEX_HEADING_LEVELS[self.depth]
130
+ return base + self.offset
131
+
132
+
133
+ AttributePrimitiveType = Literal["any", "string", "integer", "float", "boolean", "list", "mapping"]
134
+ AttributeFormatType = Literal["markdown", "raw"]
135
+ AttributeEscapeMode = Literal["latex"]
136
+
137
+
138
+ _UNSET = object()
139
+ _CODE_ENGINES = {"minted", "listings", "verbatim", "pygments"}
140
+
141
+
142
+ def _lookup_path(container: Mapping[str, Any], path: str) -> tuple[Any, bool]:
143
+ """Return a value stored at ``path`` within ``container``."""
144
+ segments = [segment for segment in path.split(".") if segment]
145
+ current: Any = container
146
+ for index, segment in enumerate(segments):
147
+ if not isinstance(current, Mapping):
148
+ return (None, False)
149
+ if segment not in current:
150
+ return (None, False)
151
+ current = current[segment]
152
+ if current is None and index + 1 < len(segments):
153
+ return (None, False)
154
+ return (current, True)
155
+
156
+
157
+ def _normalise_sources(payload: Any) -> list[str]:
158
+ if payload is None:
159
+ return []
160
+ if isinstance(payload, str):
161
+ candidate = payload.strip()
162
+ return [candidate] if candidate else []
163
+ if isinstance(payload, Sequence) and not isinstance(payload, (str, bytes)):
164
+ tokens: list[str] = []
165
+ for element in payload:
166
+ if isinstance(element, str) and element.strip():
167
+ tokens.append(element.strip())
168
+ return tokens
169
+ return []
170
+
171
+
172
+ def _render_attribute_markdown(value: str, language: str = DEFAULT_TEMPLATE_LANGUAGE) -> str:
173
+ """Render a short Markdown snippet through the standard TeXSmith pipeline."""
174
+ from texsmith.adapters.latex.formatter import LaTeXFormatter
175
+ from texsmith.adapters.latex.renderer import LaTeXRenderer
176
+ from texsmith.core.config import BookConfig
177
+ from texsmith.core.conversion.core import render_with_fallback
178
+
179
+ document = render_markdown(value, DEFAULT_MARKDOWN_EXTENSIONS)
180
+ html = document.html
181
+
182
+ config = BookConfig(
183
+ project_dir=Path("."),
184
+ language=language or DEFAULT_TEMPLATE_LANGUAGE,
185
+ legacy_latex_accents=False,
186
+ )
187
+
188
+ formatter = LaTeXFormatter()
189
+ renderer_kwargs = {
190
+ "output_root": Path("."),
191
+ "copy_assets": False,
192
+ "convert_assets": False,
193
+ "hash_assets": False,
194
+ "parser": "html.parser",
195
+ }
196
+
197
+ def _renderer_factory() -> LaTeXRenderer:
198
+ return LaTeXRenderer(config=config, formatter=formatter, **renderer_kwargs)
199
+
200
+ rendered, _ = render_with_fallback(
201
+ _renderer_factory,
202
+ html,
203
+ runtime={"language": config.language, "copy_assets": False, "convert_assets": False, "hash_assets": False},
204
+ bibliography={},
205
+ state=None,
206
+ emitter=None,
207
+ )
208
+ return rendered.strip()
209
+
210
+
211
+ def _render_attribute_markdown(value: str) -> str:
212
+ """Render a short Markdown snippet into LaTeX-safe text."""
213
+ doc = render_markdown(value, DEFAULT_MARKDOWN_EXTENSIONS)
214
+ html = doc.html
215
+ soup = BeautifulSoup(html, "html.parser")
216
+
217
+ def render_node(node: Tag | NavigableString) -> str:
218
+ if isinstance(node, NavigableString):
219
+ return escape_latex_chars(str(node))
220
+
221
+ name = (node.name or "").lower()
222
+ classes = set(node.get("class") or [])
223
+ rendered_children = "".join(render_node(child) for child in node.children)
224
+
225
+ if name in {"strong", "b"}:
226
+ return rf"\textbf{{{rendered_children}}}"
227
+ if name in {"em", "i"}:
228
+ return rf"\emph{{{rendered_children}}}"
229
+ if name == "code":
230
+ return rf"\texttt{{{rendered_children}}}"
231
+ if name == "span" and "texsmith-smallcaps" in classes:
232
+ return rf"\textsc{{{rendered_children}}}"
233
+ if name == "br":
234
+ return r"\\"
235
+ if name == "p":
236
+ return rendered_children + "\n\n"
237
+ if name == "li":
238
+ return "- " + rendered_children + "\n"
239
+ return rendered_children
240
+
241
+ body = soup.body if soup.body else soup
242
+ rendered = "".join(render_node(child) for child in body.children)
243
+ return rendered.strip()
244
+
245
+
246
+ _ATTRIBUTE_NORMALISERS: dict[str, Callable[[Any, "TemplateAttributeSpec", Any], Any]] = {}
247
+
248
+
249
+ def _register_attribute_normaliser(
250
+ name: str,
251
+ ) -> Callable[[Callable[[Any, "TemplateAttributeSpec", Any], Any]], Callable[[Any, "TemplateAttributeSpec", Any], Any]]:
252
+ """Decorator used to register attribute normaliser callables (internal use)."""
253
+
254
+ def decorator(
255
+ func: Callable[[Any, "TemplateAttributeSpec", Any], Any],
256
+ ) -> Callable[[Any, "TemplateAttributeSpec", Any], Any]:
257
+ _ATTRIBUTE_NORMALISERS[name] = func
258
+ return func
259
+
260
+ return decorator
261
+
262
+
263
+ def _resolve_attribute_normaliser(
264
+ name: str,
265
+ ) -> Callable[[Any, "TemplateAttributeSpec", Any], Any]:
266
+ try:
267
+ return _ATTRIBUTE_NORMALISERS[name]
268
+ except KeyError as exc: # pragma: no cover - defensive
269
+ raise TemplateError(f"Unknown attribute normaliser '{name}'.") from exc
270
+
271
+
272
+ @_register_attribute_normaliser("paper_option")
273
+ def _normalise_paper_option(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
274
+ valid_bases = {
275
+ "a0",
276
+ "a1",
277
+ "a2",
278
+ "a3",
279
+ "a4",
280
+ "a5",
281
+ "a6",
282
+ "b0",
283
+ "b1",
284
+ "b2",
285
+ "b3",
286
+ "b4",
287
+ "b5",
288
+ "b6",
289
+ "letter",
290
+ "legal",
291
+ "executive",
292
+ }
293
+
294
+ if value is None or value == "":
295
+ return fallback
296
+ if not isinstance(value, str):
297
+ raise TemplateError(
298
+ f"Invalid paper option type '{type(value).__name__}' for attribute '{spec.name}'."
299
+ )
300
+
301
+ candidate = value.strip().lower()
302
+ if not candidate:
303
+ return fallback
304
+
305
+ if candidate.endswith("paper"):
306
+ candidate = candidate[:-5]
307
+
308
+ if candidate not in valid_bases:
309
+ allowed = ", ".join(sorted(f"{base}paper" for base in valid_bases))
310
+ raise TemplateError(
311
+ f"Invalid paper option '{value}' for attribute '{spec.name}'. Allowed values: {allowed}."
312
+ )
313
+
314
+ return f"{candidate}paper"
315
+
316
+
317
+ @_register_attribute_normaliser("orientation")
318
+ def _normalise_orientation(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
319
+ valid_orientations = {"portrait", "landscape"}
320
+
321
+ if value is None or value == "":
322
+ return fallback
323
+
324
+ if isinstance(value, str):
325
+ candidate = value.strip().lower()
326
+ if candidate == "vertical":
327
+ candidate = "portrait"
328
+ elif candidate == "horizontal":
329
+ candidate = "landscape"
330
+ else:
331
+ raise TemplateError(
332
+ f"Invalid orientation type '{type(value).__name__}' for attribute '{spec.name}'."
333
+ )
334
+
335
+ if not candidate:
336
+ return fallback
337
+
338
+ if candidate not in valid_orientations:
339
+ allowed = ", ".join(sorted(valid_orientations))
340
+ raise TemplateError(
341
+ f"Invalid orientation option '{value}' for attribute '{spec.name}'. "
342
+ f"Allowed values: {allowed}."
343
+ )
344
+
345
+ return candidate
346
+
347
+
348
+ @_register_attribute_normaliser("babel_language")
349
+ def _normalise_language(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
350
+ if value is None or value == "":
351
+ return fallback
352
+
353
+ if not isinstance(value, str):
354
+ candidate = str(value)
355
+ else:
356
+ candidate = value
357
+
358
+ mapped = _map_babel_language(candidate)
359
+ if mapped:
360
+ return mapped
361
+
362
+ if fallback is not None:
363
+ return fallback
364
+
365
+ raise TemplateError(
366
+ f"Attribute '{spec.name}' received unsupported language value '{value}'."
367
+ )
368
+
369
+
370
+ @_register_attribute_normaliser("callout_style")
371
+ def _normalise_callout_style_attribute(
372
+ value: Any, spec: "TemplateAttributeSpec", fallback: Any
373
+ ) -> Any:
374
+ allowed = {"fancy", "classic", "minimal"}
375
+ if value is None or value == "":
376
+ return fallback
377
+
378
+ if isinstance(value, str):
379
+ candidate = value.strip().lower()
380
+ else:
381
+ candidate = str(value).strip().lower()
382
+
383
+ if not candidate:
384
+ return fallback
385
+
386
+ if candidate not in allowed:
387
+ allowed_values = ", ".join(sorted(allowed))
388
+ raise TemplateError(
389
+ f"Attribute '{spec.name}' value '{value}' is invalid. Expected one of: {allowed_values}."
390
+ )
391
+
392
+ return candidate
393
+
394
+
395
+ @_register_attribute_normaliser("margin_style")
396
+ def _normalise_margin_style(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
397
+ if value is None or value == "":
398
+ return fallback
399
+
400
+ if isinstance(value, str):
401
+ candidate = value.strip()
402
+ else:
403
+ candidate = str(value).strip()
404
+
405
+ lowered = candidate.lower()
406
+ if lowered in {"narrow", "default", "wide"}:
407
+ return lowered
408
+ return candidate
409
+
410
+
411
+ @_register_attribute_normaliser("code_options")
412
+ def _normalise_code_options(value: Any, spec: "TemplateAttributeSpec", fallback: Any) -> Any:
413
+ """Normalise code highlighting options ensuring a supported engine."""
414
+
415
+ def _pick_engine(candidate: Any) -> str:
416
+ if isinstance(candidate, str):
417
+ engine = candidate.strip().lower()
418
+ else:
419
+ engine = str(candidate).strip().lower() if candidate is not None else ""
420
+ if not engine:
421
+ return "pygments"
422
+ if engine not in _CODE_ENGINES:
423
+ allowed = ", ".join(sorted(_CODE_ENGINES))
424
+ raise TemplateError(
425
+ f"Attribute '{spec.name}' value '{engine}' is invalid. Expected one of: {allowed}."
426
+ )
427
+ return engine
428
+
429
+ def _pick_style(candidate: Any, default: str) -> str:
430
+ if isinstance(candidate, str):
431
+ stripped = candidate.strip()
432
+ return stripped or default
433
+ if candidate is None:
434
+ return default
435
+ stripped = str(candidate).strip()
436
+ return stripped or default
437
+
438
+ fallback_engine = (
439
+ _pick_engine(fallback.get("engine")) if isinstance(fallback, Mapping) else "pygments"
440
+ )
441
+ fallback_style = (
442
+ _pick_style(fallback.get("style"), "bw") if isinstance(fallback, Mapping) else "bw"
443
+ )
444
+ options: dict[str, Any] = {"engine": fallback_engine, "style": fallback_style}
445
+
446
+ if value is None:
447
+ return options
448
+
449
+ if isinstance(value, Mapping):
450
+ engine_value = value.get("engine", fallback_engine)
451
+ style_value = value.get("style", fallback_style)
452
+ options = dict(value)
453
+ options["engine"] = _pick_engine(engine_value)
454
+ options["style"] = _pick_style(style_value, fallback_style)
455
+ return options
456
+
457
+ if isinstance(value, str):
458
+ engine_value = value.strip()
459
+ if not engine_value:
460
+ return options
461
+ options["engine"] = _pick_engine(engine_value)
462
+ return options
463
+
464
+ options["engine"] = fallback_engine
465
+ return options
466
+
467
+
468
+ class TemplateAttributeSpec(BaseModel):
469
+ """Typed attribute definition used to build template defaults."""
470
+
471
+ model_config = ConfigDict(extra="forbid")
472
+
473
+ default: Any = None
474
+ type: AttributePrimitiveType | None = None
475
+ format: AttributeFormatType = "markdown"
476
+ choices: list[Any] = Field(default_factory=list)
477
+ sources: list[str] = Field(default_factory=list)
478
+ escape: AttributeEscapeMode | None = None
479
+ normaliser: str | None = None
480
+ required: bool = False
481
+ allow_empty: bool = True
482
+ description: str | None = None
483
+ range: list[Any] | None = None
484
+ owner: str | None = None
485
+
486
+ # Populated lazily after validation
487
+ name: str = ""
488
+
489
+ # Private caches
490
+ _default_cache: Any = PrivateAttr(default=_UNSET)
491
+
492
+ @model_validator(mode="before")
493
+ @classmethod
494
+ def _coerce_sources(cls, data: dict[str, Any]) -> dict[str, Any]:
495
+ if not isinstance(data, Mapping):
496
+ return data
497
+ coerced = dict(data)
498
+ coerced["sources"] = _normalise_sources(data.get("sources"))
499
+ return coerced
500
+
501
+ @model_validator(mode="after")
502
+ def _finalise(self) -> "TemplateAttributeSpec":
503
+ self._default_cache = self._coerce_value(
504
+ self.default,
505
+ from_override=False,
506
+ is_default=True,
507
+ fallback=None,
508
+ )
509
+ return self
510
+
511
+ def default_value(self) -> Any:
512
+ """Return a deep copy of the attribute default."""
513
+ return copy.deepcopy(self._default_cache)
514
+
515
+ def _effective_type(self) -> AttributePrimitiveType:
516
+ if self.type:
517
+ return self.type
518
+ default = self.default
519
+ if isinstance(default, bool):
520
+ return "boolean"
521
+ if isinstance(default, int) and not isinstance(default, bool):
522
+ return "integer"
523
+ if isinstance(default, float):
524
+ return "float"
525
+ if isinstance(default, str):
526
+ return "string"
527
+ if isinstance(default, Mapping):
528
+ return "mapping"
529
+ if isinstance(default, Sequence) and not isinstance(default, (str, bytes)):
530
+ return "list"
531
+ return "any"
532
+
533
+ def effective_sources(self) -> list[str]:
534
+ if self.sources:
535
+ return list(self.sources)
536
+ return [self.name]
537
+
538
+ def fetch_override(
539
+ self,
540
+ overrides: Mapping[str, Any],
541
+ ) -> tuple[Any, bool]:
542
+ for path in self.effective_sources():
543
+ if not path:
544
+ continue
545
+ value, found = _lookup_path(overrides, path)
546
+ if found:
547
+ return (value, True)
548
+ return (_UNSET, False)
549
+
550
+ def coerce_value(self, value: Any, *, from_override: bool) -> Any:
551
+ fallback = self._default_cache if self._default_cache is not _UNSET else None
552
+ result = self._coerce_value(
553
+ value,
554
+ from_override=from_override,
555
+ is_default=False,
556
+ fallback=fallback,
557
+ )
558
+ if result is None and self.required:
559
+ raise TemplateError(f"Attribute '{self.name}' requires a value.")
560
+ return result
561
+
562
+ def _coerce_value(
563
+ self,
564
+ value: Any,
565
+ *,
566
+ from_override: bool,
567
+ is_default: bool,
568
+ fallback: Any,
569
+ ) -> Any:
570
+ target_type = self._effective_type()
571
+
572
+ if value is None:
573
+ result: Any = None
574
+ elif target_type == "string":
575
+ if isinstance(value, str):
576
+ result = value.strip()
577
+ else:
578
+ result = str(value).strip()
579
+ if not result and not self.allow_empty:
580
+ result = None
581
+ if result and self.format == "markdown":
582
+ result = _render_attribute_markdown(result)
583
+ elif target_type == "integer":
584
+ try:
585
+ result = int(value)
586
+ except (TypeError, ValueError) as exc:
587
+ raise TemplateError(
588
+ f"Attribute '{self.name}' expects an integer value."
589
+ ) from exc
590
+ elif target_type == "float":
591
+ try:
592
+ result = float(value)
593
+ except (TypeError, ValueError) as exc:
594
+ raise TemplateError(
595
+ f"Attribute '{self.name}' expects a numeric value."
596
+ ) from exc
597
+ elif target_type == "boolean":
598
+ if isinstance(value, bool):
599
+ result = value
600
+ elif isinstance(value, str):
601
+ lowered = value.strip().lower()
602
+ if lowered in {"true", "yes", "1", "on"}:
603
+ result = True
604
+ elif lowered in {"false", "no", "0", "off"}:
605
+ result = False
606
+ else:
607
+ raise TemplateError(
608
+ f"Attribute '{self.name}' expects a boolean value."
609
+ )
610
+ elif isinstance(value, (int, float)):
611
+ result = bool(value)
612
+ else:
613
+ raise TemplateError(
614
+ f"Attribute '{self.name}' expects a boolean-compatible value."
615
+ )
616
+ elif target_type == "list":
617
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
618
+ result = [copy.deepcopy(item) for item in value]
619
+ elif isinstance(value, str):
620
+ tokens = [item.strip() for item in value.split(",")]
621
+ result = [token for token in tokens if token]
622
+ else:
623
+ result = [value]
624
+ elif target_type == "mapping":
625
+ if isinstance(value, Mapping):
626
+ result = dict(value)
627
+ else:
628
+ raise TemplateError(f"Attribute '{self.name}' expects a mapping value.")
629
+ else:
630
+ result = copy.deepcopy(value)
631
+
632
+ if self.normaliser and result is not None:
633
+ normaliser = _resolve_attribute_normaliser(self.normaliser)
634
+ result = normaliser(result, self, fallback)
635
+
636
+ if self.choices and result is not None and result not in self.choices:
637
+ allowed = ", ".join(str(choice) for choice in self.choices)
638
+ raise TemplateError(
639
+ f"Attribute '{self.name}' value '{result}' is invalid. Expected one of: {allowed}."
640
+ )
641
+
642
+ if (
643
+ self.escape == "latex"
644
+ and isinstance(result, str)
645
+ and from_override
646
+ and not is_default
647
+ and self.format != "markdown"
648
+ ):
649
+ result = escape_latex_chars(result)
650
+
651
+ return copy.deepcopy(result)
652
+
653
+
654
+ class TemplateAttributeResolver:
655
+ """Resolve attribute values from overrides using a typed specification."""
656
+
657
+ def __init__(self, specs: Mapping[str, TemplateAttributeSpec]):
658
+ self._specs = dict(specs)
659
+
660
+ def defaults(self) -> dict[str, Any]:
661
+ return {name: spec.default_value() for name, spec in self._specs.items()}
662
+
663
+ def merge(self, overrides: Mapping[str, Any] | None) -> dict[str, Any]:
664
+ resolved = self.defaults()
665
+ if not overrides:
666
+ return resolved
667
+
668
+ override_payload = dict(overrides)
669
+ try:
670
+ normalise_press_metadata(override_payload)
671
+ except PressMetadataError:
672
+ pass
673
+
674
+ for name, spec in self._specs.items():
675
+ value, from_override = spec.fetch_override(override_payload)
676
+ if value is _UNSET:
677
+ continue
678
+ coerced = spec.coerce_value(value, from_override=from_override)
679
+ resolved[name] = coerced
680
+
681
+ return resolved
682
+
683
+
684
+ class TemplateInfo(BaseModel):
685
+ """Metadata describing the LaTeX template payload."""
686
+
687
+ model_config = ConfigDict(extra="allow")
688
+
689
+ name: str
690
+ version: str
691
+ entrypoint: str = "template.tex"
692
+ engine: str | None = None
693
+ shell_escape: bool = False
694
+ texlive_year: int | None = None
695
+ tlmgr_packages: list[str] = Field(default_factory=list)
696
+ fragments: list[str] | None = None
697
+ override: list[str] = Field(default_factory=list)
698
+ required_partials: list[str] = Field(default_factory=list)
699
+ attributes: dict[str, TemplateAttributeSpec] = Field(default_factory=dict)
700
+ assets: dict[str, TemplateAsset] = Field(default_factory=dict)
701
+ slots: dict[str, TemplateSlot] = Field(default_factory=dict)
702
+ emit: dict[str, Any] = Field(default_factory=dict)
703
+
704
+ _attribute_resolver: TemplateAttributeResolver = PrivateAttr()
705
+ _attribute_defaults: dict[str, Any] = PrivateAttr(default_factory=dict)
706
+ _attribute_owners: dict[str, str] = PrivateAttr(default_factory=dict)
707
+
708
+ @model_validator(mode="before")
709
+ @classmethod
710
+ def _normalise_assets(cls, data: dict[str, Any]) -> dict[str, Any]:
711
+ assets = data.get("assets")
712
+ if isinstance(assets, dict):
713
+ normalised: dict[str, Any] = {}
714
+ for destination, spec in assets.items():
715
+ if isinstance(spec, str):
716
+ normalised[destination] = {"source": spec}
717
+ else:
718
+ normalised[destination] = spec
719
+ data = dict(data)
720
+ data["assets"] = normalised
721
+ return data
722
+
723
+ @model_validator(mode="before")
724
+ @classmethod
725
+ def _normalise_attributes(cls, data: dict[str, Any]) -> dict[str, Any]:
726
+ attributes = data.get("attributes")
727
+ if not isinstance(attributes, Mapping):
728
+ return data
729
+
730
+ normalised: dict[str, Any] = {}
731
+ for name, payload in attributes.items():
732
+ if isinstance(payload, TemplateAttributeSpec):
733
+ normalised[name] = payload
734
+ elif isinstance(payload, Mapping):
735
+ if "default" not in payload and "type" not in payload:
736
+ normalised[name] = {"default": payload}
737
+ else:
738
+ normalised[name] = payload
739
+ else:
740
+ normalised[name] = {"default": payload}
741
+
742
+ updated = dict(data)
743
+ updated["attributes"] = normalised
744
+ return updated
745
+
746
+ @model_validator(mode="after")
747
+ def _bind_attribute_names(self) -> "TemplateInfo":
748
+ for name, spec in self.attributes.items():
749
+ spec.name = name
750
+ if spec.owner is None:
751
+ spec.owner = self.name
752
+ self._attribute_owners[name] = spec.owner
753
+ self._attribute_resolver = TemplateAttributeResolver(self.attributes)
754
+ self._attribute_defaults = self._attribute_resolver.defaults()
755
+ normalised_required: list[str] = []
756
+ for entry in self.required_partials or []:
757
+ if not isinstance(entry, str):
758
+ continue
759
+ key = normalise_partial_key(entry)
760
+ if key:
761
+ normalised_required.append(key)
762
+ self.required_partials = normalised_required
763
+ return self
764
+
765
+ def resolve_slots(self) -> tuple[dict[str, TemplateSlot], str]:
766
+ """Return declared slots ensuring a single default sink exists."""
767
+ resolved = {
768
+ name: slot if isinstance(slot, TemplateSlot) else TemplateSlot.model_validate(slot)
769
+ for name, slot in self.slots.items()
770
+ }
771
+
772
+ if "mainmatter" not in resolved:
773
+ resolved["mainmatter"] = TemplateSlot(default=True)
774
+
775
+ defaults = [name for name, slot in resolved.items() if slot.default]
776
+ if not defaults:
777
+ resolved["mainmatter"] = resolved["mainmatter"].model_copy(update={"default": True})
778
+ defaults = ["mainmatter"]
779
+ elif len(defaults) > 1:
780
+ formatted = ", ".join(defaults)
781
+ raise TemplateError(f"Multiple default slots declared: {formatted}")
782
+
783
+ return resolved, defaults[0]
784
+
785
+ def attribute_defaults(self) -> dict[str, Any]:
786
+ """Return a deep copy of template attribute defaults."""
787
+ return copy.deepcopy(self._attribute_defaults)
788
+
789
+ def emit_defaults(self) -> dict[str, Any]:
790
+ """Return default attributes emitted by the template."""
791
+ return copy.deepcopy(self.emit)
792
+
793
+ def get_attribute_default(self, name: str, default: Any | None = None) -> Any:
794
+ return copy.deepcopy(self._attribute_defaults.get(name, default))
795
+
796
+ def resolve_attributes(self, overrides: Mapping[str, Any] | None = None) -> dict[str, Any]:
797
+ """Return defaults merged with overrides using the attribute specification."""
798
+ return self._attribute_resolver.merge(overrides)
799
+
800
+ def attribute_owners(self) -> dict[str, str]:
801
+ """Return attribute ownership map (name -> owner)."""
802
+ return dict(self._attribute_owners)
803
+
804
+
805
+ class LatexSection(BaseModel):
806
+ """Section grouping LaTeX-specific manifest settings."""
807
+
808
+ template: TemplateInfo
809
+
810
+
811
+ class TemplateManifest(BaseModel):
812
+ """Structured manifest describing a LaTeX template."""
813
+
814
+ compat: CompatInfo | None = None
815
+ latex: LatexSection
816
+
817
+ @classmethod
818
+ def load(cls, manifest_path: Path) -> TemplateManifest:
819
+ """Load and validate a manifest from disk."""
820
+ try:
821
+ content = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
822
+ except FileNotFoundError as exc: # pragma: no cover - sanity check
823
+ raise TemplateError(f"Template manifest is missing: {manifest_path}") from exc
824
+ except OSError as exc: # pragma: no cover - IO failure
825
+ raise TemplateError(f"Failed to read template manifest: {exc}") from exc
826
+ except tomllib.TOMLDecodeError as exc:
827
+ raise TemplateError(f"Invalid template manifest: {exc}") from exc
828
+
829
+ try:
830
+ return cls.model_validate(content)
831
+ except ValidationError as exc:
832
+ raise TemplateError(f"Template manifest validation failed: {exc}") from exc
833
+
834
+
835
+ __all__ = [
836
+ "CompatInfo",
837
+ "DEFAULT_TEMPLATE_LANGUAGE",
838
+ "LATEX_HEADING_LEVELS",
839
+ "LatexSection",
840
+ "TemplateAsset",
841
+ "TemplateAttributeSpec",
842
+ "TemplateError",
843
+ "TemplateInfo",
844
+ "TemplateManifest",
845
+ "TemplateSlot",
846
+ ]
847
+ def _map_babel_language(value: str | None) -> str | None:
848
+ if value is None:
849
+ return None
850
+ candidate = value.strip()
851
+ if not candidate:
852
+ return None
853
+ lowered = candidate.lower().replace("_", "-")
854
+ if lowered in _BABEL_LANGUAGE_ALIASES:
855
+ return _BABEL_LANGUAGE_ALIASES[lowered]
856
+ primary = lowered.split("-", 1)[0]
857
+ if primary in _BABEL_LANGUAGE_ALIASES:
858
+ return _BABEL_LANGUAGE_ALIASES[primary]
859
+ if lowered.isalpha():
860
+ return lowered
861
+ return None