Skip to content

Commit 91bf225

Browse files
authored
✨ NEW: Add dollarmath plugin (#38)
`dollarmath` is an improved version of `texmath`, for `$`/`$$` enclosed math only. It is more performant, handles `\` escaping properly and allows for more configuration. It is implemented in a more "markdown-it" way; primarily iterating through the source character, rather than using regexes. This is easier to understand, actually more performant (according to the benchmark tests), and allows for greater control over options like allowing internal spaces (which texmath hardcodes).
1 parent d70a7ff commit 91bf225

File tree

13 files changed

+1010
-7
lines changed

13 files changed

+1010
-7
lines changed

benchmarking/bench_plugins.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
amsmath,
77
container,
88
deflist,
9+
dollarmath,
910
footnote,
1011
front_matter,
1112
texmath,
@@ -67,3 +68,9 @@ def test_front_matter(benchmark, parser, spec_text):
6768
def test_texmath(benchmark, parser, spec_text):
6869
parser.use(texmath.texmath_plugin)
6970
benchmark(parser.render, spec_text)
71+
72+
73+
@pytest.mark.benchmark(group="plugins")
74+
def test_dollarmath(benchmark, parser, spec_text):
75+
parser.use(dollarmath.dollarmath_plugin)
76+
benchmark(parser.render, spec_text)

docs/contributing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ For documentation build tests:
6666

6767
1. Does it already exist as JavaScript implementation ([see npm](https://www.npmjs.com/search?q=keywords:markdown-it-plugin))?
6868
Where possible try to port directly from that.
69-
It is ususually better to modify existing code, instead of writing all from scratch.
69+
It is usually better to modify existing code, instead of writing all from scratch.
7070
2. Try to find the right place for your plugin rule:
7171
- Will it conflict with existing markup (by priority)?
7272
- If yes - you need to write an inline or block rule.

docs/plugins.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ Other plugins are then available *via* the `markdown_it.extensions` package:
6262
$\alpha = \frac{1}{2}$
6363
```
6464

65+
- `dollarmath` is an improved version of `texmath`, for `$`/`$$` enclosed math only.
66+
It is more performant, handles `\` escaping properly and allows for more configuration.
67+
6568
- `amsmath` also parses TeX math equations, but without the surrounding delimiters and only for top-level [amsmath](https://ctan.org/pkg/amsmath) environments:
6669

6770
```latex
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .index import dollarmath_plugin # noqa F401
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import re
2+
3+
from markdown_it import MarkdownIt
4+
from markdown_it.rules_inline import StateInline
5+
from markdown_it.rules_block import StateBlock
6+
from markdown_it.common.utils import isWhiteSpace
7+
8+
9+
def dollarmath_plugin(
10+
md: MarkdownIt, allow_labels=True, allow_space=True, allow_digits=True
11+
):
12+
"""Plugin for parsing dollar enclosed math, e.g. ``$a=1$``.
13+
14+
:param allow_labels: Capture math blocks with label suffix, e.g. ``$$a=1$$ (eq1)``
15+
:param allow_space: Parse inline math when there is space
16+
after/before the opening/closing ``$``, e.g. ``$ a $``
17+
:param allow_digits: Parse inline math when there is a digit
18+
before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``.
19+
This is useful when also using currency.
20+
"""
21+
22+
md.inline.ruler.before(
23+
"escape", "math_inline", math_inline_dollar(allow_space, allow_digits)
24+
)
25+
md.add_render_rule("math_inline", render_math_inline)
26+
27+
md.block.ruler.before("fence", "math_block", math_block_dollar(allow_labels))
28+
md.add_render_rule("math_block", render_math_block)
29+
md.add_render_rule("math_block_eqno", render_math_block_eqno)
30+
31+
32+
def render_math_inline(self, tokens, idx, options, env):
33+
return "<eq>{0}</eq>".format(tokens[idx].content)
34+
35+
36+
def render_math_block(self, tokens, idx, options, env):
37+
return "<section>\n<eqn>{0}</eqn>\n</section>\n".format(tokens[idx].content)
38+
39+
40+
def render_math_block_eqno(self, tokens, idx, options, env):
41+
return '<section>\n<eqn>{0}</eqn>\n<span class="eqno">({1})</span>\n</section>\n'.format(
42+
tokens[idx].content, tokens[idx].info
43+
)
44+
45+
46+
def is_escaped(state: StateInline, back_pos: int, mod: int = 0):
47+
# count how many \ are before the current position
48+
backslashes = 0
49+
while back_pos >= 0:
50+
back_pos = back_pos - 1
51+
if state.srcCharCode[back_pos] == 0x5C: # /* \ */
52+
backslashes += 1
53+
else:
54+
break
55+
56+
if not backslashes:
57+
return
58+
59+
# if an odd number of \ then ignore
60+
if (backslashes % 2) != mod:
61+
return True
62+
63+
return False
64+
65+
66+
def math_inline_dollar(allow_space=True, allow_digits=True):
67+
def _math_inline_dollar(state: StateInline, silent: bool):
68+
69+
# TODO options:
70+
# even/odd backslash escaping
71+
# allow $$ blocks
72+
73+
if state.srcCharCode[state.pos] != 0x24: # /* $ */
74+
return False
75+
76+
if not allow_space:
77+
# whitespace not allowed straight after opening $
78+
try:
79+
if isWhiteSpace(state.srcCharCode[state.pos + 1]):
80+
return False
81+
except IndexError:
82+
return False
83+
84+
if not allow_digits:
85+
# digit not allowed straight before opening $
86+
try:
87+
if state.src[state.pos - 1].isdigit():
88+
return False
89+
except IndexError:
90+
pass
91+
92+
if is_escaped(state, state.pos):
93+
return False
94+
95+
# find closing $
96+
pos = state.pos + 1
97+
found_closing = False
98+
while True:
99+
try:
100+
end = state.srcCharCode.index(0x24, pos)
101+
except ValueError:
102+
return False
103+
104+
if is_escaped(state, end):
105+
pos = end + 1
106+
else:
107+
found_closing = True
108+
break
109+
110+
if not found_closing:
111+
return False
112+
113+
if not allow_space:
114+
# whitespace not allowed straight before closing $
115+
try:
116+
if isWhiteSpace(state.srcCharCode[end - 1]):
117+
return False
118+
except IndexError:
119+
return False
120+
121+
if not allow_digits:
122+
# digit not allowed straight after closing $
123+
try:
124+
if state.src[end + 1].isdigit():
125+
return False
126+
except IndexError:
127+
pass
128+
129+
text = state.src[state.pos + 1 : end]
130+
131+
# ignore empty
132+
if not text:
133+
return False
134+
135+
if not silent:
136+
token = state.push("math_inline", "math", 0)
137+
token.content = text
138+
token.markup = "$"
139+
140+
state.pos = end + 1
141+
142+
return True
143+
144+
return _math_inline_dollar
145+
146+
147+
# reversed end of block dollar equation, with equation label
148+
DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}")
149+
150+
151+
def math_block_dollar(allow_labels=True):
152+
def _math_block_dollar(
153+
state: StateBlock, startLine: int, endLine: int, silent: bool
154+
):
155+
156+
# TODO internal backslash escaping
157+
158+
haveEndMarker = False
159+
startPos = state.bMarks[startLine] + state.tShift[startLine]
160+
end = state.eMarks[startLine]
161+
162+
# if it's indented more than 3 spaces, it should be a code block
163+
if state.sCount[startLine] - state.blkIndent >= 4:
164+
return False
165+
166+
if startPos + 2 > end:
167+
return False
168+
169+
if (
170+
state.srcCharCode[startPos] != 0x24
171+
or state.srcCharCode[startPos + 1] != 0x24
172+
): # /* $ */
173+
return False
174+
175+
# search for end of block
176+
nextLine = startLine
177+
label = None
178+
179+
# search for end of block on same line
180+
lineText = state.src[startPos:end]
181+
if len(lineText.strip()) > 3:
182+
lineText = state.src[startPos:end]
183+
if lineText.strip().endswith("$$"):
184+
haveEndMarker = True
185+
end = end - 2 - (len(lineText) - len(lineText.strip()))
186+
elif allow_labels:
187+
# reverse the line and match
188+
eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
189+
if eqnoMatch:
190+
haveEndMarker = True
191+
label = eqnoMatch.group(1)[::-1]
192+
end = end - eqnoMatch.end()
193+
194+
# search for end of block on subsequent line
195+
if not haveEndMarker:
196+
while True:
197+
nextLine += 1
198+
if nextLine >= endLine:
199+
break
200+
201+
start = state.bMarks[nextLine] + state.tShift[nextLine]
202+
end = state.eMarks[nextLine]
203+
204+
if end - start < 2:
205+
continue
206+
207+
lineText = state.src[start:end]
208+
209+
if lineText.strip().endswith("$$"):
210+
haveEndMarker = True
211+
end = end - 2 - (len(lineText) - len(lineText.strip()))
212+
break
213+
214+
# reverse the line and match
215+
if allow_labels:
216+
eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
217+
if eqnoMatch:
218+
haveEndMarker = True
219+
label = eqnoMatch.group(1)[::-1]
220+
end = end - eqnoMatch.end()
221+
break
222+
223+
if not haveEndMarker:
224+
return False
225+
226+
state.line = nextLine + (1 if haveEndMarker else 0)
227+
228+
token = state.push("math_block_eqno" if label else "math_block", "math", 0)
229+
token.block = True
230+
token.content = state.src[startPos + 2 : end]
231+
token.markup = "$$"
232+
token.map = [startLine, state.line]
233+
if label:
234+
token.info = label
235+
236+
return True
237+
238+
return _math_block_dollar

setup.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,12 @@ def get_version():
5151
"psutil",
5252
],
5353
"rtd": [
54-
"sphinx>=2,<4",
55-
"pyyaml",
56-
"sphinx_book_theme",
5754
"myst-nb",
58-
"sphinx-copybutton",
55+
"sphinx_book_theme",
5956
"sphinx-panels~=0.4.0",
57+
"sphinx-copybutton",
58+
"sphinx>=2,<4",
59+
"pyyaml",
6060
],
6161
"compare": [
6262
"commonmark~=0.9.1",

0 commit comments

Comments
 (0)