diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 2cc03f4aec..5d95c402ed 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -69,6 +69,9 @@ from django.conf import settings from evennia.utils import logger, utils from evennia.utils.utils import to_str +from evennia.utils.hex_colors import HexToTruecolor + +hex_sub = HexToTruecolor.hex_sub MXP_ENABLED = settings.MXP_ENABLED @@ -431,7 +434,7 @@ class ANSIParser(object): """ return self.unsafe_tokens.sub("", string) - def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False): + def parse_ansi(self, string, strip_ansi=False, xterm256=False, mxp=False, truecolor=False): """ Parses a string, subbing color codes according to the stored mapping. @@ -458,13 +461,18 @@ class ANSIParser(object): # check cached parsings global _PARSE_CACHE - cachekey = "%s-%s-%s-%s" % (string, strip_ansi, xterm256, mxp) + cachekey = f"{string}-{strip_ansi}-{xterm256}-{mxp}-{truecolor}" + if cachekey in _PARSE_CACHE: return _PARSE_CACHE[cachekey] # pre-convert bright colors to xterm256 color tags string = self.brightbg_sub.sub(self.sub_brightbg, string) + def do_truecolor(part: re.Match, truecolor=truecolor): + hex2truecolor = HexToTruecolor() + return hex2truecolor.sub_truecolor(part, truecolor) + def do_xterm256_fg(part): return self.sub_xterm256(part, xterm256, "fg") @@ -483,7 +491,8 @@ class ANSIParser(object): parsed_string = [] parts = self.ansi_escapes.split(in_string) + [" "] for part, sep in zip(parts[::2], parts[1::2]): - pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, part) + pstring = hex_sub.sub(do_truecolor, part) + pstring = self.xterm256_fg_sub.sub(do_xterm256_fg, pstring) pstring = self.xterm256_bg_sub.sub(do_xterm256_bg, pstring) pstring = self.xterm256_gfg_sub.sub(do_xterm256_gfg, pstring) pstring = self.xterm256_gbg_sub.sub(do_xterm256_gbg, pstring) @@ -515,11 +524,12 @@ ANSI_PARSER = ANSIParser() # -def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp=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. @@ -531,7 +541,7 @@ def parse_ansi(string, strip_ansi=False, parser=ANSI_PARSER, xterm256=False, mxp """ string = string or "" - return parser.parse_ansi(string, strip_ansi=strip_ansi, xterm256=xterm256, mxp=mxp) + 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 new file mode 100644 index 0000000000..4118c179c3 --- /dev/null +++ b/evennia/utils/hex_colors.py @@ -0,0 +1,145 @@ +import re + + +class HexToTruecolor: + """ + This houses a method for converting hex codes to xterm truecolor codes + or falls back to evennia xterm256 codes to be handled by sub_xterm256 + + 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_3_BYTES = f'({_RE_BYTE})({_RE_BYTE})({_RE_BYTE})' + + # Used in hex_sub + _RE_HEX_PATTERN = f'({_RE_FG_OR_BG})({_RE_HEX_LONG}|{_RE_HEX_SHORT})' + + # Used for truecolor_sub + _RE_24_BIT_RGB_FG = f'{_RE_FG}{_RE_3_BYTES}' + _RE_24_BIT_RGB_BG = f'{_RE_BG}{_RE_3_BYTES}' + + # Used for greyscale + _GREYS = "abcdefghijklmnopqrstuvwxyz" + + # 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 _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]: + """ + Splits hex string into separate bytes: + #00FF00 -> ('00', 'FF', '00') + #CF3 -> ('CC', 'FF', '33') + + Args: + tag (str): the tag to convert + + Returns: + str: the text with converted tags + """ + 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)) + + else: + # 3 digits + r, g, b = (tag[i:i + 1] * 2 for i in range(0, 3, 1)) + + return r, g, b + + def _grey_int(self, num: int) -> int: + """ + Returns a grey greyscale integer + + Returns: + + """ + return round(max((int(num) - 8), 0) / 10) + + def _hue_int(self, num: int) -> int: + return round(max((int(num) - 45), 0) / 40) + + def _hex_to_rgb_24_bit(self, hex_code: str) -> tuple[int, int, int]: + """ + Converts a hex color code (#000 or #000000) into + a 3-int tuple (0, 255, 90) + + Args: + hex_code (str): HTML hex color code + + Returns: + 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) + + r, g, b = self._split_hex_to_bytes(hex_code) + + return int(r, 16), int(g, 16), int(b, 16) + + def _rgb_24_bit_to_256(self, r: int, g: int, b: int) -> tuple[int, int, int]: + """ + converts 0-255 hex color codes to 0-5 + + Args: + r (int): red + g (int): green + b (int): blue + + Returns: + 256 color rgb tuple: (int, int, int) + + """ + + return self._hue_int(r), self._hue_int(g), self._hue_int(b) diff --git a/evennia/utils/tests/test_ansi.py b/evennia/utils/tests/test_ansi.py index 4ff9d468c6..0ecc8d1b04 100644 --- a/evennia/utils/tests/test_ansi.py +++ b/evennia/utils/tests/test_ansi.py @@ -8,7 +8,9 @@ Test of the ANSI parsing and ANSIStrings. from django.test import TestCase -from evennia.utils.ansi import ANSIString as AN +from evennia.utils.ansi import ANSIString as AN, ANSIParser + +parser = ANSIParser().parse_ansi class TestANSIString(TestCase): @@ -52,3 +54,124 @@ 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")