tags.
"""
yield 0, ""
yield from inner
yield 0, ""
def _add_newline(self, inner):
# Add newlines around the inner contents so that _strict_tag_block_re matches the outer div.
yield 0, "\n"
yield from inner
yield 0, "\n"
def wrap(self, source, outfile=None):
"""Return the source with a code, pre, and div."""
if outfile is None:
# pygments >= 2.12
return self._add_newline(self._wrap_pre(self._wrap_code(source)))
else:
# pygments < 2.12
return self._wrap_div(self._add_newline(self._wrap_pre(self._wrap_code(source))))
formatter_opts.setdefault("cssclass", "codehilite")
formatter = HtmlCodeFormatter(**formatter_opts)
return pygments.highlight(codeblock, lexer, formatter)
def _code_block_sub(self, match: re.Match[str]) -> str:
codeblock = match.group(1)
codeblock = self._outdent(codeblock)
codeblock = self._detab(codeblock)
codeblock = codeblock.lstrip('\n') # trim leading newlines
codeblock = codeblock.rstrip() # trim trailing whitespace
pre_class_str = self._html_class_str_from_tag("pre")
code_class_str = self._html_class_str_from_tag("code")
codeblock = self._encode_code(codeblock)
return "\n{}\n
\n".format(
pre_class_str, code_class_str, codeblock)
def _html_class_str_from_tag(self, tag: str) -> str:
"""Get the appropriate ' class="..."' string (note the leading
space), if any, for the given tag.
"""
if "html-classes" not in self.extras:
return ""
try:
html_classes_from_tag = self.extras["html-classes"]
except TypeError:
return ""
else:
if isinstance(html_classes_from_tag, dict):
if tag in html_classes_from_tag:
return ' class="%s"' % html_classes_from_tag[tag]
return ""
@mark_stage(Stage.CODE_BLOCKS)
def _do_code_blocks(self, text: str) -> str:
"""Process Markdown `` blocks."""
code_block_re = re.compile(r'''
(?:\n\n|\A\n?)
( # $1 = the code block -- one or more lines, starting with a space/tab
(?:
(?:[ ]{%d} | \t) # Lines must start with a tab or a tab-width of spaces
.*\n+
)+
)
((?=^[ ]{0,%d}\S)|\Z) # Lookahead for non-space at line-start, or end of doc
# Lookahead to make sure this block isn't already in a code block.
# Needed when syntax highlighting is being used.
(?!([^<]|<(/?)span)*\)
''' % (self.tab_width, self.tab_width),
re.M | re.X)
return code_block_re.sub(self._code_block_sub, text)
# Rules for a code span:
# - backslash escapes are not interpreted in a code span
# - to include one or or a run of more backticks the delimiters must
# be a longer run of backticks
# - cannot start or end a code span with a backtick; pad with a
# space and that space will be removed in the emitted HTML
# See `test/tm-cases/escapes.text` for a number of edge-case
# examples.
_code_span_re = re.compile(r'''
(? str:
c = match.group(2).strip(" \t")
c = self._encode_code(c)
return "{}".format(self._html_class_str_from_tag("code"), c)
@mark_stage(Stage.CODE_SPANS)
def _do_code_spans(self, text: str) -> str:
# * Backtick quotes are used for spans.
#
# * You can use multiple backticks as the delimiters if you want to
# include literal backticks in the code span. So, this input:
#
# Just type ``foo `bar` baz`` at the prompt.
#
# Will translate to:
#
# Just type foo `bar` baz at the prompt.
#
# There's no arbitrary limit to the number of backticks you
# can use as delimters. If you need three consecutive backticks
# in your code, use four for delimiters, etc.
#
# * You can use spaces to get literal backticks at the edges:
#
# ... type `` `bar` `` ...
#
# Turns to:
#
# ... type `bar` ...
return self._code_span_re.sub(self._code_span_sub, text)
def _encode_code(self, text: str) -> str:
"""Encode/escape certain characters inside Markdown code runs.
The point is that in code, these characters are literals,
and lose their special Markdown meanings.
"""
replacements = [
# Encode all ampersands; HTML entities are not
# entities within a Markdown code span.
('&', '&'),
# Do the angle bracket song and dance:
('<', '<'),
('>', '>'),
]
for before, after in replacements:
text = text.replace(before, after)
hashed = _hash_text(text)
self._code_table[text] = hashed
return hashed
_strong_re = re.compile(r'''
(?:_{1,}|\*{1,})? # ignore any leading em chars because we want to wrap `` as tightly around the text as possible
# eg: `***abc***` -> `*abc*` instead of `*abc*`
# Makes subsequent processing easier
(\*\*|__)(?=\S) # strong syntax - must be followed by a non whitespace char
(.+?) # the strong text itself
(?<=\S)\1 # closing syntax - must be preceeded by non whitespace char
''',
re.S | re.X
)
_em_re = re.compile(r"(\*|_)(?=\S)(.*?\S)\1", re.S)
_iab_processor = None
@mark_stage(Stage.ITALIC_AND_BOLD)
def _do_italics_and_bold(self, text: str) -> str:
if not self._iab_processor:
self._iab_processor = GFMItalicAndBoldProcessor(self, None)
if self._iab_processor.test(text):
text = self._iab_processor.run(text)
return text
_block_quote_base = r'''
( # Wrap whole match in \1
(
^[ \t]*>%s[ \t]? # '>' at the start of a line
.+\n # rest of the first line
(.+\n)* # subsequent consecutive lines
)+
)
'''
_block_quote_re = re.compile(_block_quote_base % '', re.M | re.X)
_block_quote_re_spoiler = re.compile(_block_quote_base % '[ \t]*?!?', re.M | re.X)
_bq_one_level_re = re.compile('^[ \t]*>[ \t]?', re.M)
_bq_one_level_re_spoiler = re.compile('^[ \t]*>[ \t]*?![ \t]?', re.M)
_bq_all_lines_spoilers = re.compile(r'\A(?:^[ \t]*>[ \t]*?!.*[\n\r]*)+\Z', re.M)
_html_pre_block_re = re.compile(r'(\s*.+?
)', re.S)
def _dedent_two_spaces_sub(self, match: re.Match[str]) -> str:
return re.sub(r'(?m)^ ', '', match.group(1))
def _block_quote_sub(self, match: re.Match[str]) -> str:
bq = match.group(1)
is_spoiler = 'spoiler' in self.extras and self._bq_all_lines_spoilers.match(bq)
# trim one level of quoting
if is_spoiler:
bq = self._bq_one_level_re_spoiler.sub('', bq)
else:
bq = self._bq_one_level_re.sub('', bq)
# trim whitespace-only lines
bq = self._ws_only_line_re.sub('', bq)
bq = self._run_block_gamut(bq) # recurse
bq = re.sub('(?m)^', ' ', bq)
# These leading spaces screw with content, so we need to fix that:
bq = self._html_pre_block_re.sub(self._dedent_two_spaces_sub, bq)
if is_spoiler:
return '\n%s\n
\n\n' % bq
else:
return '\n%s\n
\n\n' % bq
@mark_stage(Stage.BLOCK_QUOTES)
def _do_block_quotes(self, text: str) -> str:
if '>' not in text:
return text
if 'spoiler' in self.extras:
return self._block_quote_re_spoiler.sub(self._block_quote_sub, text)
else:
return self._block_quote_re.sub(self._block_quote_sub, text)
@mark_stage(Stage.PARAGRAPHS)
def _form_paragraphs(self, text: str) -> str:
# Strip leading and trailing lines:
text = text.strip('\n')
# Wrap tags.
grafs = []
for i, graf in enumerate(re.split(r"\n{2,}", text)):
if graf in self.html_blocks:
# Unhashify HTML blocks
grafs.append(self.html_blocks[graf])
else:
cuddled_list = None
if "cuddled-lists" in self.extras:
# Need to put back trailing '\n' for `_list_item_re`
# match at the end of the paragraph.
li = self._list_item_re.search(graf + '\n')
# Two of the same list marker in this paragraph: a likely
# candidate for a list cuddled to preceding paragraph
# text (issue 33). Note the `[-1]` is a quick way to
# consider numeric bullets (e.g. "1." and "2.") to be
# equal.
if (li and len(li.group(2)) <= 3
and (
(li.group("next_marker") and li.group("marker")[-1] == li.group("next_marker")[-1])
or
li.group("next_marker") is None
)
):
start = li.start()
cuddled_list = self._do_lists(graf[start:]).rstrip("\n")
if re.match(r'^<(?:ul|ol).*?>', cuddled_list):
graf = graf[:start]
else:
# Not quite a cuddled list. (See not_quite_a_list_cuddled_lists test case)
# Store as a simple paragraph.
graf = cuddled_list
cuddled_list = None
# Wrap
tags.
graf = self._run_span_gamut(graf)
grafs.append("
" % self._html_class_str_from_tag('p') + graf.lstrip(" \t") + "
")
if cuddled_list:
grafs.append(cuddled_list)
return "\n\n".join(grafs)
def _add_footnotes(self, text: str) -> str:
if self.footnotes:
footer = [
'')
return text + '\n\n' + '\n'.join(footer)
else:
return text
_naked_lt_re = re.compile(r'<(?![a-z/?\$!])', re.I)
_naked_gt_re = re.compile(r'''(?''', re.I)
def _encode_amps_and_angles(self, text: str) -> str:
# Smart processing for ampersands and angle brackets that need
# to be encoded.
text = _AMPERSAND_RE.sub('&', text)
text = _ESCAPED_AMPERSAND_RE.sub(r'&\1', text)
# Encode naked <'s
text = self._naked_lt_re.sub('<', text)
# Encode naked >'s
# Note: Other markdown implementations (e.g. Markdown.pl, PHP
# Markdown) don't do this.
text = self._naked_gt_re.sub('>', text)
return text
_incomplete_tags_re = re.compile(r"\\*<(!--|/?\w+?(?!\w)\s*?.+?(?:[\s/]+?|$))")
def _encode_incomplete_tags(self, text: str) -> str:
if self.safe_mode not in ("replace", "escape"):
return text
if self._is_auto_link(text):
return text # this is not an incomplete tag, this is a link in the form
def incomplete_tags_sub(match):
text = match.group()
# ensure that we handle escaped incomplete tags properly by consuming and replacing the escapes
if not self._is_unescaped_re.match(text):
text = text.replace('\\<', '<')
return text.replace('<', '<')
text = self._incomplete_tags_re.sub(incomplete_tags_sub, text)
return text
def _encode_backslash_escapes(self, text: str) -> str:
for ch, escape in list(self._escape_table.items()):
text = text.replace("\\"+ch, escape)
return text
_auto_link_re = re.compile(r'<((https?|ftp):[^\'">\s]+)>', re.I)
def _auto_link_sub(self, match: re.Match[str]) -> str:
g1 = match.group(1)
return '{}'.format(self._protect_url(g1), g1)
_auto_email_link_re = re.compile(r"""
<
(?:mailto:)?
(
[-.\w]+
\@
[-\w]+(\.[-\w]+)*\.[a-z]+
)
>
""", re.I | re.X | re.U)
def _auto_email_link_sub(self, match: re.Match[str]) -> str:
return self._encode_email_address(
self._unescape_special_chars(match.group(1)))
def _do_auto_links(self, text: str) -> str:
text = self._auto_link_re.sub(self._auto_link_sub, text)
text = self._auto_email_link_re.sub(self._auto_email_link_sub, text)
return text
def _encode_email_address(self, addr: str) -> str:
# Input: an email address, e.g. "foo@example.com"
#
# Output: the email address as a mailto link, with each character
# of the address encoded as either a decimal or hex entity, in
# the hopes of foiling most address harvesting spam bots. E.g.:
#
# foo
# @example.com
#
# Based on a filter by Matthew Wickline, posted to the BBEdit-Talk
# mailing list:
chars = [_xml_encode_email_char_at_random(ch)
for ch in "mailto:" + addr]
# Strip the mailto: from the visible part.
addr = '%s' \
% (''.join(chars), ''.join(chars[7:]))
return addr
def _unescape_special_chars(self, text: str) -> str:
# Swap back in all the special characters we've hidden.
hashmap = tuple(self._escape_table.items()) + tuple(self._code_table.items())
# html_blocks table is in format {hash: item} compared to usual {item: hash}
hashmap += tuple(tuple(reversed(i)) for i in self.html_blocks.items())
while True:
orig_text = text
for ch, hash in hashmap:
text = text.replace(hash, ch)
if text == orig_text:
break
return text
def _outdent(self, text: str) -> str:
# Remove one level of line-leading tabs or spaces
return self._outdent_re.sub('', text)
def _hash_span(self, text: str, hash_table: Optional[dict] = None) -> str:
'''
Wrapper around `_hash_text` that also adds the hash to `self.hash_spans`,
meaning it will be automatically unhashed during conversion.
Args:
text: the text to hash
hash_table: the dict to insert the hash into. If omitted will default to `self.html_spans`
Returns:
The hashed text
'''
key = _hash_text(text)
if hash_table is not None:
hash_table[key] = text
else:
self.html_spans[key] = text
return key
@staticmethod
def _uniform_outdent(
text: str,
min_outdent: Optional[str] = None,
max_outdent: Optional[str] = None
) -> tuple[str, str]:
'''
Removes the smallest common leading indentation from each (non empty)
line of `text` and returns said indent along with the outdented text.
Args:
min_outdent: make sure the smallest common whitespace is at least this size
max_outdent: the maximum amount a line can be outdented by
'''
# find the leading whitespace for every line
whitespace: list[Union[str, None]] = [
re.findall(r'^[ \t]*', line)[0] if line else None
for line in text.splitlines()
]
whitespace_not_empty = [i for i in whitespace if i is not None]
# if no whitespace detected (ie: no lines in code block, issue #505)
if not whitespace_not_empty:
return '', text
# get minimum common whitespace
outdent = min(whitespace_not_empty)
# adjust min common ws to be within bounds
if min_outdent is not None:
outdent = min([i for i in whitespace_not_empty if i >= min_outdent] or [min_outdent])
if max_outdent is not None:
outdent = min(outdent, max_outdent)
outdented = []
for line_ws, line in zip(whitespace, text.splitlines(True)):
if line.startswith(outdent):
# if line starts with smallest common ws, dedent it
outdented.append(line.replace(outdent, '', 1))
elif line_ws is not None and line_ws < outdent:
# if less indented than min common whitespace then outdent as much as possible
outdented.append(line.replace(line_ws, '', 1))
else:
outdented.append(line)
return outdent, ''.join(outdented)
@staticmethod
def _uniform_indent(
text: str,
indent: str,
include_empty_lines: bool = False,
indent_empty_lines: bool = False
) -> str:
'''
Uniformly indent a block of text by a fixed amount
Args:
text: the text to indent
indent: a string containing the indent to apply
include_empty_lines: don't remove whitespace only lines
indent_empty_lines: indent whitespace only lines with the rest of the text
'''
blocks = []
for line in text.splitlines(True):
if line.strip() or indent_empty_lines:
blocks.append(indent + line)
elif include_empty_lines:
blocks.append(line)
else:
blocks.append('')
return ''.join(blocks)
@staticmethod
def _match_overlaps_substr(text: str, match: re.Match[str], substr: str) -> bool:
'''
Checks if a regex match overlaps with a substring in the given text.
'''
for instance in re.finditer(re.escape(substr), text):
start, end = instance.span()
if start <= match.start() <= end:
return True
if start <= match.end() <= end:
return True
return False
class MarkdownWithExtras(Markdown):
"""A markdowner class that enables most extras:
- footnotes
- fenced-code-blocks (only highlights code if 'pygments' Python module on path)
These are not included:
- pyshell (specific to Python-related documenting)
- code-friendly (because it *disables* part of the syntax)
- link-patterns (because you need to specify some actual
link-patterns anyway)
"""
extras = ["footnotes", "fenced-code-blocks"] # type: ignore
# ----------------------------------------------------------
# Extras
# ----------------------------------------------------------
# Base classes
# ----------------------------------------------------------
class Extra(ABC):
_registry: dict[str, type['Extra']] = {}
_exec_order: dict[Stage, tuple[list[type['Extra']], list[type['Extra']]]] = {}
name: str
'''
An identifiable name that users can use to invoke the extra
in the Markdown class
'''
order: tuple[Collection[Union[Stage, type['Extra']]], Collection[Union[Stage, type['Extra']]]]
'''
Tuple of two iterables containing the stages/extras this extra will run before and
after, respectively
'''
def __init__(self, md: Markdown, options: Optional[dict]):
'''
Args:
md: An instance of `Markdown`
options: a dict of settings to alter the extra's behaviour
'''
self.md = md
self.options = options if options is not None else {}
@classmethod
def deregister(cls):
'''
Removes the class from the extras registry and unsets its execution order.
'''
if cls.name in cls._registry:
del cls._registry[cls.name]
for exec_order in Extra._exec_order.values():
# find everywhere this extra is mentioned and remove it
for section in exec_order:
while cls in section:
section.remove(cls)
@classmethod
def register(cls):
'''
Registers the class for use with `Markdown` and calculates its execution order based on
the `order` class attribute.
'''
cls._registry[cls.name] = cls
for index, item in enumerate((*cls.order[0], *cls.order[1])):
before = index < len(cls.order[0])
if not isinstance(item, Stage) and issubclass(item, Extra):
# eg: FencedCodeBlocks
for exec_orders in Extra._exec_order.values():
# insert this extra everywhere the other one is mentioned
for section in exec_orders:
if item in section:
to_index = section.index(item)
if not before:
to_index += 1
section.insert(to_index, cls)
else:
# eg: Stage.PREPROCESS
Extra._exec_order.setdefault(item, ([], []))
if cls in Extra._exec_order[item][0 if before else 1]:
# extra is already runnig after this stage. Don't duplicate that effort
continue
if before:
Extra._exec_order[item][0].insert(0, cls)
else:
Extra._exec_order[item][1].append(cls)
@abstractmethod
def run(self, text: str) -> str:
'''
Run the extra against the given text.
Returns:
The new text after being modified by the extra
'''
...
def test(self, text: str) -> bool:
'''
Check a section of markdown to see if this extra should be run upon it.
The default implementation will always return True but it's recommended to override
this behaviour to improve performance.
'''
return True
class ItalicAndBoldProcessor(Extra):
'''
An ABC that provides hooks for dealing with italics and bold syntax.
This class is set to trigger both before AND after the italics and bold stage.
This allows any child classes to intercept instances of bold or italic syntax and
change the output or hash it to prevent it from being processed.
After the I&B stage any hashes in the `hash_tables` instance variable are replaced.
'''
name = 'italic-and-bold-processor'
order = (Stage.ITALIC_AND_BOLD,), (Stage.ITALIC_AND_BOLD,)
strong_re = Markdown._strong_re
em_re = Markdown._em_re
def __init__(self, md: Markdown, options: Optional[dict]):
super().__init__(md, options)
self.hash_table = {}
def run(self, text: str):
if self.md.order < Stage.ITALIC_AND_BOLD:
text = self.strong_re.sub(self.sub, text)
text = self.em_re.sub(self.sub, text)
else:
# push any hashed values back, using a while loop to deal with recursive hashes
orig_text = ''
while orig_text != text:
orig_text = text
for key, substr in self.hash_table.items():
text = text.replace(key, substr)
return text
@abstractmethod
def sub(self, match: re.Match[str]) -> str:
# do nothing. Let `Markdown._do_italics_and_bold` do its thing later
return match.string[match.start(): match.end()]
def sub_hash(self, match: re.Match[str]) -> str:
substr = match.string[match.start(): match.end()]
return self.md._hash_span(substr, self.hash_table)
def test(self, text: str):
if self.md.order < Stage.ITALIC_AND_BOLD:
return '*' in text or '_' in text
return self.hash_table and re.search(r'md5-[0-9a-z]{32}', text)
class GFMItalicAndBoldProcessor(Extra):
'''
An upgraded version of the `ItalicAndBoldProcessor` that covers far more edge cases and gets close
to Github Flavoured Markdown compliance.
'''
name = 'gfm-italic-and-bold-processor'
order = (Stage.ITALIC_AND_BOLD,), tuple()
def run(self, text: str):
nesting = True
orig_text = ""
while nesting and orig_text != _hash_text(text):
orig_text = _hash_text(text)
nesting = False
opens = {'*': [], '_': []}
'''Mapping of em type to a list of opening runs of that em type'''
unused_opens = {'*': {}, '_': {}}
'''
Mapping of em type to another mapping of unused opening runs of that em type.
An unused run is one that has been skipped, or only partially consumed (eg: **foo*) and
could be consumed by another closing run. The inner mapping is a mapping of the
delimiter run to an offset number, which is the number of characters from that run that
have been consumed so far
'''
unused_closes = {'*': [], '_': []}
'''
Mapping of em type to a list of closing delimiter runs that have not been fully consumed.
EG: *foo*bar*
'''
tokens = []
'''List of processed spans of text that will be joined to form the new `text`'''
index = 0
'''Number of chars of `text` that has been processed so far'''
delim_runs_iter = re.finditer(r'(\*+|_+)', text)
next_delim_run = self._next_run(delim_runs_iter)
while next_delim_run:
delim_run, left, right = next_delim_run
next_delim_run = self._next_run(delim_runs_iter)
syntax = delim_run.group(1)
em_type = syntax[0]
# if not a closing run, or there are no opens to consume
if not right or not opens[em_type]:
# if it can also be an opening run
if left:
opens[em_type].append(delim_run)
continue
# grab the open run. If it crosses a span, keep looking backwards
while opens[em_type] and self.body_crosses_span_borders(opens[em_type][-1], delim_run):
opens[em_type].pop(-1)
if not opens[em_type]:
if left:
opens[em_type].append(delim_run)
continue
open = opens[em_type].pop(-1)
if open.start() < index:
# this happens with things like `*(**foo**)*`. We process LTR so the strong gets
# processed first (since that has the first closing delimiter). We now have
# `*(foo)*` and now we get round to processing the em.
# It's hard compare the match (against the original text var) to the processed text
# so it's easier to just note down that nesting is detected and re-run the loop
nesting = True
continue
# if the opening run was joined to a previous closing run (eg: **strong***em*)
# then re-use that previous closing run, but ignore the part that was used to
# close the previous emphasis
open_offset = unused_opens[em_type].pop(open, 0)
open_syntax = open.group(1)[open_offset:]
middle = None
# if the delimiter runs don't match then we need to figure out how to resolve this
if open_syntax != syntax:
has_middle = self.has_middle(
open, delim_run, opens[em_type],
unused_opens[em_type], unused_closes[em_type]
)
if has_middle is not False:
middle = has_middle[1]
if has_middle[0] != open:
# only re-assign and re-calc opening offsets if that run HAS changed
open = has_middle[0]
open_offset = unused_opens[em_type].pop(open, 0)
open_syntax = open.group(1)[open_offset:]
elif not self.should_process_imbalanced_delimiter_runs(
open, delim_run, unused_opens[em_type], next_delim_run
):
# if we shouldn't process them now, save these opens for a future pass
unused_opens[em_type][open] = open_offset
opens[em_type].append(open)
if left:
unused_opens[em_type][delim_run] = 0
opens[em_type].append(delim_run)
else:
unused_closes[em_type].append(delim_run)
continue
# add all the text leading up to the opening delimiter
tokens.append(delim_run.string[index: open.start() + open_offset])
span, close_syntax_used_chars = self.process_span(
open, delim_run, middle,
open_syntax=open_syntax, close_syntax=syntax
)
tokens.extend(span)
if close_syntax_used_chars is None:
close_syntax_used_chars = len(syntax)
elif close_syntax_used_chars < len(syntax):
# if we didn't use up the entire closing delimiter, mark it as unused
unused_opens[em_type][delim_run] = close_syntax_used_chars
opens[em_type].append(delim_run)
elif close_syntax_used_chars < len(open_syntax) and opens[em_type]:
# if we skipped an open before, perhaps it wasn't a close at the time but now is?
# eg: *a->***b**
prev_open = opens[em_type][-1]
prev_open_syntax = prev_open.group(1)
if len(prev_open_syntax) >= (len(open_syntax) - close_syntax_used_chars):
nesting = True
# Move index to end of the used delim run
index = delim_run.start() + close_syntax_used_chars
if index < len(text):
tokens.append(text[index:])
text = ''.join(tokens)
return text
def process_span(
self, open: re.Match[str], close: re.Match[str],
middle: Optional[re.Match[str]] = None,
open_syntax: Optional[str] = None,
close_syntax: Optional[str] = None
) -> Tuple[List[str], Optional[int]]:
'''
Args:
open: the match against the opening delimiter run
close: the match against the closing delimiter run
middle: an optional delimiter run in the middle of the span
open_syntax: the string of the opening delimiter run. If omitted `open.group(1)` will be used.
Useful if there are characters in the delimiter run that need to be skipped
close_syntax: the string of the opening delimiter run. If omitted `close.group(1)` will be used.
Useful if there are characters in the delimiter run that need to be skipped
Returns:
A list of processed tokens, and then the number of chars from the closing syntax that were
consumed. If the latter item is None, then assume all chars were consumed
'''
open_syntax = open_syntax or cast(str, open.group(1))
middle_syntax = middle.group(1) if middle else ''
close_syntax = close_syntax or cast(str, close.group(1))
# calculate what em type the inner and outer emphasis is
outer_syntax_length = len(min(open_syntax, close_syntax))
inner_syntax_length = len(min(max(open_syntax, close_syntax), middle_syntax)) if middle else 0
tokens = [
# add anything from the opening syntax that will not be consumed
# eg: **one*
open_syntax[:-(outer_syntax_length + inner_syntax_length)],
# add opening tags
'' * (outer_syntax_length % 2),
'' * (outer_syntax_length // 2)
]
if middle:
# if there is a middle em (eg: ***abc*def**) then do some wrangling to figure
# out where to put the opening/closing inner tags depending on the size of the
# opening delim run
inner_tag = 'strong' if len(middle_syntax) == 2 else 'em'
if open_syntax > close_syntax:
tokens.append(f'<{inner_tag}>')
tokens.append(close.string[open.end(): middle.start()])
if open_syntax > close_syntax:
tokens.append(f'{inner_tag}>')
else:
tokens.append(f'<{inner_tag}>')
tokens.append(close.string[middle.end(): close.start()])
if open_syntax < close_syntax:
tokens.append(f'{inner_tag}>')
else:
# if no middle em then it's easy. Just add the whole text body
tokens.append(close.string[open.end(): close.start()])
# now add closing tags
tokens.append(
('' * (outer_syntax_length // 2))
+ ('' * (outer_syntax_length % 2))
)
# figure out how many chars from the closing delimiter we've actually used
close_delim_chars_used = outer_syntax_length
if middle and open_syntax < close_syntax:
# if there's a middle part and it's right-aligned then add that on
close_delim_chars_used += inner_syntax_length
return tokens, close_delim_chars_used
def has_middle(
self, open: re.Match[str], close: re.Match[str], opens: List[re.Match[str]],
unused_opens: Dict[re.Match[str], int], unused_closes: List[re.Match[str]]
) -> Union[Tuple[re.Match[str], Optional[re.Match[str]]], Literal[False]]:
'''
Check if an emphasis span has a middle delimiter run, which may change the outer tags
Args:
open: the current opening delimiter run
close: the closing delimiter run
opens: a list of all opening delimiter runs in the text
unused_opens: a mapping of unused opens within the text to their offset values
unused_closes: a list of unused closes within the text
Returns:
False if there is no middle run. Otherwise, a tuple of the new opening run and the optional
middle span. The middle span may be None if it is invalid
'''
open_offset = unused_opens.get(open, 0)
open_syntax = open.group(1)[open_offset:]
syntax = close.group(1)
if open_syntax < syntax and opens:
# expand the em span to the left, meaning we're covering additional chars.
# check we don't cross an existing span border
if self.body_crosses_span_borders(opens[-1], open):
return False
middle = open
open = opens.pop(-1)
open_offset = unused_opens.pop(open, 0)
open_syntax = open.group(1)[open_offset:]
if open_syntax == syntax:
# if it turns out the previous open is a perfect match then ignore the middle part
# eg: **foo*bar**
middle = None
elif open_syntax > syntax and unused_closes:
# check if there is a previous closing delim run in the current body
# since this is already within the body we don't need to do a cross-span border check
# as we're not expanding into new ground and that is covered later
middle = next((i for i in unused_closes if open.end() < i.start() < close.start()), None)
else:
return False
return open, middle
def should_process_imbalanced_delimiter_runs(
self, open: re.Match[str], close: re.Match[str],
unused_opens: Dict[re.Match[str], int],
next_delim_run: Optional[Tuple[re.Match[str], Optional[re.Match[str]], Optional[re.Match[str]]]] = None
):
'''
Check if an imbalanced delimiter run should be consumed now, or left for a later pass
Args:
open: the opening delimiter run
close: the closing delimiter run
unused_opens: a mapping of unused opens within the text to their offset values
next_delim_run: the next delimiter run after the closing run
'''
# if no delimiter run after then close span immediately
if next_delim_run is None:
return True
open_offset = unused_opens.get(open, 0)
open_syntax = open.group(1)[open_offset:]
syntax = close.group(1)
if open_syntax < syntax and len(syntax) >= 3:
# if closing syntax is bigger and its >= three long then focus on closing any
# open em spans
return True
em_type = syntax[0]
next_delim_run_syntax = next_delim_run[0].group(1)
# if next run is of a different syntax
if next_delim_run_syntax[0] != em_type:
return True
left, right = self.delimiter_left_or_right(close)
if open_syntax < syntax and (
# if this run can be an opener, but the next run won't close both of them
(left and (
not next_delim_run[2]
or next_delim_run_syntax < open_syntax + syntax
))
# if the next run is not an opener and won't consume this run
and not next_delim_run[1]
):
return True
if open_syntax > syntax and (
# if this run can be a closer, but the next run is not a fresh opener
(right and not next_delim_run[1])
# if the next run is not a closer
and not next_delim_run[2]
):
return True
# if there are no unused opens or closes to use up then this is just imbalanced.
# mark as unused and leave for later processing
return False
def delimiter_left_or_right(self, delim_run: re.Match[str]):
'''
Determine if a delimiter run is left or right flanking
Returns:
Tuple of bools that mean left and right flanking respectively
'''
run = delim_run.string[max(0, delim_run.start() - 1): delim_run.end() + 1]
return self._delimiter_left_or_right(run, delim_run.group(1))
@functools.lru_cache(maxsize=512)
def _delimiter_left_or_right(self, run: str, syntax: str):
'''
Cached version of `delimiter_left_or_right` that massively speeds things up when dealing
with many repetetive delimiter runs - eg: in a ReDoS scenario
'''
syntax_re = syntax.replace('*', r'\*')
left = (
# not followed by whitespace
re.match(r'.*%s\S' % syntax_re, run, re.S)
and (
# either not followed by punctuation
re.match(r'.*%s[\s\w]' % syntax_re, run, re.S)
# or followed by punct and preceded by punct/whitespace
or re.match(r'(^|[\s\W])%s([^\s\w]|$)' % syntax_re, run, re.S | re.M)
)
)
right = (
# not preceded by whitespace
re.match(r'\S%s.*' % syntax_re, run, re.S)
and (
# either not preceded by punct
re.match(r'[\s\w]%s.*' % syntax_re, run, re.S)
# or preceded by punct and followed by whitespace or punct
or re.match(r'[^\s\w]%s(\s|[^\s\w]|$)' % syntax_re, run, re.S | re.M)
)
)
return left, right
def body_crosses_span_borders(self, open: re.Match[str], close: re.Match[str]):
'''
Checks if the body of an emphasis crosses a span border
Args:
open: the opening delimiter run
close: the closing delimiter run
Returns:
True if the emphasis crosses a span border (invalid). False if not
'''
text = open.string[open.end(): close.start()]
if len(text) < 7:
# 7 chars min is needed for '