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

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

273 statements  

1"""Process footnotes""" 

2 

3from __future__ import annotations 

4 

5from functools import partial 

6from typing import TYPE_CHECKING, Sequence, TypedDict 

7 

8from markdown_it import MarkdownIt 

9from markdown_it.helpers import parseLinkLabel 

10from markdown_it.rules_block import StateBlock 

11from markdown_it.rules_core import StateCore 

12from markdown_it.rules_inline import StateInline 

13from markdown_it.token import Token 

14 

15from mdit_py_plugins.utils import is_code_block 

16 

17if TYPE_CHECKING: 

18 from markdown_it.renderer import RendererProtocol 

19 from markdown_it.utils import EnvType, OptionsDict 

20 

21 

22def footnote_plugin( 

23 md: MarkdownIt, 

24 *, 

25 inline: bool = True, 

26 move_to_end: bool = True, 

27 always_match_refs: bool = False, 

28) -> None: 

29 """Plugin ported from 

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

31 

32 It is based on the 

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

34 

35 .. code-block:: md 

36 

37 Normal footnote: 

38 

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

40 

41 [^1]: Here is the footnote. 

42 

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

44 

45 Subsequent paragraphs are indented to show that they 

46 belong to the previous footnote. 

47 

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

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

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

51 

52 """ 

53 md.block.ruler.before( 

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

55 ) 

56 _footnote_ref = partial(footnote_ref, always_match=always_match_refs) 

57 if inline: 

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

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

60 else: 

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

62 if move_to_end: 

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

64 

65 md.add_render_rule("footnote_ref", render_footnote_ref) 

66 md.add_render_rule("footnote_block_open", render_footnote_block_open) 

67 md.add_render_rule("footnote_block_close", render_footnote_block_close) 

68 md.add_render_rule("footnote_open", render_footnote_open) 

69 md.add_render_rule("footnote_close", render_footnote_close) 

70 md.add_render_rule("footnote_anchor", render_footnote_anchor) 

71 

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

73 md.add_render_rule("footnote_caption", render_footnote_caption) 

74 md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name) 

75 

76 

77class _RefData(TypedDict, total=False): 

78 # standard 

79 label: str 

80 count: int 

81 # inline 

82 content: str 

83 tokens: list[Token] 

84 

85 

86class _FootnoteData(TypedDict): 

87 refs: dict[str, int] 

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

89 list: dict[int, _RefData] 

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

91 

92 

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

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

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

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

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

98 

99 

100# ## RULES ## 

101 

102 

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

104 """Process footnote block definition""" 

105 

106 if is_code_block(state, startLine): 

107 return False 

108 

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

110 maximum = state.eMarks[startLine] 

111 

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

113 if start + 4 > maximum: 

114 return False 

115 

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

117 return False 

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

119 return False 

120 

121 pos = start + 2 

122 while pos < maximum: 

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

124 return False 

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

126 break 

127 pos += 1 

128 

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

130 return False 

131 pos += 1 

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

133 return False 

134 if silent: 

135 return True 

136 pos += 1 

137 

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

139 footnote_data = _data_from_env(state.env) 

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

141 

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

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

144 open_token.level = state.level 

145 state.level += 1 

146 state.tokens.append(open_token) 

147 

148 oldBMark = state.bMarks[startLine] 

149 oldTShift = state.tShift[startLine] 

150 oldSCount = state.sCount[startLine] 

151 oldParentType = state.parentType 

152 

153 posAfterColon = pos 

154 initial = offset = ( 

155 state.sCount[startLine] 

156 + pos 

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

158 ) 

159 

160 while pos < maximum: 

161 ch = state.src[pos] 

162 

163 if ch == "\t": 

164 offset += 4 - offset % 4 

165 elif ch == " ": 

166 offset += 1 

167 

168 else: 

169 break 

170 

171 pos += 1 

172 

173 state.tShift[startLine] = pos - posAfterColon 

174 state.sCount[startLine] = offset - initial 

175 

176 state.bMarks[startLine] = posAfterColon 

177 state.blkIndent += 4 

178 state.parentType = "footnote" 

179 

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

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

182 

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

184 

185 state.parentType = oldParentType 

186 state.blkIndent -= 4 

187 state.tShift[startLine] = oldTShift 

188 state.sCount[startLine] = oldSCount 

189 state.bMarks[startLine] = oldBMark 

190 

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

192 

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

194 state.level -= 1 

195 token.level = state.level 

196 state.tokens.append(token) 

197 

198 return True 

199 

200 

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

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

203 

204 maximum = state.posMax 

205 start = state.pos 

206 

207 if start + 2 >= maximum: 

208 return False 

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

210 return False 

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

212 return False 

213 

214 labelStart = start + 2 

215 labelEnd = parseLinkLabel(state, start + 1) 

216 

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

218 if labelEnd < 0: 

219 return False 

220 

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

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

223 # 

224 if not silent: 

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

226 footnoteId = len(refs) 

227 

228 tokens: list[Token] = [] 

229 state.md.inline.parse( 

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

231 ) 

232 

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

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

235 

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

237 

238 state.pos = labelEnd + 1 

239 state.posMax = maximum 

240 return True 

241 

242 

243def footnote_ref( 

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

245) -> bool: 

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

247 

248 maximum = state.posMax 

249 start = state.pos 

250 

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

252 if start + 3 > maximum: 

253 return False 

254 

255 footnote_data = _data_from_env(state.env) 

256 

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

258 return False 

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

260 return False 

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

262 return False 

263 

264 pos = start + 2 

265 while pos < maximum: 

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

267 return False 

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

269 break 

270 pos += 1 

271 

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

273 return False 

274 if pos >= maximum: 

275 return False 

276 pos += 1 

277 

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

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

280 return False 

281 

282 if not silent: 

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

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

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

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

287 else: 

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

289 

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

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

292 

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

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

295 

296 state.pos = pos 

297 state.posMax = maximum 

298 return True 

299 

300 

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

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

303 

304 Also removes un-referenced tokens. 

305 """ 

306 

307 insideRef = False 

308 refTokens = {} 

309 

310 if "footnotes" not in state.env: 

311 return 

312 

313 current: list[Token] = [] 

314 tok_filter = [] 

315 for tok in state.tokens: 

316 if tok.type == "footnote_reference_open": 

317 insideRef = True 

318 current = [] 

319 currentLabel = tok.meta["label"] 

320 tok_filter.append(False) 

321 continue 

322 

323 if tok.type == "footnote_reference_close": 

324 insideRef = False 

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

326 refTokens[":" + currentLabel] = current 

327 tok_filter.append(False) 

328 continue 

329 

330 if insideRef: 

331 current.append(tok) 

332 

333 tok_filter.append(not insideRef) 

334 

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

336 

337 footnote_data = _data_from_env(state.env) 

338 if not footnote_data["list"]: 

339 return 

340 

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

342 state.tokens.append(token) 

343 

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

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

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

347 # TODO propagate line positions of original foot note 

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

349 state.tokens.append(token) 

350 

351 if "tokens" in foot_note: 

352 tokens = [] 

353 

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

355 token.block = True 

356 tokens.append(token) 

357 

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

359 token.children = foot_note["tokens"] 

360 token.content = foot_note["content"] 

361 tokens.append(token) 

362 

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

364 token.block = True 

365 tokens.append(token) 

366 

367 elif "label" in foot_note: 

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

369 

370 state.tokens.extend(tokens) 

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

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

373 else: 

374 lastParagraph = None 

375 

376 t = ( 

377 foot_note["count"] 

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

379 else 1 

380 ) 

381 j = 0 

382 while j < t: 

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

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

385 state.tokens.append(token) 

386 j += 1 

387 

388 if lastParagraph: 

389 state.tokens.append(lastParagraph) 

390 

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

392 state.tokens.append(token) 

393 

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

395 state.tokens.append(token) 

396 

397 

398######################################## 

399# Renderer partials 

400 

401 

402def render_footnote_anchor_name( 

403 self: RendererProtocol, 

404 tokens: Sequence[Token], 

405 idx: int, 

406 options: OptionsDict, 

407 env: EnvType, 

408) -> str: 

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

410 prefix = "" 

411 

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

413 if isinstance(doc_id, str): 

414 prefix = f"-{doc_id}-" 

415 

416 return prefix + n 

417 

418 

419def render_footnote_caption( 

420 self: RendererProtocol, 

421 tokens: Sequence[Token], 

422 idx: int, 

423 options: OptionsDict, 

424 env: EnvType, 

425) -> str: 

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

427 

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

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

430 

431 return "[" + n + "]" 

432 

433 

434def render_footnote_ref( 

435 self: RendererProtocol, 

436 tokens: Sequence[Token], 

437 idx: int, 

438 options: OptionsDict, 

439 env: EnvType, 

440) -> str: 

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

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

443 refid = ident 

444 

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

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

447 

448 return ( 

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

450 + ident 

451 + '" id="fnref' 

452 + refid 

453 + '">' 

454 + caption 

455 + "</a></sup>" 

456 ) 

457 

458 

459def render_footnote_block_open( 

460 self: RendererProtocol, 

461 tokens: Sequence[Token], 

462 idx: int, 

463 options: OptionsDict, 

464 env: EnvType, 

465) -> str: 

466 return ( 

467 ( 

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

469 if options.xhtmlOut 

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

471 ) 

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

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

474 ) 

475 

476 

477def render_footnote_block_close( 

478 self: RendererProtocol, 

479 tokens: Sequence[Token], 

480 idx: int, 

481 options: OptionsDict, 

482 env: EnvType, 

483) -> str: 

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

485 

486 

487def render_footnote_open( 

488 self: RendererProtocol, 

489 tokens: Sequence[Token], 

490 idx: int, 

491 options: OptionsDict, 

492 env: EnvType, 

493) -> str: 

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

495 

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

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

498 

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

500 

501 

502def render_footnote_close( 

503 self: RendererProtocol, 

504 tokens: Sequence[Token], 

505 idx: int, 

506 options: OptionsDict, 

507 env: EnvType, 

508) -> str: 

509 return "</li>\n" 

510 

511 

512def render_footnote_anchor( 

513 self: RendererProtocol, 

514 tokens: Sequence[Token], 

515 idx: int, 

516 options: OptionsDict, 

517 env: EnvType, 

518) -> str: 

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

520 

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

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

523 

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

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