From 01d9635538251d54bf6c2d1ac153ad33b3fbbdd7 Mon Sep 17 00:00:00 2001 From: miltolstoy Date: Fri, 28 Jan 2022 13:02:38 +0200 Subject: [PATCH] Add truecolor support (#155) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sorin Sbarnea --- ansi2html/converter.py | 71 ++++++++++++++++++++++++++++++++--------- ansi2html/style.py | 27 +++++++++++++++- tests/test_ansi2html.py | 49 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 16 deletions(-) diff --git a/ansi2html/converter.py b/ansi2html/converter.py index 55aea3f..875b268 100644 --- a/ansi2html/converter.py +++ b/ansi2html/converter.py @@ -26,7 +26,12 @@ from collections import OrderedDict from typing import Iterator, List, Optional, Set, Tuple, Union -from ansi2html.style import SCHEME, get_styles +from ansi2html.style import ( + SCHEME, + add_truecolor_style_rule, + get_styles, + pop_truecolor_styles, +) if sys.version_info >= (3, 8): from importlib.metadata import version @@ -56,11 +61,11 @@ ANSI_VISIBILITY_OFF = 8 ANSI_FOREGROUND_CUSTOM_MIN = 30 ANSI_FOREGROUND_CUSTOM_MAX = 37 -ANSI_FOREGROUND_256 = 38 +ANSI_FOREGROUND = 38 ANSI_FOREGROUND_DEFAULT = 39 ANSI_BACKGROUND_CUSTOM_MIN = 40 ANSI_BACKGROUND_CUSTOM_MAX = 47 -ANSI_BACKGROUND_256 = 48 +ANSI_BACKGROUND = 48 ANSI_BACKGROUND_DEFAULT = 49 ANSI_NEGATIVE_ON = 7 ANSI_NEGATIVE_OFF = 27 @@ -68,6 +73,8 @@ ANSI_FOREGROUND_HIGH_INTENSITY_MAX = 97 ANSI_BACKGROUND_HIGH_INTENSITY_MIN = 100 ANSI_BACKGROUND_HIGH_INTENSITY_MAX = 107 +ANSI_256_COLOR_ID = 5 +ANSI_TRUECOLOR_ID = 2 VT100_BOX_CODES = { "0x71": "─", @@ -131,11 +138,11 @@ def reset(self) -> None: self.underline: int = ANSI_UNDERLINE_OFF self.crossedout: int = ANSI_CROSSED_OUT_OFF self.visibility: int = ANSI_VISIBILITY_ON - self.foreground: Tuple[int, Optional[int]] = (ANSI_FOREGROUND_DEFAULT, None) - self.background: Tuple[int, Optional[int]] = (ANSI_BACKGROUND_DEFAULT, None) + self.foreground: Tuple[int, Optional[str]] = (ANSI_FOREGROUND_DEFAULT, None) + self.background: Tuple[int, Optional[str]] = (ANSI_BACKGROUND_DEFAULT, None) self.negative: int = ANSI_NEGATIVE_OFF - def adjust(self, ansi_code: int, parameter: Optional[int] = None) -> None: + def adjust(self, ansi_code: int, parameter: Optional[str] = None) -> None: if ansi_code in ( ANSI_INTENSITY_INCREASED, ANSI_INTENSITY_REDUCED, @@ -160,7 +167,7 @@ def adjust(self, ansi_code: int, parameter: Optional[int] = None) -> None: <= ANSI_FOREGROUND_HIGH_INTENSITY_MAX ): self.foreground = (ansi_code, None) - elif ansi_code == ANSI_FOREGROUND_256: + elif ansi_code == ANSI_FOREGROUND: self.foreground = (ansi_code, parameter) elif ansi_code == ANSI_FOREGROUND_DEFAULT: self.foreground = (ansi_code, None) @@ -172,13 +179,25 @@ def adjust(self, ansi_code: int, parameter: Optional[int] = None) -> None: <= ANSI_BACKGROUND_HIGH_INTENSITY_MAX ): self.background = (ansi_code, None) - elif ansi_code == ANSI_BACKGROUND_256: + elif ansi_code == ANSI_BACKGROUND: self.background = (ansi_code, parameter) elif ansi_code == ANSI_BACKGROUND_DEFAULT: self.background = (ansi_code, None) elif ansi_code in (ANSI_NEGATIVE_ON, ANSI_NEGATIVE_OFF): self.negative = ansi_code + def adjust_truecolor(self, ansi_code: int, r: int, g: int, b: int) -> None: + parameter = "{:03d}{:03d}{:03d}".format( + r, g, b + ) # r=1, g=64, b=255 -> 001064255 + + is_foreground = ansi_code == ANSI_FOREGROUND + add_truecolor_style_rule(is_foreground, ansi_code, r, g, b, parameter) + if is_foreground: + self.foreground = (ansi_code, parameter) + else: + self.background = (ansi_code, parameter) + def to_css_classes(self) -> List[str]: css_classes: List[str] = [] @@ -189,7 +208,7 @@ def append_unless_default(output: List[str], value: int, default: int) -> None: def append_color_unless_default( output: List[str], - color: Tuple[int, Optional[int]], + color: Tuple[int, Optional[str]], default: int, negative: bool, neg_css_class: str, @@ -198,7 +217,7 @@ def append_color_unless_default( if value != default: prefix = "inv" if negative else "ansi" css_class_index = ( - str(value) if (parameter is None) else "%d-%d" % (value, parameter) + str(value) if (parameter is None) else "%d-%s" % (value, parameter) ) output.append(prefix + css_class_index) elif negative: @@ -297,7 +316,6 @@ def __init__( self.title = title self._attrs: Attributes self.hyperref = False - if inline: self.styles = dict( [ @@ -449,8 +467,14 @@ def _handle_ansi_code( if v == ANSI_FULL_RESET: last_null_index = i - elif v in (ANSI_FOREGROUND_256, ANSI_BACKGROUND_256): - skip_after_index = i + 2 + elif v in (ANSI_FOREGROUND, ANSI_BACKGROUND): + try: + x_bit_color_id = params[i + 1] + except IndexError: + x_bit_color_id = -1 + is_256_color = x_bit_color_id == ANSI_256_COLOR_ID + shift = 2 if is_256_color else 4 + skip_after_index = i + shift # Process reset marker, drop everything before if last_null_index is not None: @@ -472,12 +496,28 @@ def _handle_ansi_code( if i <= skip_after_index: continue - if v in (ANSI_FOREGROUND_256, ANSI_BACKGROUND_256): + is_x_bit_color = v in (ANSI_FOREGROUND, ANSI_BACKGROUND) + try: + x_bit_color_id = params[i + 1] + except IndexError: + x_bit_color_id = -1 + is_256_color = x_bit_color_id == ANSI_256_COLOR_ID + is_truecolor = x_bit_color_id == ANSI_TRUECOLOR_ID + if is_x_bit_color and is_256_color: try: - parameter: Optional[int] = params[i + 2] + parameter: Optional[str] = str(params[i + 2]) except IndexError: continue skip_after_index = i + 2 + elif is_x_bit_color and is_truecolor: + try: + state.adjust_truecolor( + v, params[i + 2], params[i + 3], params[i + 4] + ) + except IndexError: + continue + skip_after_index = i + 4 + continue else: parameter = None state.adjust(v, parameter=parameter) @@ -495,6 +535,7 @@ def _handle_ansi_code( styles_used.update(css_classes) if self.inline: + self.styles.update(pop_truecolor_styles()) if self.latex: style = [ self.styles[klass].kwl[0][1] diff --git a/ansi2html/style.py b/ansi2html/style.py index 900d2df..3ba78e5 100644 --- a/ansi2html/style.py +++ b/ansi2html/style.py @@ -17,7 +17,7 @@ # . -from typing import List +from typing import Dict, List class Rule: @@ -166,6 +166,9 @@ def index2(grey: int) -> str: ), } +# to be filled in runtime, when truecolor found +truecolor_rules: List[Rule] = [] + def intensify(color: str, dark_bg: bool, amount: int = 64) -> str: if not dark_bg: @@ -267,4 +270,26 @@ def get_styles( css.append(Rule(".ansi48-%s" % index2(grey), background=level(grey))) css.append(Rule(".inv48-%s" % index2(grey), color=level(grey))) + css.extend(truecolor_rules) + return css + + +# as truecolor encoding has 16 millions colors, adding only used colors during parsing +def add_truecolor_style_rule( + is_foreground: bool, ansi_code: int, r: int, g: int, b: int, parameter: str +) -> None: + rule_name = ".ansi{}-{}".format(ansi_code, parameter) + color = "#{:02X}{:02X}{:02X}".format(r, g, b) + if is_foreground: + rule = Rule(rule_name, color=color) + else: + rule = Rule(rule_name, background_color=color) + truecolor_rules.append(rule) + + +def pop_truecolor_styles() -> Dict[str, Rule]: + global truecolor_rules # pylint: disable=global-statement + styles = dict([(item.klass.strip("."), item) for item in truecolor_rules]) + truecolor_rules = [] + return styles diff --git a/tests/test_ansi2html.py b/tests/test_ansi2html.py index 09529be..d725097 100644 --- a/tests/test_ansi2html.py +++ b/tests/test_ansi2html.py @@ -417,6 +417,55 @@ def test_latex_linkify(self) -> None: latex = Ansi2HTMLConverter(latex=True, inline=True, linkify=True).convert(ansi) assert target in latex + def test_truecolor(self) -> None: + ansi = ( + "\u001b[38;2;255;102;102m 1 \u001b[0m " + + "\u001b[38;2;0;0;255m 2 \u001b[0m " + + "\u001b[48;2;65;105;225m 3 \u001b[0m" + ) + target = ( + ' 1 ' + + ' 2 ' + + ' 3 ' + ) + html = Ansi2HTMLConverter().convert(ansi) + assert target in html + + def test_truecolor_inline(self) -> None: + ansi = ( + "\u001b[38;2;255;102;102m 1 \u001b[0m " + + "\u001b[38;2;0;0;255m 2 \u001b[0m " + + "\u001b[48;2;65;105;225m 3 \u001b[0m" + ) + target = ( + ' 1 ' + + ' 2 ' + + ' 3 ' + ) + html = Ansi2HTMLConverter(inline=True).convert(ansi) + assert target in html + + def test_truecolor_malformed(self) -> None: + ansi = "\u001b[38;2;255;102m malformed \u001b[0m " + # ^ e.g. ";102" missed + target = ' malformed ' + html = Ansi2HTMLConverter().convert(ansi) + assert target in html + + def test_256_color_malformed(self) -> None: + ansi = "\u001b[38;5m malformed \u001b[0m " + # ^ e.g. ";255" missed + target = ' malformed ' + html = Ansi2HTMLConverter().convert(ansi) + assert target in html + + def test_x_bit_color_malformed(self) -> None: + ansi = "\u001b[38m malformed \u001b[0m " + # ^ e.g. ";5;255m" missed + target = ' malformed ' + html = Ansi2HTMLConverter().convert(ansi) + assert target in html + def test_command_script(self) -> None: result = run(["ansi2html", "--version"], check=True) assert result.returncode == 0