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
texsmith/core/rules.py ADDED
@@ -0,0 +1,394 @@
1
+ """Rule declaration and execution engine for the LaTeX renderer.
2
+
3
+ This module implements the rule-based architecture that powers Texsmith's
4
+ HTML-to-LaTeX pipeline. Handlers declare their intent via the ``@renders``
5
+ decorator, which records structural metadata (phase, priority, targeted tags).
6
+ At runtime the :class:`RenderEngine` collects those declarations, organises them
7
+ per :class:`RenderPhase`, and walks the BeautifulSoup DOM ensuring that each
8
+ pass is executed in a predictable, stable order.
9
+
10
+ Architecture
11
+
12
+ `Declaration layer`
13
+ : ``@renders`` stores a lightweight :class:`RuleDefinition` on every handler.
14
+
15
+ `Registry layer`
16
+ : :class:`RenderRegistry` collates definitions into sortable :class:`RenderRule`
17
+ instances grouped by phase/tag.
18
+
19
+ `Execution layer`
20
+ : :class:`RenderEngine` coordinates multi-pass traversal using the private
21
+ :class:`_DOMVisitor` to apply handlers depth-first while respecting
22
+ auto-marking and child-suppression semantics.
23
+
24
+ This separation keeps rule authors focused on transformations while the engine
25
+ handles ordering, deduplication, and orchestration concerns.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections import deque
31
+ from collections.abc import Callable, Iterable
32
+ from dataclasses import dataclass
33
+ from enum import Enum, auto
34
+ from typing import TYPE_CHECKING, Any, Protocol, cast
35
+
36
+
37
+ if TYPE_CHECKING: # pragma: no cover - typing only
38
+ from bs4.element import Tag
39
+
40
+ from .context import RenderContext
41
+
42
+
43
+ class RenderPhase(Enum):
44
+ """Ordered passes executed while mutating the parsed HTML tree.
45
+
46
+ The renderer performs multiple sweeps over the DOM instead of a single
47
+ monolithic traversal. Each phase isolates a category of mutations so that
48
+ earlier transformations stabilise before later ones begin. This drastically
49
+ reduces coupling between handlers and makes ordering guarantees explicit.
50
+
51
+ Phases progress from coarse structural edits to fine-grained formatting:
52
+
53
+ ``PRE``
54
+ : normalise the tree and discard unwanted nodes before any heavy lifting
55
+ occurs.
56
+
57
+ ``BLOCK``
58
+ : build block-level LaTeX (paragraphs, lists, figures) once the structure is
59
+ stable.
60
+
61
+ ``INLINE``
62
+ : apply inline formatting after blocks have established their final shape.
63
+
64
+ ``POST``
65
+ : run cleanup or bookkeeping steps that depend on previous phases, such as
66
+ final numbering, synthetic nodes, or state aggregation.
67
+ """
68
+
69
+ PRE = auto()
70
+ """DOM normalisation pass: strip/reshape nodes before structural work begins."""
71
+
72
+ BLOCK = auto()
73
+ """Block transformation pass: convert paragraphs, lists, figures, etc."""
74
+
75
+ INLINE = auto()
76
+ """Inline formatting pass: apply emphasis, links, inline math once blocks exist."""
77
+
78
+ POST = auto()
79
+ """Finalisation pass: run cleanup that depends on earlier transformations."""
80
+
81
+
82
+ RuleCallable = Callable[[Any, "RenderContext"], None]
83
+
84
+
85
+ class RuleFactory(Protocol):
86
+ """Protocol implemented by rule decorators.
87
+
88
+ Decorators return lightweight factory objects instead of immediately
89
+ constructing :class:`RenderRule` instances. This indirection lets us bind
90
+ metadata once (at decoration time) while deferring handler resolution until
91
+ the registry collects rules. The factory pattern keeps the decorator API
92
+ ergonomic, avoids premature instantiation, and allows the same definition
93
+ to be rebound for different callables (e.g. class/static methods) without
94
+ duplicating registration logic.
95
+ """
96
+
97
+ def bind(self, handler: RuleCallable) -> RenderRule:
98
+ """Create a concrete render rule for the decorated handler."""
99
+ ...
100
+
101
+
102
+ DOCUMENT_NODE = "__document__"
103
+
104
+
105
+ @dataclass
106
+ class RenderRule:
107
+ """Concrete rendering rule registered in the engine."""
108
+
109
+ priority: int
110
+ phase: RenderPhase
111
+ tags: tuple[str, ...]
112
+ name: str
113
+ handler: RuleCallable
114
+ auto_mark: bool = True
115
+ nestable: bool = True
116
+ after_children: bool = False
117
+ before: tuple[str, ...] = ()
118
+ after: tuple[str, ...] = ()
119
+
120
+ def applies_to_document(self) -> bool:
121
+ """Return True when the rule targets the synthetic document node."""
122
+ return self.tags == (DOCUMENT_NODE,)
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class RuleDefinition:
127
+ """Descriptor installed on handler callables by the decorator."""
128
+
129
+ phase: RenderPhase
130
+ tags: tuple[str, ...]
131
+ priority: int = 0
132
+ name: str | None = None
133
+ auto_mark: bool = True
134
+ nestable: bool = True
135
+ after_children: bool = False
136
+ before: tuple[str, ...] = ()
137
+ after: tuple[str, ...] = ()
138
+
139
+ def bind(self, handler: RuleCallable) -> RenderRule:
140
+ """Create a concrete rule instance bound to the callable."""
141
+ name = self.name or getattr(handler, "__name__", handler.__class__.__name__)
142
+ return RenderRule(
143
+ phase=self.phase,
144
+ tags=self.tags,
145
+ priority=self.priority,
146
+ name=name,
147
+ handler=handler,
148
+ auto_mark=self.auto_mark,
149
+ nestable=self.nestable,
150
+ after_children=self.after_children,
151
+ before=self.before,
152
+ after=self.after,
153
+ )
154
+
155
+
156
+ class RenderRegistry:
157
+ """Container used to gather render rules before execution."""
158
+
159
+ def __init__(self) -> None:
160
+ self._rules: dict[RenderPhase, dict[str, list[RenderRule]]] = {}
161
+ self._rule_sources: dict[int, str] = {}
162
+
163
+ def register(self, rule: RenderRule) -> None:
164
+ """Register a rule for later execution."""
165
+ phase_bucket = self._rules.setdefault(rule.phase, {})
166
+ self._rule_sources.setdefault(id(rule), rule.name)
167
+ if rule.applies_to_document():
168
+ tag_bucket = phase_bucket.setdefault(DOCUMENT_NODE, [])
169
+ tag_bucket.append(rule)
170
+ tag_bucket[:] = self._sort_rules(tag_bucket)
171
+ return
172
+
173
+ for tag in rule.tags:
174
+ tag_bucket = phase_bucket.setdefault(tag, [])
175
+ tag_bucket.append(rule)
176
+ tag_bucket[:] = self._sort_rules(tag_bucket)
177
+
178
+ def iter_phase(self, phase: RenderPhase) -> Iterable[RenderRule]:
179
+ """Iterate over rules for the provided phase."""
180
+ buckets = self._rules.get(phase, {})
181
+ for tag_rules in buckets.values():
182
+ yield from tag_rules
183
+
184
+ def rules_for_phase(self, phase: RenderPhase) -> dict[str, tuple[RenderRule, ...]]:
185
+ """Return the rule mapping for the requested phase."""
186
+ phase_bucket = self._rules.get(phase, {})
187
+ return {tag: tuple(rules) for tag, rules in phase_bucket.items()}
188
+
189
+ def describe(self) -> list[dict[str, object]]:
190
+ """Return a serialisable snapshot of the registered rules."""
191
+ entries: list[dict[str, object]] = []
192
+ for phase in RenderPhase:
193
+ for tag, rules in sorted(self.rules_for_phase(phase).items(), key=lambda item: item[0]):
194
+ for order, rule in enumerate(rules):
195
+ entries.append(
196
+ {
197
+ "phase": phase.name,
198
+ "tag": tag,
199
+ "name": rule.name,
200
+ "priority": rule.priority,
201
+ "before": list(rule.before),
202
+ "after": list(rule.after),
203
+ "order": order,
204
+ }
205
+ )
206
+ return entries
207
+
208
+ def _sort_rules(self, rules: list[RenderRule]) -> list[RenderRule]:
209
+ """Return rules ordered deterministically using before/after constraints."""
210
+ if len(rules) <= 1:
211
+ return list(rules)
212
+
213
+ name_to_index: dict[str, int] = {}
214
+ for index, rule in enumerate(rules):
215
+ name_to_index.setdefault(rule.name, index)
216
+
217
+ adjacency: dict[int, set[int]] = {index: set() for index in range(len(rules))}
218
+ indegree: dict[int, int] = dict.fromkeys(range(len(rules)), 0)
219
+
220
+ def _add_edge(source: int, target: int) -> None:
221
+ if target in adjacency[source]:
222
+ return
223
+ adjacency[source].add(target)
224
+ indegree[target] += 1
225
+
226
+ for current_index, rule in enumerate(rules):
227
+ for target_name in rule.before:
228
+ target_index = name_to_index.get(target_name)
229
+ if target_index is not None:
230
+ _add_edge(current_index, target_index)
231
+ for target_name in rule.after:
232
+ target_index = name_to_index.get(target_name)
233
+ if target_index is not None:
234
+ _add_edge(target_index, current_index)
235
+
236
+ queue: deque[int] = deque(
237
+ sorted(
238
+ (index for index, count in indegree.items() if count == 0),
239
+ key=lambda idx: (rules[idx].priority, rules[idx].name, idx),
240
+ )
241
+ )
242
+ ordered: list[int] = []
243
+
244
+ while queue:
245
+ current = queue.popleft()
246
+ ordered.append(current)
247
+ for neighbour in sorted(
248
+ adjacency[current], key=lambda idx: (rules[idx].priority, rules[idx].name, idx)
249
+ ):
250
+ indegree[neighbour] -= 1
251
+ if indegree[neighbour] == 0:
252
+ queue.append(neighbour)
253
+
254
+ queue = deque(
255
+ sorted(queue, key=lambda idx: (rules[idx].priority, rules[idx].name, idx))
256
+ )
257
+
258
+ if len(ordered) != len(rules): # pragma: no cover - defensive
259
+ cycle_names = sorted(
260
+ rule.name for index, rule in enumerate(rules) if index not in ordered
261
+ )
262
+ raise RuntimeError(
263
+ "Cyclic render rule dependencies detected: " + ", ".join(cycle_names)
264
+ )
265
+
266
+ return [rules[index] for index in ordered]
267
+
268
+
269
+ def renders(
270
+ *tags: str,
271
+ phase: RenderPhase = RenderPhase.BLOCK,
272
+ priority: int = 0,
273
+ name: str | None = None,
274
+ auto_mark: bool = True,
275
+ nestable: bool = True,
276
+ after_children: bool = False,
277
+ before: Iterable[str] = (),
278
+ after: Iterable[str] = (),
279
+ ) -> Callable[[RuleCallable], RuleCallable]:
280
+ """Decorator used to register element handlers."""
281
+ selected_tags = tags or (DOCUMENT_NODE,)
282
+ definition = RuleDefinition(
283
+ phase=phase,
284
+ tags=tuple(selected_tags),
285
+ priority=priority,
286
+ name=name,
287
+ auto_mark=auto_mark,
288
+ nestable=nestable,
289
+ after_children=after_children,
290
+ before=tuple(before),
291
+ after=tuple(after),
292
+ )
293
+
294
+ def decorator(handler: RuleCallable) -> RuleCallable:
295
+ cast(Any, handler).__render_rule__ = definition
296
+ return handler
297
+
298
+ return decorator
299
+
300
+
301
+ class RenderEngine:
302
+ """Execution engine that orchestrates the registered rules."""
303
+
304
+ def __init__(self, registry: RenderRegistry | None = None) -> None:
305
+ self.registry = registry or RenderRegistry()
306
+
307
+ def collect_from(self, owner: Any) -> None:
308
+ """Collect decorated callables from an object or module."""
309
+ for attribute in dir(owner):
310
+ handler = getattr(owner, attribute)
311
+ definition = getattr(handler, "__render_rule__", None)
312
+ if definition is None and hasattr(handler, "__func__"):
313
+ definition = getattr(handler.__func__, "__render_rule__", None)
314
+ if isinstance(definition, RuleDefinition):
315
+ self.registry.register(definition.bind(handler))
316
+
317
+ def register(self, handler: RuleCallable) -> None:
318
+ """Register a standalone callable decorated with ``@renders``."""
319
+ definition = getattr(handler, "__render_rule__", None)
320
+ if not isinstance(definition, RuleDefinition):
321
+ msg = "Handler must be decorated with @renders"
322
+ raise TypeError(msg)
323
+ self.registry.register(definition.bind(handler))
324
+
325
+ def run(self, root: Tag, context: RenderContext) -> None:
326
+ """Execute all registered rules against the provided DOM root."""
327
+ for phase in RenderPhase:
328
+ context.enter_phase(phase)
329
+ phase_rules = self.registry.rules_for_phase(phase)
330
+ document_rules = phase_rules.get(DOCUMENT_NODE, ())
331
+
332
+ for rule in document_rules:
333
+ self._execute_rule(rule, root, context)
334
+
335
+ visitor = _DOMVisitor(phase, phase_rules, context)
336
+ visitor.walk(root)
337
+
338
+ def _execute_rule(self, rule: RenderRule, node: Any, context: RenderContext) -> None:
339
+ """Execute a rule against a specific node applying bookkeeping."""
340
+ if rule.auto_mark and context.is_processed(node):
341
+ return
342
+ rule.handler(node, context)
343
+ if rule.auto_mark:
344
+ context.mark_processed(node)
345
+ if not rule.nestable:
346
+ context.suppress_children(node)
347
+
348
+
349
+ class _DOMVisitor:
350
+ """Depth-first visitor applying rules by tag while traversing the DOM tree."""
351
+
352
+ def __init__(
353
+ self,
354
+ phase: RenderPhase,
355
+ rules_by_tag: dict[str, tuple[RenderRule, ...]],
356
+ context: RenderContext,
357
+ ) -> None:
358
+ self.phase = phase
359
+ self.rules_by_tag = rules_by_tag
360
+ self.context = context
361
+
362
+ def walk(self, node: Tag) -> None:
363
+ """Traverse descendants depth-first and apply matching rules."""
364
+ self._dispatch(node, after_children=False)
365
+ if self.context.should_skip_children(node, phase=self.phase):
366
+ return
367
+
368
+ # Copy the children list to avoid concurrent modification issues
369
+ for child in list(getattr(node, "children", ())):
370
+ # Only Tags should be traversed recursively
371
+ if getattr(child, "name", None):
372
+ self.walk(child)
373
+
374
+ self._dispatch(node, after_children=True)
375
+
376
+ def _dispatch(self, node: Tag, *, after_children: bool) -> None:
377
+ """Dispatch node to registered rules for its tag name."""
378
+ tag_name = getattr(node, "name", None)
379
+ if not tag_name:
380
+ return
381
+
382
+ for rule in self.rules_by_tag.get(tag_name, ()):
383
+ if rule.after_children != after_children:
384
+ continue
385
+ if rule.tags == (DOCUMENT_NODE,):
386
+ # Document handlers already executed
387
+ continue
388
+ if rule.auto_mark and self.context.is_processed(node):
389
+ continue
390
+ rule.handler(node, self.context)
391
+ if rule.auto_mark:
392
+ self.context.mark_processed(node)
393
+ if not rule.nestable:
394
+ self.context.suppress_children(node)
@@ -0,0 +1,58 @@
1
+ """Public template helpers shared across the conversion pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import BaseTemplate, ResolvedAsset, WrappableTemplate
6
+ from .loader import copy_template_assets, discover_templates, load_template
7
+ from .manifest import (
8
+ DEFAULT_TEMPLATE_LANGUAGE,
9
+ LATEX_HEADING_LEVELS,
10
+ TemplateAttributeSpec,
11
+ TemplateAsset,
12
+ TemplateError,
13
+ TemplateInfo,
14
+ TemplateManifest,
15
+ TemplateSlot,
16
+ )
17
+ from .runtime import (
18
+ TemplateBinding,
19
+ TemplateRuntime,
20
+ build_template_overrides,
21
+ coerce_base_level,
22
+ extract_base_level_override,
23
+ extract_language_from_front_matter,
24
+ load_template_runtime,
25
+ normalise_template_language,
26
+ resolve_template_binding,
27
+ resolve_template_language,
28
+ )
29
+ from .wrapper import TemplateWrapResult, wrap_template_document
30
+
31
+ __all__ = [
32
+ "BaseTemplate",
33
+ "DEFAULT_TEMPLATE_LANGUAGE",
34
+ "LATEX_HEADING_LEVELS",
35
+ "ResolvedAsset",
36
+ "TemplateAttributeSpec",
37
+ "TemplateAsset",
38
+ "TemplateBinding",
39
+ "TemplateError",
40
+ "TemplateInfo",
41
+ "TemplateManifest",
42
+ "TemplateRuntime",
43
+ "TemplateSlot",
44
+ "TemplateWrapResult",
45
+ "WrappableTemplate",
46
+ "build_template_overrides",
47
+ "coerce_base_level",
48
+ "copy_template_assets",
49
+ "discover_templates",
50
+ "extract_base_level_override",
51
+ "extract_language_from_front_matter",
52
+ "load_template",
53
+ "load_template_runtime",
54
+ "normalise_template_language",
55
+ "wrap_template_document",
56
+ "resolve_template_binding",
57
+ "resolve_template_language",
58
+ ]