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

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

173 statements  

1from __future__ import annotations 

2 

3import re 

4from typing import TYPE_CHECKING, Any, Callable, Sequence 

5 

6from markdown_it import MarkdownIt 

7from markdown_it.common.utils import escapeHtml, isWhiteSpace 

8from markdown_it.rules_block import StateBlock 

9from markdown_it.rules_inline import StateInline 

10 

11from mdit_py_plugins.utils import is_code_block 

12 

13if TYPE_CHECKING: 

14 from markdown_it.renderer import RendererProtocol 

15 from markdown_it.token import Token 

16 from markdown_it.utils import EnvType, OptionsDict 

17 

18 

19def dollarmath_plugin( 

20 md: MarkdownIt, 

21 *, 

22 allow_labels: bool = True, 

23 allow_space: bool = True, 

24 allow_digits: bool = True, 

25 allow_blank_lines: bool = True, 

26 double_inline: bool = False, 

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

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

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

30) -> None: 

31 """Plugin for parsing dollar enclosed math, 

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

33 

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

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

36 

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

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

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

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

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

42 This is useful when also using currency. 

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

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

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

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

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

48 :param label_normalizer: Function to normalize the label, 

49 by default replaces whitespace with `-` 

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

51 by default escapes HTML 

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

53 

54 """ 

55 if label_normalizer is None: 

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

57 

58 md.inline.ruler.before( 

59 "escape", 

60 "math_inline", 

61 math_inline_dollar(allow_space, allow_digits, double_inline), 

62 ) 

63 md.block.ruler.before( 

64 "fence", 

65 "math_block", 

66 math_block_dollar(allow_labels, label_normalizer, allow_blank_lines), 

67 ) 

68 

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

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

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

72 

73 _renderer = ( 

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

75 ) 

76 

77 _label_renderer: Callable[[str], str] 

78 if label_renderer is None: 

79 _label_renderer = ( # noqa: E731 

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

81 ) 

82 else: 

83 _label_renderer = label_renderer 

84 

85 def render_math_inline( 

86 self: RendererProtocol, 

87 tokens: Sequence[Token], 

88 idx: int, 

89 options: OptionsDict, 

90 env: EnvType, 

91 ) -> str: 

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

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

94 

95 def render_math_inline_double( 

96 self: RendererProtocol, 

97 tokens: Sequence[Token], 

98 idx: int, 

99 options: OptionsDict, 

100 env: EnvType, 

101 ) -> str: 

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

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

104 

105 def render_math_block( 

106 self: RendererProtocol, 

107 tokens: Sequence[Token], 

108 idx: int, 

109 options: OptionsDict, 

110 env: EnvType, 

111 ) -> str: 

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

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

114 

115 def render_math_block_label( 

116 self: RendererProtocol, 

117 tokens: Sequence[Token], 

118 idx: int, 

119 options: OptionsDict, 

120 env: EnvType, 

121 ) -> str: 

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

123 _id = tokens[idx].info 

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

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

126 

127 md.add_render_rule("math_inline", render_math_inline) 

128 md.add_render_rule("math_inline_double", render_math_inline_double) 

129 

130 md.add_render_rule("math_block", render_math_block) 

131 md.add_render_rule("math_block_label", render_math_block_label) 

132 

133 

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

135 """Test if dollar is escaped.""" 

136 # count how many \ are before the current position 

137 backslashes = 0 

138 while back_pos >= 0: 

139 back_pos = back_pos - 1 

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

141 backslashes += 1 

142 else: 

143 break 

144 

145 if not backslashes: 

146 return False 

147 

148 # if an odd number of \ then ignore 

149 if (backslashes % 2) != mod: 

150 return True 

151 

152 return False 

153 

154 

155def math_inline_dollar( 

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

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

158 """Generate inline dollar rule. 

159 

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

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

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

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

164 This is useful when also using currency. 

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

166 

167 """ 

168 

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

170 """Inline dollar rule. 

171 

172 - Initial check: 

173 - check if first character is a $ 

174 - check if the first character is escaped 

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

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

177 - Advance one, if allow_double 

178 - Find closing (advance one, if allow_double) 

179 - Check closing: 

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

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

182 - Check empty content 

183 """ 

184 

185 # TODO options: 

186 # even/odd backslash escaping 

187 

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

189 return False 

190 

191 if not allow_space: 

192 # whitespace not allowed straight after opening $ 

193 try: 

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

195 return False 

196 except IndexError: 

197 return False 

198 

199 if not allow_digits: 

200 # digit not allowed straight before opening $ 

201 try: 

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

203 return False 

204 except IndexError: 

205 pass 

206 

207 if is_escaped(state, state.pos): 

208 return False 

209 

210 try: 

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

212 except IndexError: 

213 return False 

214 

215 # find closing $ 

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

217 found_closing = False 

218 while not found_closing: 

219 try: 

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

221 except ValueError: 

222 return False 

223 

224 if is_escaped(state, end): 

225 pos = end + 1 

226 continue 

227 

228 try: 

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

230 pos = end + 1 

231 continue 

232 except IndexError: 

233 return False 

234 

235 if is_double: 

236 end += 1 

237 

238 found_closing = True 

239 

240 if not found_closing: 

241 return False 

242 

243 if not allow_space: 

244 # whitespace not allowed straight before closing $ 

245 try: 

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

247 return False 

248 except IndexError: 

249 return False 

250 

251 if not allow_digits: 

252 # digit not allowed straight after closing $ 

253 try: 

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

255 return False 

256 except IndexError: 

257 pass 

258 

259 text = ( 

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

261 if is_double 

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

263 ) 

264 

265 # ignore empty 

266 if not text: 

267 return False 

268 

269 if not silent: 

270 token = state.push( 

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

272 ) 

273 token.content = text 

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

275 

276 state.pos = end + 1 

277 

278 return True 

279 

280 return _math_inline_dollar 

281 

282 

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

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

285 

286 

287def math_block_dollar( 

288 allow_labels: bool = True, 

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

290 allow_blank_lines: bool = False, 

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

292 """Generate block dollar rule.""" 

293 

294 def _math_block_dollar( 

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

296 ) -> bool: 

297 # TODO internal backslash escaping 

298 

299 if is_code_block(state, startLine): 

300 return False 

301 

302 haveEndMarker = False 

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

304 end = state.eMarks[startLine] 

305 

306 if startPos + 2 > end: 

307 return False 

308 

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

310 return False 

311 

312 # search for end of block 

313 nextLine = startLine 

314 label = None 

315 

316 # search for end of block on same line 

317 lineText = state.src[startPos:end] 

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

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

320 haveEndMarker = True 

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

322 elif allow_labels: 

323 # reverse the line and match 

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

325 if eqnoMatch: 

326 haveEndMarker = True 

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

328 end = end - eqnoMatch.end() 

329 

330 # search for end of block on subsequent line 

331 if not haveEndMarker: 

332 while True: 

333 nextLine += 1 

334 if nextLine >= endLine: 

335 break 

336 

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

338 end = state.eMarks[nextLine] 

339 

340 lineText = state.src[start:end] 

341 

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

343 haveEndMarker = True 

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

345 break 

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

347 break # blank lines are not allowed within $$ 

348 

349 # reverse the line and match 

350 if allow_labels: 

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

352 if eqnoMatch: 

353 haveEndMarker = True 

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

355 end = end - eqnoMatch.end() 

356 break 

357 

358 if not haveEndMarker: 

359 return False 

360 

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

362 

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

364 token.block = True 

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

366 token.markup = "$$" 

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

368 if label: 

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

370 

371 return True 

372 

373 return _math_block_dollar