Changes per Griatch:

-Reordered methods in HexColors
-Separated truecolor tests
-Clarified comment re: classes and styles in text2html.py
-Changed ansi.py to only instatiate HexColors once 🤦‍♂️
-Fixed missing docsctring in parse_ansi re: truecolor
This commit is contained in:
mike 2024-04-27 14:01:44 -07:00
parent 5554946721
commit 5bec1a29d6
5 changed files with 243 additions and 231 deletions

View file

@ -72,6 +72,7 @@ from evennia.utils import logger, utils
from evennia.utils.utils import to_str
from evennia.utils.hex_colors import HexColors
hex2truecolor = HexColors()
hex_sub = HexColors.hex_sub
MXP_ENABLED = settings.MXP_ENABLED
@ -471,7 +472,6 @@ class ANSIParser(object):
string = self.brightbg_sub.sub(self.sub_brightbg, string)
def do_truecolor(part: re.Match, truecolor=truecolor):
hex2truecolor = HexColors()
return hex2truecolor.sub_truecolor(part, truecolor)
def do_xterm256_fg(part):
@ -525,24 +525,28 @@ ANSI_PARSER = ANSIParser()
#
def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False, truecolor=False):
def parse_ansi(
string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=False, truecolor=False
):
"""
Parses a string, subbing color codes as needed.
Args:
truecolor:
string (str): The string to parse.
strip_ansi (bool, optional): Strip all ANSI sequences.
parser (ansi.AnsiParser, optional): A parser instance to use.
xterm256 (bool, optional): Support xterm256 or not.
mxp (bool, optional): Support MXP markup or not.
truecolor (bool, optional): Support for truecolor or not.
Returns:
string (str): The parsed string.
"""
string = string or ""
return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp, truecolor=truecolor)
return parser.parse_ansi(
string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp, truecolor=truecolor
)
def strip_ansi(string, parser=ANSI_PARSER):

View file

@ -9,97 +9,25 @@ class HexColors:
Based on code from @InspectorCaracal
"""
_RE_FG = '\|#'
_RE_BG = '\|\[#'
_RE_FG_OR_BG = '\|\[?#'
_RE_HEX_LONG = '[0-9a-fA-F]{6}'
_RE_HEX_SHORT = '[0-9a-fA-F]{3}'
_RE_BYTE = '[0-2]?[0-9]?[0-9]'
_RE_XTERM_TRUECOLOR = rf'\[([34])8;2;({_RE_BYTE});({_RE_BYTE});({_RE_BYTE})m'
_RE_FG = "\|#"
_RE_BG = "\|\[#"
_RE_FG_OR_BG = "\|\[?#"
_RE_HEX_LONG = "[0-9a-fA-F]{6}"
_RE_HEX_SHORT = "[0-9a-fA-F]{3}"
_RE_BYTE = "[0-2]?[0-9]?[0-9]"
_RE_XTERM_TRUECOLOR = rf"\[([34])8;2;({_RE_BYTE});({_RE_BYTE});({_RE_BYTE})m"
# Used in hex_sub
_RE_HEX_PATTERN = f'({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})'
_RE_HEX_PATTERN = f"({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})"
# Used for greyscale
_GREYS = "abcdefghijklmnopqrstuvwxyz"
TRUECOLOR_FG = f'\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m'
TRUECOLOR_BG = f'\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m'
TRUECOLOR_FG = f"\x1b\[38;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m"
TRUECOLOR_BG = f"\x1b\[48;2;{_RE_BYTE};{_RE_BYTE};{_RE_BYTE}m"
# Our matchers for use with ANSIParser and ANSIString
hex_sub = re.compile(rf'{_RE_HEX_PATTERN}', re.DOTALL)
def sub_truecolor(self, match: re.Match, truecolor=False) -> str:
"""
Converts a hex string to xterm truecolor code, greyscale, or
falls back to evennia xterm256 to be handled by sub_xterm256
Args:
match (re.match): first group is the leading indicator,
second is the tag
truecolor (bool): return xterm truecolor or fallback
Returns:
Newly formatted indicator and tag (str)
"""
indicator, tag = match.groups()
# Remove the # sign
indicator = indicator.replace('#', '')
r, g, b = self._hex_to_rgb_24_bit(tag)
# Is it greyscale?
if r == g and g == b:
return f"{indicator}=" + self._GREYS[self._grey_int(r)]
else:
if not truecolor:
# Fallback to xterm256 syntax
r, g, b = self._rgb_24_bit_to_256(r, g, b)
return f"{indicator}{r}{g}{b}"
else:
xtag = f"\033["
if '[' in indicator:
# Background Color
xtag += '4'
else:
xtag += '3'
xtag += f"8;2;{r};{g};{b}m"
return xtag
def xterm_truecolor_to_html_style(self, fg="", bg="") -> str:
"""
Converts xterm truecolor to an html style property
Args:
fg: xterm truecolor
bg: xterm truecolor
Returns: style='color and or background-color'
"""
prop = 'style="'
if fg != '':
res = re.search(self._RE_XTERM_TRUECOLOR, fg, re.DOTALL)
fg_bg, r, g, b = res.groups()
r = hex(int(r))[2:].zfill(2)
g = hex(int(g))[2:].zfill(2)
b = hex(int(b))[2:].zfill(2)
prop += f"color: #{r}{g}{b};"
if bg != '':
res = re.search(self._RE_XTERM_TRUECOLOR, bg, re.DOTALL)
fg_bg, r, g, b = res.groups()
r = hex(int(r))[2:].zfill(2)
g = hex(int(g))[2:].zfill(2)
b = hex(int(b))[2:].zfill(2)
prop += f"background-color: #{r}{g}{b};"
prop += f'"'
return prop
hex_sub = re.compile(rf"{_RE_HEX_PATTERN}", re.DOTALL)
def _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]:
"""
@ -113,16 +41,16 @@ class HexColors:
Returns:
str: the text with converted tags
"""
strip_leading = re.compile(rf'{self._RE_FG_OR_BG}')
strip_leading = re.compile(rf"{self._RE_FG_OR_BG}")
tag = strip_leading.sub("", tag)
if len(tag) == 6:
# 6 digits
r, g, b = (tag[i:i + 2] for i in range(0, 6, 2))
r, g, b = (tag[i : i + 2] for i in range(0, 6, 2))
else:
# 3 digits
r, g, b = (tag[i:i + 1] * 2 for i in range(0, 3, 1))
r, g, b = (tag[i : i + 1] * 2 for i in range(0, 3, 1))
return r, g, b
@ -150,7 +78,7 @@ class HexColors:
24-bit rgb tuple: (int, int, int)
"""
# Strip the leading indicator if present
hex_code = re.sub(rf'{self._RE_FG_OR_BG}', '', hex_code)
hex_code = re.sub(rf"{self._RE_FG_OR_BG}", "", hex_code)
r, g, b = self._split_hex_to_bytes(hex_code)
@ -171,3 +99,75 @@ class HexColors:
"""
return self._hue_int(r), self._hue_int(g), self._hue_int(b)
def sub_truecolor(self, match: re.Match, truecolor=False) -> str:
"""
Converts a hex string to xterm truecolor code, greyscale, or
falls back to evennia xterm256 to be handled by sub_xterm256
Args:
match (re.match): first group is the leading indicator,
second is the tag
truecolor (bool): return xterm truecolor or fallback
Returns:
Newly formatted indicator and tag (str)
"""
indicator, tag = match.groups()
# Remove the # sign
indicator = indicator.replace("#", "")
r, g, b = self._hex_to_rgb_24_bit(tag)
# Is it greyscale?
if r == g and g == b:
return f"{indicator}=" + self._GREYS[self._grey_int(r)]
else:
if not truecolor:
# Fallback to xterm256 syntax
r, g, b = self._rgb_24_bit_to_256(r, g, b)
return f"{indicator}{r}{g}{b}"
else:
xtag = f"\033["
if "[" in indicator:
# Background Color
xtag += "4"
else:
xtag += "3"
xtag += f"8;2;{r};{g};{b}m"
return xtag
def xterm_truecolor_to_html_style(self, fg="", bg="") -> str:
"""
Converts xterm truecolor to an html style property
Args:
fg: xterm truecolor
bg: xterm truecolor
Returns: style='color and or background-color'
"""
prop = 'style="'
if fg != "":
res = re.search(self._RE_XTERM_TRUECOLOR, fg, re.DOTALL)
fg_bg, r, g, b = res.groups()
r = hex(int(r))[2:].zfill(2)
g = hex(int(g))[2:].zfill(2)
b = hex(int(b))[2:].zfill(2)
prop += f"color: #{r}{g}{b};"
if bg != "":
res = re.search(self._RE_XTERM_TRUECOLOR, bg, re.DOTALL)
fg_bg, r, g, b = res.groups()
r = hex(int(r))[2:].zfill(2)
g = hex(int(g))[2:].zfill(2)
b = hex(int(b))[2:].zfill(2)
prop += f"background-color: #{r}{g}{b};"
prop += f'"'
return prop

View file

@ -8,9 +8,7 @@ Test of the ANSI parsing and ANSIStrings.
from django.test import TestCase
from evennia.utils.ansi import ANSIString as AN, ANSIParser
parser = ANSIParser().parse_ansi
from evennia.utils.ansi import ANSIString as AN
class TestANSIString(TestCase):
@ -54,124 +52,3 @@ class TestANSIString(TestCase):
self.assertEqual(split2, split3, "Split 2 and 3 differ")
self.assertEqual(split1, split2, "Split 1 and 2 differ")
self.assertEqual(split1, split3, "Split 1 and 3 differ")
# TODO: Better greyscale testing
class TestANSIStringHex(TestCase):
"""
Tests the conversion of html hex colors
to xterm-style colors
"""
def setUp(self):
self.str = 'test '
self.output1 = '\x1b[38;5;16mtest \x1b[0m'
self.output2 = '\x1b[48;5;16mtest \x1b[0m'
self.output3 = '\x1b[38;5;46mtest \x1b[0m'
self.output4 = '\x1b[48;5;46mtest \x1b[0m'
def test_long_grayscale_fg(self):
raw = f'|#000000{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output1, "Output")
def test_long_grayscale_bg(self):
raw = f'|[#000000{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output2, "Output")
def test_short_grayscale_fg(self):
raw = f'|#000{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output1, "Output")
def test_short_grayscale_bg(self):
raw = f'|[#000{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output2, "Output")
def test_short_color_fg(self):
raw = f'|#0F0{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output3, "Output")
def test_short_color_bg(self):
raw = f'|[#0f0{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output4, "Output")
def test_long_color_fg(self):
raw = f'|#00ff00{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output3, "Output")
def test_long_color_bg(self):
raw = f'|[#00FF00{self.str}|n'
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output4, "Output")
class TestANSIParser(TestCase):
"""
Tests the ansi fallback of the hex color conversion and
truecolor conversion
"""
def setUp(self):
self.parser = ANSIParser().parse_ansi
self.str = 'test '
# ANSI FALLBACK
# Red
self.output1 = '\x1b[1m\x1b[31mtest \x1b[0m'
# White
self.output2 = '\x1b[1m\x1b[37mtest \x1b[0m'
# Red BG
self.output3 = '\x1b[41mtest \x1b[0m'
# Blue FG, Red BG
self.output4 = '\x1b[41m\x1b[1m\x1b[34mtest \x1b[0m'
def test_hex_color(self):
raw = f'|#F00{self.str}|n'
ansi = parser(raw)
# self.assertEqual(ansi, self.str, "Cleaned")
self.assertEqual(ansi, self.output1, "Output")
def test_hex_greyscale(self):
raw = f'|#FFF{self.str}|n'
ansi = parser(raw)
self.assertEqual(ansi, self.output2, "Output")
def test_hex_color_bg(self):
raw = f'|[#Ff0000{self.str}|n'
ansi = parser(raw)
self.assertEqual(ansi, self.output3, "Output")
def test_hex_color_fg_bg(self):
raw = f'|[#Ff0000|#00f{self.str}|n'
ansi = parser(raw)
self.assertEqual(ansi, self.output4, "Output")
def test_truecolor_fg(self):
raw = f'|#00c700{self.str}|n'
ansi = parser(raw, truecolor=True)
output = f'\x1b[38;2;0;199;0m{self.str}\x1b[0m'
self.assertEqual(ansi, output, "Output")
def test_truecolor_bg(self):
raw = f'|[#00c700{self.str}|n'
ansi = parser(raw, truecolor=True)
output = f'\x1b[48;2;0;199;0m{self.str}\x1b[0m'
self.assertEqual(ansi, output, "Output")
def test_truecolor_fg_bg(self):
raw = f'|[#00c700|#880000{self.str}|n'
ansi = parser(raw, truecolor=True)
output = f'\x1b[48;2;0;199;0m\x1b[38;2;136;0;0m{self.str}\x1b[0m'
self.assertEqual(ansi, output, "Output")

View file

@ -0,0 +1,127 @@
from django.test import TestCase
from evennia.utils.ansi import ANSIString as AN, ANSIParser
parser = ANSIParser().parse_ansi
class TestANSIStringHex(TestCase):
"""
Tests the conversion of html hex colors
to xterm-style colors
"""
def setUp(self):
self.str = "test "
self.output1 = "\x1b[38;5;16mtest \x1b[0m"
self.output2 = "\x1b[48;5;16mtest \x1b[0m"
self.output3 = "\x1b[38;5;46mtest \x1b[0m"
self.output4 = "\x1b[48;5;46mtest \x1b[0m"
def test_long_grayscale_fg(self):
raw = f"|#000000{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output1, "Output")
def test_long_grayscale_bg(self):
raw = f"|[#000000{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output2, "Output")
def test_short_grayscale_fg(self):
raw = f"|#000{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output1, "Output")
def test_short_grayscale_bg(self):
raw = f"|[#000{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output2, "Output")
def test_short_color_fg(self):
raw = f"|#0F0{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output3, "Output")
def test_short_color_bg(self):
raw = f"|[#0f0{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output4, "Output")
def test_long_color_fg(self):
raw = f"|#00ff00{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output3, "Output")
def test_long_color_bg(self):
raw = f"|[#00FF00{self.str}|n"
ansi = AN(raw)
self.assertEqual(ansi.clean(), self.str, "Cleaned")
self.assertEqual(ansi.raw(), self.output4, "Output")
class TestANSIParser(TestCase):
"""
Tests the ansi fallback of the hex color conversion and
truecolor conversion
"""
def setUp(self):
self.parser = ANSIParser().parse_ansi
self.str = "test "
# ANSI FALLBACK
# Red
self.output1 = "\x1b[1m\x1b[31mtest \x1b[0m"
# White
self.output2 = "\x1b[1m\x1b[37mtest \x1b[0m"
# Red BG
self.output3 = "\x1b[41mtest \x1b[0m"
# Blue FG, Red BG
self.output4 = "\x1b[41m\x1b[1m\x1b[34mtest \x1b[0m"
def test_hex_color(self):
raw = f"|#F00{self.str}|n"
ansi = parser(raw)
# self.assertEqual(ansi, self.str, "Cleaned")
self.assertEqual(ansi, self.output1, "Output")
def test_hex_greyscale(self):
raw = f"|#FFF{self.str}|n"
ansi = parser(raw)
self.assertEqual(ansi, self.output2, "Output")
def test_hex_color_bg(self):
raw = f"|[#Ff0000{self.str}|n"
ansi = parser(raw)
self.assertEqual(ansi, self.output3, "Output")
def test_hex_color_fg_bg(self):
raw = f"|[#Ff0000|#00f{self.str}|n"
ansi = parser(raw)
self.assertEqual(ansi, self.output4, "Output")
def test_truecolor_fg(self):
raw = f"|#00c700{self.str}|n"
ansi = parser(raw, truecolor=True)
output = f"\x1b[38;2;0;199;0m{self.str}\x1b[0m"
self.assertEqual(ansi, output, "Output")
def test_truecolor_bg(self):
raw = f"|[#00c700{self.str}|n"
ansi = parser(raw, truecolor=True)
output = f"\x1b[48;2;0;199;0m{self.str}\x1b[0m"
self.assertEqual(ansi, output, "Output")
def test_truecolor_fg_bg(self):
raw = f"|[#00c700|#880000{self.str}|n"
ansi = parser(raw, truecolor=True)
output = f"\x1b[48;2;0;199;0m\x1b[38;2;136;0;0m{self.str}\x1b[0m"
self.assertEqual(ansi, output, "Output")

View file

@ -75,7 +75,9 @@ class TextToHTMLparser(object):
r"({}|{})".format(
"|".join(
style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes
).replace("[", r"\["), "|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG]))
).replace("[", r"\["),
"|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG]),
)
)
colorlist = (
@ -256,8 +258,8 @@ class TextToHTMLparser(object):
fg = ANSI_WHITE
# default bg is black
bg = ANSI_BACK_BLACK
truecolor_fg = ''
truecolor_bg = ''
truecolor_fg = ""
truecolor_bg = ""
for i, substr in enumerate(str_list):
# reset all current styling
@ -271,8 +273,8 @@ class TextToHTMLparser(object):
hilight = ANSI_UNHILITE
fg = ANSI_WHITE
bg = ANSI_BACK_BLACK
truecolor_fg = ''
truecolor_bg = ''
truecolor_fg = ""
truecolor_bg = ""
# change color
elif substr in self.ansi_color_codes + self.xterm_fg_codes:
@ -289,7 +291,7 @@ class TextToHTMLparser(object):
bg = substr
elif re.match(hex_colors.TRUECOLOR_FG, substr):
str_list[i] = ''
str_list[i] = ""
truecolor_fg = substr
elif re.match(hex_colors.TRUECOLOR_BG, substr):
@ -334,18 +336,18 @@ class TextToHTMLparser(object):
color_index = self.colorlist.index(fg)
if inverse:
if truecolor_fg != '' and truecolor_bg != '':
if truecolor_fg != "" and truecolor_bg != "":
# True startcolor only
truecolor_fg, truecolor_bg = truecolor_bg, truecolor_fg
elif truecolor_fg != '' and truecolor_bg == '':
elif truecolor_fg != "" and truecolor_bg == "":
# Truecolor fg, class based bg
truecolor_bg = truecolor_fg
truecolor_fg = ''
truecolor_fg = ""
color_class = "color-{}".format(str(bg_index).rjust(3, "0"))
elif truecolor_fg == '' and truecolor_bg != '':
elif truecolor_fg == "" and truecolor_bg != "":
# Truecolor bg, class based fg
truecolor_fg = truecolor_bg
truecolor_bg = ''
truecolor_bg = ""
bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0"))
else:
# inverse means swap fg and bg indices
@ -364,13 +366,15 @@ class TextToHTMLparser(object):
classes.append(color_class)
# define the new style span
if truecolor_fg == '' and truecolor_bg == '':
if truecolor_fg == "" and truecolor_bg == "":
prefix = f'<span class="{" ".join(classes)}">'
else:
# Classes can't be used for true color
prefix = (f'<span '
f'class="{" ".join(classes)}" '
f'{hex_colors.xterm_truecolor_to_html_style(fg=truecolor_fg, bg=truecolor_bg)}>')
# Classes can't be used for truecolor--but they can be extras such as 'blink'
prefix = (
f"<span "
f'class="{" ".join(classes)}" '
f"{hex_colors.xterm_truecolor_to_html_style(fg=truecolor_fg, bg=truecolor_bg)}>"
)
# close any prior span
if not clean: