1# Footnotes Extension for Python-Markdown
2# =======================================
3
4# Adds footnote handling to Python-Markdown.
5
6# See https://Python-Markdown.github.io/extensions/footnotes
7# for documentation.
8
9# Copyright The Python Markdown Project
10
11# License: [BSD](https://opensource.org/licenses/bsd-license.php)
12
13"""
14Adds footnote handling to Python-Markdown.
15
16See the [documentation](https://Python-Markdown.github.io/extensions/footnotes)
17for details.
18"""
19
20from __future__ import annotations
21
22from . import Extension
23from ..blockprocessors import BlockProcessor
24from ..inlinepatterns import InlineProcessor
25from ..treeprocessors import Treeprocessor
26from ..postprocessors import Postprocessor
27from .. import util
28from collections import OrderedDict
29import re
30import copy
31import xml.etree.ElementTree as etree
32
33FN_BACKLINK_TEXT = util.STX + "zz1337820767766393qq" + util.ETX
34NBSP_PLACEHOLDER = util.STX + "qq3936677670287331zz" + util.ETX
35RE_REF_ID = re.compile(r'(fnref)(\d+)')
36RE_REFERENCE = re.compile(r'(?<!!)\[\^([^\]]*)\](?!\s*:)')
37
38
39class FootnoteExtension(Extension):
40 """ Footnote Extension. """
41
42 def __init__(self, **kwargs):
43 """ Setup configs. """
44
45 self.config = {
46 'PLACE_MARKER': [
47 '///Footnotes Go Here///', 'The text string that marks where the footnotes go'
48 ],
49 'UNIQUE_IDS': [
50 False, 'Avoid name collisions across multiple calls to `reset()`.'
51 ],
52 'BACKLINK_TEXT': [
53 '↩', "The text string that links from the footnote to the reader's place."
54 ],
55 'SUPERSCRIPT_TEXT': [
56 '{}', "The text string that links from the reader's place to the footnote."
57 ],
58 'BACKLINK_TITLE': [
59 'Jump back to footnote %d in the text',
60 'The text string used for the title HTML attribute of the backlink. '
61 '%d will be replaced by the footnote number.'
62 ],
63 'SEPARATOR': [
64 ':', 'Footnote separator.'
65 ],
66 'USE_DEFINITION_ORDER': [
67 True,
68 'Order footnote labels by definition order (True) or by document order (False). '
69 'Default: True.'
70 ]
71 }
72 """ Default configuration options. """
73 super().__init__(**kwargs)
74
75 # In multiple invocations, emit links that don't get tangled.
76 self.unique_prefix = 0
77 self.found_refs: dict[str, int] = {}
78 self.used_refs: set[str] = set()
79
80 # Backward compatibility with old '%d' placeholder
81 self.setConfig('BACKLINK_TITLE', self.getConfig("BACKLINK_TITLE").replace("%d", "{}"))
82
83 self.reset()
84
85 def extendMarkdown(self, md):
86 """ Register the processors.
87
88 | Class Instance | Registry | Name | Priority |
89 | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------ | :------: |
90 | [`FootnoteBlockProcessor`][markdown.extensions.footnotes.FootnoteBlockProcessor] | [`blockprocessors`][markdown.blockprocessors.build_block_parser] | `footnote` | `17` |
91 | [`FootnoteInlineProcessor`][markdown.extensions.footnotes.FootnoteInlineProcessor] | [`inlinepatterns`][markdown.inlinepatterns.build_inlinepatterns] | `footnote` | `175` |
92 | [`FootnoteTreeprocessor`][markdown.extensions.footnotes.FootnoteTreeprocessor] | [`treeprocessors`][markdown.treeprocessors.build_treeprocessors] | `footnote` | `50` |
93 | [`FootnoteReorderingProcessor`][markdown.extensions.footnotes.FootnoteReorderingProcessor] | [`treeprocessors`][markdown.treeprocessors.build_treeprocessors] | `footnote-reorder` | `19` |
94 | [`FootnotePostTreeprocessor`][markdown.extensions.footnotes.FootnotePostTreeprocessor] | [`treeprocessors`][markdown.treeprocessors.build_treeprocessors] | `footnote-duplicate` | `15` |
95 | [`FootnotePostprocessor`][markdown.extensions.footnotes.FootnotePostprocessor] | [`postprocessors`][markdown.postprocessors.build_postprocessors] | `footnote` | `25` |
96
97 """
98 # flake8: noqa: E501 88-95
99 md.registerExtension(self)
100 self.parser = md.parser
101 self.md = md
102 # Insert a `blockprocessor` before `ReferencePreprocessor`
103 md.parser.blockprocessors.register(FootnoteBlockProcessor(self), 'footnote', 17)
104
105 # Insert an inline pattern before `ImageReferencePattern`
106 FOOTNOTE_RE = r'\[\^([^\]]*)\]' # blah blah [^1] blah
107 md.inlinePatterns.register(FootnoteInlineProcessor(FOOTNOTE_RE, self), 'footnote', 175)
108 # Insert a tree-processor that would actually add the footnote div
109 # This must be before all other tree-processors (i.e., `inline` and
110 # `codehilite`) so they can run on the the contents of the div.
111 md.treeprocessors.register(FootnoteTreeprocessor(self), 'footnote', 50)
112
113 # Insert a tree-processor to reorder the footnotes if necessary. This must be after
114 # `inline` tree-processor so it can access the footnote reference order
115 # (`self.footnote_order`) that gets populated by the `FootnoteInlineProcessor`.
116 if not self.getConfig("USE_DEFINITION_ORDER"):
117 md.treeprocessors.register(FootnoteReorderingProcessor(self), 'footnote-reorder', 19)
118
119 # Insert a tree-processor that will run after inline is done.
120 # In this tree-processor we want to check our duplicate footnote tracker
121 # And add additional `backrefs` to the footnote pointing back to the
122 # duplicated references.
123 md.treeprocessors.register(FootnotePostTreeprocessor(self), 'footnote-duplicate', 15)
124
125 # Insert a postprocessor after amp_substitute processor
126 md.postprocessors.register(FootnotePostprocessor(self), 'footnote', 25)
127
128 def reset(self) -> None:
129 """ Clear footnotes on reset, and prepare for distinct document. """
130 self.footnote_order: list[str] = []
131 self.footnotes: OrderedDict[str, str] = OrderedDict()
132 self.unique_prefix += 1
133 self.found_refs = {}
134 self.used_refs = set()
135
136 def unique_ref(self, reference: str, found: bool = False) -> str:
137 """ Get a unique reference if there are duplicates. """
138 if not found:
139 return reference
140
141 original_ref = reference
142 while reference in self.used_refs:
143 ref, rest = reference.split(self.get_separator(), 1)
144 m = RE_REF_ID.match(ref)
145 if m:
146 reference = '%s%d%s%s' % (m.group(1), int(m.group(2))+1, self.get_separator(), rest)
147 else:
148 reference = '%s%d%s%s' % (ref, 2, self.get_separator(), rest)
149
150 self.used_refs.add(reference)
151 if original_ref in self.found_refs:
152 self.found_refs[original_ref] += 1
153 else:
154 self.found_refs[original_ref] = 1
155 return reference
156
157 def findFootnotesPlaceholder(
158 self, root: etree.Element
159 ) -> tuple[etree.Element, etree.Element, bool] | None:
160 """ Return ElementTree Element that contains Footnote placeholder. """
161 def finder(element):
162 for child in element:
163 if child.text:
164 if child.text.find(self.getConfig("PLACE_MARKER")) > -1:
165 return child, element, True
166 if child.tail:
167 if child.tail.find(self.getConfig("PLACE_MARKER")) > -1:
168 return child, element, False
169 child_res = finder(child)
170 if child_res is not None:
171 return child_res
172 return None
173
174 res = finder(root)
175 return res
176
177 def setFootnote(self, id: str, text: str) -> None:
178 """ Store a footnote for later retrieval. """
179 self.footnotes[id] = text
180
181 def addFootnoteRef(self, id: str) -> None:
182 """ Store a footnote reference id in order of appearance. """
183 if id not in self.footnote_order:
184 self.footnote_order.append(id)
185
186 def get_separator(self) -> str:
187 """ Get the footnote separator. """
188 return self.getConfig("SEPARATOR")
189
190 def makeFootnoteId(self, id: str) -> str:
191 """ Return footnote link id. """
192 if self.getConfig("UNIQUE_IDS"):
193 return 'fn%s%d-%s' % (self.get_separator(), self.unique_prefix, id)
194 else:
195 return 'fn{}{}'.format(self.get_separator(), id)
196
197 def makeFootnoteRefId(self, id: str, found: bool = False) -> str:
198 """ Return footnote back-link id. """
199 if self.getConfig("UNIQUE_IDS"):
200 return self.unique_ref('fnref%s%d-%s' % (self.get_separator(), self.unique_prefix, id), found)
201 else:
202 return self.unique_ref('fnref{}{}'.format(self.get_separator(), id), found)
203
204 def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None:
205 """ Return `div` of footnotes as `etree` Element. """
206
207 if not list(self.footnotes.keys()):
208 return None
209
210 div = etree.Element("div")
211 div.set('class', 'footnote')
212 etree.SubElement(div, "hr")
213 ol = etree.SubElement(div, "ol")
214 surrogate_parent = etree.Element("div")
215
216 for index, id in enumerate(self.footnotes.keys(), start=1):
217 li = etree.SubElement(ol, "li")
218 li.set("id", self.makeFootnoteId(id))
219 # Parse footnote with surrogate parent as `li` cannot be used.
220 # List block handlers have special logic to deal with `li`.
221 # When we are done parsing, we will copy everything over to `li`.
222 self.parser.parseChunk(surrogate_parent, self.footnotes[id])
223 for el in list(surrogate_parent):
224 li.append(el)
225 surrogate_parent.remove(el)
226 backlink = etree.Element("a")
227 backlink.set("href", "#" + self.makeFootnoteRefId(id))
228 backlink.set("class", "footnote-backref")
229 backlink.set(
230 "title",
231 self.getConfig('BACKLINK_TITLE').format(index)
232 )
233 backlink.text = FN_BACKLINK_TEXT
234
235 if len(li):
236 node = li[-1]
237 if node.tag == "p":
238 node.text = node.text + NBSP_PLACEHOLDER
239 node.append(backlink)
240 else:
241 p = etree.SubElement(li, "p")
242 p.append(backlink)
243 return div
244
245
246class FootnoteBlockProcessor(BlockProcessor):
247 """ Find footnote definitions and store for later use. """
248
249 RE = re.compile(r'^[ ]{0,3}\[\^([^\]]*)\]:[ ]*(.*)$', re.MULTILINE)
250
251 def __init__(self, footnotes: FootnoteExtension):
252 super().__init__(footnotes.parser)
253 self.footnotes = footnotes
254
255 def test(self, parent: etree.Element, block: str) -> bool:
256 return True
257
258 def run(self, parent: etree.Element, blocks: list[str]) -> bool:
259 """ Find, set, and remove footnote definitions. """
260 block = blocks.pop(0)
261
262 m = self.RE.search(block)
263 if m:
264 id = m.group(1)
265 fn_blocks = [m.group(2)]
266
267 # Handle rest of block
268 therest = block[m.end():].lstrip('\n')
269 m2 = self.RE.search(therest)
270 if m2:
271 # Another footnote exists in the rest of this block.
272 # Any content before match is continuation of this footnote, which may be lazily indented.
273 before = therest[:m2.start()].rstrip('\n')
274 fn_blocks[0] = '\n'.join([fn_blocks[0], self.detab(before)]).lstrip('\n')
275 # Add back to blocks everything from beginning of match forward for next iteration.
276 blocks.insert(0, therest[m2.start():])
277 else:
278 # All remaining lines of block are continuation of this footnote, which may be lazily indented.
279 fn_blocks[0] = '\n'.join([fn_blocks[0], self.detab(therest)]).strip('\n')
280
281 # Check for child elements in remaining blocks.
282 fn_blocks.extend(self.detectTabbed(blocks))
283
284 footnote = "\n\n".join(fn_blocks)
285 self.footnotes.setFootnote(id, footnote.rstrip())
286
287 if block[:m.start()].strip():
288 # Add any content before match back to blocks as separate block
289 blocks.insert(0, block[:m.start()].rstrip('\n'))
290 return True
291 # No match. Restore block.
292 blocks.insert(0, block)
293 return False
294
295 def detectTabbed(self, blocks: list[str]) -> list[str]:
296 """ Find indented text and remove indent before further processing.
297
298 Returns:
299 A list of blocks with indentation removed.
300 """
301 fn_blocks = []
302 while blocks:
303 if blocks[0].startswith(' '*4):
304 block = blocks.pop(0)
305 # Check for new footnotes within this block and split at new footnote.
306 m = self.RE.search(block)
307 if m:
308 # Another footnote exists in this block.
309 # Any content before match is continuation of this footnote, which may be lazily indented.
310 before = block[:m.start()].rstrip('\n')
311 fn_blocks.append(self.detab(before))
312 # Add back to blocks everything from beginning of match forward for next iteration.
313 blocks.insert(0, block[m.start():])
314 # End of this footnote.
315 break
316 else:
317 # Entire block is part of this footnote.
318 fn_blocks.append(self.detab(block))
319 else:
320 # End of this footnote.
321 break
322 return fn_blocks
323
324 def detab(self, block: str) -> str:
325 """ Remove one level of indent from a block.
326
327 Preserve lazily indented blocks by only removing indent from indented lines.
328 """
329 lines = block.split('\n')
330 for i, line in enumerate(lines):
331 if line.startswith(' '*4):
332 lines[i] = line[4:]
333 return '\n'.join(lines)
334
335
336class FootnoteInlineProcessor(InlineProcessor):
337 """ `InlineProcessor` for footnote markers in a document's body text. """
338
339 def __init__(self, pattern: str, footnotes: FootnoteExtension):
340 super().__init__(pattern)
341 self.footnotes = footnotes
342
343 def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]:
344 id = m.group(1)
345 if id in self.footnotes.footnotes.keys():
346 self.footnotes.addFootnoteRef(id)
347
348 if not self.footnotes.getConfig("USE_DEFINITION_ORDER"):
349 # Order by reference
350 footnote_num = self.footnotes.footnote_order.index(id) + 1
351 else:
352 # Order by definition
353 footnote_num = list(self.footnotes.footnotes.keys()).index(id) + 1
354
355 sup = etree.Element("sup")
356 a = etree.SubElement(sup, "a")
357 sup.set('id', self.footnotes.makeFootnoteRefId(id, found=True))
358 a.set('href', '#' + self.footnotes.makeFootnoteId(id))
359 a.set('class', 'footnote-ref')
360 a.text = self.footnotes.getConfig("SUPERSCRIPT_TEXT").format(footnote_num)
361 return sup, m.start(0), m.end(0)
362 else:
363 return None, None, None
364
365
366class FootnotePostTreeprocessor(Treeprocessor):
367 """ Amend footnote div with duplicates. """
368
369 def __init__(self, footnotes: FootnoteExtension):
370 self.footnotes = footnotes
371
372 def add_duplicates(self, li: etree.Element, duplicates: int) -> None:
373 """ Adjust current `li` and add the duplicates: `fnref2`, `fnref3`, etc. """
374 for link in li.iter('a'):
375 # Find the link that needs to be duplicated.
376 if link.attrib.get('class', '') == 'footnote-backref':
377 ref, rest = link.attrib['href'].split(self.footnotes.get_separator(), 1)
378 # Duplicate link the number of times we need to
379 # and point the to the appropriate references.
380 links = []
381 for index in range(2, duplicates + 1):
382 sib_link = copy.deepcopy(link)
383 sib_link.attrib['href'] = '%s%d%s%s' % (ref, index, self.footnotes.get_separator(), rest)
384 links.append(sib_link)
385 self.offset += 1
386 # Add all the new duplicate links.
387 el = list(li)[-1]
388 for link in links:
389 el.append(link)
390 break
391
392 def get_num_duplicates(self, li: etree.Element) -> int:
393 """ Get the number of duplicate refs of the footnote. """
394 fn, rest = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1)
395 link_id = '{}ref{}{}'.format(fn, self.footnotes.get_separator(), rest)
396 return self.footnotes.found_refs.get(link_id, 0)
397
398 def handle_duplicates(self, parent: etree.Element) -> None:
399 """ Find duplicate footnotes and format and add the duplicates. """
400 for li in list(parent):
401 # Check number of duplicates footnotes and insert
402 # additional links if needed.
403 count = self.get_num_duplicates(li)
404 if count > 1:
405 self.add_duplicates(li, count)
406
407 def run(self, root: etree.Element) -> None:
408 """ Crawl the footnote div and add missing duplicate footnotes. """
409 self.offset = 0
410 for div in root.iter('div'):
411 if div.attrib.get('class', '') == 'footnote':
412 # Footnotes should be under the first ordered list under
413 # the footnote div. So once we find it, quit.
414 for ol in div.iter('ol'):
415 self.handle_duplicates(ol)
416 break
417
418
419class FootnoteTreeprocessor(Treeprocessor):
420 """ Build and append footnote div to end of document. """
421
422 def __init__(self, footnotes: FootnoteExtension):
423 self.footnotes = footnotes
424
425 def run(self, root: etree.Element) -> None:
426 footnotesDiv = self.footnotes.makeFootnotesDiv(root)
427 if footnotesDiv is not None:
428 result = self.footnotes.findFootnotesPlaceholder(root)
429 if result:
430 child, parent, isText = result
431 ind = list(parent).index(child)
432 if isText:
433 parent.remove(child)
434 parent.insert(ind, footnotesDiv)
435 else:
436 parent.insert(ind + 1, footnotesDiv)
437 child.tail = None
438 else:
439 root.append(footnotesDiv)
440
441
442class FootnoteReorderingProcessor(Treeprocessor):
443 """ Reorder list items in the footnotes div. """
444
445 def __init__(self, footnotes: FootnoteExtension):
446 self.footnotes = footnotes
447
448 def run(self, root: etree.Element) -> None:
449 if not self.footnotes.footnotes:
450 return
451 if self.footnotes.footnote_order != list(self.footnotes.footnotes.keys()):
452 for div in root.iter('div'):
453 if div.attrib.get('class', '') == 'footnote':
454 self.reorder_footnotes(div)
455 break
456
457 def reorder_footnotes(self, parent: etree.Element) -> None:
458 old_list = parent.find('ol')
459 parent.remove(old_list)
460 items = old_list.findall('li')
461
462 def order_by_id(li) -> int:
463 id = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1)[-1]
464 return (
465 self.footnotes.footnote_order.index(id)
466 if id in self.footnotes.footnote_order
467 else len(self.footnotes.footnotes)
468 )
469
470 items = sorted(items, key=order_by_id)
471
472 new_list = etree.SubElement(parent, 'ol')
473
474 for index, item in enumerate(items, start=1):
475 backlink = item.find('.//a[@class="footnote-backref"]')
476 backlink.set("title", self.footnotes.getConfig("BACKLINK_TITLE").format(index))
477 new_list.append(item)
478
479
480class FootnotePostprocessor(Postprocessor):
481 """ Replace placeholders with html entities. """
482 def __init__(self, footnotes: FootnoteExtension):
483 self.footnotes = footnotes
484
485 def run(self, text: str) -> str:
486 text = text.replace(
487 FN_BACKLINK_TEXT, self.footnotes.getConfig("BACKLINK_TEXT")
488 )
489 return text.replace(NBSP_PLACEHOLDER, " ")
490
491
492def makeExtension(**kwargs): # pragma: no cover
493 """ Return an instance of the `FootnoteExtension` """
494 return FootnoteExtension(**kwargs)