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: f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>' 

82 ) 

83 else: 

84 _label_renderer = label_renderer 

85 

86 def render_math_inline( 

87 self: RendererProtocol, 

88 tokens: Sequence[Token], 

89 idx: int, 

90 options: OptionsDict, 

91 env: EnvType, 

92 ) -> str: 

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

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

95 

96 def render_math_inline_double( 

97 self: RendererProtocol, 

98 tokens: Sequence[Token], 

99 idx: int, 

100 options: OptionsDict, 

101 env: EnvType, 

102 ) -> str: 

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

104 return f'<div class="math inline">{content}</div>' 

105 

106 def render_math_block( 

107 self: RendererProtocol, 

108 tokens: Sequence[Token], 

109 idx: int, 

110 options: OptionsDict, 

111 env: EnvType, 

112 ) -> str: 

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

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

115 

116 def render_math_block_label( 

117 self: RendererProtocol, 

118 tokens: Sequence[Token], 

119 idx: int, 

120 options: OptionsDict, 

121 env: EnvType, 

122 ) -> str: 

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

124 _id = tokens[idx].info 

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

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

127 

128 md.add_render_rule("math_inline", render_math_inline) 

129 md.add_render_rule("math_inline_double", render_math_inline_double) 

130 

131 md.add_render_rule("math_block", render_math_block) 

132 md.add_render_rule("math_block_label", render_math_block_label) 

133 

134 

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

136 """Test if dollar is escaped.""" 

137 # count how many \ are before the current position 

138 backslashes = 0 

139 while back_pos >= 0: 

140 back_pos = back_pos - 1 

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

142 backslashes += 1 

143 else: 

144 break 

145 

146 if not backslashes: 

147 return False 

148 

149 # if an odd number of \ then ignore 

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

151 return True 

152 

153 return False 

154 

155 

156def math_inline_dollar( 

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

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

159 """Generate inline dollar rule. 

160 

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

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

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

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

165 This is useful when also using currency. 

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

167 

168 """ 

169 

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

171 """Inline dollar rule. 

172 

173 - Initial check: 

174 - check if first character is a $ 

175 - check if the first character is escaped 

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

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

178 - Advance one, if allow_double 

179 - Find closing (advance one, if allow_double) 

180 - Check closing: 

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

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

183 - Check empty content 

184 """ 

185 

186 # TODO options: 

187 # even/odd backslash escaping 

188 

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

190 return False 

191 

192 if not allow_space: 

193 # whitespace not allowed straight after opening $ 

194 try: 

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

196 return False 

197 except IndexError: 

198 return False 

199 

200 if not allow_digits: 

201 # digit not allowed straight before opening $ 

202 try: 

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

204 return False 

205 except IndexError: 

206 pass 

207 

208 if is_escaped(state, state.pos): 

209 return False 

210 

211 try: 

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

213 except IndexError: 

214 return False 

215 

216 # find closing $ 

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

218 found_closing = False 

219 while not found_closing: 

220 try: 

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

222 except ValueError: 

223 return False 

224 

225 if is_escaped(state, end): 

226 pos = end + 1 

227 continue 

228 

229 try: 

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

231 pos = end + 1 

232 continue 

233 except IndexError: 

234 return False 

235 

236 if is_double: 

237 end += 1 

238 

239 found_closing = True 

240 

241 if not found_closing: 

242 return False 

243 

244 if not allow_space: 

245 # whitespace not allowed straight before closing $ 

246 try: 

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

248 return False 

249 except IndexError: 

250 return False 

251 

252 if not allow_digits: 

253 # digit not allowed straight after closing $ 

254 try: 

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

256 return False 

257 except IndexError: 

258 pass 

259 

260 text = ( 

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

262 if is_double 

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

264 ) 

265 

266 # ignore empty 

267 if not text: 

268 return False 

269 

270 if not silent: 

271 token = state.push( 

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

273 ) 

274 token.content = text 

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

276 

277 state.pos = end + 1 

278 

279 return True 

280 

281 return _math_inline_dollar 

282 

283 

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

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

286 

287 

288def math_block_dollar( 

289 allow_labels: bool = True, 

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

291 allow_blank_lines: bool = False, 

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

293 """Generate block dollar rule.""" 

294 

295 def _math_block_dollar( 

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

297 ) -> bool: 

298 # TODO internal backslash escaping 

299 

300 if is_code_block(state, startLine): 

301 return False 

302 

303 haveEndMarker = False 

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

305 end = state.eMarks[startLine] 

306 

307 if startPos + 2 > end: 

308 return False 

309 

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

311 return False 

312 

313 # search for end of block 

314 nextLine = startLine 

315 label = None 

316 

317 # search for end of block on same line 

318 lineText = state.src[startPos:end] 

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

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

321 haveEndMarker = True 

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

323 elif allow_labels: 

324 # reverse the line and match 

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

326 if eqnoMatch: 

327 haveEndMarker = True 

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

329 end = end - eqnoMatch.end() 

330 

331 # search for end of block on subsequent line 

332 if not haveEndMarker: 

333 while True: 

334 nextLine += 1 

335 if nextLine >= endLine: 

336 break 

337 

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

339 end = state.eMarks[nextLine] 

340 

341 lineText = state.src[start:end] 

342 

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

344 haveEndMarker = True 

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

346 break 

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

348 break # blank lines are not allowed within $$ 

349 

350 # reverse the line and match 

351 if allow_labels: 

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

353 if eqnoMatch: 

354 haveEndMarker = True 

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

356 end = end - eqnoMatch.end() 

357 break 

358 

359 if not haveEndMarker: 

360 return False 

361 

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

363 

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

365 token.block = True 

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

367 token.markup = "$$" 

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

369 if label: 

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

371 

372 return True 

373 

374 return _math_block_dollar