diff options
Diffstat (limited to 'mdit_py_plugins/dollarmath/index.py')
-rw-r--r-- | mdit_py_plugins/dollarmath/index.py | 339 |
1 files changed, 339 insertions, 0 deletions
diff --git a/mdit_py_plugins/dollarmath/index.py b/mdit_py_plugins/dollarmath/index.py new file mode 100644 index 0000000..5fe0381 --- /dev/null +++ b/mdit_py_plugins/dollarmath/index.py @@ -0,0 +1,339 @@ +import re +from typing import Any, Callable, Dict, Optional + +from markdown_it import MarkdownIt +from markdown_it.common.utils import escapeHtml, isWhiteSpace +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline + + +def dollarmath_plugin( + md: MarkdownIt, + *, + allow_labels: bool = True, + allow_space: bool = True, + allow_digits: bool = True, + double_inline: bool = False, + label_normalizer: Optional[Callable[[str], str]] = None, + renderer: Optional[Callable[[str, Dict[str, Any]], str]] = None, + label_renderer: Optional[Callable[[str], str]] = None, +) -> None: + """Plugin for parsing dollar enclosed math, + e.g. inline: ``$a=1$``, block: ``$$b=2$$`` + + This is an improved version of ``texmath``; it is more performant, + and handles ``\\`` escaping properly and allows for more configuration. + + :param allow_labels: Capture math blocks with label suffix, e.g. ``$$a=1$$ (eq1)`` + :param allow_space: Parse inline math when there is space + after/before the opening/closing ``$``, e.g. ``$ a $`` + :param allow_digits: Parse inline math when there is a digit + before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. + This is useful when also using currency. + :param double_inline: Search for double-dollar math within inline contexts + :param label_normalizer: Function to normalize the label, + by default replaces whitespace with `-` + :param renderer: Function to render content: `(str, {"display_mode": bool}) -> str`, + by default escapes HTML + :param label_renderer: Function to render labels, by default creates anchor + + """ + if label_normalizer is None: + label_normalizer = lambda label: re.sub(r"\s+", "-", label) + + md.inline.ruler.before( + "escape", + "math_inline", + math_inline_dollar(allow_space, allow_digits, double_inline), + ) + md.block.ruler.before( + "fence", "math_block", math_block_dollar(allow_labels, label_normalizer) + ) + + # TODO the current render rules are really just for testing + # would be good to allow "proper" math rendering, + # e.g. https://github.com/roniemartinez/latex2mathml + + if renderer is None: + _renderer = lambda content, _: escapeHtml(content) + else: + _renderer = renderer + + if label_renderer is None: + _label_renderer = ( + lambda label: f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">ΒΆ</a>' # noqa: E501 + ) + else: + _label_renderer = label_renderer + + def render_math_inline(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": False}) + return f'<span class="math inline">{content}</span>' + + def render_math_inline_double(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + return f'<div class="math inline">{content}</div>' + + def render_math_block(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + return f'<div class="math block">\n{content}\n</div>\n' + + def render_math_block_label(self, tokens, idx, options, env) -> str: + content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) + _id = tokens[idx].info + label = _label_renderer(tokens[idx].info) + return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n' + + md.add_render_rule("math_inline", render_math_inline) + md.add_render_rule("math_inline_double", render_math_inline_double) + + md.add_render_rule("math_block", render_math_block) + md.add_render_rule("math_block_label", render_math_block_label) + + +def is_escaped(state: StateInline, back_pos: int, mod: int = 0) -> bool: + """Test if dollar is escaped.""" + # count how many \ are before the current position + backslashes = 0 + while back_pos >= 0: + back_pos = back_pos - 1 + if state.srcCharCode[back_pos] == 0x5C: # /* \ */ + backslashes += 1 + else: + break + + if not backslashes: + return False + + # if an odd number of \ then ignore + if (backslashes % 2) != mod: + return True + + return False + + +def math_inline_dollar( + allow_space: bool = True, allow_digits: bool = True, allow_double: bool = False +) -> Callable[[StateInline, bool], bool]: + """Generate inline dollar rule. + + :param allow_space: Parse inline math when there is space + after/before the opening/closing ``$``, e.g. ``$ a $`` + :param allow_digits: Parse inline math when there is a digit + before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. + This is useful when also using currency. + :param allow_double: Search for double-dollar math within inline contexts + + """ + + def _math_inline_dollar(state: StateInline, silent: bool) -> bool: + """Inline dollar rule. + + - Initial check: + - check if first character is a $ + - check if the first character is escaped + - check if the next character is a space (if not allow_space) + - check if the next character is a digit (if not allow_digits) + - Advance one, if allow_double + - Find closing (advance one, if allow_double) + - Check closing: + - check if the previous character is a space (if not allow_space) + - check if the next character is a digit (if not allow_digits) + - Check empty content + """ + + # TODO options: + # even/odd backslash escaping + + if state.srcCharCode[state.pos] != 0x24: # /* $ */ + return False + + if not allow_space: + # whitespace not allowed straight after opening $ + try: + if isWhiteSpace(state.srcCharCode[state.pos + 1]): + return False + except IndexError: + return False + + if not allow_digits: + # digit not allowed straight before opening $ + try: + if state.src[state.pos - 1].isdigit(): + return False + except IndexError: + pass + + if is_escaped(state, state.pos): + return False + + try: + is_double = allow_double and state.srcCharCode[state.pos + 1] == 0x24 + except IndexError: + return False + + # find closing $ + pos = state.pos + 1 + (1 if is_double else 0) + found_closing = False + while not found_closing: + try: + end = state.srcCharCode.index(0x24, pos) + except ValueError: + return False + + if is_escaped(state, end): + pos = end + 1 + continue + + try: + if is_double and not state.srcCharCode[end + 1] == 0x24: + pos = end + 1 + continue + except IndexError: + return False + + if is_double: + end += 1 + + found_closing = True + + if not found_closing: + return False + + if not allow_space: + # whitespace not allowed straight before closing $ + try: + if isWhiteSpace(state.srcCharCode[end - 1]): + return False + except IndexError: + return False + + if not allow_digits: + # digit not allowed straight after closing $ + try: + if state.src[end + 1].isdigit(): + return False + except IndexError: + pass + + text = ( + state.src[state.pos + 2 : end - 1] + if is_double + else state.src[state.pos + 1 : end] + ) + + # ignore empty + if not text: + return False + + if not silent: + token = state.push( + "math_inline_double" if is_double else "math_inline", "math", 0 + ) + token.content = text + token.markup = "$$" if is_double else "$" + + state.pos = end + 1 + + return True + + return _math_inline_dollar + + +# reversed end of block dollar equation, with equation label +DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}") + + +def math_block_dollar( + allow_labels: bool = True, + label_normalizer: Optional[Callable[[str], str]] = None, +) -> Callable[[StateBlock, int, int, bool], bool]: + """Generate block dollar rule.""" + + def _math_block_dollar( + state: StateBlock, startLine: int, endLine: int, silent: bool + ) -> bool: + + # TODO internal backslash escaping + + haveEndMarker = False + startPos = state.bMarks[startLine] + state.tShift[startLine] + end = state.eMarks[startLine] + + # if it's indented more than 3 spaces, it should be a code block + if state.sCount[startLine] - state.blkIndent >= 4: + return False + + if startPos + 2 > end: + return False + + if ( + state.srcCharCode[startPos] != 0x24 + or state.srcCharCode[startPos + 1] != 0x24 + ): # /* $ */ + return False + + # search for end of block + nextLine = startLine + label = None + + # search for end of block on same line + lineText = state.src[startPos:end] + if len(lineText.strip()) > 3: + + if lineText.strip().endswith("$$"): + haveEndMarker = True + end = end - 2 - (len(lineText) - len(lineText.strip())) + elif allow_labels: + # reverse the line and match + eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) + if eqnoMatch: + haveEndMarker = True + label = eqnoMatch.group(1)[::-1] + end = end - eqnoMatch.end() + + # search for end of block on subsequent line + if not haveEndMarker: + while True: + nextLine += 1 + if nextLine >= endLine: + break + + start = state.bMarks[nextLine] + state.tShift[nextLine] + end = state.eMarks[nextLine] + + if end - start < 2: + continue + + lineText = state.src[start:end] + + if lineText.strip().endswith("$$"): + haveEndMarker = True + end = end - 2 - (len(lineText) - len(lineText.strip())) + break + + # reverse the line and match + if allow_labels: + eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) + if eqnoMatch: + haveEndMarker = True + label = eqnoMatch.group(1)[::-1] + end = end - eqnoMatch.end() + break + + if not haveEndMarker: + return False + + state.line = nextLine + (1 if haveEndMarker else 0) + + token = state.push("math_block_label" if label else "math_block", "math", 0) + token.block = True + token.content = state.src[startPos + 2 : end] + token.markup = "$$" + token.map = [startLine, state.line] + if label: + token.info = label if label_normalizer is None else label_normalizer(label) + + return True + + return _math_block_dollar |