diff --git a/evennia/utils/ansi.py b/evennia/utils/ansi.py index 5d95c402ed..2c07ccd7f1 100644 --- a/evennia/utils/ansi.py +++ b/evennia/utils/ansi.py @@ -69,9 +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 +from evennia.utils.hex_colors import HexColors -hex_sub = HexToTruecolor.hex_sub +hex_sub = HexColors.hex_sub MXP_ENABLED = settings.MXP_ENABLED @@ -470,7 +470,7 @@ class ANSIParser(object): string = self.brightbg_sub.sub(self.sub_brightbg, string) def do_truecolor(part: re.Match, truecolor=truecolor): - hex2truecolor = HexToTruecolor() + hex2truecolor = HexColors() return hex2truecolor.sub_truecolor(part, truecolor) def do_xterm256_fg(part): diff --git a/evennia/utils/hex_colors.py b/evennia/utils/hex_colors.py index 4118c179c3..dcea3531a7 100644 --- a/evennia/utils/hex_colors.py +++ b/evennia/utils/hex_colors.py @@ -1,7 +1,7 @@ import re -class HexToTruecolor: +class HexColors: """ 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 @@ -14,19 +14,18 @@ class HexToTruecolor: _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})' + _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})' - # 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" + 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) @@ -73,6 +72,35 @@ class HexToTruecolor: 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 + def _split_hex_to_bytes(self, tag: str) -> tuple[str, str, str]: """ Splits hex string into separate bytes: diff --git a/evennia/utils/tests/test_text2html.py b/evennia/utils/tests/test_text2html.py index aea0cb87e7..74bd61bf18 100644 --- a/evennia/utils/tests/test_text2html.py +++ b/evennia/utils/tests/test_text2html.py @@ -46,6 +46,14 @@ class TestText2Html(TestCase): parser.format_styles("a " + ansi.ANSI_INVERSE + "red" + ansi.ANSI_NORMAL + "foo"), ) + # True Color + self.assertEqual( + 'redfoo', + parser.format_styles( + f'\x1b[38;2;255;0;0m' + "red" + ansi.ANSI_NORMAL + "foo" + ), + ) + def test_remove_bells(self): parser = text2html.HTML_PARSER self.assertEqual("foo", parser.remove_bells("foo")) diff --git a/evennia/utils/text2html.py b/evennia/utils/text2html.py index 1effdd3266..e8000a7f81 100644 --- a/evennia/utils/text2html.py +++ b/evennia/utils/text2html.py @@ -13,11 +13,15 @@ from html import escape as html_escape from .ansi import * +from .hex_colors import HexColors + # All xterm256 RGB equivalents XTERM256_FG = "\033[38;5;{}m" XTERM256_BG = "\033[48;5;{}m" +hex_colors = HexColors() + class TextToHTMLparser(object): """ @@ -67,13 +71,11 @@ class TextToHTMLparser(object): ] xterm_bg_codes = [XTERM256_BG.format(i + 16) for i in range(240)] - re_style = re.compile( - r"({})".format( + r"({}|{})".format( "|".join( style_codes + ansi_color_codes + xterm_fg_codes + ansi_bg_codes + xterm_bg_codes - ).replace("[", r"\[") - ) + ).replace("[", r"\["), "|".join([HexColors.TRUECOLOR_FG, HexColors.TRUECOLOR_BG])) ) colorlist = ( @@ -244,6 +246,7 @@ class TextToHTMLparser(object): # split out the ANSI codes and clean out any empty items str_list = [substr for substr in self.re_style.split(text) if substr] + # initialize all the flags and classes classes = [] clean = True @@ -253,6 +256,8 @@ class TextToHTMLparser(object): fg = ANSI_WHITE # default bg is black bg = ANSI_BACK_BLACK + truecolor_fg = '' + truecolor_bg = '' for i, substr in enumerate(str_list): # reset all current styling @@ -266,6 +271,8 @@ class TextToHTMLparser(object): hilight = ANSI_UNHILITE fg = ANSI_WHITE bg = ANSI_BACK_BLACK + truecolor_fg = '' + truecolor_bg = '' # change color elif substr in self.ansi_color_codes + self.xterm_fg_codes: @@ -281,6 +288,14 @@ class TextToHTMLparser(object): # set new bg bg = substr + elif re.match(hex_colors.TRUECOLOR_FG, substr): + str_list[i] = '' + truecolor_fg = substr + + elif re.match(hex_colors.TRUECOLOR_BG, substr): + str_list[i] = "" + truecolor_bg = substr + # non-color codes elif substr in self.style_codes: # erase ANSI code from output @@ -319,9 +334,23 @@ class TextToHTMLparser(object): color_index = self.colorlist.index(fg) if inverse: - # inverse means swap fg and bg indices - bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) - color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + if truecolor_fg != '' and truecolor_bg != '': + # True startcolor only + truecolor_fg, truecolor_bg = truecolor_bg, truecolor_fg + elif truecolor_fg != '' and truecolor_bg == '': + # Truecolor fg, class based bg + truecolor_bg = truecolor_fg + truecolor_fg = '' + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) + elif truecolor_fg == '' and truecolor_bg != '': + # Truecolor bg, class based fg + truecolor_fg = truecolor_bg + truecolor_bg = '' + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + else: + # inverse means swap fg and bg indices + bg_class = "bgcolor-{}".format(str(color_index).rjust(3, "0")) + color_class = "color-{}".format(str(bg_index).rjust(3, "0")) else: # use fg and bg indices for classes bg_class = "bgcolor-{}".format(str(bg_index).rjust(3, "0")) @@ -333,8 +362,16 @@ class TextToHTMLparser(object): # light grey text is the default, don't explicitly style if color_class != "color-007": classes.append(color_class) + # define the new style span - prefix = ''.format(" ".join(classes)) + if truecolor_fg == '' and truecolor_bg == '': + prefix = f'' + else: + # Classes can't be used for true color + prefix = (f'') + # close any prior span if not clean: prefix = "" + prefix @@ -366,7 +403,7 @@ class TextToHTMLparser(object): """ # parse everything to ansi first - text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True) + text = parse_ansi(text, strip_ansi=strip_ansi, xterm256=True, mxp=True, truecolor=True) # convert all ansi to html result = re.sub(self.re_string, self.sub_text, text) result = re.sub(self.re_mxplink, self.sub_mxp_links, result)