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,937 @@
1
+ """Advanced inline handlers ported from the legacy renderer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ import hashlib
7
+ import re
8
+ import warnings
9
+
10
+ from bs4.element import NavigableString, Tag
11
+ import emoji
12
+ from requests.utils import requote_uri as requote_url
13
+
14
+ from texsmith.core.context import RenderContext
15
+ from texsmith.core.exceptions import InvalidNodeError, TransformerExecutionError
16
+ from texsmith.core.rules import RenderPhase, renders
17
+ from texsmith.fonts.scripts import record_script_usage_for_slug, render_moving_text
18
+
19
+ from ..latex.utils import escape_latex_chars
20
+ from ..transformers import fetch_image, svg2pdf
21
+ from ._helpers import coerce_attribute, gather_classes, is_valid_url, mark_processed
22
+ from .code import _resolve_code_engine
23
+
24
+
25
+ _MATH_PAYLOAD_PATTERN = re.compile(
26
+ r"""
27
+ (?:\$\$.*?\$\$) # display math $$...$$
28
+ |(?:\\\[.*?\\\]) # display math \[...\]
29
+ |(?:\\\(.*?\\\)) # inline math \(...\)
30
+ |(?:\\begin\{[a-zA-Z*]+\}.*?\\end\{[a-zA-Z*]+\})# LaTeX environments
31
+ |(?<!\\)\$(?!\$)(?!\s)(?:\\.|[^$])*?(?<!\\)\$ # inline math $...$
32
+ """,
33
+ re.DOTALL | re.VERBOSE,
34
+ )
35
+
36
+ _SUPERSCRIPT_MAP = {
37
+ "⁰": "0",
38
+ "¹": "1",
39
+ "²": "2",
40
+ "³": "3",
41
+ "⁴": "4",
42
+ "⁵": "5",
43
+ "⁶": "6",
44
+ "⁷": "7",
45
+ "⁸": "8",
46
+ "⁹": "9",
47
+ "⁺": "+",
48
+ "⁻": "-",
49
+ "⁼": "=",
50
+ "⁽": "(",
51
+ "⁾": ")",
52
+ "ⁿ": "n",
53
+ "ⁱ": "i",
54
+ "ᵃ": "a",
55
+ "ᵇ": "b",
56
+ "ᶜ": "c",
57
+ "ᵈ": "d",
58
+ "ᵉ": "e",
59
+ "ᶠ": "f",
60
+ "ᵍ": "g",
61
+ "ʰ": "h",
62
+ "ᶦ": "i",
63
+ "ʲ": "j",
64
+ "ᵏ": "k",
65
+ "ˡ": "l",
66
+ "ᵐ": "m",
67
+ "ᶰ": "n",
68
+ "ᵒ": "o",
69
+ "ᵖ": "p",
70
+ "ʳ": "r",
71
+ "ˢ": "s",
72
+ "ᵗ": "t",
73
+ "ᵘ": "u",
74
+ "ᵛ": "v",
75
+ "ʷ": "w",
76
+ "ˣ": "x",
77
+ "ʸ": "y",
78
+ "ᶻ": "z",
79
+ "ᴬ": "A",
80
+ "ᴮ": "B",
81
+ "ᴰ": "D",
82
+ "ᴱ": "E",
83
+ "ᴳ": "G",
84
+ "ᴴ": "H",
85
+ "ᴵ": "I",
86
+ "ᴶ": "J",
87
+ "ᴷ": "K",
88
+ "ᴸ": "L",
89
+ "ᴹ": "M",
90
+ "ᴺ": "N",
91
+ "ᴼ": "O",
92
+ "ᴾ": "P",
93
+ "ᴿ": "R",
94
+ "ᵀ": "T",
95
+ "ᵁ": "U",
96
+ "ⱽ": "V",
97
+ "ᵂ": "W",
98
+ }
99
+
100
+ _SUPERSCRIPT_PATTERN = re.compile(f"([{''.join(re.escape(char) for char in _SUPERSCRIPT_MAP)}]+)")
101
+
102
+ _SUBSCRIPT_MAP = {
103
+ "₀": "0",
104
+ "₁": "1",
105
+ "₂": "2",
106
+ "₃": "3",
107
+ "₄": "4",
108
+ "₅": "5",
109
+ "₆": "6",
110
+ "₇": "7",
111
+ "₈": "8",
112
+ "₉": "9",
113
+ "₊": "+",
114
+ "₋": "-",
115
+ "₌": "=",
116
+ "₍": "(",
117
+ "₎": ")",
118
+ "ₐ": "a",
119
+ "ₑ": "e",
120
+ "ₒ": "o",
121
+ "ₔ": "ə",
122
+ "ₓ": "x",
123
+ "ₕ": "h",
124
+ "ₖ": "k",
125
+ "ₗ": "l",
126
+ "ₘ": "m",
127
+ "ₙ": "n",
128
+ "ₚ": "p",
129
+ "ₛ": "s",
130
+ "ₜ": "t",
131
+ "ᵢ": "i",
132
+ "ᵣ": "r",
133
+ "ᵤ": "u",
134
+ "ᵥ": "v",
135
+ "ᵦ": r"\beta",
136
+ "ᵧ": r"\gamma",
137
+ "ᵨ": r"\rho",
138
+ "ᵩ": r"\phi",
139
+ "ᵪ": r"\chi",
140
+ }
141
+
142
+ _SUBSCRIPT_PATTERN = re.compile(f"([{''.join(re.escape(char) for char in _SUBSCRIPT_MAP)}]+)")
143
+ _UNICODE_DASH_MAP = {
144
+ "\N{EN DASH}": "--",
145
+ "\N{FIGURE DASH}": "--",
146
+ "\N{EM DASH}": "---",
147
+ "\N{HORIZONTAL BAR}": "---",
148
+ }
149
+ _UNICODE_DASH_PATTERN = re.compile("[" + "".join(re.escape(k) for k in _UNICODE_DASH_MAP) + "]")
150
+
151
+ _UNICODE_PUNCT_MAP = {
152
+ "\N{RIGHT SINGLE QUOTATION MARK}": "'",
153
+ "\N{LEFT SINGLE QUOTATION MARK}": "`",
154
+ "\N{SINGLE LOW-9 QUOTATION MARK}": ",",
155
+ "\N{SINGLE HIGH-REVERSED-9 QUOTATION MARK}": "'",
156
+ "\N{LEFT DOUBLE QUOTATION MARK}": "``",
157
+ "\N{RIGHT DOUBLE QUOTATION MARK}": "''",
158
+ "\N{DOUBLE LOW-9 QUOTATION MARK}": ",,",
159
+ "\N{DOUBLE HIGH-REVERSED-9 QUOTATION MARK}": "''",
160
+ "\N{HORIZONTAL ELLIPSIS}": "...",
161
+ }
162
+ _UNICODE_PUNCT_PATTERN = re.compile("[" + "".join(re.escape(k) for k in _UNICODE_PUNCT_MAP) + "]")
163
+ _MINTINLINE_DELIMITERS: tuple[str, ...] = (
164
+ "|",
165
+ "!",
166
+ ";",
167
+ ":",
168
+ "+",
169
+ "/",
170
+ "-",
171
+ "=",
172
+ "~",
173
+ "*",
174
+ "#",
175
+ "?",
176
+ )
177
+
178
+
179
+ def _replace_unicode_scripts(
180
+ text: str, pattern: re.Pattern[str], mapping: dict[str, str], command: str
181
+ ) -> str:
182
+ if not text:
183
+ return text
184
+
185
+ def _normalize(match: re.Match[str]) -> str:
186
+ payload = match.group(0)
187
+ normalized = "".join(mapping.get(char, char) for char in payload)
188
+ return f"\\{command}{{{normalized}}}"
189
+
190
+ return pattern.sub(_normalize, text)
191
+
192
+
193
+ def _replace_unicode_superscripts(text: str) -> str:
194
+ """Convert sequences of Unicode superscript characters to LaTeX text macros."""
195
+ return _replace_unicode_scripts(text, _SUPERSCRIPT_PATTERN, _SUPERSCRIPT_MAP, "textsuperscript")
196
+
197
+
198
+ def _replace_unicode_subscripts(text: str) -> str:
199
+ """Convert sequences of Unicode subscript characters to LaTeX text macros."""
200
+ return _replace_unicode_scripts(text, _SUBSCRIPT_PATTERN, _SUBSCRIPT_MAP, "textsubscript")
201
+
202
+
203
+ def _replace_unicode_dashes(text: str) -> str:
204
+ """Convert Unicode dash characters to LaTeX-friendly representations."""
205
+ if not text:
206
+ return text
207
+ return _UNICODE_DASH_PATTERN.sub(lambda match: _UNICODE_DASH_MAP.get(match.group(0), "-"), text)
208
+
209
+
210
+ def _replace_unicode_punctuation(text: str) -> str:
211
+ """Replace curly quotes and ellipsis with LaTeX-friendly sequences."""
212
+ if not text:
213
+ return text
214
+ return _UNICODE_PUNCT_PATTERN.sub(
215
+ lambda match: _UNICODE_PUNCT_MAP.get(match.group(0), ""), text
216
+ )
217
+
218
+
219
+ def _get_emoji_mode(context: RenderContext) -> str:
220
+ value = context.runtime.get("emoji_mode")
221
+ if isinstance(value, str):
222
+ candidate = value.strip()
223
+ if candidate:
224
+ return candidate
225
+ return "black"
226
+
227
+
228
+ def _get_emoji_command(context: RenderContext) -> str:
229
+ value = context.runtime.get("emoji_command")
230
+ if isinstance(value, str) and value.strip():
231
+ return value.strip()
232
+ return r"\texsmithEmoji"
233
+
234
+
235
+ def _has_ancestor(node: NavigableString, *names: str) -> bool:
236
+ parent = node.parent
237
+ while parent is not None:
238
+ if getattr(parent, "name", None) in names:
239
+ return True
240
+ parent = getattr(parent, "parent", None)
241
+ return False
242
+
243
+
244
+ def _allow_hyphenation(text: str) -> str:
245
+ """Allow hyphenation in long words.
246
+
247
+ TODO: This function is bad. Instead we should look into a dictionary
248
+ based hyphenation solution.
249
+ """
250
+ return text # Temporary disable hyphenation handling
251
+
252
+ if len(text) < 50:
253
+ return text
254
+ return re.sub(r"(\b[^\W\d_]{2,}-)([^\W\d_]{7,})\b", r"\1\\allowhyphens \2", text)
255
+
256
+
257
+ def _prepare_plain_text(text: str, *, legacy_latex_accents: bool) -> str:
258
+ text = _replace_unicode_punctuation(text)
259
+ text = _replace_unicode_dashes(text)
260
+ escaped = escape_latex_chars(text, legacy_accents=legacy_latex_accents)
261
+ escaped = _allow_hyphenation(escaped)
262
+ escaped = _replace_unicode_superscripts(escaped)
263
+ return _replace_unicode_subscripts(escaped)
264
+
265
+
266
+ def _segment_text_with_emoji(text: str) -> list[tuple[str, str]]:
267
+ """Split text into plain fragments and emoji clusters."""
268
+ if not text:
269
+ return []
270
+ if text.isascii():
271
+ return [("text", text)]
272
+ entries = emoji.emoji_list(text)
273
+ if not entries:
274
+ return [("text", text)]
275
+
276
+ segments: list[tuple[str, str]] = []
277
+ cursor = 0
278
+ for entry in entries:
279
+ start = entry["match_start"]
280
+ end = entry["match_end"]
281
+ if start > cursor:
282
+ segments.append(("text", text[cursor:start]))
283
+ token = text[start:end]
284
+ segments.append(("emoji", token))
285
+ cursor = end
286
+ if cursor < len(text):
287
+ segments.append(("text", text[cursor:]))
288
+ return segments
289
+
290
+
291
+ def _emoji_twemoji_url(token: str) -> str:
292
+ codepoints = "-".join(f"{ord(char):x}" for char in token)
293
+ return f"https://twemoji.maxcdn.com/v/latest/svg/{codepoints}.svg"
294
+
295
+
296
+ def _render_emoji(token: str, context: RenderContext) -> str:
297
+ url = _emoji_twemoji_url(token)
298
+ try:
299
+ artefact = fetch_image(url, output_dir=context.assets.output_root)
300
+ except TransformerExecutionError as exc:
301
+ warnings.warn(f"Failed to fetch emoji '{token}': {exc}", stacklevel=2)
302
+ legacy = getattr(context.config, "legacy_latex_accents", False)
303
+ return _prepare_plain_text(token, legacy_latex_accents=legacy)
304
+
305
+ stored_path = context.assets.register(url, artefact)
306
+ asset_path = context.assets.latex_path(stored_path)
307
+ return context.formatter.icon(asset_path)
308
+
309
+
310
+ def _render_font_emoji(token: str, command: str) -> str:
311
+ if not token:
312
+ return ""
313
+ return f"{command}{{{token}}}"
314
+
315
+
316
+ def _escape_text_segment(text: str, context: RenderContext, *, legacy_latex_accents: bool) -> str:
317
+ chunks: list[str] = []
318
+ emoji_mode = _get_emoji_mode(context)
319
+ emoji_command = _get_emoji_command(context)
320
+ for kind, payload in _segment_text_with_emoji(text):
321
+ if kind == "text":
322
+ if payload:
323
+ chunks.append(
324
+ _prepare_plain_text(payload, legacy_latex_accents=legacy_latex_accents)
325
+ )
326
+ else:
327
+ if emoji_mode == "artifact":
328
+ chunks.append(_render_emoji(payload, context))
329
+ else:
330
+ chunks.append(_render_font_emoji(payload, emoji_command))
331
+ return "".join(chunks)
332
+
333
+
334
+ @renders(phase=RenderPhase.PRE, name="escape_plain_text", auto_mark=False)
335
+ def escape_plain_text(root: Tag, context: RenderContext) -> None:
336
+ """Escape LaTeX characters on plain text nodes outside code blocks."""
337
+ legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
338
+ for node in list(root.find_all(string=True)):
339
+ if getattr(node, "processed", False):
340
+ continue
341
+ if _has_ancestor(node, "code", "script"):
342
+ continue
343
+ ancestor = getattr(node, "parent", None)
344
+ skip_plain_text = False
345
+ while ancestor is not None:
346
+ classes = gather_classes(getattr(ancestor, "get", lambda *_: None)("class"))
347
+ if "latex-raw" in classes or "arithmatex" in classes:
348
+ skip_plain_text = True
349
+ break
350
+ ancestor = getattr(ancestor, "parent", None)
351
+ if skip_plain_text:
352
+ continue
353
+ text = str(node)
354
+ if not text:
355
+ continue
356
+ if "\\keystroke{" in text or "\\keystrokes{" in text:
357
+ node.replace_with(mark_processed(NavigableString(text)))
358
+ continue
359
+ matches = list(_MATH_PAYLOAD_PATTERN.finditer(text))
360
+ if not matches:
361
+ escaped = _escape_text_segment(text, context, legacy_latex_accents=legacy_latex_accents)
362
+ if escaped != text:
363
+ node.replace_with(mark_processed(NavigableString(escaped)))
364
+ continue
365
+
366
+ parts: list[str] = []
367
+ cursor = 0
368
+ for match in matches:
369
+ if match.start() > cursor:
370
+ segment = text[cursor : match.start()]
371
+ if segment:
372
+ escaped = _escape_text_segment(
373
+ segment,
374
+ context,
375
+ legacy_latex_accents=legacy_latex_accents,
376
+ )
377
+ parts.append(escaped)
378
+ parts.append(match.group(0))
379
+ cursor = match.end()
380
+ if cursor < len(text):
381
+ tail = text[cursor:]
382
+ if tail:
383
+ escaped = _escape_text_segment(
384
+ tail,
385
+ context,
386
+ legacy_latex_accents=legacy_latex_accents,
387
+ )
388
+ parts.append(escaped)
389
+
390
+ replacement = mark_processed(NavigableString("".join(parts)))
391
+ node.replace_with(replacement)
392
+
393
+
394
+ @renders("a", phase=RenderPhase.PRE, priority=80, name="unicode_links", nestable=False)
395
+ def render_unicode_link(element: Tag, context: RenderContext) -> None:
396
+ """Render Unicode helper links."""
397
+ classes = gather_classes(element.get("class"))
398
+ if "ycr-unicode" not in classes:
399
+ return
400
+
401
+ code = element.get_text(strip=True)
402
+ href = coerce_attribute(element.get("href")) or ""
403
+ latex = context.formatter.href(text=f"U+{code}", url=requote_url(href))
404
+ element.replace_with(mark_processed(NavigableString(latex)))
405
+
406
+
407
+ @renders("a", phase=RenderPhase.PRE, priority=70, name="regex_links", nestable=False)
408
+ def render_regex_link(element: Tag, context: RenderContext) -> None:
409
+ """Render custom regex helper links."""
410
+ classes = gather_classes(element.get("class"))
411
+ if "ycr-regex" not in classes:
412
+ return
413
+
414
+ code = element.get_text(strip=False)
415
+ if code_tag := element.find("code"):
416
+ code = code_tag.get_text(strip=False)
417
+ code = code.replace("&", "\\&").replace("#", "\\#")
418
+
419
+ href = coerce_attribute(element.get("href")) or ""
420
+ latex = context.formatter.regex(code, url=requote_url(href))
421
+ element.replace_with(mark_processed(NavigableString(latex)))
422
+
423
+
424
+ def _extract_code_text(element: Tag) -> str:
425
+ classes = gather_classes(element.get("class"))
426
+ if any(cls.startswith("language-") for cls in classes) or "highlight" in classes:
427
+ return "".join(child.get_text(strip=False) for child in element.find_all("span"))
428
+ return element.get_text(strip=False)
429
+
430
+
431
+ def _pick_mintinline_delimiter(text: str) -> str | None:
432
+ for delimiter in _MINTINLINE_DELIMITERS:
433
+ if delimiter not in text:
434
+ return delimiter
435
+ return None
436
+
437
+
438
+ @renders("code", phase=RenderPhase.PRE, priority=50, name="inline_code", nestable=False)
439
+ def render_inline_code(element: Tag, context: RenderContext) -> None:
440
+ """Render inline code elements using the formatter."""
441
+ if element.find_parent("pre"):
442
+ return
443
+
444
+ classes = gather_classes(element.get("class"))
445
+ code = _extract_code_text(element)
446
+ if "\n" in code:
447
+ return
448
+
449
+ engine = _resolve_code_engine(context)
450
+ language_hint = None
451
+ if code.startswith("#!"):
452
+ shebang_parts = code[2:].strip().split(None, 1)
453
+ if shebang_parts:
454
+ language_hint = shebang_parts[0]
455
+ code = shebang_parts[1] if len(shebang_parts) > 1 else ""
456
+
457
+ has_language = any(cls.startswith("language-") for cls in classes)
458
+ language = None
459
+ if has_language or "highlight" in classes:
460
+ language = next(
461
+ (cls[len("language-") :] or "text" for cls in classes if cls.startswith("language-")),
462
+ "text",
463
+ )
464
+ if language_hint and not language:
465
+ language = language_hint
466
+
467
+ if language:
468
+ delimiter = _pick_mintinline_delimiter(code)
469
+ if delimiter and engine == "minted":
470
+ context.state.requires_shell_escape = (
471
+ context.state.requires_shell_escape or engine == "minted"
472
+ )
473
+ latex = context.formatter.codeinline(
474
+ language=language or "text",
475
+ text=code,
476
+ engine=engine,
477
+ )
478
+ element.replace_with(mark_processed(NavigableString(latex)))
479
+ return
480
+ latex = context.formatter.codeinline(
481
+ language=language or "text",
482
+ text=code,
483
+ engine=engine,
484
+ state=context.state,
485
+ )
486
+ element.replace_with(mark_processed(NavigableString(latex)))
487
+ return
488
+
489
+ latex = context.formatter.codeinlinett(code)
490
+ element.replace_with(mark_processed(NavigableString(latex)))
491
+
492
+
493
+ @renders(
494
+ "span",
495
+ phase=RenderPhase.PRE,
496
+ priority=60,
497
+ name="inline_math",
498
+ auto_mark=False,
499
+ )
500
+ def render_math_inline(element: Tag, _context: RenderContext) -> None:
501
+ """Preserve inline math payloads untouched."""
502
+ classes = gather_classes(element.get("class"))
503
+ if "arithmatex" not in classes:
504
+ return
505
+ text = element.get_text(strip=False)
506
+ element.replace_with(mark_processed(NavigableString(text)))
507
+
508
+
509
+ @renders(
510
+ "div",
511
+ phase=RenderPhase.PRE,
512
+ priority=30,
513
+ name="math_block",
514
+ auto_mark=False,
515
+ )
516
+ def render_math_block(element: Tag, _context: RenderContext) -> None:
517
+ """Preserve block math payloads."""
518
+ classes = gather_classes(element.get("class"))
519
+ if "arithmatex" not in classes:
520
+ return
521
+ text = element.get_text(strip=False)
522
+ stripped = text.strip()
523
+
524
+ match = _DISPLAY_MATH_PATTERN.match(stripped)
525
+ if match:
526
+ inner = match.group(1)
527
+ if _payload_is_block_environment(inner):
528
+ # align/equation environments already provide display math.
529
+ latex = f"\n{inner.strip()}\n"
530
+ element.replace_with(mark_processed(NavigableString(latex)))
531
+ return
532
+
533
+ element.replace_with(mark_processed(NavigableString(f"\n{text}\n")))
534
+
535
+
536
+ @renders(
537
+ "script",
538
+ phase=RenderPhase.PRE,
539
+ priority=65,
540
+ name="math_script",
541
+ nestable=False,
542
+ auto_mark=False,
543
+ )
544
+ def render_math_script(element: Tag, _context: RenderContext) -> None:
545
+ """Preserve math payloads generated via script tags (e.g. mdx_math)."""
546
+ type_attr = coerce_attribute(element.get("type"))
547
+ if type_attr is None:
548
+ return
549
+ if not type_attr.startswith("math/tex"):
550
+ return
551
+
552
+ payload = element.get_text(strip=False)
553
+ if payload is None:
554
+ payload = ""
555
+ payload = payload.strip()
556
+ is_display = "mode=display" in type_attr
557
+
558
+ if not payload:
559
+ node = NavigableString("")
560
+ elif is_display:
561
+ if _payload_is_block_environment(payload):
562
+ node = NavigableString(f"\n{payload}\n")
563
+ else:
564
+ node = NavigableString(f"\n$$\n{payload}\n$$\n")
565
+ else:
566
+ node = NavigableString(f"${payload}$")
567
+
568
+ element.replace_with(mark_processed(node))
569
+
570
+
571
+ @renders("abbr", phase=RenderPhase.INLINE, priority=30, name="abbreviation", nestable=False)
572
+ def render_abbreviation(element: Tag, context: RenderContext) -> None:
573
+ """Register and render abbreviations."""
574
+ title_attr = element.get("title")
575
+ description = title_attr.strip() if isinstance(title_attr, str) else ""
576
+ term = element.get_text(strip=True)
577
+
578
+ if not term:
579
+ return
580
+
581
+ if not description:
582
+ legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
583
+ latex_text = escape_latex_chars(term, legacy_accents=legacy_latex_accents)
584
+ element.replace_with(mark_processed(NavigableString(latex_text)))
585
+ return
586
+
587
+ key = context.state.remember_abbreviation(term, description)
588
+ if not key:
589
+ key = term
590
+
591
+ latex = f"\\acrshort{{{key}}}"
592
+ element.replace_with(mark_processed(NavigableString(latex)))
593
+
594
+
595
+ @renders("span", phase=RenderPhase.INLINE, priority=40, name="keystrokes", nestable=False)
596
+ def render_keystrokes(element: Tag, context: RenderContext) -> None:
597
+ """Render keyboard shortcut markup."""
598
+ classes = gather_classes(element.get("class"))
599
+ if "keys" not in classes:
600
+ return
601
+
602
+ keys: list[str] = []
603
+ for key in element.find_all("kbd"):
604
+ key_classes = gather_classes(key.get("class"))
605
+ matched: Iterable[str] = (cls[4:] for cls in key_classes if cls.startswith("key-"))
606
+ value = next(matched, None)
607
+ if value:
608
+ keys.append(value)
609
+ else:
610
+ keys.append(key.get_text(strip=True))
611
+
612
+ latex = context.formatter.keystroke(keys)
613
+ node = mark_processed(NavigableString(latex))
614
+ context.mark_processed(element)
615
+ context.suppress_children(element)
616
+ element.replace_with(node)
617
+
618
+
619
+ @renders(
620
+ "span",
621
+ phase=RenderPhase.INLINE,
622
+ priority=30,
623
+ name="script_spans",
624
+ nestable=False,
625
+ auto_mark=False,
626
+ )
627
+ def render_script_spans(element: Tag, context: RenderContext) -> None:
628
+ """Render spans tagged with data-script into explicit text commands."""
629
+ slug = coerce_attribute(element.get("data-script"))
630
+ if not slug:
631
+ return
632
+
633
+ raw_text = element.get_text(strip=False)
634
+ if not raw_text:
635
+ element.decompose()
636
+ return
637
+
638
+ record_script_usage_for_slug(slug, raw_text, context)
639
+ legacy_accents = getattr(context.config, "legacy_latex_accents", False)
640
+ payload = escape_latex_chars(raw_text, legacy_accents=legacy_accents)
641
+ latex = f"\\text{slug}{{{payload}}}"
642
+ parent = element.parent
643
+ if parent is not None and getattr(parent, "attrs", None) is not None:
644
+ parent.attrs["data-texsmith-latex"] = "true"
645
+ context.mark_processed(element)
646
+ element.replace_with(mark_processed(NavigableString(latex)))
647
+
648
+
649
+ @renders(
650
+ "span",
651
+ phase=RenderPhase.INLINE,
652
+ priority=30,
653
+ name="latex_text",
654
+ nestable=False,
655
+ auto_mark=False,
656
+ )
657
+ def render_latex_text_span(element: Tag, context: RenderContext) -> None:
658
+ """Render the custom ``latex-text`` span into canonical LaTeX."""
659
+ classes = gather_classes(element.get("class"))
660
+ if "latex-text" not in classes:
661
+ return
662
+
663
+ latex = mark_processed(NavigableString(r"\LaTeX{}"))
664
+ context.mark_processed(element)
665
+ context.suppress_children(element)
666
+ element.replace_with(latex)
667
+
668
+
669
+ def _extract_emoji_token(element: Tag) -> str:
670
+ for attr in ("alt", "data-emoji"):
671
+ candidate = coerce_attribute(element.get(attr))
672
+ if candidate:
673
+ return candidate
674
+ fallback = coerce_attribute(element.get("title"))
675
+ return fallback or ""
676
+
677
+
678
+ @renders("img", phase=RenderPhase.INLINE, priority=20, name="twemoji_images", nestable=False)
679
+ def render_twemoji_image(element: Tag, context: RenderContext) -> None:
680
+ """Render Twitter emoji images as inline icons."""
681
+ classes = gather_classes(element.get("class"))
682
+ if not {"twemoji", "emojione"}.intersection(classes):
683
+ return
684
+ emoji_mode = _get_emoji_mode(context)
685
+ if emoji_mode != "artifact":
686
+ token = _extract_emoji_token(element)
687
+ latex = _render_font_emoji(token, _get_emoji_command(context))
688
+ element.replace_with(mark_processed(NavigableString(latex)))
689
+ return
690
+ if not context.runtime.get("copy_assets", True):
691
+ placeholder = (
692
+ coerce_attribute(element.get("alt")) or coerce_attribute(element.get("title")) or ""
693
+ )
694
+ element.replace_with(mark_processed(NavigableString(placeholder)))
695
+ return
696
+
697
+ src = coerce_attribute(element.get("src"))
698
+ if not src:
699
+ raise InvalidNodeError("Twemoji image without 'src' attribute")
700
+ if not is_valid_url(src):
701
+ raise InvalidNodeError("Twemoji images must reference remote assets")
702
+
703
+ artefact = fetch_image(src, output_dir=context.assets.output_root)
704
+ stored_path = context.assets.register(src, artefact)
705
+ asset_path = context.assets.latex_path(stored_path)
706
+
707
+ latex = context.formatter.icon(asset_path)
708
+ element.replace_with(mark_processed(NavigableString(latex)))
709
+
710
+
711
+ @renders(
712
+ "span",
713
+ phase=RenderPhase.INLINE,
714
+ priority=25,
715
+ name="twemoji_svg",
716
+ nestable=False,
717
+ auto_mark=False,
718
+ )
719
+ def render_twemoji_span(element: Tag, context: RenderContext) -> None:
720
+ """Render inline SVG emoji payloads."""
721
+ classes = gather_classes(element.get("class"))
722
+ if "twemoji" not in classes:
723
+ return
724
+ emoji_mode = _get_emoji_mode(context)
725
+ if emoji_mode != "artifact":
726
+ token = _extract_emoji_token(element)
727
+ latex = _render_font_emoji(token, _get_emoji_command(context))
728
+ element.replace_with(mark_processed(NavigableString(latex)))
729
+ return
730
+ if not context.runtime.get("copy_assets", True):
731
+ placeholder = coerce_attribute(element.get("title")) or element.get_text(strip=True) or ""
732
+ element.replace_with(mark_processed(NavigableString(placeholder)))
733
+ return
734
+
735
+ svg = element.find("svg")
736
+ if svg is None:
737
+ raise InvalidNodeError("Expected inline SVG inside span.twemoji")
738
+
739
+ svg_payload = str(svg)
740
+ artefact = svg2pdf(svg_payload, output_dir=context.assets.output_root)
741
+ digest = hashlib.sha256(svg_payload.encode("utf-8")).hexdigest()
742
+ stored_path = context.assets.register(f"twemoji::{digest}", artefact)
743
+ asset_path = context.assets.latex_path(stored_path)
744
+
745
+ latex = context.formatter.icon(asset_path)
746
+ node = mark_processed(NavigableString(latex))
747
+ context.mark_processed(element)
748
+ context.suppress_children(element)
749
+ element.replace_with(node)
750
+
751
+
752
+ @renders(
753
+ "span",
754
+ "a",
755
+ phase=RenderPhase.INLINE,
756
+ priority=45,
757
+ name="index_entries",
758
+ nestable=False,
759
+ auto_mark=False,
760
+ )
761
+ def render_index_entry(element: Tag, context: RenderContext) -> None:
762
+ """Render inline index term annotations."""
763
+ tag_name = coerce_attribute(element.get("data-tag-name"))
764
+ if not tag_name:
765
+ return
766
+
767
+ raw_entry = str(tag_name)
768
+ parts = [segment.strip() for segment in raw_entry.split(",") if segment.strip()]
769
+ if not parts:
770
+ return
771
+
772
+ legacy_latex_accents = getattr(context.config, "legacy_latex_accents", False)
773
+ escaped_fragments = [
774
+ render_moving_text(part, context, legacy_accents=legacy_latex_accents, wrap_scripts=True)
775
+ or ""
776
+ for part in parts
777
+ ]
778
+ escaped_entry = "!".join(fragment for fragment in escaped_fragments if fragment)
779
+ style_value = coerce_attribute(element.get("data-tag-style"))
780
+ style_key = style_value.strip().lower() if style_value else ""
781
+ if style_key not in {"b", "i", "bi"}:
782
+ style_key = ""
783
+
784
+ display_text = element.get_text(strip=False) or ""
785
+ escaped_text = (
786
+ render_moving_text(
787
+ display_text, context, legacy_accents=legacy_latex_accents, wrap_scripts=True
788
+ )
789
+ or ""
790
+ )
791
+
792
+ latex = context.formatter.index(escaped_text, entry=escaped_entry, style=style_key)
793
+ node = mark_processed(NavigableString(latex))
794
+ context.state.has_index_entries = True
795
+ context.mark_processed(element)
796
+ context.suppress_children(element)
797
+ element.replace_with(node)
798
+
799
+
800
+ @renders(
801
+ "del",
802
+ phase=RenderPhase.INLINE,
803
+ priority=35,
804
+ name="critic_deletions",
805
+ auto_mark=False,
806
+ )
807
+ def render_critic_deletions(element: Tag, context: RenderContext) -> None:
808
+ """Convert critic-marked deletions into LaTeX review macros."""
809
+ classes = gather_classes(element.get("class"))
810
+ if "critic" not in classes:
811
+ return
812
+
813
+ text = element.get_text(strip=False)
814
+ latex = context.formatter.deletion(text=text)
815
+ node = mark_processed(NavigableString(latex))
816
+ context.mark_processed(element)
817
+ context.suppress_children(element)
818
+ element.replace_with(node)
819
+
820
+
821
+ @renders(
822
+ "ins",
823
+ phase=RenderPhase.INLINE,
824
+ priority=35,
825
+ name="critic_additions",
826
+ auto_mark=False,
827
+ )
828
+ def render_critic_additions(element: Tag, context: RenderContext) -> None:
829
+ """Convert critic-marked insertions into LaTeX review macros."""
830
+ classes = gather_classes(element.get("class"))
831
+ if "critic" not in classes:
832
+ return
833
+
834
+ text = element.get_text(strip=False)
835
+ latex = context.formatter.addition(text=text)
836
+ node = mark_processed(NavigableString(latex))
837
+ context.mark_processed(element)
838
+ context.suppress_children(element)
839
+ element.replace_with(node)
840
+
841
+
842
+ @renders(
843
+ "span",
844
+ phase=RenderPhase.INLINE,
845
+ priority=35,
846
+ name="critic_comments",
847
+ auto_mark=False,
848
+ )
849
+ def render_critic_comments(element: Tag, context: RenderContext) -> None:
850
+ """Render critic comments as inline LaTeX annotations."""
851
+ classes = gather_classes(element.get("class"))
852
+ if "critic" not in classes or "comment" not in classes:
853
+ return
854
+
855
+ text = element.get_text(strip=False)
856
+ latex = context.formatter.comment(text=text)
857
+ node = mark_processed(NavigableString(latex))
858
+ context.mark_processed(element)
859
+ context.suppress_children(element)
860
+ element.replace_with(node)
861
+
862
+
863
+ @renders(
864
+ "mark",
865
+ phase=RenderPhase.INLINE,
866
+ priority=35,
867
+ name="critic_highlight",
868
+ auto_mark=False,
869
+ )
870
+ def render_critic_highlight(element: Tag, context: RenderContext) -> None:
871
+ """Render critic highlights using the formatter highlighting helper."""
872
+ classes = gather_classes(element.get("class"))
873
+ if "critic" not in classes:
874
+ return
875
+
876
+ text = element.get_text(strip=False)
877
+ latex = context.formatter.highlight(text=text)
878
+ node = mark_processed(NavigableString(latex))
879
+ context.mark_processed(element)
880
+ context.suppress_children(element)
881
+ element.replace_with(node)
882
+
883
+
884
+ @renders(
885
+ "span",
886
+ phase=RenderPhase.INLINE,
887
+ priority=-10,
888
+ name="critic_substitution",
889
+ auto_mark=False,
890
+ )
891
+ def render_critic_substitution(element: Tag, context: RenderContext) -> None:
892
+ """Render critic substitutions as paired deletion/addition markup."""
893
+ classes = gather_classes(element.get("class"))
894
+ if "critic" not in classes or "subst" not in classes:
895
+ return
896
+
897
+ deleted = element.find("del")
898
+ inserted = element.find("ins")
899
+ if deleted is None or inserted is None:
900
+ raise InvalidNodeError("Critic substitution requires both <del> and <ins> children")
901
+
902
+ original = deleted.get_text(strip=False)
903
+ replacement = inserted.get_text(strip=False)
904
+
905
+ latex = context.formatter.substitution(original=original, replacement=replacement)
906
+ node = mark_processed(NavigableString(latex))
907
+ context.mark_processed(element)
908
+ context.suppress_children(element)
909
+ element.replace_with(node)
910
+
911
+
912
+ _BLOCK_MATH_ENVIRONMENTS = {
913
+ "align",
914
+ "align*",
915
+ "equation",
916
+ "equation*",
917
+ }
918
+
919
+
920
+ _DISPLAY_MATH_PATTERN = re.compile(r"^\\\[\s*(.*?)\s*\\\]\s*$", re.DOTALL)
921
+
922
+
923
+ def _payload_is_block_environment(payload: str) -> bool:
924
+ stripped = payload.lstrip()
925
+ match = re.match(r"\\begin\{([^}]+)\}", stripped)
926
+ return bool(match and match.group(1).lower() in _BLOCK_MATH_ENVIRONMENTS)
927
+
928
+
929
+ @renders(phase=RenderPhase.POST, priority=5, name="inline_code_fallback", auto_mark=False)
930
+ def render_inline_code_fallback(root: Tag, context: RenderContext) -> None:
931
+ """Convert lingering inline code nodes that escaped the PRE phase."""
932
+ for code in list(root.find_all("code")):
933
+ if code.find_parent("pre"):
934
+ continue
935
+ if context.is_processed(code):
936
+ continue
937
+ render_inline_code(code, context)