diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 7bb6643e5f..68dbc0c103 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -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): diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py index dcea3531a7..781496ac71 100644 --- a/evennia/utils/hex_colors.py +++ b/evennia/utils/hex_colors.py @@ -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 diff --git a/evennia/utils/tests/test_ansi.py b/evennia/utils/tests/test_ansi.py index 0ecc8d1b04..4ff9d468c6 100644 --- a/evennia/utils/tests/test_ansi.py +++ b/evennia/utils/tests/test_ansi.py @@ -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") diff --git a/evennia/utils/tests/test_truecolor.py b/evennia/utils/tests/test_truecolor.py new file mode 100644 index 0000000000..33fddeca16 --- /dev/null +++ b/evennia/utils/tests/test_truecolor.py @@ -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") diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index e8000a7f81..bb9d642915 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -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'' else: - # Classes can't be used for true color - prefix = (f'') + # Classes can't be used for truecolor--but they can be extras such as 'blink' + prefix = ( + f"" + ) # close any prior span if not clean: