index.py 12 KB

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