footnotes.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. """
  2. Footnotes Extension for Python-Markdown
  3. =======================================
  4. Adds footnote handling to Python-Markdown.
  5. See <https://Python-Markdown.github.io/extensions/footnotes>
  6. for documentation.
  7. Copyright The Python Markdown Project
  8. License: [BSD](https://opensource.org/licenses/bsd-license.php)
  9. """
  10. from . import Extension
  11. from ..preprocessors import Preprocessor
  12. from ..inlinepatterns import InlineProcessor
  13. from ..treeprocessors import Treeprocessor
  14. from ..postprocessors import Postprocessor
  15. from .. import util
  16. from collections import OrderedDict
  17. import re
  18. import copy
  19. import xml.etree.ElementTree as etree
  20. FN_BACKLINK_TEXT = util.STX + "zz1337820767766393qq" + util.ETX
  21. NBSP_PLACEHOLDER = util.STX + "qq3936677670287331zz" + util.ETX
  22. DEF_RE = re.compile(r'[ ]{0,3}\[\^([^\]]*)\]:\s*(.*)')
  23. TABBED_RE = re.compile(r'((\t)|( ))(.*)')
  24. RE_REF_ID = re.compile(r'(fnref)(\d+)')
  25. class FootnoteExtension(Extension):
  26. """ Footnote Extension. """
  27. def __init__(self, **kwargs):
  28. """ Setup configs. """
  29. self.config = {
  30. 'PLACE_MARKER':
  31. ["///Footnotes Go Here///",
  32. "The text string that marks where the footnotes go"],
  33. 'UNIQUE_IDS':
  34. [False,
  35. "Avoid name collisions across "
  36. "multiple calls to reset()."],
  37. "BACKLINK_TEXT":
  38. ["&#8617;",
  39. "The text string that links from the footnote "
  40. "to the reader's place."],
  41. "BACKLINK_TITLE":
  42. ["Jump back to footnote %d in the text",
  43. "The text string used for the title HTML attribute "
  44. "of the backlink. %d will be replaced by the "
  45. "footnote number."],
  46. "SEPARATOR":
  47. [":",
  48. "Footnote separator."]
  49. }
  50. super().__init__(**kwargs)
  51. # In multiple invocations, emit links that don't get tangled.
  52. self.unique_prefix = 0
  53. self.found_refs = {}
  54. self.used_refs = set()
  55. self.reset()
  56. def extendMarkdown(self, md):
  57. """ Add pieces to Markdown. """
  58. md.registerExtension(self)
  59. self.parser = md.parser
  60. self.md = md
  61. # Insert a preprocessor before ReferencePreprocessor
  62. md.preprocessors.register(FootnotePreprocessor(self), 'footnote', 15)
  63. # Insert an inline pattern before ImageReferencePattern
  64. FOOTNOTE_RE = r'\[\^([^\]]*)\]' # blah blah [^1] blah
  65. md.inlinePatterns.register(FootnoteInlineProcessor(FOOTNOTE_RE, self), 'footnote', 175)
  66. # Insert a tree-processor that would actually add the footnote div
  67. # This must be before all other treeprocessors (i.e., inline and
  68. # codehilite) so they can run on the the contents of the div.
  69. md.treeprocessors.register(FootnoteTreeprocessor(self), 'footnote', 50)
  70. # Insert a tree-processor that will run after inline is done.
  71. # In this tree-processor we want to check our duplicate footnote tracker
  72. # And add additional backrefs to the footnote pointing back to the
  73. # duplicated references.
  74. md.treeprocessors.register(FootnotePostTreeprocessor(self), 'footnote-duplicate', 15)
  75. # Insert a postprocessor after amp_substitute processor
  76. md.postprocessors.register(FootnotePostprocessor(self), 'footnote', 25)
  77. def reset(self):
  78. """ Clear footnotes on reset, and prepare for distinct document. """
  79. self.footnotes = OrderedDict()
  80. self.unique_prefix += 1
  81. self.found_refs = {}
  82. self.used_refs = set()
  83. def unique_ref(self, reference, found=False):
  84. """ Get a unique reference if there are duplicates. """
  85. if not found:
  86. return reference
  87. original_ref = reference
  88. while reference in self.used_refs:
  89. ref, rest = reference.split(self.get_separator(), 1)
  90. m = RE_REF_ID.match(ref)
  91. if m:
  92. reference = '%s%d%s%s' % (m.group(1), int(m.group(2))+1, self.get_separator(), rest)
  93. else:
  94. reference = '%s%d%s%s' % (ref, 2, self.get_separator(), rest)
  95. self.used_refs.add(reference)
  96. if original_ref in self.found_refs:
  97. self.found_refs[original_ref] += 1
  98. else:
  99. self.found_refs[original_ref] = 1
  100. return reference
  101. def findFootnotesPlaceholder(self, root):
  102. """ Return ElementTree Element that contains Footnote placeholder. """
  103. def finder(element):
  104. for child in element:
  105. if child.text:
  106. if child.text.find(self.getConfig("PLACE_MARKER")) > -1:
  107. return child, element, True
  108. if child.tail:
  109. if child.tail.find(self.getConfig("PLACE_MARKER")) > -1:
  110. return child, element, False
  111. child_res = finder(child)
  112. if child_res is not None:
  113. return child_res
  114. return None
  115. res = finder(root)
  116. return res
  117. def setFootnote(self, id, text):
  118. """ Store a footnote for later retrieval. """
  119. self.footnotes[id] = text
  120. def get_separator(self):
  121. """ Get the footnote separator. """
  122. return self.getConfig("SEPARATOR")
  123. def makeFootnoteId(self, id):
  124. """ Return footnote link id. """
  125. if self.getConfig("UNIQUE_IDS"):
  126. return 'fn%s%d-%s' % (self.get_separator(), self.unique_prefix, id)
  127. else:
  128. return 'fn{}{}'.format(self.get_separator(), id)
  129. def makeFootnoteRefId(self, id, found=False):
  130. """ Return footnote back-link id. """
  131. if self.getConfig("UNIQUE_IDS"):
  132. return self.unique_ref('fnref%s%d-%s' % (self.get_separator(), self.unique_prefix, id), found)
  133. else:
  134. return self.unique_ref('fnref{}{}'.format(self.get_separator(), id), found)
  135. def makeFootnotesDiv(self, root):
  136. """ Return div of footnotes as et Element. """
  137. if not list(self.footnotes.keys()):
  138. return None
  139. div = etree.Element("div")
  140. div.set('class', 'footnote')
  141. etree.SubElement(div, "hr")
  142. ol = etree.SubElement(div, "ol")
  143. surrogate_parent = etree.Element("div")
  144. for index, id in enumerate(self.footnotes.keys(), start=1):
  145. li = etree.SubElement(ol, "li")
  146. li.set("id", self.makeFootnoteId(id))
  147. # Parse footnote with surrogate parent as li cannot be used.
  148. # List block handlers have special logic to deal with li.
  149. # When we are done parsing, we will copy everything over to li.
  150. self.parser.parseChunk(surrogate_parent, self.footnotes[id])
  151. for el in list(surrogate_parent):
  152. li.append(el)
  153. surrogate_parent.remove(el)
  154. backlink = etree.Element("a")
  155. backlink.set("href", "#" + self.makeFootnoteRefId(id))
  156. backlink.set("class", "footnote-backref")
  157. backlink.set(
  158. "title",
  159. self.getConfig("BACKLINK_TITLE") % (index)
  160. )
  161. backlink.text = FN_BACKLINK_TEXT
  162. if len(li):
  163. node = li[-1]
  164. if node.tag == "p":
  165. node.text = node.text + NBSP_PLACEHOLDER
  166. node.append(backlink)
  167. else:
  168. p = etree.SubElement(li, "p")
  169. p.append(backlink)
  170. return div
  171. class FootnotePreprocessor(Preprocessor):
  172. """ Find all footnote references and store for later use. """
  173. def __init__(self, footnotes):
  174. self.footnotes = footnotes
  175. def run(self, lines):
  176. """
  177. Loop through lines and find, set, and remove footnote definitions.
  178. Keywords:
  179. * lines: A list of lines of text
  180. Return: A list of lines of text with footnote definitions removed.
  181. """
  182. newlines = []
  183. i = 0
  184. while True:
  185. m = DEF_RE.match(lines[i])
  186. if m:
  187. fn, _i = self.detectTabbed(lines[i+1:])
  188. fn.insert(0, m.group(2))
  189. i += _i-1 # skip past footnote
  190. footnote = "\n".join(fn)
  191. self.footnotes.setFootnote(m.group(1), footnote.rstrip())
  192. # Preserve a line for each block to prevent raw HTML indexing issue.
  193. # https://github.com/Python-Markdown/markdown/issues/584
  194. num_blocks = (len(footnote.split('\n\n')) * 2)
  195. newlines.extend([''] * (num_blocks))
  196. else:
  197. newlines.append(lines[i])
  198. if len(lines) > i+1:
  199. i += 1
  200. else:
  201. break
  202. return newlines
  203. def detectTabbed(self, lines):
  204. """ Find indented text and remove indent before further proccesing.
  205. Keyword arguments:
  206. * lines: an array of strings
  207. Returns: a list of post processed items and the index of last line.
  208. """
  209. items = []
  210. blank_line = False # have we encountered a blank line yet?
  211. i = 0 # to keep track of where we are
  212. def detab(line):
  213. match = TABBED_RE.match(line)
  214. if match:
  215. return match.group(4)
  216. for line in lines:
  217. if line.strip(): # Non-blank line
  218. detabbed_line = detab(line)
  219. if detabbed_line:
  220. items.append(detabbed_line)
  221. i += 1
  222. continue
  223. elif not blank_line and not DEF_RE.match(line):
  224. # not tabbed but still part of first par.
  225. items.append(line)
  226. i += 1
  227. continue
  228. else:
  229. return items, i+1
  230. else: # Blank line: _maybe_ we are done.
  231. blank_line = True
  232. i += 1 # advance
  233. # Find the next non-blank line
  234. for j in range(i, len(lines)):
  235. if lines[j].strip():
  236. next_line = lines[j]
  237. break
  238. else:
  239. # Include extreaneous padding to prevent raw HTML
  240. # parsing issue: https://github.com/Python-Markdown/markdown/issues/584
  241. items.append("")
  242. i += 1
  243. else:
  244. break # There is no more text; we are done.
  245. # Check if the next non-blank line is tabbed
  246. if detab(next_line): # Yes, more work to do.
  247. items.append("")
  248. continue
  249. else:
  250. break # No, we are done.
  251. else:
  252. i += 1
  253. return items, i
  254. class FootnoteInlineProcessor(InlineProcessor):
  255. """ InlinePattern for footnote markers in a document's body text. """
  256. def __init__(self, pattern, footnotes):
  257. super().__init__(pattern)
  258. self.footnotes = footnotes
  259. def handleMatch(self, m, data):
  260. id = m.group(1)
  261. if id in self.footnotes.footnotes.keys():
  262. sup = etree.Element("sup")
  263. a = etree.SubElement(sup, "a")
  264. sup.set('id', self.footnotes.makeFootnoteRefId(id, found=True))
  265. a.set('href', '#' + self.footnotes.makeFootnoteId(id))
  266. a.set('class', 'footnote-ref')
  267. a.text = str(list(self.footnotes.footnotes.keys()).index(id) + 1)
  268. return sup, m.start(0), m.end(0)
  269. else:
  270. return None, None, None
  271. class FootnotePostTreeprocessor(Treeprocessor):
  272. """ Amend footnote div with duplicates. """
  273. def __init__(self, footnotes):
  274. self.footnotes = footnotes
  275. def add_duplicates(self, li, duplicates):
  276. """ Adjust current li and add the duplicates: fnref2, fnref3, etc. """
  277. for link in li.iter('a'):
  278. # Find the link that needs to be duplicated.
  279. if link.attrib.get('class', '') == 'footnote-backref':
  280. ref, rest = link.attrib['href'].split(self.footnotes.get_separator(), 1)
  281. # Duplicate link the number of times we need to
  282. # and point the to the appropriate references.
  283. links = []
  284. for index in range(2, duplicates + 1):
  285. sib_link = copy.deepcopy(link)
  286. sib_link.attrib['href'] = '%s%d%s%s' % (ref, index, self.footnotes.get_separator(), rest)
  287. links.append(sib_link)
  288. self.offset += 1
  289. # Add all the new duplicate links.
  290. el = list(li)[-1]
  291. for l in links:
  292. el.append(l)
  293. break
  294. def get_num_duplicates(self, li):
  295. """ Get the number of duplicate refs of the footnote. """
  296. fn, rest = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1)
  297. link_id = '{}ref{}{}'.format(fn, self.footnotes.get_separator(), rest)
  298. return self.footnotes.found_refs.get(link_id, 0)
  299. def handle_duplicates(self, parent):
  300. """ Find duplicate footnotes and format and add the duplicates. """
  301. for li in list(parent):
  302. # Check number of duplicates footnotes and insert
  303. # additional links if needed.
  304. count = self.get_num_duplicates(li)
  305. if count > 1:
  306. self.add_duplicates(li, count)
  307. def run(self, root):
  308. """ Crawl the footnote div and add missing duplicate footnotes. """
  309. self.offset = 0
  310. for div in root.iter('div'):
  311. if div.attrib.get('class', '') == 'footnote':
  312. # Footnotes shoul be under the first orderd list under
  313. # the footnote div. So once we find it, quit.
  314. for ol in div.iter('ol'):
  315. self.handle_duplicates(ol)
  316. break
  317. class FootnoteTreeprocessor(Treeprocessor):
  318. """ Build and append footnote div to end of document. """
  319. def __init__(self, footnotes):
  320. self.footnotes = footnotes
  321. def run(self, root):
  322. footnotesDiv = self.footnotes.makeFootnotesDiv(root)
  323. if footnotesDiv is not None:
  324. result = self.footnotes.findFootnotesPlaceholder(root)
  325. if result:
  326. child, parent, isText = result
  327. ind = list(parent).index(child)
  328. if isText:
  329. parent.remove(child)
  330. parent.insert(ind, footnotesDiv)
  331. else:
  332. parent.insert(ind + 1, footnotesDiv)
  333. child.tail = None
  334. else:
  335. root.append(footnotesDiv)
  336. class FootnotePostprocessor(Postprocessor):
  337. """ Replace placeholders with html entities. """
  338. def __init__(self, footnotes):
  339. self.footnotes = footnotes
  340. def run(self, text):
  341. text = text.replace(
  342. FN_BACKLINK_TEXT, self.footnotes.getConfig("BACKLINK_TEXT")
  343. )
  344. return text.replace(NBSP_PLACEHOLDER, "&#160;")
  345. def makeExtension(**kwargs): # pragma: no cover
  346. """ Return an instance of the FootnoteExtension """
  347. return FootnoteExtension(**kwargs)