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,267 @@
1
+ """Handlers for generic admonition and callout markup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ import re
7
+
8
+ from bs4.element import NavigableString, Tag
9
+
10
+ from texsmith.core.callouts import DEFAULT_CALLOUTS
11
+ from texsmith.core.context import RenderContext
12
+ from texsmith.core.diagnostics import DiagnosticEmitter
13
+ from texsmith.core.rules import RenderPhase, renders
14
+
15
+ from ._helpers import gather_classes, mark_processed
16
+ from .code import (
17
+ render_code_blocks as _render_code_block,
18
+ render_preformatted_code as _render_preformatted_code,
19
+ render_standalone_code_blocks as _render_standalone_code_block,
20
+ )
21
+ from .inline import render_inline_code as _render_inline_code
22
+
23
+
24
+ IGNORED_CLASSES = {
25
+ "admonition",
26
+ "annotate",
27
+ "inline",
28
+ "end",
29
+ "left",
30
+ "right",
31
+ "checkbox",
32
+ }
33
+
34
+
35
+ def _trim_paragraph_prefix(paragraph: Tag, length: int) -> None:
36
+ """Remove the first ``length`` characters from a paragraph in-place."""
37
+ remaining = length
38
+ for node in list(paragraph.descendants):
39
+ if not isinstance(node, NavigableString):
40
+ continue
41
+ text = str(node)
42
+ if remaining >= len(text):
43
+ remaining -= len(text)
44
+ node.extract()
45
+ if remaining == 0:
46
+ break
47
+ continue
48
+ node.replace_with(NavigableString(text[remaining:]))
49
+ remaining = 0
50
+ break
51
+
52
+
53
+ @contextmanager
54
+ def _use_tcolorbox_figures(context: RenderContext):
55
+ """Render nested figures inside callouts with the tcolorbox template."""
56
+ previous = context.runtime.get("figure_template")
57
+ context.runtime["figure_template"] = "figure_tcolorbox"
58
+ try:
59
+ yield
60
+ finally:
61
+ if previous is None:
62
+ context.runtime.pop("figure_template", None)
63
+ else:
64
+ context.runtime["figure_template"] = previous
65
+
66
+
67
+ def _extract_title(node: Tag | None) -> str:
68
+ """Extract and remove the title paragraph produced by MkDocs Material."""
69
+ if node is None:
70
+ return ""
71
+ if label := node.find("span", class_="exercise-label"):
72
+ label.decompose()
73
+ title = node.get_text(strip=True)
74
+ node.decompose()
75
+ return title
76
+
77
+
78
+ CALLOUT_ALIASES = {"seealso": "info"}
79
+ DEFAULT_CALLOUT_SET = set(DEFAULT_CALLOUTS)
80
+
81
+
82
+ def _runtime_emitter(context: RenderContext) -> DiagnosticEmitter | None:
83
+ emitter = context.runtime.get("emitter")
84
+ return emitter if getattr(emitter, "warning", None) else None
85
+
86
+
87
+ def _prepare_callout_content(element: Tag, context: RenderContext) -> None:
88
+ """Normalise inline and block code inside callouts before flattening."""
89
+ for highlight in list(element.find_all("div")):
90
+ highlight_classes = gather_classes(highlight.get("class"))
91
+ if "highlight" in highlight_classes or "codehilite" in highlight_classes:
92
+ _render_code_block(highlight, context)
93
+
94
+ for pre in list(element.find_all("pre")):
95
+ _render_preformatted_code(pre, context)
96
+
97
+ for code_block in list(element.find_all("code")):
98
+ _render_standalone_code_block(code_block, context)
99
+
100
+ for code in list(element.find_all("code")):
101
+ if code.find_parent("pre"):
102
+ continue
103
+ _render_inline_code(code, context)
104
+
105
+
106
+ def _promote_callout(
107
+ element: Tag, context: RenderContext, *, classes: list[str], title: str
108
+ ) -> None:
109
+ """Re-tag a node so it can be converted after its children render."""
110
+ element["data-callout-title"] = title
111
+ element["class"] = classes
112
+ element.name = "texsmith-callout"
113
+ _prepare_callout_content(element, context)
114
+ element.attrs["data-callout-prepared"] = True
115
+ context.mark_processed(element, phase=RenderPhase.POST)
116
+
117
+
118
+ def _render_admonition(
119
+ element: Tag, context: RenderContext, *, classes: list[str], title: str
120
+ ) -> None:
121
+ """Convert a generic admonition container into a LaTeX callout."""
122
+ admonition_classes = [cls for cls in classes if cls not in IGNORED_CLASSES]
123
+ admonition_type = admonition_classes[0] if admonition_classes else "note"
124
+ admonition_type = CALLOUT_ALIASES.get(admonition_type, admonition_type)
125
+ callouts_definitions = context.runtime.get("callouts_definitions") or DEFAULT_CALLOUTS
126
+ if admonition_type not in callouts_definitions:
127
+ emitter = _runtime_emitter(context)
128
+ if emitter:
129
+ emitter.warning(f"Unknown callout/admonition type '{admonition_type}', using default.")
130
+ admonition_type = "default"
131
+
132
+ if not element.attrs.pop("data-callout-prepared", False):
133
+ _prepare_callout_content(element, context)
134
+
135
+ with _use_tcolorbox_figures(context):
136
+ content = element.get_text(strip=False).strip()
137
+
138
+ context.state.callouts_used = True
139
+ latex = context.formatter.callout(content, title=title, type=admonition_type)
140
+ parent = element.parent
141
+ replacement = mark_processed(NavigableString(latex))
142
+ if parent is None:
143
+ element.decompose()
144
+ context.document.append(replacement)
145
+ else:
146
+ element.replace_with(replacement)
147
+
148
+
149
+ @renders(
150
+ "div",
151
+ phase=RenderPhase.POST,
152
+ priority=50,
153
+ name="admonitions",
154
+ nestable=True,
155
+ auto_mark=False,
156
+ )
157
+ def render_div_admonitions(element: Tag, context: RenderContext) -> None:
158
+ """Handle MkDocs Material admonition blocks."""
159
+ classes = gather_classes(element.get("class"))
160
+ if "admonition" not in classes:
161
+ return
162
+ if "exercise" in classes:
163
+ return
164
+
165
+ title = _extract_title(element.find("p", class_="admonition-title"))
166
+ _promote_callout(element, context, classes=classes, title=title)
167
+
168
+
169
+ @renders(
170
+ "blockquote",
171
+ phase=RenderPhase.POST,
172
+ priority=15,
173
+ name="blockquote_callouts",
174
+ nestable=True,
175
+ auto_mark=False,
176
+ )
177
+ def render_blockquote_callouts(element: Tag, context: RenderContext) -> None:
178
+ """Handle Obsidian/Docusaurus style blockquote callouts."""
179
+ first_paragraph = element.find("p")
180
+ if first_paragraph is None:
181
+ return
182
+
183
+ first_text_node = next(
184
+ (child for child in first_paragraph.contents if isinstance(child, NavigableString)),
185
+ None,
186
+ )
187
+ if first_text_node is None:
188
+ return
189
+
190
+ raw_text = str(first_text_node)
191
+ stripped = raw_text.lstrip()
192
+ offset = len(raw_text) - len(stripped)
193
+ match = CALLOUT_PATTERN.match(stripped)
194
+ if not match:
195
+ return
196
+
197
+ callout_type = match.group("kind").lower()
198
+ remainder = match.group("content") or ""
199
+ remainder = remainder.lstrip()
200
+
201
+ first_text_node.replace_with(NavigableString(raw_text[:offset] + remainder))
202
+
203
+ lines_with_endings = remainder.splitlines(keepends=True)
204
+ lines = [line.strip() for line in remainder.splitlines() if line.strip()]
205
+ title = lines[0] if lines else callout_type.capitalize()
206
+
207
+ if len(lines_with_endings) > 1:
208
+ prefix_length = len(raw_text[:offset]) + len(lines_with_endings[0])
209
+ _trim_paragraph_prefix(first_paragraph, prefix_length)
210
+ first_text = next(
211
+ (child for child in first_paragraph.contents if isinstance(child, NavigableString)),
212
+ None,
213
+ )
214
+ if first_text is not None:
215
+ first_text.replace_with(NavigableString(str(first_text).lstrip()))
216
+ else:
217
+ first_paragraph.decompose()
218
+
219
+ classes = gather_classes(element.get("class"))
220
+ preserved = [cls for cls in classes if cls != "admonition"]
221
+ new_classes = ["admonition", callout_type]
222
+ for cls in preserved:
223
+ if cls not in new_classes:
224
+ new_classes.append(cls)
225
+ element["class"] = new_classes
226
+ _promote_callout(element, context, classes=new_classes, title=title)
227
+
228
+
229
+ @renders(
230
+ "details",
231
+ phase=RenderPhase.POST,
232
+ priority=55,
233
+ name="details_admonitions",
234
+ nestable=True,
235
+ auto_mark=False,
236
+ )
237
+ def render_details_admonitions(element: Tag, context: RenderContext) -> None:
238
+ """Handle collapsible callouts converted from MkDocs Material's details blocks."""
239
+ classes = gather_classes(element.get("class"))
240
+ if "exercise" in classes:
241
+ return
242
+
243
+ title = ""
244
+ if summary := element.find("summary"):
245
+ title = summary.get_text(strip=True)
246
+ summary.decompose()
247
+
248
+ _promote_callout(element, context, classes=classes, title=title or "")
249
+
250
+
251
+ @renders(
252
+ "texsmith-callout",
253
+ phase=RenderPhase.POST,
254
+ priority=140,
255
+ name="finalize_callouts",
256
+ nestable=False,
257
+ auto_mark=False,
258
+ after_children=True,
259
+ )
260
+ def render_texsmith_callouts(element: Tag, context: RenderContext) -> None:
261
+ """Convert promoted callout nodes once their children have rendered."""
262
+ classes = gather_classes(element.get("class"))
263
+ title = element.attrs.pop("data-callout-title", "")
264
+ _render_admonition(element, context, classes=classes, title=title)
265
+
266
+
267
+ CALLOUT_PATTERN = re.compile(r"^\s*\[!(?P<kind>[A-Za-z0-9_-]+)\]\s*(?P<content>.*)$", re.DOTALL)
@@ -0,0 +1,228 @@
1
+ """Built-in baseline handlers used by the renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from bs4.element import NavigableString, Tag
6
+ from slugify import slugify
7
+
8
+ from texsmith.core.context import RenderContext
9
+ from texsmith.core.rules import RenderPhase, renders
10
+ from texsmith.fonts.scripts import render_moving_text
11
+
12
+ from ._helpers import coerce_attribute, gather_classes, mark_processed
13
+
14
+
15
+ UNWANTED_NODES: tuple[tuple[str, tuple[str, ...], str], ...] = (
16
+ ("html", (), "unwrap"),
17
+ ("head", (), "decompose"),
18
+ ("body", (), "unwrap"),
19
+ ("a", ("headerlink", "footnote-backref"), "extract"),
20
+ ("a", ("glightbox",), "unwrap"),
21
+ ("div", ("latex-ignore",), "extract"),
22
+ ("span", ("exercise-title",), "unwrap"),
23
+ ("div", ("exercise-checkbox",), "extract"),
24
+ ("figure", ("mermaid-figure",), "extract"),
25
+ )
26
+
27
+
28
+ def _merge_strip_rules(
29
+ context: RenderContext,
30
+ ) -> list[tuple[str, tuple[str, ...], str]]:
31
+ """Merge default strip rules with runtime overrides."""
32
+ rules: dict[tuple[str, tuple[str, ...]], str] = {
33
+ (tag, classes): mode for tag, classes, mode in UNWANTED_NODES
34
+ }
35
+
36
+ runtime_rules = context.runtime.get("strip_tags") or {}
37
+ if isinstance(runtime_rules, dict):
38
+ for tag_name, payload in runtime_rules.items():
39
+ if isinstance(payload, str):
40
+ mode = payload
41
+ classes: tuple[str, ...] = ()
42
+ elif isinstance(payload, dict):
43
+ mode = payload.get("mode", "unwrap")
44
+ classes_value = payload.get("classes", ())
45
+ if isinstance(classes_value, str):
46
+ classes = (classes_value,)
47
+ else:
48
+ classes = tuple(classes_value)
49
+ else:
50
+ continue
51
+ if mode not in {"unwrap", "extract", "decompose"}:
52
+ continue
53
+ rules[(tag_name, classes)] = mode
54
+
55
+ merged = [(tag, classes, rules[(tag, classes)]) for tag, classes in rules]
56
+ return merged
57
+
58
+
59
+ @renders(phase=RenderPhase.PRE, auto_mark=False, name="discard_unwanted")
60
+ def discard_unwanted(root: Tag, context: RenderContext) -> None:
61
+ """Discard or unwrap nodes that must not reach later phases."""
62
+ for tag_name, classes, mode in _merge_strip_rules(context):
63
+ class_filter = list(classes)
64
+ candidates = (
65
+ root.find_all(tag_name, class_=class_filter)
66
+ if class_filter
67
+ else root.find_all(tag_name)
68
+ )
69
+ for node in candidates:
70
+ if mode == "unwrap":
71
+ node.unwrap()
72
+ elif mode == "extract":
73
+ node.extract()
74
+ elif mode == "decompose":
75
+ node.decompose()
76
+
77
+
78
+ @renders("hr", phase=RenderPhase.BLOCK, name="render_horizontal_rule")
79
+ def render_horizontal_rule(element: Tag, context: RenderContext) -> None:
80
+ """Render ``<hr>`` nodes as LaTeX horizontal rules."""
81
+ latex = context.formatter.horizontal_rule()
82
+ element.replace_with(mark_processed(NavigableString(latex)))
83
+
84
+
85
+ @renders("br", phase=RenderPhase.INLINE, name="line_breaks")
86
+ def replace_line_breaks(element: Tag, _context: RenderContext) -> None:
87
+ """Convert ``<br>`` tags into explicit LaTeX line breaks."""
88
+ element.replace_with(mark_processed(NavigableString("\\")))
89
+
90
+
91
+ @renders("ins", phase=RenderPhase.INLINE, name="inline_underline", after_children=True)
92
+ def render_inline_underline(element: Tag, context: RenderContext) -> None:
93
+ """Render ``<ins>`` tags using the formatter."""
94
+ text = element.get_text(strip=False)
95
+ latex = context.formatter.underline(text=text)
96
+ element.replace_with(NavigableString(latex))
97
+
98
+
99
+ @renders("span", phase=RenderPhase.INLINE, name="inline_smallcaps", auto_mark=False)
100
+ def render_inline_smallcaps(element: Tag, context: RenderContext) -> None:
101
+ """Render ``<span class=\"texsmith-smallcaps\">`` nodes as small capitals."""
102
+ classes = gather_classes(element.get("class"))
103
+ if "texsmith-smallcaps" not in classes:
104
+ return
105
+ text = element.get_text(strip=False)
106
+ latex = context.formatter.smallcaps(text=text)
107
+ element.replace_with(NavigableString(latex))
108
+ context.mark_processed(element)
109
+
110
+
111
+ @renders("strong", phase=RenderPhase.INLINE, name="inline_strong", after_children=True)
112
+ def render_inline_strong(element: Tag, context: RenderContext) -> None:
113
+ """Render ``<strong>`` tags using bold template."""
114
+ text = element.get_text(strip=False)
115
+ latex = context.formatter.strong(text=text)
116
+ element.replace_with(NavigableString(latex))
117
+
118
+
119
+ @renders("em", phase=RenderPhase.INLINE, name="inline_emphasis", after_children=True)
120
+ def render_inline_emphasis(element: Tag, context: RenderContext) -> None:
121
+ """Render ``<em>`` tags using emphasis template."""
122
+ text = element.get_text(strip=False)
123
+ latex = context.formatter.italic(text=text)
124
+ element.replace_with(NavigableString(latex))
125
+
126
+
127
+ @renders("del", phase=RenderPhase.INLINE, name="inline_deletion", after_children=True)
128
+ def render_inline_deletion(element: Tag, context: RenderContext) -> None:
129
+ """Render ``<del>`` tags using strikethrough template."""
130
+ text = element.get_text(strip=False)
131
+ latex = context.formatter.strikethrough(text=text)
132
+ element.replace_with(NavigableString(latex))
133
+
134
+
135
+ @renders("mark", phase=RenderPhase.INLINE, name="inline_mark", after_children=True)
136
+ def render_inline_mark(element: Tag, context: RenderContext) -> None:
137
+ """Render ``<mark>`` tags using highlight template."""
138
+ text = element.get_text(strip=False)
139
+ latex = context.formatter.highlight(text=text)
140
+ element.replace_with(NavigableString(latex))
141
+
142
+
143
+ @renders("sub", phase=RenderPhase.INLINE, name="inline_subscript", after_children=True)
144
+ def render_inline_subscript(element: Tag, context: RenderContext) -> None:
145
+ """Render ``<sub>`` tags."""
146
+ text = element.get_text(strip=False)
147
+ latex = context.formatter.subscript(text=text)
148
+ element.replace_with(NavigableString(latex))
149
+
150
+
151
+ @renders("sup", phase=RenderPhase.INLINE, name="inline_superscript", after_children=True)
152
+ def render_inline_superscript(element: Tag, context: RenderContext) -> None:
153
+ """Render ``<sup>`` tags, skipping footnote references."""
154
+ if element.get("id"):
155
+ return
156
+
157
+ text = element.get_text(strip=False)
158
+ latex = context.formatter.superscript(text=text)
159
+ element.replace_with(NavigableString(latex))
160
+
161
+
162
+ @renders("q", phase=RenderPhase.INLINE, name="inline_quote", after_children=True)
163
+ def render_inline_quote(element: Tag, context: RenderContext) -> None:
164
+ """Render inline quotations using \\enquote{}."""
165
+ text = element.get_text(strip=False)
166
+ latex = context.formatter.enquote(text=text)
167
+ element.replace_with(NavigableString(latex))
168
+
169
+
170
+ @renders("div", phase=RenderPhase.BLOCK, name="grid_cards", auto_mark=False)
171
+ def unwrap_grid_cards(element: Tag, _context: RenderContext) -> None:
172
+ """Unwrap ``div.grid-cards`` containers."""
173
+ classes = element.get("class") or []
174
+ if "grid-cards" in classes:
175
+ element.unwrap()
176
+
177
+
178
+ @renders(
179
+ "h1",
180
+ "h2",
181
+ "h3",
182
+ "h4",
183
+ "h5",
184
+ "h6",
185
+ phase=RenderPhase.POST,
186
+ name="render_headings",
187
+ )
188
+ def render_headings(element: Tag, context: RenderContext) -> None:
189
+ """Convert HTML headings to LaTeX sectioning commands."""
190
+ # Drop anchor tags within headings
191
+ for anchor in element.find_all("a"):
192
+ anchor.unwrap()
193
+
194
+ drop_title = context.runtime.get("drop_title")
195
+ if drop_title:
196
+ context.runtime["drop_title"] = False
197
+ latex = context.formatter.pagestyle(text="plain")
198
+ element.replace_with(mark_processed(NavigableString(latex)))
199
+ return
200
+
201
+ raw_text = element.get_text(strip=False)
202
+ text = render_moving_text(
203
+ raw_text,
204
+ context,
205
+ legacy_accents=getattr(context.config, "legacy_latex_accents", False),
206
+ escape="\\" not in raw_text,
207
+ wrap_scripts=True,
208
+ )
209
+ plain_text = element.get_text(strip=True)
210
+ level = int(element.name[1:])
211
+ base_level = context.runtime.get("base_level", 0)
212
+ rendered_level = level + base_level - 1
213
+ ref = coerce_attribute(element.get("id"))
214
+ if not ref:
215
+ slug = slugify(plain_text, separator="-")
216
+ ref = slug or None
217
+ numbered = context.runtime.get("numbered", True)
218
+
219
+ latex = context.formatter.heading(
220
+ text=text,
221
+ level=rendered_level,
222
+ ref=ref,
223
+ numbered=numbered,
224
+ )
225
+
226
+ element.replace_with(mark_processed(NavigableString(latex)))
227
+
228
+ context.state.add_heading(level=rendered_level, text=plain_text, ref=ref)