527 lines
14 KiB
Python
527 lines
14 KiB
Python
|
|
"""Process footnotes"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from collections.abc import Sequence
|
||
|
|
from functools import partial
|
||
|
|
from typing import TYPE_CHECKING, TypedDict
|
||
|
|
|
||
|
|
from markdown_it import MarkdownIt
|
||
|
|
from markdown_it.helpers import parseLinkLabel
|
||
|
|
from markdown_it.rules_block import StateBlock
|
||
|
|
from markdown_it.rules_core import StateCore
|
||
|
|
from markdown_it.rules_inline import StateInline
|
||
|
|
from markdown_it.token import Token
|
||
|
|
|
||
|
|
from mdit_py_plugins.utils import is_code_block
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from markdown_it.renderer import RendererProtocol
|
||
|
|
from markdown_it.utils import EnvType, OptionsDict
|
||
|
|
|
||
|
|
|
||
|
|
def footnote_plugin(
|
||
|
|
md: MarkdownIt,
|
||
|
|
*,
|
||
|
|
inline: bool = True,
|
||
|
|
move_to_end: bool = True,
|
||
|
|
always_match_refs: bool = False,
|
||
|
|
) -> None:
|
||
|
|
"""Plugin ported from
|
||
|
|
`markdown-it-footnote <https://github.com/markdown-it/markdown-it-footnote>`__.
|
||
|
|
|
||
|
|
It is based on the
|
||
|
|
`pandoc definition <http://johnmacfarlane.net/pandoc/README.html#footnotes>`__:
|
||
|
|
|
||
|
|
.. code-block:: md
|
||
|
|
|
||
|
|
Normal footnote:
|
||
|
|
|
||
|
|
Here is a footnote reference,[^1] and another.[^longnote]
|
||
|
|
|
||
|
|
[^1]: Here is the footnote.
|
||
|
|
|
||
|
|
[^longnote]: Here's one with multiple blocks.
|
||
|
|
|
||
|
|
Subsequent paragraphs are indented to show that they
|
||
|
|
belong to the previous footnote.
|
||
|
|
|
||
|
|
:param inline: If True, also parse inline footnotes (^[...]).
|
||
|
|
:param move_to_end: If True, move footnote definitions to the end of the token stream.
|
||
|
|
:param always_match_refs: If True, match references, even if the footnote is not defined.
|
||
|
|
|
||
|
|
"""
|
||
|
|
md.block.ruler.before(
|
||
|
|
"reference", "footnote_def", footnote_def, {"alt": ["paragraph", "reference"]}
|
||
|
|
)
|
||
|
|
_footnote_ref = partial(footnote_ref, always_match=always_match_refs)
|
||
|
|
if inline:
|
||
|
|
md.inline.ruler.after("image", "footnote_inline", footnote_inline)
|
||
|
|
md.inline.ruler.after("footnote_inline", "footnote_ref", _footnote_ref)
|
||
|
|
else:
|
||
|
|
md.inline.ruler.after("image", "footnote_ref", _footnote_ref)
|
||
|
|
if move_to_end:
|
||
|
|
md.core.ruler.after("inline", "footnote_tail", footnote_tail)
|
||
|
|
|
||
|
|
md.add_render_rule("footnote_ref", render_footnote_ref)
|
||
|
|
md.add_render_rule("footnote_block_open", render_footnote_block_open)
|
||
|
|
md.add_render_rule("footnote_block_close", render_footnote_block_close)
|
||
|
|
md.add_render_rule("footnote_open", render_footnote_open)
|
||
|
|
md.add_render_rule("footnote_close", render_footnote_close)
|
||
|
|
md.add_render_rule("footnote_anchor", render_footnote_anchor)
|
||
|
|
|
||
|
|
# helpers (only used in other rules, no tokens are attached to those)
|
||
|
|
md.add_render_rule("footnote_caption", render_footnote_caption)
|
||
|
|
md.add_render_rule("footnote_anchor_name", render_footnote_anchor_name)
|
||
|
|
|
||
|
|
|
||
|
|
class _RefData(TypedDict, total=False):
|
||
|
|
# standard
|
||
|
|
label: str
|
||
|
|
count: int
|
||
|
|
# inline
|
||
|
|
content: str
|
||
|
|
tokens: list[Token]
|
||
|
|
|
||
|
|
|
||
|
|
class _FootnoteData(TypedDict):
|
||
|
|
refs: dict[str, int]
|
||
|
|
"""A mapping of all footnote labels (prefixed with ``:``) to their ID (-1 if not yet set)."""
|
||
|
|
list: dict[int, _RefData]
|
||
|
|
"""A mapping of all footnote IDs to their data."""
|
||
|
|
|
||
|
|
|
||
|
|
def _data_from_env(env: EnvType) -> _FootnoteData:
|
||
|
|
footnotes = env.setdefault("footnotes", {})
|
||
|
|
footnotes.setdefault("refs", {})
|
||
|
|
footnotes.setdefault("list", {})
|
||
|
|
return footnotes # type: ignore[no-any-return]
|
||
|
|
|
||
|
|
|
||
|
|
# ## RULES ##
|
||
|
|
|
||
|
|
|
||
|
|
def footnote_def(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool:
|
||
|
|
"""Process footnote block definition"""
|
||
|
|
|
||
|
|
if is_code_block(state, startLine):
|
||
|
|
return False
|
||
|
|
|
||
|
|
start = state.bMarks[startLine] + state.tShift[startLine]
|
||
|
|
maximum = state.eMarks[startLine]
|
||
|
|
|
||
|
|
# line should be at least 5 chars - "[^x]:"
|
||
|
|
if start + 4 > maximum:
|
||
|
|
return False
|
||
|
|
|
||
|
|
if state.src[start] != "[":
|
||
|
|
return False
|
||
|
|
if state.src[start + 1] != "^":
|
||
|
|
return False
|
||
|
|
|
||
|
|
pos = start + 2
|
||
|
|
while pos < maximum:
|
||
|
|
if state.src[pos] == " ":
|
||
|
|
return False
|
||
|
|
if state.src[pos] == "]":
|
||
|
|
break
|
||
|
|
pos += 1
|
||
|
|
|
||
|
|
if pos == start + 2: # no empty footnote labels
|
||
|
|
return False
|
||
|
|
pos += 1
|
||
|
|
if pos >= maximum or state.src[pos] != ":":
|
||
|
|
return False
|
||
|
|
if silent:
|
||
|
|
return True
|
||
|
|
pos += 1
|
||
|
|
|
||
|
|
label = state.src[start + 2 : pos - 2]
|
||
|
|
footnote_data = _data_from_env(state.env)
|
||
|
|
footnote_data["refs"][":" + label] = -1
|
||
|
|
|
||
|
|
open_token = Token("footnote_reference_open", "", 1)
|
||
|
|
open_token.meta = {"label": label}
|
||
|
|
open_token.level = state.level
|
||
|
|
state.level += 1
|
||
|
|
state.tokens.append(open_token)
|
||
|
|
|
||
|
|
oldBMark = state.bMarks[startLine]
|
||
|
|
oldTShift = state.tShift[startLine]
|
||
|
|
oldSCount = state.sCount[startLine]
|
||
|
|
oldParentType = state.parentType
|
||
|
|
|
||
|
|
posAfterColon = pos
|
||
|
|
initial = offset = (
|
||
|
|
state.sCount[startLine]
|
||
|
|
+ pos
|
||
|
|
- (state.bMarks[startLine] + state.tShift[startLine])
|
||
|
|
)
|
||
|
|
|
||
|
|
while pos < maximum:
|
||
|
|
ch = state.src[pos]
|
||
|
|
|
||
|
|
if ch == "\t":
|
||
|
|
offset += 4 - offset % 4
|
||
|
|
elif ch == " ":
|
||
|
|
offset += 1
|
||
|
|
|
||
|
|
else:
|
||
|
|
break
|
||
|
|
|
||
|
|
pos += 1
|
||
|
|
|
||
|
|
state.tShift[startLine] = pos - posAfterColon
|
||
|
|
state.sCount[startLine] = offset - initial
|
||
|
|
|
||
|
|
state.bMarks[startLine] = posAfterColon
|
||
|
|
state.blkIndent += 4
|
||
|
|
state.parentType = "footnote"
|
||
|
|
|
||
|
|
if state.sCount[startLine] < state.blkIndent:
|
||
|
|
state.sCount[startLine] += state.blkIndent
|
||
|
|
|
||
|
|
state.md.block.tokenize(state, startLine, endLine)
|
||
|
|
|
||
|
|
state.parentType = oldParentType
|
||
|
|
state.blkIndent -= 4
|
||
|
|
state.tShift[startLine] = oldTShift
|
||
|
|
state.sCount[startLine] = oldSCount
|
||
|
|
state.bMarks[startLine] = oldBMark
|
||
|
|
|
||
|
|
open_token.map = [startLine, state.line]
|
||
|
|
|
||
|
|
token = Token("footnote_reference_close", "", -1)
|
||
|
|
state.level -= 1
|
||
|
|
token.level = state.level
|
||
|
|
state.tokens.append(token)
|
||
|
|
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def footnote_inline(state: StateInline, silent: bool) -> bool:
|
||
|
|
"""Process inline footnotes (^[...])"""
|
||
|
|
|
||
|
|
maximum = state.posMax
|
||
|
|
start = state.pos
|
||
|
|
|
||
|
|
if start + 2 >= maximum:
|
||
|
|
return False
|
||
|
|
if state.src[start] != "^":
|
||
|
|
return False
|
||
|
|
if state.src[start + 1] != "[":
|
||
|
|
return False
|
||
|
|
|
||
|
|
labelStart = start + 2
|
||
|
|
labelEnd = parseLinkLabel(state, start + 1)
|
||
|
|
|
||
|
|
# parser failed to find ']', so it's not a valid note
|
||
|
|
if labelEnd < 0:
|
||
|
|
return False
|
||
|
|
|
||
|
|
# We found the end of the link, and know for a fact it's a valid link
|
||
|
|
# so all that's left to do is to call tokenizer.
|
||
|
|
#
|
||
|
|
if not silent:
|
||
|
|
refs = _data_from_env(state.env)["list"]
|
||
|
|
footnoteId = len(refs)
|
||
|
|
|
||
|
|
tokens: list[Token] = []
|
||
|
|
state.md.inline.parse(
|
||
|
|
state.src[labelStart:labelEnd], state.md, state.env, tokens
|
||
|
|
)
|
||
|
|
|
||
|
|
token = state.push("footnote_ref", "", 0)
|
||
|
|
token.meta = {"id": footnoteId}
|
||
|
|
|
||
|
|
refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens}
|
||
|
|
|
||
|
|
state.pos = labelEnd + 1
|
||
|
|
state.posMax = maximum
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def footnote_ref(
|
||
|
|
state: StateInline, silent: bool, *, always_match: bool = False
|
||
|
|
) -> bool:
|
||
|
|
"""Process footnote references ([^...])"""
|
||
|
|
|
||
|
|
maximum = state.posMax
|
||
|
|
start = state.pos
|
||
|
|
|
||
|
|
# should be at least 4 chars - "[^x]"
|
||
|
|
if start + 3 > maximum:
|
||
|
|
return False
|
||
|
|
|
||
|
|
footnote_data = _data_from_env(state.env)
|
||
|
|
|
||
|
|
if not (always_match or footnote_data["refs"]):
|
||
|
|
return False
|
||
|
|
if state.src[start] != "[":
|
||
|
|
return False
|
||
|
|
if state.src[start + 1] != "^":
|
||
|
|
return False
|
||
|
|
|
||
|
|
pos = start + 2
|
||
|
|
while pos < maximum:
|
||
|
|
if state.src[pos] in (" ", "\n"):
|
||
|
|
return False
|
||
|
|
if state.src[pos] == "]":
|
||
|
|
break
|
||
|
|
pos += 1
|
||
|
|
|
||
|
|
if pos == start + 2: # no empty footnote labels
|
||
|
|
return False
|
||
|
|
if pos >= maximum:
|
||
|
|
return False
|
||
|
|
pos += 1
|
||
|
|
|
||
|
|
label = state.src[start + 2 : pos - 1]
|
||
|
|
if ((":" + label) not in footnote_data["refs"]) and not always_match:
|
||
|
|
return False
|
||
|
|
|
||
|
|
if not silent:
|
||
|
|
if footnote_data["refs"].get(":" + label, -1) < 0:
|
||
|
|
footnoteId = len(footnote_data["list"])
|
||
|
|
footnote_data["list"][footnoteId] = {"label": label, "count": 0}
|
||
|
|
footnote_data["refs"][":" + label] = footnoteId
|
||
|
|
else:
|
||
|
|
footnoteId = footnote_data["refs"][":" + label]
|
||
|
|
|
||
|
|
footnoteSubId = footnote_data["list"][footnoteId]["count"]
|
||
|
|
footnote_data["list"][footnoteId]["count"] += 1
|
||
|
|
|
||
|
|
token = state.push("footnote_ref", "", 0)
|
||
|
|
token.meta = {"id": footnoteId, "subId": footnoteSubId, "label": label}
|
||
|
|
|
||
|
|
state.pos = pos
|
||
|
|
state.posMax = maximum
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def footnote_tail(state: StateCore) -> None:
|
||
|
|
"""Post-processing step, to move footnote tokens to end of the token stream.
|
||
|
|
|
||
|
|
Also removes un-referenced tokens.
|
||
|
|
"""
|
||
|
|
|
||
|
|
insideRef = False
|
||
|
|
refTokens = {}
|
||
|
|
|
||
|
|
if "footnotes" not in state.env:
|
||
|
|
return
|
||
|
|
|
||
|
|
current: list[Token] = []
|
||
|
|
tok_filter = []
|
||
|
|
for tok in state.tokens:
|
||
|
|
if tok.type == "footnote_reference_open":
|
||
|
|
insideRef = True
|
||
|
|
current = []
|
||
|
|
currentLabel = tok.meta["label"]
|
||
|
|
tok_filter.append(False)
|
||
|
|
continue
|
||
|
|
|
||
|
|
if tok.type == "footnote_reference_close":
|
||
|
|
insideRef = False
|
||
|
|
# prepend ':' to avoid conflict with Object.prototype members
|
||
|
|
refTokens[":" + currentLabel] = current
|
||
|
|
tok_filter.append(False)
|
||
|
|
continue
|
||
|
|
|
||
|
|
if insideRef:
|
||
|
|
current.append(tok)
|
||
|
|
|
||
|
|
tok_filter.append(not insideRef)
|
||
|
|
|
||
|
|
state.tokens = [t for t, f in zip(state.tokens, tok_filter, strict=False) if f]
|
||
|
|
|
||
|
|
footnote_data = _data_from_env(state.env)
|
||
|
|
if not footnote_data["list"]:
|
||
|
|
return
|
||
|
|
|
||
|
|
token = Token("footnote_block_open", "", 1)
|
||
|
|
state.tokens.append(token)
|
||
|
|
|
||
|
|
for i, foot_note in footnote_data["list"].items():
|
||
|
|
token = Token("footnote_open", "", 1)
|
||
|
|
token.meta = {"id": i, "label": foot_note.get("label", None)}
|
||
|
|
# TODO propagate line positions of original foot note
|
||
|
|
# (but don't store in token.map, because this is used for scroll syncing)
|
||
|
|
state.tokens.append(token)
|
||
|
|
|
||
|
|
if "tokens" in foot_note:
|
||
|
|
tokens = []
|
||
|
|
|
||
|
|
token = Token("paragraph_open", "p", 1)
|
||
|
|
token.block = True
|
||
|
|
tokens.append(token)
|
||
|
|
|
||
|
|
token = Token("inline", "", 0)
|
||
|
|
token.children = foot_note["tokens"]
|
||
|
|
token.content = foot_note["content"]
|
||
|
|
tokens.append(token)
|
||
|
|
|
||
|
|
token = Token("paragraph_close", "p", -1)
|
||
|
|
token.block = True
|
||
|
|
tokens.append(token)
|
||
|
|
|
||
|
|
elif "label" in foot_note:
|
||
|
|
tokens = refTokens.get(":" + foot_note["label"], [])
|
||
|
|
|
||
|
|
state.tokens.extend(tokens)
|
||
|
|
if state.tokens[len(state.tokens) - 1].type == "paragraph_close":
|
||
|
|
lastParagraph: Token | None = state.tokens.pop()
|
||
|
|
else:
|
||
|
|
lastParagraph = None
|
||
|
|
|
||
|
|
t = (
|
||
|
|
foot_note["count"]
|
||
|
|
if (("count" in foot_note) and (foot_note["count"] > 0))
|
||
|
|
else 1
|
||
|
|
)
|
||
|
|
j = 0
|
||
|
|
while j < t:
|
||
|
|
token = Token("footnote_anchor", "", 0)
|
||
|
|
token.meta = {"id": i, "subId": j, "label": foot_note.get("label", None)}
|
||
|
|
state.tokens.append(token)
|
||
|
|
j += 1
|
||
|
|
|
||
|
|
if lastParagraph:
|
||
|
|
state.tokens.append(lastParagraph)
|
||
|
|
|
||
|
|
token = Token("footnote_close", "", -1)
|
||
|
|
state.tokens.append(token)
|
||
|
|
|
||
|
|
token = Token("footnote_block_close", "", -1)
|
||
|
|
state.tokens.append(token)
|
||
|
|
|
||
|
|
|
||
|
|
########################################
|
||
|
|
# Renderer partials
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_anchor_name(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
n = str(tokens[idx].meta["id"] + 1)
|
||
|
|
prefix = ""
|
||
|
|
|
||
|
|
doc_id = env.get("docId", None)
|
||
|
|
if isinstance(doc_id, str):
|
||
|
|
prefix = f"-{doc_id}-"
|
||
|
|
|
||
|
|
return prefix + n
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_caption(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
n = str(tokens[idx].meta["id"] + 1)
|
||
|
|
|
||
|
|
if tokens[idx].meta.get("subId", -1) > 0:
|
||
|
|
n += ":" + str(tokens[idx].meta["subId"])
|
||
|
|
|
||
|
|
return "[" + n + "]"
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_ref(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined]
|
||
|
|
caption: str = self.rules["footnote_caption"](tokens, idx, options, env) # type: ignore[attr-defined]
|
||
|
|
refid = ident
|
||
|
|
|
||
|
|
if tokens[idx].meta.get("subId", -1) > 0:
|
||
|
|
refid += ":" + str(tokens[idx].meta["subId"])
|
||
|
|
|
||
|
|
return (
|
||
|
|
'<sup class="footnote-ref"><a href="#fn'
|
||
|
|
+ ident
|
||
|
|
+ '" id="fnref'
|
||
|
|
+ refid
|
||
|
|
+ '">'
|
||
|
|
+ caption
|
||
|
|
+ "</a></sup>"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_block_open(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
return (
|
||
|
|
(
|
||
|
|
'<hr class="footnotes-sep" />\n'
|
||
|
|
if options.xhtmlOut
|
||
|
|
else '<hr class="footnotes-sep">\n'
|
||
|
|
)
|
||
|
|
+ '<section class="footnotes">\n'
|
||
|
|
+ '<ol class="footnotes-list">\n'
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_block_close(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
return "</ol>\n</section>\n"
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_open(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined]
|
||
|
|
|
||
|
|
if tokens[idx].meta.get("subId", -1) > 0:
|
||
|
|
ident += ":" + tokens[idx].meta["subId"]
|
||
|
|
|
||
|
|
return '<li id="fn' + ident + '" class="footnote-item">'
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_close(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
return "</li>\n"
|
||
|
|
|
||
|
|
|
||
|
|
def render_footnote_anchor(
|
||
|
|
self: RendererProtocol,
|
||
|
|
tokens: Sequence[Token],
|
||
|
|
idx: int,
|
||
|
|
options: OptionsDict,
|
||
|
|
env: EnvType,
|
||
|
|
) -> str:
|
||
|
|
ident: str = self.rules["footnote_anchor_name"](tokens, idx, options, env) # type: ignore[attr-defined]
|
||
|
|
|
||
|
|
if tokens[idx].meta["subId"] > 0:
|
||
|
|
ident += ":" + str(tokens[idx].meta["subId"])
|
||
|
|
|
||
|
|
# ↩ with escape code to prevent display as Apple Emoji on iOS
|
||
|
|
return ' <a href="#fnref' + ident + '" class="footnote-backref">\u21a9\ufe0e</a>'
|