1from __future__ import annotations
2
3from collections.abc import Callable, Sequence
4import re
5from typing import TYPE_CHECKING, Any
6
7from markdown_it import MarkdownIt
8from markdown_it.common.utils import escapeHtml, isWhiteSpace
9from markdown_it.rules_block import StateBlock
10from markdown_it.rules_inline import StateInline
11
12from mdit_py_plugins.utils import is_code_block
13
14if TYPE_CHECKING:
15 from markdown_it.renderer import RendererProtocol
16 from markdown_it.token import Token
17 from markdown_it.utils import EnvType, OptionsDict
18
19
20def dollarmath_plugin(
21 md: MarkdownIt,
22 *,
23 allow_labels: bool = True,
24 allow_space: bool = True,
25 allow_digits: bool = True,
26 allow_blank_lines: bool = True,
27 double_inline: bool = False,
28 label_normalizer: Callable[[str], str] | None = None,
29 renderer: Callable[[str, dict[str, Any]], str] | None = None,
30 label_renderer: Callable[[str], str] | None = None,
31) -> None:
32 """Plugin for parsing dollar enclosed math,
33 e.g. inline: ``$a=1$``, block: ``$$b=2$$``
34
35 This is an improved version of ``texmath``; it is more performant,
36 and handles ``\\`` escaping properly and allows for more configuration.
37
38 :param allow_labels: Capture math blocks with label suffix, e.g. ``$$a=1$$ (eq1)``
39 :param allow_space: Parse inline math when there is space
40 after/before the opening/closing ``$``, e.g. ``$ a $``
41 :param allow_digits: Parse inline math when there is a digit
42 before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``.
43 This is useful when also using currency.
44 :param allow_blank_lines: Allow blank lines inside ``$$``. Note that blank lines are
45 not allowed in LaTeX, executablebooks/markdown-it-dollarmath, or the Github or
46 StackExchange markdown dialects. Hoever, they have special semantics if used
47 within Sphinx `..math` admonitions, so are allowed for backwards-compatibility.
48 :param double_inline: Search for double-dollar math within inline contexts
49 :param label_normalizer: Function to normalize the label,
50 by default replaces whitespace with `-`
51 :param renderer: Function to render content: `(str, {"display_mode": bool}) -> str`,
52 by default escapes HTML
53 :param label_renderer: Function to render labels, by default creates anchor
54
55 """
56 if label_normalizer is None:
57 label_normalizer = lambda label: re.sub(r"\s+", "-", label) # noqa: E731
58
59 md.inline.ruler.before(
60 "escape",
61 "math_inline",
62 math_inline_dollar(allow_space, allow_digits, double_inline),
63 )
64 md.block.ruler.before(
65 "fence",
66 "math_block",
67 math_block_dollar(allow_labels, label_normalizer, allow_blank_lines),
68 )
69
70 # TODO the current render rules are really just for testing
71 # would be good to allow "proper" math rendering,
72 # e.g. https://github.com/roniemartinez/latex2mathml
73
74 _renderer = (
75 (lambda content, _: escapeHtml(content)) if renderer is None else renderer
76 )
77
78 _label_renderer: Callable[[str], str]
79 if label_renderer is None:
80 _label_renderer = ( # noqa: E731
81 lambda label: f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>'
82 )
83 else:
84 _label_renderer = label_renderer
85
86 def render_math_inline(
87 self: RendererProtocol,
88 tokens: Sequence[Token],
89 idx: int,
90 options: OptionsDict,
91 env: EnvType,
92 ) -> str:
93 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": False})
94 return f'<span class="math inline">{content}</span>'
95
96 def render_math_inline_double(
97 self: RendererProtocol,
98 tokens: Sequence[Token],
99 idx: int,
100 options: OptionsDict,
101 env: EnvType,
102 ) -> str:
103 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
104 return f'<div class="math inline">{content}</div>'
105
106 def render_math_block(
107 self: RendererProtocol,
108 tokens: Sequence[Token],
109 idx: int,
110 options: OptionsDict,
111 env: EnvType,
112 ) -> str:
113 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
114 return f'<div class="math block">\n{content}\n</div>\n'
115
116 def render_math_block_label(
117 self: RendererProtocol,
118 tokens: Sequence[Token],
119 idx: int,
120 options: OptionsDict,
121 env: EnvType,
122 ) -> str:
123 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
124 _id = tokens[idx].info
125 label = _label_renderer(tokens[idx].info)
126 return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n'
127
128 md.add_render_rule("math_inline", render_math_inline)
129 md.add_render_rule("math_inline_double", render_math_inline_double)
130
131 md.add_render_rule("math_block", render_math_block)
132 md.add_render_rule("math_block_label", render_math_block_label)
133
134
135def is_escaped(state: StateInline, back_pos: int, mod: int = 0) -> bool:
136 """Test if dollar is escaped."""
137 # count how many \ are before the current position
138 backslashes = 0
139 while back_pos >= 0:
140 back_pos = back_pos - 1
141 if state.src[back_pos] == "\\":
142 backslashes += 1
143 else:
144 break
145
146 if not backslashes:
147 return False
148
149 # if an odd number of \ then ignore
150 if (backslashes % 2) != mod: # noqa: SIM103
151 return True
152
153 return False
154
155
156def math_inline_dollar(
157 allow_space: bool = True, allow_digits: bool = True, allow_double: bool = False
158) -> Callable[[StateInline, bool], bool]:
159 """Generate inline dollar rule.
160
161 :param allow_space: Parse inline math when there is space
162 after/before the opening/closing ``$``, e.g. ``$ a $``
163 :param allow_digits: Parse inline math when there is a digit
164 before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``.
165 This is useful when also using currency.
166 :param allow_double: Search for double-dollar math within inline contexts
167
168 """
169
170 def _math_inline_dollar(state: StateInline, silent: bool) -> bool:
171 """Inline dollar rule.
172
173 - Initial check:
174 - check if first character is a $
175 - check if the first character is escaped
176 - check if the next character is a space (if not allow_space)
177 - check if the next character is a digit (if not allow_digits)
178 - Advance one, if allow_double
179 - Find closing (advance one, if allow_double)
180 - Check closing:
181 - check if the previous character is a space (if not allow_space)
182 - check if the next character is a digit (if not allow_digits)
183 - Check empty content
184 """
185
186 # TODO options:
187 # even/odd backslash escaping
188
189 if state.src[state.pos] != "$":
190 return False
191
192 if not allow_space:
193 # whitespace not allowed straight after opening $
194 try:
195 if isWhiteSpace(ord(state.src[state.pos + 1])):
196 return False
197 except IndexError:
198 return False
199
200 if not allow_digits:
201 # digit not allowed straight before opening $
202 try:
203 if state.src[state.pos - 1].isdigit():
204 return False
205 except IndexError:
206 pass
207
208 if is_escaped(state, state.pos):
209 return False
210
211 try:
212 is_double = allow_double and state.src[state.pos + 1] == "$"
213 except IndexError:
214 return False
215
216 # find closing $
217 pos = state.pos + 1 + (1 if is_double else 0)
218 found_closing = False
219 while not found_closing:
220 try:
221 end = state.src.index("$", pos)
222 except ValueError:
223 return False
224
225 if is_escaped(state, end):
226 pos = end + 1
227 continue
228
229 try:
230 if is_double and state.src[end + 1] != "$":
231 pos = end + 1
232 continue
233 except IndexError:
234 return False
235
236 if is_double:
237 end += 1
238
239 found_closing = True
240
241 if not found_closing:
242 return False
243
244 if not allow_space:
245 # whitespace not allowed straight before closing $
246 try:
247 if isWhiteSpace(ord(state.src[end - 1])):
248 return False
249 except IndexError:
250 return False
251
252 if not allow_digits:
253 # digit not allowed straight after closing $
254 try:
255 if state.src[end + 1].isdigit():
256 return False
257 except IndexError:
258 pass
259
260 text = (
261 state.src[state.pos + 2 : end - 1]
262 if is_double
263 else state.src[state.pos + 1 : end]
264 )
265
266 # ignore empty
267 if not text:
268 return False
269
270 if not silent:
271 token = state.push(
272 "math_inline_double" if is_double else "math_inline", "math", 0
273 )
274 token.content = text
275 token.markup = "$$" if is_double else "$"
276
277 state.pos = end + 1
278
279 return True
280
281 return _math_inline_dollar
282
283
284# reversed end of block dollar equation, with equation label
285DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}")
286
287
288def math_block_dollar(
289 allow_labels: bool = True,
290 label_normalizer: Callable[[str], str] | None = None,
291 allow_blank_lines: bool = False,
292) -> Callable[[StateBlock, int, int, bool], bool]:
293 """Generate block dollar rule."""
294
295 def _math_block_dollar(
296 state: StateBlock, startLine: int, endLine: int, silent: bool
297 ) -> bool:
298 # TODO internal backslash escaping
299
300 if is_code_block(state, startLine):
301 return False
302
303 haveEndMarker = False
304 startPos = state.bMarks[startLine] + state.tShift[startLine]
305 end = state.eMarks[startLine]
306
307 if startPos + 2 > end:
308 return False
309
310 if state.src[startPos] != "$" or state.src[startPos + 1] != "$":
311 return False
312
313 # search for end of block
314 nextLine = startLine
315 label = None
316
317 # search for end of block on same line
318 lineText = state.src[startPos:end]
319 if len(lineText.strip()) > 3:
320 if lineText.strip().endswith("$$"):
321 haveEndMarker = True
322 end = end - 2 - (len(lineText) - len(lineText.strip()))
323 elif allow_labels:
324 # reverse the line and match
325 eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
326 if eqnoMatch:
327 haveEndMarker = True
328 label = eqnoMatch.group(1)[::-1]
329 end = end - eqnoMatch.end()
330
331 # search for end of block on subsequent line
332 if not haveEndMarker:
333 while True:
334 nextLine += 1
335 if nextLine >= endLine:
336 break
337
338 start = state.bMarks[nextLine] + state.tShift[nextLine]
339 end = state.eMarks[nextLine]
340
341 lineText = state.src[start:end]
342
343 if lineText.strip().endswith("$$"):
344 haveEndMarker = True
345 end = end - 2 - (len(lineText) - len(lineText.strip()))
346 break
347 if lineText.strip() == "" and not allow_blank_lines:
348 break # blank lines are not allowed within $$
349
350 # reverse the line and match
351 if allow_labels:
352 eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
353 if eqnoMatch:
354 haveEndMarker = True
355 label = eqnoMatch.group(1)[::-1]
356 end = end - eqnoMatch.end()
357 break
358
359 if not haveEndMarker:
360 return False
361
362 state.line = nextLine + (1 if haveEndMarker else 0)
363
364 token = state.push("math_block_label" if label else "math_block", "math", 0)
365 token.block = True
366 token.content = state.src[startPos + 2 : end]
367 token.markup = "$$"
368 token.map = [startLine, state.line]
369 if label:
370 token.info = label if label_normalizer is None else label_normalizer(label)
371
372 return True
373
374 return _math_block_dollar