Skip to content

Commit

Permalink
Add truecolor support (#155)
Browse files Browse the repository at this point in the history
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Sorin Sbarnea <[email protected]>
  • Loading branch information
3 people authored Jan 28, 2022
1 parent da3a275 commit 01d9635
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 16 deletions.
71 changes: 56 additions & 15 deletions ansi2html/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,18 +61,20 @@
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
ANSI_FOREGROUND_HIGH_INTENSITY_MIN = 90
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": "─",
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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] = []

Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -297,7 +316,6 @@ def __init__(
self.title = title
self._attrs: Attributes
self.hyperref = False

if inline:
self.styles = dict(
[
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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]
Expand Down
27 changes: 26 additions & 1 deletion ansi2html/style.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# <http://www.gnu.org/licenses/>.


from typing import List
from typing import Dict, List


class Rule:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
49 changes: 49 additions & 0 deletions tests/test_ansi2html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<span class="ansi38-255102102"> 1 </span> '
+ '<span class="ansi38-000000255"> 2 </span> '
+ '<span class="ansi48-065105225"> 3 </span>'
)
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 = (
'<span style="color: #FF6666"> 1 </span> '
+ '<span style="color: #0000FF"> 2 </span> '
+ '<span style="background-color: #4169E1"> 3 </span>'
)
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 = '<span class="ansi2 ansi102"> malformed </span> '
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 = '<span class="ansi5"> malformed </span> '
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 = '<span class="ansi38"> malformed </span> '
html = Ansi2HTMLConverter().convert(ansi)
assert target in html

def test_command_script(self) -> None:
result = run(["ansi2html", "--version"], check=True)
assert result.returncode == 0
Expand Down

0 comments on commit 01d9635

Please sign in to comment.