123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- from __future__ import annotations
- import re
- from typing import TYPE_CHECKING, Any, Callable, Sequence
- 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
- from mdit_py_plugins.utils import is_code_block
- if TYPE_CHECKING:
- from markdown_it.renderer import RendererProtocol
- from markdown_it.token import Token
- from markdown_it.utils import EnvType, OptionsDict
- def dollarmath_plugin(
- md: MarkdownIt,
- *,
- allow_labels: bool = True,
- allow_space: bool = True,
- allow_digits: bool = True,
- allow_blank_lines: bool = True,
- double_inline: bool = False,
- label_normalizer: Callable[[str], str] | None = None,
- renderer: Callable[[str, dict[str, Any]], str] | None = None,
- label_renderer: Callable[[str], str] | None = 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 allow_blank_lines: Allow blank lines inside ``$$``. Note that blank lines are
- not allowed in LaTeX, executablebooks/markdown-it-dollarmath, or the Github or
- StackExchange markdown dialects. Hoever, they have special semantics if used
- within Sphinx `..math` admonitions, so are allowed for backwards-compatibility.
- :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) # noqa: E731
- 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, allow_blank_lines),
- )
- # 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
- _renderer = (
- (lambda content, _: escapeHtml(content)) if renderer is None else renderer
- )
- _label_renderer: Callable[[str], str]
- if label_renderer is None:
- _label_renderer = ( # noqa: E731
- lambda label: f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>'
- )
- else:
- _label_renderer = label_renderer
- def render_math_inline(
- self: RendererProtocol,
- tokens: Sequence[Token],
- idx: int,
- options: OptionsDict,
- env: EnvType,
- ) -> 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: RendererProtocol,
- tokens: Sequence[Token],
- idx: int,
- options: OptionsDict,
- env: EnvType,
- ) -> str:
- content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
- return f'<div class="math inline">{content}</div>'
- def render_math_block(
- self: RendererProtocol,
- tokens: Sequence[Token],
- idx: int,
- options: OptionsDict,
- env: EnvType,
- ) -> 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: RendererProtocol,
- tokens: Sequence[Token],
- idx: int,
- options: OptionsDict,
- env: EnvType,
- ) -> 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.src[back_pos] == "\\":
- 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.src[state.pos] != "$":
- return False
- if not allow_space:
- # whitespace not allowed straight after opening $
- try:
- if isWhiteSpace(ord(state.src[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.src[state.pos + 1] == "$"
- 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.src.index("$", pos)
- except ValueError:
- return False
- if is_escaped(state, end):
- pos = end + 1
- continue
- try:
- if is_double and state.src[end + 1] != "$":
- 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(ord(state.src[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: Callable[[str], str] | None = None,
- allow_blank_lines: bool = False,
- ) -> 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
- if is_code_block(state, startLine):
- return False
- haveEndMarker = False
- startPos = state.bMarks[startLine] + state.tShift[startLine]
- end = state.eMarks[startLine]
- if startPos + 2 > end:
- return False
- if state.src[startPos] != "$" or state.src[startPos + 1] != "$":
- 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]
- lineText = state.src[start:end]
- if lineText.strip().endswith("$$"):
- haveEndMarker = True
- end = end - 2 - (len(lineText) - len(lineText.strip()))
- break
- if lineText.strip() == "" and not allow_blank_lines:
- break # blank lines are not allowed within $$
- # 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
|