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: (
82 f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>'
83 )
84 )
85 else:
86 _label_renderer = label_renderer
87
88 def render_math_inline(
89 self: RendererProtocol,
90 tokens: Sequence[Token],
91 idx: int,
92 options: OptionsDict,
93 env: EnvType,
94 ) -> str:
95 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": False})
96 return f'<span class="math inline">{content}</span>'
97
98 def render_math_inline_double(
99 self: RendererProtocol,
100 tokens: Sequence[Token],
101 idx: int,
102 options: OptionsDict,
103 env: EnvType,
104 ) -> str:
105 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
106 return f'<div class="math inline">{content}</div>'
107
108 def render_math_block(
109 self: RendererProtocol,
110 tokens: Sequence[Token],
111 idx: int,
112 options: OptionsDict,
113 env: EnvType,
114 ) -> str:
115 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
116 return f'<div class="math block">\n{content}\n</div>\n'
117
118 def render_math_block_label(
119 self: RendererProtocol,
120 tokens: Sequence[Token],
121 idx: int,
122 options: OptionsDict,
123 env: EnvType,
124 ) -> str:
125 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
126 _id = tokens[idx].info
127 label = _label_renderer(tokens[idx].info)
128 return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n'
129
130 md.add_render_rule("math_inline", render_math_inline)
131 md.add_render_rule("math_inline_double", render_math_inline_double)
132
133 md.add_render_rule("math_block", render_math_block)
134 md.add_render_rule("math_block_label", render_math_block_label)
135
136
137def is_escaped(state: StateInline, back_pos: int, mod: int = 0) -> bool:
138 """Test if dollar is escaped."""
139 # count how many \ are before the current position
140 backslashes = 0
141 while back_pos >= 0:
142 back_pos = back_pos - 1
143 if state.src[back_pos] == "\\":
144 backslashes += 1
145 else:
146 break
147
148 if not backslashes:
149 return False
150
151 # if an odd number of \ then ignore
152 if (backslashes % 2) != mod: # noqa: SIM103
153 return True
154
155 return False
156
157
158def math_inline_dollar(
159 allow_space: bool = True, allow_digits: bool = True, allow_double: bool = False
160) -> Callable[[StateInline, bool], bool]:
161 """Generate inline dollar rule.
162
163 :param allow_space: Parse inline math when there is space
164 after/before the opening/closing ``$``, e.g. ``$ a $``
165 :param allow_digits: Parse inline math when there is a digit
166 before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``.
167 This is useful when also using currency.
168 :param allow_double: Search for double-dollar math within inline contexts
169
170 """
171
172 def _math_inline_dollar(state: StateInline, silent: bool) -> bool:
173 """Inline dollar rule.
174
175 - Initial check:
176 - check if first character is a $
177 - check if the first character is escaped
178 - check if the next character is a space (if not allow_space)
179 - check if the next character is a digit (if not allow_digits)
180 - Advance one, if allow_double
181 - Find closing (advance one, if allow_double)
182 - Check closing:
183 - check if the previous character is a space (if not allow_space)
184 - check if the next character is a digit (if not allow_digits)
185 - Check empty content
186 """
187
188 # TODO options:
189 # even/odd backslash escaping
190
191 if state.src[state.pos] != "$":
192 return False
193
194 if not allow_space:
195 # whitespace not allowed straight after opening $
196 try:
197 if isWhiteSpace(ord(state.src[state.pos + 1])):
198 return False
199 except IndexError:
200 return False
201
202 if not allow_digits:
203 # digit not allowed straight before opening $
204 try:
205 if state.src[state.pos - 1].isdigit():
206 return False
207 except IndexError:
208 pass
209
210 if is_escaped(state, state.pos):
211 return False
212
213 try:
214 is_double = allow_double and state.src[state.pos + 1] == "$"
215 except IndexError:
216 return False
217
218 # find closing $
219 pos = state.pos + 1 + (1 if is_double else 0)
220 found_closing = False
221 while not found_closing:
222 try:
223 end = state.src.index("$", pos)
224 except ValueError:
225 return False
226
227 if is_escaped(state, end):
228 pos = end + 1
229 continue
230
231 try:
232 if is_double and state.src[end + 1] != "$":
233 pos = end + 1
234 continue
235 except IndexError:
236 return False
237
238 if is_double:
239 end += 1
240
241 found_closing = True
242
243 if not found_closing:
244 return False
245
246 if not allow_space:
247 # whitespace not allowed straight before closing $
248 try:
249 if isWhiteSpace(ord(state.src[end - 1])):
250 return False
251 except IndexError:
252 return False
253
254 if not allow_digits:
255 # digit not allowed straight after closing $
256 try:
257 if state.src[end + 1].isdigit():
258 return False
259 except IndexError:
260 pass
261
262 text = (
263 state.src[state.pos + 2 : end - 1]
264 if is_double
265 else state.src[state.pos + 1 : end]
266 )
267
268 # ignore empty
269 if not text:
270 return False
271
272 if not silent:
273 token = state.push(
274 "math_inline_double" if is_double else "math_inline", "math", 0
275 )
276 token.content = text
277 token.markup = "$$" if is_double else "$"
278
279 state.pos = end + 1
280
281 return True
282
283 return _math_inline_dollar
284
285
286# reversed end of block dollar equation, with equation label
287DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}")
288
289
290def math_block_dollar(
291 allow_labels: bool = True,
292 label_normalizer: Callable[[str], str] | None = None,
293 allow_blank_lines: bool = False,
294) -> Callable[[StateBlock, int, int, bool], bool]:
295 """Generate block dollar rule."""
296
297 def _math_block_dollar(
298 state: StateBlock, startLine: int, endLine: int, silent: bool
299 ) -> bool:
300 # TODO internal backslash escaping
301
302 if is_code_block(state, startLine):
303 return False
304
305 haveEndMarker = False
306 startPos = state.bMarks[startLine] + state.tShift[startLine]
307 end = state.eMarks[startLine]
308
309 if startPos + 2 > end:
310 return False
311
312 if state.src[startPos] != "$" or state.src[startPos + 1] != "$":
313 return False
314
315 # search for end of block
316 nextLine = startLine
317 label = None
318
319 # search for end of block on same line
320 lineText = state.src[startPos:end]
321 if len(lineText.strip()) > 3:
322 if lineText.strip().endswith("$$"):
323 haveEndMarker = True
324 end = end - 2 - (len(lineText) - len(lineText.strip()))
325 elif allow_labels:
326 # reverse the line and match
327 eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
328 if eqnoMatch:
329 haveEndMarker = True
330 label = eqnoMatch.group(1)[::-1]
331 end = end - eqnoMatch.end()
332
333 # search for end of block on subsequent line
334 if not haveEndMarker:
335 while True:
336 nextLine += 1
337 if nextLine >= endLine:
338 break
339
340 start = state.bMarks[nextLine] + state.tShift[nextLine]
341 end = state.eMarks[nextLine]
342
343 lineText = state.src[start:end]
344
345 if lineText.strip().endswith("$$"):
346 haveEndMarker = True
347 end = end - 2 - (len(lineText) - len(lineText.strip()))
348 break
349 if lineText.strip() == "" and not allow_blank_lines:
350 break # blank lines are not allowed within $$
351
352 # reverse the line and match
353 if allow_labels:
354 eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
355 if eqnoMatch:
356 haveEndMarker = True
357 label = eqnoMatch.group(1)[::-1]
358 end = end - eqnoMatch.end()
359 break
360
361 if not haveEndMarker:
362 return False
363
364 state.line = nextLine + (1 if haveEndMarker else 0)
365
366 token = state.push("math_block_label" if label else "math_block", "math", 0)
367 token.block = True
368 token.content = state.src[startPos + 2 : end]
369 token.markup = "$$"
370 token.map = [startLine, state.line]
371 if label:
372 token.info = label if label_normalizer is None else label_normalizer(label)
373
374 return True
375
376 return _math_block_dollar