Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/mdit_py_plugins/dollarmath/index.py: 75%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

175 statements  

1from __future__ import annotations 

2 

3from collections.abc import Callable, Sequence 

4import re 

5from typing import TYPE_CHECKING, Any 

6 

7from markdown_it import MarkdownIt 

8from markdown_it.common.utils import escapeHtml, isWhiteSpace 

9from markdown_it.rules_block import StateBlock 

10from markdown_it.rules_inline import StateInline 

11 

12from mdit_py_plugins.utils import is_code_block 

13 

14if TYPE_CHECKING: 

15 from markdown_it.renderer import RendererProtocol 

16 from markdown_it.token import Token 

17 from markdown_it.utils import EnvType, OptionsDict 

18 

19 

20def dollarmath_plugin( 

21 md: MarkdownIt, 

22 *, 

23 allow_labels: bool = True, 

24 allow_space: bool = True, 

25 allow_digits: bool = True, 

26 allow_blank_lines: bool = True, 

27 double_inline: bool = False, 

28 label_normalizer: Callable[[str], str] | None = None, 

29 renderer: Callable[[str, dict[str, Any]], str] | None = None, 

30 label_renderer: Callable[[str], str] | None = None, 

31) -> None: 

32 """Plugin for parsing dollar enclosed math, 

33 e.g. inline: ``$a=1$``, block: ``$$b=2$$`` 

34 

35 This is an improved version of ``texmath``; it is more performant, 

36 and handles ``\\`` escaping properly and allows for more configuration. 

37 

38 :param allow_labels: Capture math blocks with label suffix, e.g. ``$$a=1$$ (eq1)`` 

39 :param allow_space: Parse inline math when there is space 

40 after/before the opening/closing ``$``, e.g. ``$ a $`` 

41 :param allow_digits: Parse inline math when there is a digit 

42 before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. 

43 This is useful when also using currency. 

44 :param allow_blank_lines: Allow blank lines inside ``$$``. Note that blank lines are 

45 not allowed in LaTeX, executablebooks/markdown-it-dollarmath, or the Github or 

46 StackExchange markdown dialects. Hoever, they have special semantics if used 

47 within Sphinx `..math` admonitions, so are allowed for backwards-compatibility. 

48 :param double_inline: Search for double-dollar math within inline contexts 

49 :param label_normalizer: Function to normalize the label, 

50 by default replaces whitespace with `-` 

51 :param renderer: Function to render content: `(str, {"display_mode": bool}) -> str`, 

52 by default escapes HTML 

53 :param label_renderer: Function to render labels, by default creates anchor 

54 

55 """ 

56 if label_normalizer is None: 

57 label_normalizer = lambda label: re.sub(r"\s+", "-", label) # noqa: E731 

58 

59 md.inline.ruler.before( 

60 "escape", 

61 "math_inline", 

62 math_inline_dollar(allow_space, allow_digits, double_inline), 

63 ) 

64 md.block.ruler.before( 

65 "fence", 

66 "math_block", 

67 math_block_dollar(allow_labels, label_normalizer, allow_blank_lines), 

68 ) 

69 

70 # TODO the current render rules are really just for testing 

71 # would be good to allow "proper" math rendering, 

72 # e.g. https://github.com/roniemartinez/latex2mathml 

73 

74 _renderer = ( 

75 (lambda content, _: escapeHtml(content)) if renderer is None else renderer 

76 ) 

77 

78 _label_renderer: Callable[[str], str] 

79 if label_renderer is None: 

80 _label_renderer = ( # noqa: E731 

81 lambda label: ( 

82 f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>' 

83 ) 

84 ) 

85 else: 

86 _label_renderer = label_renderer 

87 

88 def render_math_inline( 

89 self: RendererProtocol, 

90 tokens: Sequence[Token], 

91 idx: int, 

92 options: OptionsDict, 

93 env: EnvType, 

94 ) -> str: 

95 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": False}) 

96 return f'<span class="math inline">{content}</span>' 

97 

98 def render_math_inline_double( 

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 return f'<div class="math inline">{content}</div>' 

107 

108 def render_math_block( 

109 self: RendererProtocol, 

110 tokens: Sequence[Token], 

111 idx: int, 

112 options: OptionsDict, 

113 env: EnvType, 

114 ) -> str: 

115 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) 

116 return f'<div class="math block">\n{content}\n</div>\n' 

117 

118 def render_math_block_label( 

119 self: RendererProtocol, 

120 tokens: Sequence[Token], 

121 idx: int, 

122 options: OptionsDict, 

123 env: EnvType, 

124 ) -> str: 

125 content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True}) 

126 _id = tokens[idx].info 

127 label = _label_renderer(tokens[idx].info) 

128 return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n' 

129 

130 md.add_render_rule("math_inline", render_math_inline) 

131 md.add_render_rule("math_inline_double", render_math_inline_double) 

132 

133 md.add_render_rule("math_block", render_math_block) 

134 md.add_render_rule("math_block_label", render_math_block_label) 

135 

136 

137def is_escaped(state: StateInline, back_pos: int, mod: int = 0) -> bool: 

138 """Test if dollar is escaped.""" 

139 # count how many \ are before the current position 

140 backslashes = 0 

141 while back_pos >= 0: 

142 back_pos = back_pos - 1 

143 if state.src[back_pos] == "\\": 

144 backslashes += 1 

145 else: 

146 break 

147 

148 if not backslashes: 

149 return False 

150 

151 # if an odd number of \ then ignore 

152 if (backslashes % 2) != mod: # noqa: SIM103 

153 return True 

154 

155 return False 

156 

157 

158def math_inline_dollar( 

159 allow_space: bool = True, allow_digits: bool = True, allow_double: bool = False 

160) -> Callable[[StateInline, bool], bool]: 

161 """Generate inline dollar rule. 

162 

163 :param allow_space: Parse inline math when there is space 

164 after/before the opening/closing ``$``, e.g. ``$ a $`` 

165 :param allow_digits: Parse inline math when there is a digit 

166 before/after the opening/closing ``$``, e.g. ``1$`` or ``$2``. 

167 This is useful when also using currency. 

168 :param allow_double: Search for double-dollar math within inline contexts 

169 

170 """ 

171 

172 def _math_inline_dollar(state: StateInline, silent: bool) -> bool: 

173 """Inline dollar rule. 

174 

175 - Initial check: 

176 - check if first character is a $ 

177 - check if the first character is escaped 

178 - check if the next character is a space (if not allow_space) 

179 - check if the next character is a digit (if not allow_digits) 

180 - Advance one, if allow_double 

181 - Find closing (advance one, if allow_double) 

182 - Check closing: 

183 - check if the previous character is a space (if not allow_space) 

184 - check if the next character is a digit (if not allow_digits) 

185 - Check empty content 

186 """ 

187 

188 # TODO options: 

189 # even/odd backslash escaping 

190 

191 if state.src[state.pos] != "$": 

192 return False 

193 

194 if not allow_space: 

195 # whitespace not allowed straight after opening $ 

196 try: 

197 if isWhiteSpace(ord(state.src[state.pos + 1])): 

198 return False 

199 except IndexError: 

200 return False 

201 

202 if not allow_digits: 

203 # digit not allowed straight before opening $ 

204 try: 

205 if state.src[state.pos - 1].isdigit(): 

206 return False 

207 except IndexError: 

208 pass 

209 

210 if is_escaped(state, state.pos): 

211 return False 

212 

213 try: 

214 is_double = allow_double and state.src[state.pos + 1] == "$" 

215 except IndexError: 

216 return False 

217 

218 # find closing $ 

219 pos = state.pos + 1 + (1 if is_double else 0) 

220 found_closing = False 

221 while not found_closing: 

222 try: 

223 end = state.src.index("$", pos) 

224 except ValueError: 

225 return False 

226 

227 if is_escaped(state, end): 

228 pos = end + 1 

229 continue 

230 

231 try: 

232 if is_double and state.src[end + 1] != "$": 

233 pos = end + 1 

234 continue 

235 except IndexError: 

236 return False 

237 

238 if is_double: 

239 end += 1 

240 

241 found_closing = True 

242 

243 if not found_closing: 

244 return False 

245 

246 if not allow_space: 

247 # whitespace not allowed straight before closing $ 

248 try: 

249 if isWhiteSpace(ord(state.src[end - 1])): 

250 return False 

251 except IndexError: 

252 return False 

253 

254 if not allow_digits: 

255 # digit not allowed straight after closing $ 

256 try: 

257 if state.src[end + 1].isdigit(): 

258 return False 

259 except IndexError: 

260 pass 

261 

262 text = ( 

263 state.src[state.pos + 2 : end - 1] 

264 if is_double 

265 else state.src[state.pos + 1 : end] 

266 ) 

267 

268 # ignore empty 

269 if not text: 

270 return False 

271 

272 if not silent: 

273 token = state.push( 

274 "math_inline_double" if is_double else "math_inline", "math", 0 

275 ) 

276 token.content = text 

277 token.markup = "$$" if is_double else "$" 

278 

279 state.pos = end + 1 

280 

281 return True 

282 

283 return _math_inline_dollar 

284 

285 

286# reversed end of block dollar equation, with equation label 

287DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}") 

288 

289 

290def math_block_dollar( 

291 allow_labels: bool = True, 

292 label_normalizer: Callable[[str], str] | None = None, 

293 allow_blank_lines: bool = False, 

294) -> Callable[[StateBlock, int, int, bool], bool]: 

295 """Generate block dollar rule.""" 

296 

297 def _math_block_dollar( 

298 state: StateBlock, startLine: int, endLine: int, silent: bool 

299 ) -> bool: 

300 # TODO internal backslash escaping 

301 

302 if is_code_block(state, startLine): 

303 return False 

304 

305 haveEndMarker = False 

306 startPos = state.bMarks[startLine] + state.tShift[startLine] 

307 end = state.eMarks[startLine] 

308 

309 if startPos + 2 > end: 

310 return False 

311 

312 if state.src[startPos] != "$" or state.src[startPos + 1] != "$": 

313 return False 

314 

315 # search for end of block 

316 nextLine = startLine 

317 label = None 

318 

319 # search for end of block on same line 

320 lineText = state.src[startPos:end] 

321 if len(lineText.strip()) > 3: 

322 if lineText.strip().endswith("$$"): 

323 haveEndMarker = True 

324 end = end - 2 - (len(lineText) - len(lineText.strip())) 

325 elif allow_labels: 

326 # reverse the line and match 

327 eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) 

328 if eqnoMatch: 

329 haveEndMarker = True 

330 label = eqnoMatch.group(1)[::-1] 

331 end = end - eqnoMatch.end() 

332 

333 # search for end of block on subsequent line 

334 if not haveEndMarker: 

335 while True: 

336 nextLine += 1 

337 if nextLine >= endLine: 

338 break 

339 

340 start = state.bMarks[nextLine] + state.tShift[nextLine] 

341 end = state.eMarks[nextLine] 

342 

343 lineText = state.src[start:end] 

344 

345 if lineText.strip().endswith("$$"): 

346 haveEndMarker = True 

347 end = end - 2 - (len(lineText) - len(lineText.strip())) 

348 break 

349 if lineText.strip() == "" and not allow_blank_lines: 

350 break # blank lines are not allowed within $$ 

351 

352 # reverse the line and match 

353 if allow_labels: 

354 eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1]) 

355 if eqnoMatch: 

356 haveEndMarker = True 

357 label = eqnoMatch.group(1)[::-1] 

358 end = end - eqnoMatch.end() 

359 break 

360 

361 if not haveEndMarker: 

362 return False 

363 

364 state.line = nextLine + (1 if haveEndMarker else 0) 

365 

366 token = state.push("math_block_label" if label else "math_block", "math", 0) 

367 token.block = True 

368 token.content = state.src[startPos + 2 : end] 

369 token.markup = "$$" 

370 token.map = [startLine, state.line] 

371 if label: 

372 token.info = label if label_normalizer is None else label_normalizer(label) 

373 

374 return True 

375 

376 return _math_block_dollar