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

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

276 statements  

1"""Process footnotes""" 

2 

3from __future__ import annotations 

4 

5from collections.abc import Sequence 

6from functools import partial 

7from typing import TYPE_CHECKING, TypedDict 

8 

9from markdown_it import MarkdownIt 

10from markdown_it.helpers import parseLinkLabel 

11from markdown_it.rules_block import StateBlock 

12from markdown_it.rules_core import StateCore 

13from markdown_it.rules_inline import StateInline 

14from markdown_it.token import Token 

15 

16from mdit_py_plugins.utils import is_code_block 

17 

18if TYPE_CHECKING: 

19 from markdown_it.renderer import RendererProtocol 

20 from markdown_it.utils import EnvType, OptionsDict 

21 

22 

23def footnote_plugin( 

24 md: MarkdownIt, 

25 *, 

26 inline: bool = True, 

27 move_to_end: bool = True, 

28 always_match_refs: bool = False, 

29) -> None: 

30 """Plugin ported from 

31 `markdown-it-footnote <https://github.com/markdown-it/markdown-it-footnote>`__. 

32 

33 It is based on the 

34 `pandoc definition <http://johnmacfarlane.net/pandoc/README.html#footnotes>`__: 

35 

36 .. code-block:: md 

37 

38 Normal footnote: 

39 

40 Here is a footnote reference,[^1] and another.[^longnote] 

41 

42 [^1]: Here is the footnote. 

43 

44 [^longnote]: Here's one with multiple blocks. 

45 

46 Subsequent paragraphs are indented to show that they 

47 belong to the previous footnote. 

48 

49 :param inline: If True, also parse inline footnotes (^[...]). 

50 :param move_to_end: If True, move footnote definitions to the end of the token stream. 

51 :param always_match_refs: If True, match references, even if the footnote is not defined. 

52 

53 """ 

54 md.block.ruler.before( 

55 "reference", "footnote_def", footnote_def, {"alt": ["paragraph", "reference"]} 

56 ) 

57 _footnote_ref = partial(footnote_ref, always_match=always_match_refs) 

58 if inline: 

59 md.inline.ruler.after("image", "footnote_inline", footnote_inline) 

60 md.inline.ruler.after("footnote_inline", "footnote_ref", _footnote_ref) 

61 else: 

62 md.inline.ruler.after("image", "footnote_ref", _footnote_ref) 

63 if move_to_end: 

64 md.core.ruler.after("inline", "footnote_tail", footnote_tail) 

65 

66 md.add_render_rule("footnote_ref", render_footnote_ref) 

67 md.add_render_rule("footnote_block_open", render_footnote_block_open) 

68 md.add_render_rule("footnote_block_close", render_footnote_block_close) 

69 md.add_render_rule("footnote_open", render_footnote_open) 

70 md.add_render_rule("footnote_close", render_footnote_close) 

71 md.add_render_rule("footnote_anchor", render_footnote_anchor) 

72 

73 # helpers (only used in other rules, no tokens are attached to those) 

74 md.add_render_rule("footnote_caption", render_footnote_caption) 

75 md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name) 

76 

77 

78class _RefData(TypedDict, total=False): 

79 # standard 

80 label: str 

81 count: int 

82 # inline 

83 content: str 

84 tokens: list[Token] 

85 

86 

87class _FootnoteData(TypedDict): 

88 refs: dict[str, int] 

89 """A mapping of all footnote labels (prefixed with ``:``) to their ID (-1 if not yet set).""" 

90 list: dict[int, _RefData] 

91 """A mapping of all footnote IDs to their data.""" 

92 

93 

94def _data_from_env(env: EnvType) -> _FootnoteData: 

95 footnotes = env.setdefault("footnotes", {}) 

96 footnotes.setdefault("refs", {}) 

97 footnotes.setdefault("list", {}) 

98 return footnotes # type: ignore[no-any-return] 

99 

100 

101# ## RULES ## 

102 

103 

104def footnote_def(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: 

105 """Process footnote block definition""" 

106 

107 if is_code_block(state, startLine): 

108 return False 

109 

110 start = state.bMarks[startLine] + state.tShift[startLine] 

111 maximum = state.eMarks[startLine] 

112 

113 # line should be at least 5 chars - "[^x]:" 

114 if start + 4 > maximum: 

115 return False 

116 

117 if state.src[start] != "[": 

118 return False 

119 if state.src[start + 1] != "^": 

120 return False 

121 

122 pos = start + 2 

123 while pos < maximum: 

124 if state.src[pos] == " ": 

125 return False 

126 if state.src[pos] == "]": 

127 break 

128 pos += 1 

129 

130 if pos == start + 2: # no empty footnote labels 

131 return False 

132 pos += 1 

133 if pos >= maximum or state.src[pos] != ":": 

134 return False 

135 if silent: 

136 return True 

137 pos += 1 

138 

139 label = state.src[start + 2 : pos - 2] 

140 footnote_data = _data_from_env(state.env) 

141 footnote_data["refs"][":" + label] = -1 

142 

143 open_token = Token("footnote_reference_open", "", 1) 

144 open_token.meta = {"label": label} 

145 open_token.level = state.level 

146 state.level += 1 

147 state.tokens.append(open_token) 

148 

149 oldBMark = state.bMarks[startLine] 

150 oldTShift = state.tShift[startLine] 

151 oldSCount = state.sCount[startLine] 

152 oldParentType = state.parentType 

153 

154 posAfterColon = pos 

155 initial = offset = ( 

156 state.sCount[startLine] 

157 + pos 

158 - (state.bMarks[startLine] + state.tShift[startLine]) 

159 ) 

160 

161 while pos < maximum: 

162 ch = state.src[pos] 

163 

164 if ch == "\t": 

165 offset += 4 - offset % 4 

166 elif ch == " ": 

167 offset += 1 

168 

169 else: 

170 break 

171 

172 pos += 1 

173 

174 state.tShift[startLine] = pos - posAfterColon 

175 state.sCount[startLine] = offset - initial 

176 

177 state.bMarks[startLine] = posAfterColon 

178 state.blkIndent += 4 

179 state.parentType = "footnote" 

180 

181 if state.sCount[startLine] < state.blkIndent: 

182 state.sCount[startLine] += state.blkIndent 

183 

184 state.md.block.tokenize(state, startLine, endLine) 

185 

186 state.parentType = oldParentType 

187 state.blkIndent -= 4 

188 state.tShift[startLine] = oldTShift 

189 state.sCount[startLine] = oldSCount 

190 state.bMarks[startLine] = oldBMark 

191 

192 open_token.map = [startLine, state.line] 

193 

194 token = Token("footnote_reference_close", "", -1) 

195 state.level -= 1 

196 token.level = state.level 

197 state.tokens.append(token) 

198 

199 return True 

200 

201 

202def footnote_inline(state: StateInline, silent: bool) -> bool: 

203 """Process inline footnotes (^[...])""" 

204 

205 maximum = state.posMax 

206 start = state.pos 

207 

208 if start + 2 >= maximum: 

209 return False 

210 if state.src[start] != "^": 

211 return False 

212 if state.src[start + 1] != "[": 

213 return False 

214 

215 labelStart = start + 2 

216 labelEnd = parseLinkLabel(state, start + 1) 

217 

218 # parser failed to find ']', so it's not a valid note 

219 if labelEnd < 0: 

220 return False 

221 

222 # We found the end of the link, and know for a fact it's a valid link 

223 # so all that's left to do is to call tokenizer. 

224 # 

225 if not silent: 

226 refs = _data_from_env(state.env)["list"] 

227 footnoteId = len(refs) 

228 

229 tokens: list[Token] = [] 

230 state.md.inline.parse( 

231 state.src[labelStart:labelEnd], state.md, state.env, tokens 

232 ) 

233 

234 token = state.push("footnote_ref", "", 0) 

235 token.meta = {"id": footnoteId} 

236 

237 refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens} 

238 

239 state.pos = labelEnd + 1 

240 state.posMax = maximum 

241 return True 

242 

243 

244def footnote_ref( 

245 state: StateInline, silent: bool, *, always_match: bool = False 

246) -> bool: 

247 """Process footnote references ([^...])""" 

248 

249 maximum = state.posMax 

250 start = state.pos 

251 

252 # should be at least 4 chars - "[^x]" 

253 if start + 3 > maximum: 

254 return False 

255 

256 footnote_data = _data_from_env(state.env) 

257 

258 if not (always_match or footnote_data["refs"]): 

259 return False 

260 if state.src[start] != "[": 

261 return False 

262 if state.src[start + 1] != "^": 

263 return False 

264 

265 pos = start + 2 

266 while pos < maximum: 

267 if state.src[pos] in (" ", "\n"): 

268 return False 

269 if state.src[pos] == "]": 

270 break 

271 pos += 1 

272 

273 if pos == start + 2: # no empty footnote labels 

274 return False 

275 if pos >= maximum: 

276 return False 

277 pos += 1 

278 

279 label = state.src[start + 2 : pos - 1] 

280 if ((":" + label) not in footnote_data["refs"]) and not always_match: 

281 return False 

282 

283 if not silent: 

284 if footnote_data["refs"].get(":" + label, -1) < 0: 

285 footnoteId = len(footnote_data["list"]) 

286 footnote_data["list"][footnoteId] = {"label": label, "count": 0} 

287 footnote_data["refs"][":" + label] = footnoteId 

288 else: 

289 footnoteId = footnote_data["refs"][":" + label] 

290 

291 footnoteSubId = footnote_data["list"][footnoteId]["count"] 

292 footnote_data["list"][footnoteId]["count"] += 1 

293 

294 token = state.push("footnote_ref", "", 0) 

295 token.meta = {"id": footnoteId, "subId": footnoteSubId, "label": label} 

296 

297 state.pos = pos 

298 state.posMax = maximum 

299 return True 

300 

301 

302def footnote_tail(state: StateCore) -> None: 

303 """Post-processing step, to move footnote tokens to end of the token stream. 

304 

305 Also removes un-referenced tokens. 

306 """ 

307 

308 insideRef = False 

309 refTokens = {} 

310 

311 if "footnotes" not in state.env: 

312 return 

313 

314 current: list[Token] = [] 

315 tok_filter = [] 

316 for tok in state.tokens: 

317 if tok.type == "footnote_reference_open": 

318 insideRef = True 

319 current = [] 

320 currentLabel = tok.meta["label"] 

321 tok_filter.append(False) 

322 continue 

323 

324 if tok.type == "footnote_reference_close": 

325 insideRef = False 

326 # prepend ':' to avoid conflict with Object.prototype members 

327 refTokens[":" + currentLabel] = current 

328 tok_filter.append(False) 

329 continue 

330 

331 if insideRef: 

332 current.append(tok) 

333 

334 tok_filter.append(not insideRef) 

335 

336 state.tokens = [t for t, f in zip(state.tokens, tok_filter, strict=False) if f] 

337 

338 footnote_data = _data_from_env(state.env) 

339 if not footnote_data["list"]: 

340 return 

341 

342 token = Token("footnote_block_open", "", 1) 

343 state.tokens.append(token) 

344 

345 for i, foot_note in footnote_data["list"].items(): 

346 token = Token("footnote_open", "", 1) 

347 token.meta = {"id": i, "label": foot_note.get("label", None)} 

348 # TODO propagate line positions of original foot note 

349 # (but don't store in token.map, because this is used for scroll syncing) 

350 state.tokens.append(token) 

351 

352 if "tokens" in foot_note: 

353 tokens = [] 

354 

355 token = Token("paragraph_open", "p", 1) 

356 token.block = True 

357 tokens.append(token) 

358 

359 token = Token("inline", "", 0) 

360 token.children = foot_note["tokens"] 

361 token.content = foot_note["content"] 

362 tokens.append(token) 

363 

364 token = Token("paragraph_close", "p", -1) 

365 token.block = True 

366 tokens.append(token) 

367 

368 elif "label" in foot_note: 

369 tokens = refTokens.get(":" + foot_note["label"], []) 

370 

371 state.tokens.extend(tokens) 

372 if state.tokens[len(state.tokens) - 1].type == "paragraph_close": 

373 lastParagraph: Token | None = state.tokens.pop() 

374 else: 

375 lastParagraph = None 

376 

377 t = ( 

378 foot_note["count"] 

379 if (("count" in foot_note) and (foot_note["count"] > 0)) 

380 else 1 

381 ) 

382 j = 0 

383 while j < t: 

384 token = Token("footnote_anchor", "", 0) 

385 token.meta = {"id": i, "subId": j, "label": foot_note.get("label", None)} 

386 state.tokens.append(token) 

387 j += 1 

388 

389 if lastParagraph: 

390 state.tokens.append(lastParagraph) 

391 

392 token = Token("footnote_close", "", -1) 

393 state.tokens.append(token) 

394 

395 token = Token("footnote_block_close", "", -1) 

396 state.tokens.append(token) 

397 

398 

399######################################## 

400# Renderer partials 

401 

402 

403def render_footnote_anchor_name( 

404 self: RendererProtocol, 

405 tokens: Sequence[Token], 

406 idx: int, 

407 options: OptionsDict, 

408 env: EnvType, 

409) -> str: 

410 n = str(tokens[idx].meta["id"] + 1) 

411 prefix = "" 

412 

413 doc_id = env.get("docId", None) 

414 if isinstance(doc_id, str): 

415 prefix = f"-{doc_id}-" 

416 

417 return prefix + n 

418 

419 

420def render_footnote_caption( 

421 self: RendererProtocol, 

422 tokens: Sequence[Token], 

423 idx: int, 

424 options: OptionsDict, 

425 env: EnvType, 

426) -> str: 

427 n = str(tokens[idx].meta["id"] + 1) 

428 

429 if tokens[idx].meta.get("subId", -1) > 0: 

430 n += ":" + str(tokens[idx].meta["subId"]) 

431 

432 return "[" + n + "]" 

433 

434 

435def render_footnote_ref( 

436 self: RendererProtocol, 

437 tokens: Sequence[Token], 

438 idx: int, 

439 options: OptionsDict, 

440 env: EnvType, 

441) -> str: 

442 ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined] 

443 caption: str = self.rules["footnote_caption"](tokens, idx, options, env) # type: ignore[attr-defined] 

444 refid = ident 

445 

446 if tokens[idx].meta.get("subId", -1) > 0: 

447 refid += ":" + str(tokens[idx].meta["subId"]) 

448 

449 return ( 

450 '<sup class="footnote-ref"><a href="#fn' 

451 + ident 

452 + '" id="fnref' 

453 + refid 

454 + '">' 

455 + caption 

456 + "</a></sup>" 

457 ) 

458 

459 

460def render_footnote_block_open( 

461 self: RendererProtocol, 

462 tokens: Sequence[Token], 

463 idx: int, 

464 options: OptionsDict, 

465 env: EnvType, 

466) -> str: 

467 return ( 

468 ( 

469 '<hr class="footnotes-sep" />\n' 

470 if options.xhtmlOut 

471 else '<hr class="footnotes-sep">\n' 

472 ) 

473 + '<section class="footnotes">\n' 

474 + '<ol class="footnotes-list">\n' 

475 ) 

476 

477 

478def render_footnote_block_close( 

479 self: RendererProtocol, 

480 tokens: Sequence[Token], 

481 idx: int, 

482 options: OptionsDict, 

483 env: EnvType, 

484) -> str: 

485 return "</ol>\n</section>\n" 

486 

487 

488def render_footnote_open( 

489 self: RendererProtocol, 

490 tokens: Sequence[Token], 

491 idx: int, 

492 options: OptionsDict, 

493 env: EnvType, 

494) -> str: 

495 ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined] 

496 

497 if tokens[idx].meta.get("subId", -1) > 0: 

498 ident += ":" + tokens[idx].meta["subId"] 

499 

500 return '<li id="fn' + ident + '" class="footnote-item">' 

501 

502 

503def render_footnote_close( 

504 self: RendererProtocol, 

505 tokens: Sequence[Token], 

506 idx: int, 

507 options: OptionsDict, 

508 env: EnvType, 

509) -> str: 

510 return "</li>\n" 

511 

512 

513def render_footnote_anchor( 

514 self: RendererProtocol, 

515 tokens: Sequence[Token], 

516 idx: int, 

517 options: OptionsDict, 

518 env: EnvType, 

519) -> str: 

520 ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined] 

521 

522 if tokens[idx].meta["subId"] > 0: 

523 ident += ":" + str(tokens[idx].meta["subId"]) 

524 

525 # ↩ with escape code to prevent display as Apple Emoji on iOS 

526 return ' <a href="#fnref' + ident + '" class="footnote-backref">\u21a9\ufe0e</a>'