import io
import logging
from collections import namedtuple
from functools import partial
from html import unescape
from itertools import chain
from ansi.colour import bg, fg, fx
from markdown import Markdown
from markdown.extensions import Extension
from markdown.extensions.fenced_code import FencedBlockPreprocessor
from markdown.inlinepatterns import SubstituteTagPattern
from markdown.postprocessors import Postprocessor
log = logging.getLogger(__name__)
# chr that should not count as a space
[docs]class NSC:
[docs] def __init__(self, s):
self.s = s
def __str__(self):
return self.s
# The translation table for the special characters.
CharacterTable = namedtuple(
"CharacterTable",
[
"fg_black",
"fg_red",
"fg_green",
"fg_yellow",
"fg_blue",
"fg_magenta",
"fg_cyan",
"fg_white",
"fg_default",
"bg_black",
"bg_red",
"bg_green",
"bg_yellow",
"bg_blue",
"bg_magenta",
"bg_cyan",
"bg_white",
"bg_default",
"fx_reset",
"fx_bold",
"fx_italic",
"fx_underline",
"fx_not_italic",
"fx_not_underline",
"fx_normal",
"fixed_width",
"end_fixed_width",
"inline_code",
"end_inline_code",
],
)
ANSI_CHRS = CharacterTable(
fg_black=fg.black,
fg_red=fg.red,
fg_green=fg.green,
fg_yellow=fg.yellow,
fg_blue=fg.blue,
fg_magenta=fg.magenta,
fg_cyan=fg.cyan,
fg_white=fg.white,
fg_default=fg.default,
bg_black=bg.black,
bg_red=bg.red,
bg_green=bg.green,
bg_yellow=bg.yellow,
bg_blue=bg.blue,
bg_magenta=bg.magenta,
bg_cyan=bg.cyan,
bg_white=bg.white,
bg_default=bg.default,
fx_reset=fx.reset,
fx_bold=fx.bold,
fx_italic=fx.italic,
fx_underline=fx.underline,
fx_not_italic=fx.not_italic,
fx_not_underline=fx.not_underline,
fx_normal=fx.normal,
fixed_width="",
end_fixed_width="",
inline_code="",
end_inline_code="",
)
# Pure Text doesn't have any graphical chrs.
TEXT_CHRS = CharacterTable(
fg_black="",
fg_red="",
fg_green="",
fg_yellow="",
fg_blue="",
fg_magenta="",
fg_cyan="",
fg_white="",
fg_default="",
bg_black="",
bg_red="",
bg_green="",
bg_yellow="",
bg_blue="",
bg_magenta="",
bg_cyan="",
bg_white="",
bg_default="",
fx_reset="",
fx_bold="",
fx_italic="",
fx_underline="",
fx_not_italic="",
fx_not_underline="",
fx_normal="",
fixed_width="",
end_fixed_width="",
inline_code="",
end_inline_code="",
)
# IMText have some formatting available
IMTEXT_CHRS = CharacterTable(
fg_black="",
fg_red="",
fg_green="",
fg_yellow="",
fg_blue="",
fg_magenta="",
fg_cyan="",
fg_white="",
fg_default="",
bg_black="",
bg_red="",
bg_green="",
bg_yellow="",
bg_blue="",
bg_magenta="",
bg_cyan="",
bg_white="",
bg_default="",
fx_reset="",
fx_bold=NSC("*"),
fx_italic="",
fx_underline=NSC("_"),
fx_not_italic="",
fx_not_underline=NSC("_"),
fx_normal=NSC("*"),
fixed_width="```\n",
end_fixed_width="```\n",
inline_code="`",
end_inline_code="`",
)
NEXT_ROW = "&NEXT_ROW;"
[docs]class Table(object):
[docs] def __init__(self, chr_table):
self.headers = []
self.rows = []
self.in_headers = False
self.ct = chr_table
[docs] def next_row(self):
if self.in_headers:
self.headers.append([]) # is that exists ?
else:
self.rows.append([])
[docs] def add_col(self):
if not self.rows:
self.rows = [[]]
else:
self.rows[-1].append(("", 0))
[docs] def write(self, text):
cells = self.headers if self.in_headers else self.rows
text_cell, count = cells[-1][-1]
if isinstance(text, str):
text_cell += text
count += len(text)
else:
text_cell += str(text) # This is a non space chr
cells[-1][-1] = text_cell, count
def __str__(self):
nbcols = max(len(row) for row in chain(self.headers, self.rows))
maxes = [
0,
] * nbcols
for row in chain(self.headers, self.rows):
for i, el in enumerate(row):
txt, length = el
# Account for multiline cells
cnt = str(txt).count(NEXT_ROW)
if cnt > 0:
length -= cnt * len(NEXT_ROW)
if maxes[i] < length:
maxes[i] = length
# add up margins
maxes = [m + 2 for m in maxes]
output = io.StringIO()
if self.headers:
output.write("┏" + "┳".join("━" * m for m in maxes) + "┓")
output.write("\n")
first = True
for row in self.headers:
if not first:
output.write("┣" + "╋".join("━" * m for m in maxes) + "┫")
output.write("\n")
first = False
for i, header in enumerate(row):
text, ln = header
output.write("┃ " + text + " " * (maxes[i] - 2 - ln) + " ")
output.write("┃")
output.write("\n")
output.write("┡" + "╇".join("━" * m for m in maxes) + "┩")
output.write("\n")
else:
output.write("┌" + "┬".join("─" * m for m in maxes) + "┐")
output.write("\n")
first = True
for row in self.rows:
max_row_height = 1
for i, item in enumerate(row):
text, _ = item
row_height = str(text).count(NEXT_ROW) + 1
if row_height > max_row_height:
max_row_height = row_height
if not first:
output.write("├" + "┼".join("─" * m for m in maxes) + "┤")
output.write("\n")
first = False
for j in range(max_row_height):
for i, item in enumerate(row):
text, ln = item
multi = text.split(NEXT_ROW)
if len(multi) > j:
text = multi[j]
ln = len(text)
else:
ln = 1
text = " "
output.write("│ " + text + " " * (maxes[i] - 2 - ln) + " ")
output.write("│")
output.write("\n")
output.write("└" + "┴".join("─" * m for m in maxes) + "┘")
output.write("\n")
return (
str(self.ct.fixed_width) + output.getvalue() + str(self.ct.end_fixed_width)
)
[docs]class BorderlessTable:
[docs] def __init__(self, chr_table):
self.headers = []
self.rows = []
self.in_headers = False
self.ct = chr_table
[docs] def next_row(self):
if self.in_headers:
self.headers.append([]) # is that exists ?
else:
self.rows.append([])
[docs] def add_col(self):
if not self.rows:
self.rows = [[]]
else:
self.rows[-1].append(("", 0))
[docs] def write(self, text):
cells = self.headers if self.in_headers else self.rows
text_cell, count = cells[-1][-1]
if isinstance(text, str):
text_cell += text
count += len(text)
else:
text_cell += str(text) # This is a non space chr
cells[-1][-1] = text_cell, count
def __str__(self):
nbcols = max(len(row) for row in chain(self.headers, self.rows))
maxes = [
0,
] * nbcols
for row in chain(self.headers, self.rows):
for i, el in enumerate(row):
txt, length = el
# Account for multiline cells
cnt = str(txt).count(NEXT_ROW)
if cnt > 0:
length -= cnt * len(NEXT_ROW)
if maxes[i] < length:
maxes[i] = length
# add up margins
maxes = [m + 2 for m in maxes]
output = io.StringIO()
if self.headers:
for row in self.headers:
for i, header in enumerate(row):
text, ln = header
output.write(text + " " * (maxes[i] - 2 - ln) + " ")
output.write("\n")
for row in self.rows:
max_row_height = 1
for i, item in enumerate(row):
text, _ = item
row_height = str(text).count(NEXT_ROW) + 1
if row_height > max_row_height:
max_row_height = row_height
for j in range(max_row_height):
for i, item in enumerate(row):
text, ln = item
multi = text.split(NEXT_ROW)
if len(multi) > j:
text = multi[j]
ln = len(text)
else:
ln = 1
text = " "
output.write(text + " " * (maxes[i] - 2 - ln) + " ")
output.write("\n")
return (
str(self.ct.fixed_width) + output.getvalue() + str(self.ct.end_fixed_width)
)
[docs]def recurse(write, chr_table, element, table=None, borders=True):
post_element = []
if element.text:
text = element.text
else:
text = ""
items = element.items()
for k, v in items:
if k == "color":
color_attr = getattr(chr_table, "fg_" + v, None)
if color_attr is None:
log.warning("there is no '%s' color in ansi.", v)
continue
write(color_attr)
post_element.append(chr_table.fg_default)
elif k == "bgcolor":
color_attr = getattr(chr_table, "bg_" + v, None)
if color_attr is None:
log.warning("there is no '%s' bgcolor in ansi", v)
continue
write(color_attr)
post_element.append(chr_table.bg_default)
if element.tag == "img":
text = dict(items)["src"]
elif element.tag == "strong":
write(chr_table.fx_bold)
post_element.append(chr_table.fx_normal)
elif element.tag == "code":
write(chr_table.inline_code)
post_element.append(chr_table.end_inline_code)
elif element.tag == "em":
write(chr_table.fx_underline)
post_element.append(chr_table.fx_not_underline)
elif element.tag == "p":
write(" ")
post_element.append("\n")
elif element.tag == "br" and table: # Treat <br/> differently in a table.
write(NEXT_ROW)
elif element.tag == "a":
post_element.append(" (" + element.get("href") + ")")
elif element.tag == "li":
write("• ")
post_element.append("\n")
elif element.tag == "hr":
write("─" * 80)
write("\n")
elif element.tag == "ul": # ignore the text part
text = None
elif element.tag == "h1":
write(chr_table.fx_bold)
text = text.upper()
post_element.append(chr_table.fx_normal)
post_element.append("\n\n")
elif element.tag == "h2":
write("\n")
write(" ")
write(chr_table.fx_bold)
post_element.append(chr_table.fx_normal)
post_element.append("\n\n")
elif element.tag == "h3":
write("\n")
write(" ")
write(chr_table.fx_underline)
post_element.append(chr_table.fx_not_underline)
post_element.append("\n\n")
elif element.tag in ("h4", "h5", "h6"):
write("\n")
write(" ")
post_element.append("\n")
elif element.tag == "table":
table = Table(chr_table) if borders else BorderlessTable(chr_table)
orig_write = write
write = table.write
text = None
elif element.tag == "tbody":
text = None
elif element.tag == "thead":
table.begin_headers()
text = None
elif element.tag == "tr":
table.next_row()
text = None
elif element.tag == "td":
table.add_col()
elif element.tag == "th":
table.add_header()
if text:
write(text)
for e in element:
recurse(write, chr_table, e, table, borders)
if element.tag == "table":
write = orig_write
write(str(table))
if element.tag == "thead":
table.end_headers()
for restore in post_element:
write(restore)
if element.tail:
tail = element.tail.rstrip("\n")
if tail:
write(tail)
[docs]def translate(element, chr_table=ANSI_CHRS, borders=True):
f = io.StringIO()
def write(ansi_obj):
return f.write(str(ansi_obj))
recurse(write, chr_table, element, borders=borders)
result = f.getvalue().rstrip("\n") # remove the useless final \n
return result + str(chr_table.fx_reset)
# patch us in
for n, ct in (("ansi", ANSI_CHRS), ("text", TEXT_CHRS), ("imtext", IMTEXT_CHRS)):
enable_format(n, ct)
[docs]class AnsiPostprocessor(Postprocessor):
"""Markdown generates html entities, this reputs them back to their unicode equivalent"""
[docs] def run(self, text):
return unescape(text)
# This is an adapted FencedBlockPreprocessor that doesn't insert <code><pre>
[docs]class AnsiPreprocessor(FencedBlockPreprocessor):
[docs] def run(self, lines):
"""Match and store Fenced Code Blocks in the HtmlStash."""
text = "\n".join(lines)
while 1:
m = self.FENCED_BLOCK_RE.search(text)
if m:
code = self._escape(m.group("code"))
placeholder = self.md.htmlStash.store(code)
text = f"{text[:m.start()]}\n{placeholder}\n{text[m.end():]}"
else:
break
return text.split("\n")
def _escape(self, txt):
"""basic html escaping"""
txt = txt.replace("&", "&")
txt = txt.replace("<", "<")
txt = txt.replace(">", ">")
txt = txt.replace('"', """)
return txt
[docs]class AnsiExtension(Extension):
"""(kinda hackish) This is just a private extension to postprocess the html text to ansi text"""
[docs] def extendMarkdown(self, md):
md.registerExtension(self)
md.postprocessors.register(AnsiPostprocessor(), "unescape_html", 15)
md.preprocessors.register(AnsiPreprocessor(md, {}), "ansi_fenced_codeblock", 20)
md.inlinePatterns.register(SubstituteTagPattern(r"<br/>", "br"), "br", 95)
md.preprocessors.deregister("fenced_code_block") # remove the old fenced block
md.treeprocessors.deregister(
"prettify"
) # remove prettify treeprocessor since it adds extra new lines