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