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,851 @@
1
+ """Block-level handlers for structural HTML elements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ import io
7
+ from pathlib import Path
8
+ import re
9
+ from typing import TYPE_CHECKING, Any
10
+ import warnings
11
+
12
+ from bs4.element import NavigableString, Tag
13
+ from pybtex.database.input import bibtex
14
+ from pybtex.exceptions import PybtexError
15
+
16
+ from texsmith.adapters.latex.utils import escape_latex_chars
17
+ from texsmith.core.context import RenderContext
18
+ from texsmith.core.exceptions import AssetMissingError, InvalidNodeError
19
+ from texsmith.core.rules import RenderPhase, renders
20
+ from texsmith.fonts.scripts import record_script_usage_for_slug, render_moving_text
21
+
22
+
23
+ if TYPE_CHECKING: # pragma: no cover - typing helpers
24
+ from texsmith.core.bibliography.collection import BibliographyCollection
25
+
26
+ from ._assets import store_local_image_asset, store_remote_image_asset
27
+ from ._helpers import (
28
+ coerce_attribute,
29
+ gather_classes,
30
+ is_valid_url,
31
+ mark_processed,
32
+ resolve_asset_path,
33
+ )
34
+ from .code import (
35
+ render_code_blocks as _render_code_block,
36
+ render_preformatted_code as _render_preformatted_code,
37
+ render_standalone_code_blocks as _render_standalone_code_block,
38
+ )
39
+ from .inline import _MATH_PAYLOAD_PATTERN, render_inline_code as _render_inline_code
40
+
41
+
42
+ def _prepare_rich_text_content(container: Tag, context: RenderContext) -> None:
43
+ """Ensure inline and block code inside containers render before flattening."""
44
+ for highlight in list(container.find_all("div")):
45
+ classes = gather_classes(highlight.get("class"))
46
+ if "highlight" in classes or "codehilite" in classes:
47
+ _render_code_block(highlight, context)
48
+
49
+ for pre in list(container.find_all("pre")):
50
+ _render_preformatted_code(pre, context)
51
+
52
+ for code_element in list(container.find_all("code")):
53
+ _render_standalone_code_block(code_element, context)
54
+
55
+ for inline in list(container.find_all("code")):
56
+ if inline.find_parent("pre"):
57
+ continue
58
+ _render_inline_code(inline, context)
59
+
60
+
61
+ def _iter_reversed(nodes: Iterable[Tag]) -> Iterable[Tag]:
62
+ stack = list(nodes)
63
+ while stack:
64
+ yield stack.pop()
65
+
66
+
67
+ def _resolve_source_path(context: RenderContext, src: str) -> Path | None:
68
+ runtime_dir = context.runtime.get("source_dir")
69
+ if runtime_dir is not None:
70
+ candidate = Path(runtime_dir) / src
71
+ if candidate.exists():
72
+ return candidate.resolve()
73
+
74
+ document_path = context.runtime.get("document_path")
75
+ if document_path is not None:
76
+ resolved = resolve_asset_path(Path(document_path), src)
77
+ if resolved is not None:
78
+ return resolved
79
+
80
+ project_dir = getattr(context.config, "project_dir", None)
81
+ if project_dir:
82
+ candidate = Path(project_dir) / src
83
+ if candidate.exists():
84
+ return candidate.resolve()
85
+
86
+ return None
87
+
88
+
89
+ def _figure_template_for(element: Tag, context: RenderContext) -> str:
90
+ current = element
91
+ while current is not None:
92
+ raw_classes = getattr(current, "get", lambda *_: None)("class")
93
+ class_list = gather_classes(raw_classes)
94
+ if any(cls in {"admonition", "exercise"} for cls in class_list):
95
+ return "figure_tcolorbox"
96
+ if getattr(current, "name", None) == "details":
97
+ return "figure_tcolorbox"
98
+ current = getattr(current, "parent", None)
99
+ return context.runtime.get("figure_template", "figure")
100
+
101
+
102
+ def _strip_caption_prefix(node: Tag | None) -> None:
103
+ if node is None:
104
+ return
105
+
106
+ for span in list(node.find_all("span")):
107
+ classes = gather_classes(span.get("class"))
108
+ if "caption-prefix" in classes or "figure-prefix" in classes:
109
+ span.decompose()
110
+
111
+
112
+ def _render_script_paragraphs(element: Tag, context: RenderContext) -> bool:
113
+ """Render consecutive data-script paragraphs into grouped environments."""
114
+ slug = coerce_attribute(element.get("data-script"))
115
+ if not slug:
116
+ return False
117
+
118
+ paragraphs: list[Tag] = []
119
+ cursor: Tag | None = element
120
+ while cursor is not None and isinstance(cursor, Tag):
121
+ if cursor.name != "p" or coerce_attribute(cursor.get("data-script")) != slug:
122
+ break
123
+ paragraphs.append(cursor)
124
+ cursor = cursor.find_next_sibling(lambda tag: isinstance(tag, Tag))
125
+
126
+ if not paragraphs:
127
+ return False
128
+
129
+ legacy_accents = getattr(context.config, "legacy_latex_accents", False)
130
+ bodies: list[str] = []
131
+ for para in paragraphs:
132
+ text = para.get_text(strip=False)
133
+ if not text.strip():
134
+ continue
135
+ bodies.append(escape_latex_chars(text, legacy_accents=legacy_accents))
136
+
137
+ plain_text = "\n\n".join(p.get_text(strip=False) for p in paragraphs)
138
+ record_script_usage_for_slug(slug, plain_text, context)
139
+
140
+ content = "\n\n".join(bodies)
141
+ latex = f"\\begin{{{slug}}}\n{content}\n\\end{{{slug}}}\n\n"
142
+ replacement = mark_processed(NavigableString(latex))
143
+ paragraphs[-1].insert_after(replacement)
144
+ for para in paragraphs:
145
+ para.decompose()
146
+ return True
147
+
148
+
149
+ def _split_citation_keys(identifier: str) -> list[str]:
150
+ """Split a comma-separated string into individual citation keys."""
151
+ if not identifier:
152
+ return []
153
+ if "," not in identifier:
154
+ return [identifier.strip()] if _is_doi_key(identifier) else []
155
+ return [part.strip() for part in identifier.split(",") if part.strip()]
156
+
157
+
158
+ def _is_multiline_footnote(text: str) -> bool:
159
+ """Check whether rendered footnote text spans multiple non-empty lines."""
160
+ lines = [line for line in text.splitlines() if line.strip()]
161
+ return len(lines) > 1
162
+
163
+
164
+ def _is_bibliography_placeholder(text: str) -> bool:
165
+ """Return True when a footnote just points readers to the bibliography."""
166
+ normalised = text.strip().rstrip(".").strip().lower()
167
+ return normalised in {"see bibliography", "see the bibliography"}
168
+
169
+
170
+ _DOI_KEY_PATTERN = r"10\.\d{4,9}/[^\s,\]]+"
171
+ _CITATION_KEY_PATTERN = rf"(?:{_DOI_KEY_PATTERN}|[A-Za-z0-9_\-:]+)"
172
+ _DOI_KEY_RE = re.compile(rf"^{_DOI_KEY_PATTERN}$")
173
+ _DEFAULT_DOI_SOURCE = Path("inline-doi-citations.bib")
174
+
175
+
176
+ def _is_doi_key(candidate: str) -> bool:
177
+ """Return True when a citation key matches a DOI shape."""
178
+ return bool(_DOI_KEY_RE.match(candidate.strip()))
179
+
180
+
181
+ def _emit_bibliography_warning(context: RenderContext, message: str) -> None:
182
+ emitter = context.runtime.get("emitter")
183
+ if emitter is not None:
184
+ emitter.warning(message)
185
+ return
186
+ warnings.warn(message, stacklevel=2)
187
+
188
+
189
+ def _inline_doi_source_path(context: RenderContext) -> Path:
190
+ """Return a synthetic source path for inline DOI citations."""
191
+ document_path = context.runtime.get("document_path")
192
+ if isinstance(document_path, Path):
193
+ return Path(f"inline-doi-{document_path.stem}.bib")
194
+ try:
195
+ return Path(f"inline-doi-{Path(str(document_path)).stem}.bib")
196
+ except Exception:
197
+ return _DEFAULT_DOI_SOURCE
198
+
199
+
200
+ def _ensure_bibliography_runtime(
201
+ context: RenderContext,
202
+ ) -> tuple[dict[str, dict[str, object]], BibliographyCollection]:
203
+ from texsmith.core.bibliography.collection import BibliographyCollection
204
+
205
+ runtime_bibliography = context.runtime.get("bibliography")
206
+ if not isinstance(runtime_bibliography, dict):
207
+ runtime_bibliography = {}
208
+ context.runtime["bibliography"] = runtime_bibliography
209
+
210
+ collection = context.runtime.get("bibliography_collection")
211
+ if not isinstance(collection, BibliographyCollection):
212
+ collection = BibliographyCollection()
213
+ context.runtime["bibliography_collection"] = collection
214
+
215
+ return runtime_bibliography, collection
216
+
217
+
218
+ def _resolve_doi_fetcher(context: RenderContext) -> Any:
219
+ from texsmith.core.bibliography.doi import DoiBibliographyFetcher
220
+
221
+ fetcher = context.runtime.get("doi_fetcher")
222
+ if fetcher is not None:
223
+ return fetcher
224
+
225
+ fetcher = DoiBibliographyFetcher()
226
+ context.runtime["doi_fetcher"] = fetcher
227
+ return fetcher
228
+
229
+
230
+ def _materialise_doi_entry(key: str, context: RenderContext) -> dict[str, object] | None:
231
+ """Fetch and register a bibliography entry for a DOI citation."""
232
+ from texsmith.core.bibliography.doi import DoiLookupError
233
+
234
+ bibliography = context.state.bibliography
235
+ runtime_bibliography, collection = _ensure_bibliography_runtime(context)
236
+
237
+ fetcher = _resolve_doi_fetcher(context)
238
+ fetch = getattr(fetcher, "fetch", None)
239
+ if not callable(fetch):
240
+ raise DoiLookupError("Configured DOI fetcher is missing a 'fetch' method.")
241
+
242
+ try:
243
+ payload = fetch(key)
244
+ except DoiLookupError as exc:
245
+ _emit_bibliography_warning(context, f"Failed to resolve DOI '{key}': {exc}")
246
+ return None
247
+ except Exception as exc: # pragma: no cover - defensive fallback
248
+ _emit_bibliography_warning(context, f"Failed to resolve DOI '{key}': {exc}")
249
+ return None
250
+
251
+ parser = bibtex.Parser()
252
+ try:
253
+ parsed = parser.parse_stream(io.StringIO(payload))
254
+ except (OSError, PybtexError) as exc:
255
+ _emit_bibliography_warning(context, f"Failed to parse bibliography entry '{key}': {exc}")
256
+ return None
257
+ if not parsed.entries:
258
+ _emit_bibliography_warning(context, f"Bibliography entry for DOI '{key}' is empty.")
259
+ return None
260
+ if len(parsed.entries) > 1:
261
+ _emit_bibliography_warning(
262
+ context,
263
+ f"Bibliography entry for DOI '{key}' contains multiple records; using the first.",
264
+ )
265
+ resolved_key, _entry_obj = next(iter(parsed.entries.items()))
266
+
267
+ source = _inline_doi_source_path(context)
268
+ collection.load_data(parsed, source=source)
269
+ entry = collection.find(resolved_key)
270
+ if entry is None:
271
+ return None
272
+
273
+ bibliography[resolved_key] = entry
274
+ runtime_bibliography[resolved_key] = entry
275
+ doi_map: dict[str, str] = context.runtime.setdefault("doi_citation_keys", {})
276
+ doi_map[key] = resolved_key
277
+ return entry
278
+
279
+
280
+ def _ensure_doi_entries(keys: list[str], context: RenderContext) -> None:
281
+ """Materialise bibliography entries for any DOI keys not yet loaded."""
282
+ doi_map: dict[str, str] = context.runtime.setdefault("doi_citation_keys", {})
283
+ for key in list(keys):
284
+ if key in context.state.bibliography:
285
+ continue
286
+ if not _is_doi_key(key):
287
+ continue
288
+ if key in doi_map:
289
+ continue
290
+ resolved = _materialise_doi_entry(key, context)
291
+ if resolved is None:
292
+ continue
293
+ resolved_key = doi_map.get(key)
294
+ if resolved_key:
295
+ # replace original DOI key in-place for downstream handling
296
+ try:
297
+ index = keys.index(key)
298
+ keys[index] = resolved_key
299
+ except ValueError:
300
+ continue
301
+
302
+
303
+ @renders("div", phase=RenderPhase.PRE, priority=120, name="tabbed_content", auto_mark=False)
304
+ def render_tabbed_content(element: Tag, context: RenderContext) -> None:
305
+ """Unwrap MkDocs tabbed content structures."""
306
+ classes = gather_classes(element.get("class"))
307
+ if "tabbed-set" not in classes:
308
+ return
309
+
310
+ titles: list[str] = []
311
+ if labels := element.find("div", class_="tabbed-labels"):
312
+ for label in labels.find_all("label"):
313
+ titles.append(label.get_text(strip=True))
314
+ labels.extract()
315
+ else:
316
+ fallback_labels = element.find_all("label", recursive=False)
317
+ for label in fallback_labels:
318
+ titles.append(label.get_text(strip=True))
319
+ label.extract()
320
+
321
+ for input_node in element.find_all("input", recursive=False):
322
+ input_node.extract()
323
+
324
+ content_containers = element.find_all("div", class_="tabbed-content", recursive=False)
325
+ if not content_containers:
326
+ candidate = element.find("div", class_="tabbed-content")
327
+ if candidate is None:
328
+ raise InvalidNodeError("Missing tabbed-content container inside tabbed-set")
329
+ content_containers = [candidate]
330
+
331
+ blocks: list[Tag] = []
332
+ for container in content_containers:
333
+ inner_blocks = container.find_all("div", class_="tabbed-block", recursive=False)
334
+ if inner_blocks:
335
+ blocks.extend(inner_blocks)
336
+ else:
337
+ blocks.append(container)
338
+
339
+ soup = element.soup
340
+
341
+ for index, block in enumerate(blocks):
342
+ title = titles[index] if index < len(titles) else ""
343
+ if soup is None:
344
+ heading = mark_processed(NavigableString(f"\\textbf{{{title}}}\\par\n"))
345
+ else:
346
+ heading = soup.new_tag("p")
347
+ strong = soup.new_tag("strong")
348
+ strong.string = title
349
+ heading.append(strong)
350
+ block.insert_before(heading)
351
+
352
+ for highlight in block.find_all("div"):
353
+ highlight_classes = gather_classes(highlight.get("class"))
354
+ if "highlight" not in highlight_classes:
355
+ continue
356
+ if context.is_processed(highlight):
357
+ continue
358
+ parent_before = highlight.parent
359
+ _render_code_block(highlight, context)
360
+ if highlight.parent is not None and highlight.parent is parent_before:
361
+ code_element = highlight.find("code")
362
+ code_text = (
363
+ code_element.get_text(strip=False)
364
+ if code_element is not None
365
+ else highlight.get_text(strip=False)
366
+ )
367
+ if code_text and not code_text.endswith("\n"):
368
+ code_text += "\n"
369
+ fallback = mark_processed(
370
+ NavigableString(
371
+ context.formatter.codeblock(
372
+ code=code_text,
373
+ language="text",
374
+ lineno=False,
375
+ filename=None,
376
+ highlight=[],
377
+ baselinestretch=None,
378
+ )
379
+ )
380
+ )
381
+ highlight.replace_with(fallback)
382
+ context.mark_processed(highlight)
383
+
384
+
385
+ @renders(
386
+ "div",
387
+ phase=RenderPhase.PRE,
388
+ priority=130,
389
+ name="tabbed_cleanup",
390
+ auto_mark=False,
391
+ after_children=True,
392
+ )
393
+ def cleanup_tabbed_content(element: Tag, _context: RenderContext) -> None:
394
+ """Remove tabbed container wrappers after children are processed."""
395
+ classes = gather_classes(element.get("class"))
396
+ if "tabbed-set" not in classes:
397
+ return
398
+
399
+ containers = element.find_all("div", class_="tabbed-content", recursive=False)
400
+ if not containers:
401
+ containers = element.find_all("div", class_="tabbed-content")
402
+ if not containers:
403
+ element.unwrap()
404
+ return
405
+
406
+ for container in containers:
407
+ inner_blocks = container.find_all("div", class_="tabbed-block", recursive=False)
408
+ if inner_blocks:
409
+ for block in inner_blocks:
410
+ block.unwrap()
411
+ container.unwrap()
412
+ element.unwrap()
413
+
414
+
415
+ @renders(
416
+ "blockquote",
417
+ phase=RenderPhase.POST,
418
+ priority=200,
419
+ name="blockquotes",
420
+ nestable=False,
421
+ after_children=True,
422
+ )
423
+ def render_blockquotes(element: Tag, context: RenderContext) -> None:
424
+ """Convert blockquote elements into LaTeX blockquote environments."""
425
+ classes = element.get("class") or []
426
+ if "epigraph" in classes:
427
+ return
428
+
429
+ text = element.get_text(strip=False)
430
+ latex = context.formatter.blockquote(text)
431
+ element.replace_with(mark_processed(NavigableString(latex)))
432
+
433
+
434
+ @renders(phase=RenderPhase.POST, name="lists", auto_mark=False)
435
+ def render_lists(root: Tag, context: RenderContext) -> None:
436
+ """Render ordered and unordered lists."""
437
+ for element in _iter_reversed(root.find_all(["ol", "ul"])):
438
+ _prepare_rich_text_content(element, context)
439
+ items: list[str] = []
440
+ checkboxes: list[int] = []
441
+
442
+ for li in element.find_all("li", recursive=False):
443
+ checkbox_input = li.find("input", attrs={"type": "checkbox"})
444
+ if checkbox_input is not None:
445
+ is_checked = checkbox_input.has_attr("checked")
446
+ checkboxes.append(1 if is_checked else -1)
447
+ checkbox_input.extract()
448
+ text = li.get_text(strip=False).strip()
449
+ else:
450
+ text = li.get_text(strip=False).strip()
451
+ if text.startswith("[ ]"):
452
+ checkboxes.append(-1)
453
+ text = text[3:].strip()
454
+ elif text.startswith("[x]") or text.startswith("[X]"):
455
+ checkboxes.append(1)
456
+ text = text[3:].strip()
457
+ else:
458
+ checkboxes.append(0)
459
+ items.append(text)
460
+
461
+ has_checkbox = any(checkboxes) or bool(gather_classes(element.get("class")))
462
+ latex: str
463
+
464
+ if element.name == "ol":
465
+ latex = context.formatter.ordered_list(items=items)
466
+ else:
467
+ if has_checkbox:
468
+ choices = list(zip((c > 0 for c in checkboxes), items, strict=False))
469
+ latex = context.formatter.choices(items=choices)
470
+ else:
471
+ latex = context.formatter.unordered_list(items=items)
472
+
473
+ element.replace_with(mark_processed(NavigableString(latex)))
474
+
475
+
476
+ @renders(phase=RenderPhase.POST, priority=15, name="description_lists", auto_mark=False)
477
+ def render_description_lists(root: Tag, context: RenderContext) -> None:
478
+ """Render <dl> elements."""
479
+ for dl in _iter_reversed(root.find_all("dl")):
480
+ _prepare_rich_text_content(dl, context)
481
+ items: list[tuple[str | None, str]] = []
482
+ current_term: str | None = None
483
+
484
+ for child in dl.find_all(["dt", "dd"], recursive=False):
485
+ if child.name == "dt":
486
+ term = child.get_text(strip=False).strip()
487
+ current_term = term or None
488
+ elif child.name == "dd":
489
+ content = child.get_text(strip=False).strip()
490
+ if not content and current_term is None:
491
+ continue
492
+ items.append((current_term, content))
493
+
494
+ if not items:
495
+ warnings.warn("Discarding empty description list.", stacklevel=2)
496
+ dl.decompose()
497
+ continue
498
+
499
+ latex = context.formatter.description_list(items=items)
500
+ dl.replace_with(mark_processed(NavigableString(latex)))
501
+
502
+
503
+ @renders(phase=RenderPhase.POST, priority=5, name="fallback_highlight_blocks", auto_mark=False)
504
+ def render_remaining_code_blocks(root: Tag, context: RenderContext) -> None:
505
+ """Convert any remaining MkDocs highlight blocks that escaped earlier passes."""
506
+ for highlight in _iter_reversed(root.find_all("div", class_="highlight")):
507
+ if context.is_processed(highlight):
508
+ continue
509
+ _render_code_block(highlight, context)
510
+
511
+
512
+ @renders(phase=RenderPhase.POST, priority=-10, name="footnotes", auto_mark=False)
513
+ def render_footnotes(root: Tag, context: RenderContext) -> None:
514
+ """Extract and render footnote references."""
515
+ footnotes: dict[str, str] = {}
516
+ bibliography = context.state.bibliography
517
+
518
+ def _normalise_footnote_id(value: str | None) -> str:
519
+ if not value:
520
+ return ""
521
+ text = str(value).strip()
522
+ if ":" in text:
523
+ prefix, suffix = text.split(":", 1)
524
+ if prefix.startswith("fnref") or prefix.startswith("fn"):
525
+ return suffix
526
+ return text or ""
527
+
528
+ def _replace_with_latex(node: Tag, latex: str) -> None:
529
+ replacement = mark_processed(NavigableString(latex))
530
+ node.replace_with(replacement)
531
+
532
+ _citation_payload_pattern = re.compile(
533
+ rf"^\s*({_CITATION_KEY_PATTERN}(?:\s*,\s*{_CITATION_KEY_PATTERN})*)\s*$"
534
+ )
535
+
536
+ def _citation_keys_from_payload(text: str | None) -> list[str]:
537
+ if not text:
538
+ return []
539
+ match = _citation_payload_pattern.match(text)
540
+ if not match:
541
+ return []
542
+ keys = [part.strip() for part in match.group(1).split(",")]
543
+ return [key for key in keys if key]
544
+
545
+ def _render_citation(node: Tag, keys: list[str]) -> bool:
546
+ if not keys:
547
+ return False
548
+ _ensure_doi_entries(keys, context)
549
+ missing = [key for key in keys if key not in bibliography]
550
+ if missing:
551
+ return False
552
+ for key in keys:
553
+ context.state.record_citation(key)
554
+ latex = context.formatter.citation(key=",".join(keys))
555
+ _replace_with_latex(node, latex)
556
+ return True
557
+
558
+ citation_footnotes: dict[str, list[str]] = {}
559
+ invalid_footnotes: set[str] = set()
560
+
561
+ for container in root.find_all("div", class_="footnote"):
562
+ for li in container.find_all("li"):
563
+ footnote_id = _normalise_footnote_id(coerce_attribute(li.get("id")))
564
+ if not footnote_id:
565
+ raise InvalidNodeError("Footnote item missing identifier")
566
+ text = li.get_text(strip=False)
567
+ if _is_multiline_footnote(text):
568
+ warnings.warn(
569
+ f"Footnote '{footnote_id}' spans multiple lines and cannot be rendered; dropping it.",
570
+ stacklevel=2,
571
+ )
572
+ invalid_footnotes.add(footnote_id)
573
+ continue
574
+ text = text.strip()
575
+ footnotes[footnote_id] = text
576
+ recovered = _citation_keys_from_payload(text)
577
+ if recovered:
578
+ citation_footnotes[footnote_id] = recovered
579
+ container.decompose()
580
+
581
+ if footnotes:
582
+ context.state.footnotes.update(footnotes)
583
+
584
+ for sup in root.find_all("sup", id=True):
585
+ footnote_id = _normalise_footnote_id(coerce_attribute(sup.get("id")))
586
+ if footnote_id in invalid_footnotes:
587
+ sup.decompose()
588
+ continue
589
+ citation_keys = citation_footnotes.get(footnote_id)
590
+ if citation_keys and _render_citation(sup, citation_keys):
591
+ continue
592
+ payload = footnotes.get(footnote_id)
593
+ if payload is None:
594
+ payload = context.state.footnotes.get(footnote_id)
595
+ if payload is None:
596
+ citation_keys = _split_citation_keys(footnote_id)
597
+ if citation_keys and _render_citation(sup, citation_keys):
598
+ continue
599
+ # Fall back to default handling/warnings for unresolved citations.
600
+ if footnote_id and footnote_id in bibliography:
601
+ placeholder_note = bool(payload) and _is_bibliography_placeholder(payload)
602
+ if payload and not placeholder_note:
603
+ warnings.warn(
604
+ f"Conflicting bibliography definition for '{footnote_id}'.",
605
+ stacklevel=2,
606
+ )
607
+ context.state.record_citation(footnote_id)
608
+ latex = context.formatter.citation(key=footnote_id)
609
+ _replace_with_latex(sup, latex)
610
+ continue
611
+
612
+ if payload is None:
613
+ if footnote_id and footnote_id not in bibliography:
614
+ warnings.warn(
615
+ f"Reference to '{footnote_id}' is not in your bibliography...",
616
+ stacklevel=2,
617
+ )
618
+ continue
619
+
620
+ latex = context.formatter.footnote(payload)
621
+ _replace_with_latex(sup, latex)
622
+
623
+ for placeholder in root.find_all("texsmith-missing-footnote"):
624
+ identifier_attr = coerce_attribute(placeholder.get("data-footnote-id"))
625
+ identifier = identifier_attr or placeholder.get_text(strip=True)
626
+ footnote_id = identifier.strip() if identifier else ""
627
+ if not footnote_id:
628
+ placeholder.decompose()
629
+ continue
630
+ if footnote_id in invalid_footnotes:
631
+ placeholder.decompose()
632
+ continue
633
+
634
+ citation_keys = citation_footnotes.get(footnote_id)
635
+ if citation_keys and _render_citation(placeholder, citation_keys):
636
+ continue
637
+
638
+ citation_keys = _split_citation_keys(footnote_id)
639
+ if citation_keys and _render_citation(placeholder, citation_keys):
640
+ continue
641
+ # Fall back to default handling for unresolved citations.
642
+
643
+ if footnote_id in bibliography:
644
+ context.state.record_citation(footnote_id)
645
+ latex = context.formatter.citation(key=footnote_id)
646
+ _replace_with_latex(placeholder, latex)
647
+ else:
648
+ payload = context.state.footnotes.get(footnote_id)
649
+ if payload:
650
+ latex = context.formatter.footnote(payload)
651
+ _replace_with_latex(placeholder, latex)
652
+ continue
653
+ warnings.warn(
654
+ f"Reference to '{footnote_id}' is not in your bibliography...",
655
+ stacklevel=2,
656
+ )
657
+ replacement = mark_processed(NavigableString(footnote_id))
658
+ placeholder.replace_with(replacement)
659
+
660
+
661
+ @renders(
662
+ "p",
663
+ "span",
664
+ phase=RenderPhase.POST,
665
+ priority=100,
666
+ name="latex_raw",
667
+ nestable=False,
668
+ )
669
+ def render_latex_raw(element: Tag, _context: RenderContext) -> None:
670
+ """Preserve raw LaTeX payloads embedded in hidden paragraphs."""
671
+ classes = gather_classes(element.get("class"))
672
+ if "latex-raw" not in classes:
673
+ return
674
+
675
+ text = element.get_text(strip=False)
676
+ replacement = mark_processed(NavigableString(text))
677
+ element.replace_with(replacement)
678
+
679
+
680
+ @renders("p", phase=RenderPhase.POST, priority=90, name="paragraphs", nestable=False)
681
+ def render_paragraphs(element: Tag, context: RenderContext) -> None:
682
+ """Render plain paragraphs with script-aware wrapping."""
683
+ if _render_script_paragraphs(element, context):
684
+ return
685
+ if element.get("data-texsmith-latex") == "true":
686
+ content = element.get_text(strip=False)
687
+ element.replace_with(mark_processed(NavigableString(f"{content}\n")))
688
+ return
689
+ if element.get("class"):
690
+ return
691
+
692
+ raw_text = element.get_text(strip=False).strip("\n")
693
+ if not raw_text.strip():
694
+ element.decompose()
695
+ return
696
+
697
+ legacy_accents = getattr(context.config, "legacy_latex_accents", False)
698
+ contains_math = bool(_MATH_PAYLOAD_PATTERN.search(raw_text))
699
+ escape_text = "\\" not in raw_text and not contains_math
700
+ rendered = render_moving_text(
701
+ raw_text,
702
+ context,
703
+ legacy_accents=legacy_accents,
704
+ include_whitespace=True,
705
+ wrap_scripts=escape_text,
706
+ escape=escape_text,
707
+ )
708
+ element.replace_with(mark_processed(NavigableString(f"{rendered}\n")))
709
+
710
+
711
+ @renders("div", phase=RenderPhase.POST, priority=60, name="multicolumns", nestable=False)
712
+ def render_columns(element: Tag, context: RenderContext) -> None:
713
+ """Render lists specially marked as multi-column blocks."""
714
+ classes = gather_classes(element.get("class"))
715
+ if "two-column-list" in classes:
716
+ columns = 2
717
+ elif "three-column-list" in classes:
718
+ columns = 3
719
+ else:
720
+ return
721
+
722
+ text = element.get_text(strip=False)
723
+ latex = context.formatter.multicolumn(text, columns=columns)
724
+ element.replace_with(mark_processed(NavigableString(latex)))
725
+
726
+
727
+ @renders("figure", phase=RenderPhase.POST, priority=30, name="figures", nestable=False)
728
+ def render_figures(element: Tag, context: RenderContext) -> None:
729
+ """Render <figure> elements and manage associated assets."""
730
+ classes = gather_classes(element.get("class"))
731
+ if "mermaid-figure" in classes:
732
+ return
733
+
734
+ image = element.find("img")
735
+ if image is None:
736
+ table = element.find("table")
737
+ if table is not None:
738
+ identifier = coerce_attribute(element.get("id"))
739
+ if identifier and not table.get("id"):
740
+ table["id"] = identifier
741
+ figcaption = element.find("figcaption")
742
+ if figcaption is not None and table.find("caption") is None:
743
+ caption = context.document.new_tag("caption")
744
+ _strip_caption_prefix(figcaption)
745
+ caption.string = figcaption.get_text(strip=False)
746
+ table.insert(0, caption)
747
+ figcaption.decompose()
748
+ render_tables(table, context)
749
+ element.unwrap()
750
+ return
751
+ raise InvalidNodeError("Figure missing <img> element")
752
+
753
+ src = coerce_attribute(image.get("src"))
754
+ if not src:
755
+ raise InvalidNodeError("Figure image missing 'src' attribute")
756
+
757
+ width = coerce_attribute(image.get("width")) or None
758
+ alt_text = coerce_attribute(image.get("alt")) or None
759
+ if not context.runtime.get("copy_assets", True):
760
+ caption_node = element.find("figcaption")
761
+ caption_text = caption_node.get_text(strip=False).strip() if caption_node else None
762
+ placeholder = caption_text or alt_text or "[figure]"
763
+ element.replace_with(mark_processed(NavigableString(placeholder)))
764
+ return
765
+
766
+ if is_valid_url(src):
767
+ stored_path = store_remote_image_asset(context, src)
768
+ else:
769
+ resolved = _resolve_source_path(context, src)
770
+ if resolved is None:
771
+ raise AssetMissingError(f"Unable to resolve figure asset '{src}'")
772
+
773
+ stored_path = store_local_image_asset(context, resolved)
774
+
775
+ caption_text = None
776
+ short_caption = alt_text
777
+
778
+ if figcaption := element.find("figcaption"):
779
+ _strip_caption_prefix(figcaption)
780
+ caption_text = figcaption.get_text(strip=False).strip()
781
+ figcaption.decompose()
782
+
783
+ if short_caption and caption_text and len(caption_text) > len(short_caption):
784
+ short_caption = None
785
+
786
+ label = coerce_attribute(element.get("id"))
787
+
788
+ template_name = _figure_template_for(element, context)
789
+ formatter = getattr(context.formatter, template_name)
790
+ asset_path = context.assets.latex_path(stored_path)
791
+ latex = formatter(
792
+ path=asset_path,
793
+ caption=caption_text or short_caption,
794
+ shortcaption=short_caption,
795
+ label=label,
796
+ width=width,
797
+ )
798
+
799
+ element.replace_with(mark_processed(NavigableString(latex)))
800
+
801
+
802
+ def _cell_alignment(cell: Tag) -> str:
803
+ style = coerce_attribute(cell.get("style")) or ""
804
+ if "text-align: right" in style:
805
+ return "right"
806
+ if "text-align: center" in style:
807
+ return "center"
808
+ return "left"
809
+
810
+
811
+ @renders("table", phase=RenderPhase.POST, priority=40, name="tables", nestable=False)
812
+ def render_tables(element: Tag, context: RenderContext) -> None:
813
+ """Render HTML tables to LaTeX."""
814
+ caption = None
815
+ if caption_node := element.find("caption"):
816
+ _strip_caption_prefix(caption_node)
817
+ caption = caption_node.get_text(strip=False).strip()
818
+ caption_node.decompose()
819
+
820
+ label = coerce_attribute(element.get("id"))
821
+
822
+ table_rows: list[list[str]] = []
823
+ styles: list[list[str]] = []
824
+ is_large = False
825
+
826
+ for row in element.find_all("tr"):
827
+ row_values: list[str] = []
828
+ row_styles: list[str] = []
829
+ for cell in row.find_all(["th", "td"]):
830
+ content = cell.get_text(strip=False).strip()
831
+ row_values.append(content)
832
+ row_styles.append(_cell_alignment(cell))
833
+ table_rows.append(row_values)
834
+ styles.append(row_styles)
835
+
836
+ stripped = "".join(
837
+ re.sub(r"\\href\{[^\}]+?\}|\\\w{3,}|[\{\}|]", "", col) for col in row_values
838
+ )
839
+ if len(stripped) > 50:
840
+ is_large = True
841
+
842
+ columns = styles[0] if styles else []
843
+ latex = context.formatter.table(
844
+ columns=columns,
845
+ rows=table_rows,
846
+ caption=caption,
847
+ label=label,
848
+ is_large=is_large,
849
+ )
850
+
851
+ element.replace_with(mark_processed(NavigableString(latex)))