Skip to content

Commit 01d9635

Browse files
miltolstoypre-commit-ci[bot]ssbarnea
authored
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 <[email protected]>
1 parent da3a275 commit 01d9635

File tree

3 files changed

+131
-16
lines changed

3 files changed

+131
-16
lines changed

ansi2html/converter.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@
2626
from collections import OrderedDict
2727
from typing import Iterator, List, Optional, Set, Tuple, Union
2828

29-
from ansi2html.style import SCHEME, get_styles
29+
from ansi2html.style import (
30+
SCHEME,
31+
add_truecolor_style_rule,
32+
get_styles,
33+
pop_truecolor_styles,
34+
)
3035

3136
if sys.version_info >= (3, 8):
3237
from importlib.metadata import version
@@ -56,18 +61,20 @@
5661
ANSI_VISIBILITY_OFF = 8
5762
ANSI_FOREGROUND_CUSTOM_MIN = 30
5863
ANSI_FOREGROUND_CUSTOM_MAX = 37
59-
ANSI_FOREGROUND_256 = 38
64+
ANSI_FOREGROUND = 38
6065
ANSI_FOREGROUND_DEFAULT = 39
6166
ANSI_BACKGROUND_CUSTOM_MIN = 40
6267
ANSI_BACKGROUND_CUSTOM_MAX = 47
63-
ANSI_BACKGROUND_256 = 48
68+
ANSI_BACKGROUND = 48
6469
ANSI_BACKGROUND_DEFAULT = 49
6570
ANSI_NEGATIVE_ON = 7
6671
ANSI_NEGATIVE_OFF = 27
6772
ANSI_FOREGROUND_HIGH_INTENSITY_MIN = 90
6873
ANSI_FOREGROUND_HIGH_INTENSITY_MAX = 97
6974
ANSI_BACKGROUND_HIGH_INTENSITY_MIN = 100
7075
ANSI_BACKGROUND_HIGH_INTENSITY_MAX = 107
76+
ANSI_256_COLOR_ID = 5
77+
ANSI_TRUECOLOR_ID = 2
7178

7279
VT100_BOX_CODES = {
7380
"0x71": "─",
@@ -131,11 +138,11 @@ def reset(self) -> None:
131138
self.underline: int = ANSI_UNDERLINE_OFF
132139
self.crossedout: int = ANSI_CROSSED_OUT_OFF
133140
self.visibility: int = ANSI_VISIBILITY_ON
134-
self.foreground: Tuple[int, Optional[int]] = (ANSI_FOREGROUND_DEFAULT, None)
135-
self.background: Tuple[int, Optional[int]] = (ANSI_BACKGROUND_DEFAULT, None)
141+
self.foreground: Tuple[int, Optional[str]] = (ANSI_FOREGROUND_DEFAULT, None)
142+
self.background: Tuple[int, Optional[str]] = (ANSI_BACKGROUND_DEFAULT, None)
136143
self.negative: int = ANSI_NEGATIVE_OFF
137144

138-
def adjust(self, ansi_code: int, parameter: Optional[int] = None) -> None:
145+
def adjust(self, ansi_code: int, parameter: Optional[str] = None) -> None:
139146
if ansi_code in (
140147
ANSI_INTENSITY_INCREASED,
141148
ANSI_INTENSITY_REDUCED,
@@ -160,7 +167,7 @@ def adjust(self, ansi_code: int, parameter: Optional[int] = None) -> None:
160167
<= ANSI_FOREGROUND_HIGH_INTENSITY_MAX
161168
):
162169
self.foreground = (ansi_code, None)
163-
elif ansi_code == ANSI_FOREGROUND_256:
170+
elif ansi_code == ANSI_FOREGROUND:
164171
self.foreground = (ansi_code, parameter)
165172
elif ansi_code == ANSI_FOREGROUND_DEFAULT:
166173
self.foreground = (ansi_code, None)
@@ -172,13 +179,25 @@ def adjust(self, ansi_code: int, parameter: Optional[int] = None) -> None:
172179
<= ANSI_BACKGROUND_HIGH_INTENSITY_MAX
173180
):
174181
self.background = (ansi_code, None)
175-
elif ansi_code == ANSI_BACKGROUND_256:
182+
elif ansi_code == ANSI_BACKGROUND:
176183
self.background = (ansi_code, parameter)
177184
elif ansi_code == ANSI_BACKGROUND_DEFAULT:
178185
self.background = (ansi_code, None)
179186
elif ansi_code in (ANSI_NEGATIVE_ON, ANSI_NEGATIVE_OFF):
180187
self.negative = ansi_code
181188

189+
def adjust_truecolor(self, ansi_code: int, r: int, g: int, b: int) -> None:
190+
parameter = "{:03d}{:03d}{:03d}".format(
191+
r, g, b
192+
) # r=1, g=64, b=255 -> 001064255
193+
194+
is_foreground = ansi_code == ANSI_FOREGROUND
195+
add_truecolor_style_rule(is_foreground, ansi_code, r, g, b, parameter)
196+
if is_foreground:
197+
self.foreground = (ansi_code, parameter)
198+
else:
199+
self.background = (ansi_code, parameter)
200+
182201
def to_css_classes(self) -> List[str]:
183202
css_classes: List[str] = []
184203

@@ -189,7 +208,7 @@ def append_unless_default(output: List[str], value: int, default: int) -> None:
189208

190209
def append_color_unless_default(
191210
output: List[str],
192-
color: Tuple[int, Optional[int]],
211+
color: Tuple[int, Optional[str]],
193212
default: int,
194213
negative: bool,
195214
neg_css_class: str,
@@ -198,7 +217,7 @@ def append_color_unless_default(
198217
if value != default:
199218
prefix = "inv" if negative else "ansi"
200219
css_class_index = (
201-
str(value) if (parameter is None) else "%d-%d" % (value, parameter)
220+
str(value) if (parameter is None) else "%d-%s" % (value, parameter)
202221
)
203222
output.append(prefix + css_class_index)
204223
elif negative:
@@ -297,7 +316,6 @@ def __init__(
297316
self.title = title
298317
self._attrs: Attributes
299318
self.hyperref = False
300-
301319
if inline:
302320
self.styles = dict(
303321
[
@@ -449,8 +467,14 @@ def _handle_ansi_code(
449467

450468
if v == ANSI_FULL_RESET:
451469
last_null_index = i
452-
elif v in (ANSI_FOREGROUND_256, ANSI_BACKGROUND_256):
453-
skip_after_index = i + 2
470+
elif v in (ANSI_FOREGROUND, ANSI_BACKGROUND):
471+
try:
472+
x_bit_color_id = params[i + 1]
473+
except IndexError:
474+
x_bit_color_id = -1
475+
is_256_color = x_bit_color_id == ANSI_256_COLOR_ID
476+
shift = 2 if is_256_color else 4
477+
skip_after_index = i + shift
454478

455479
# Process reset marker, drop everything before
456480
if last_null_index is not None:
@@ -472,12 +496,28 @@ def _handle_ansi_code(
472496
if i <= skip_after_index:
473497
continue
474498

475-
if v in (ANSI_FOREGROUND_256, ANSI_BACKGROUND_256):
499+
is_x_bit_color = v in (ANSI_FOREGROUND, ANSI_BACKGROUND)
500+
try:
501+
x_bit_color_id = params[i + 1]
502+
except IndexError:
503+
x_bit_color_id = -1
504+
is_256_color = x_bit_color_id == ANSI_256_COLOR_ID
505+
is_truecolor = x_bit_color_id == ANSI_TRUECOLOR_ID
506+
if is_x_bit_color and is_256_color:
476507
try:
477-
parameter: Optional[int] = params[i + 2]
508+
parameter: Optional[str] = str(params[i + 2])
478509
except IndexError:
479510
continue
480511
skip_after_index = i + 2
512+
elif is_x_bit_color and is_truecolor:
513+
try:
514+
state.adjust_truecolor(
515+
v, params[i + 2], params[i + 3], params[i + 4]
516+
)
517+
except IndexError:
518+
continue
519+
skip_after_index = i + 4
520+
continue
481521
else:
482522
parameter = None
483523
state.adjust(v, parameter=parameter)
@@ -495,6 +535,7 @@ def _handle_ansi_code(
495535
styles_used.update(css_classes)
496536

497537
if self.inline:
538+
self.styles.update(pop_truecolor_styles())
498539
if self.latex:
499540
style = [
500541
self.styles[klass].kwl[0][1]

ansi2html/style.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
# <http://www.gnu.org/licenses/>.
1818

1919

20-
from typing import List
20+
from typing import Dict, List
2121

2222

2323
class Rule:
@@ -166,6 +166,9 @@ def index2(grey: int) -> str:
166166
),
167167
}
168168

169+
# to be filled in runtime, when truecolor found
170+
truecolor_rules: List[Rule] = []
171+
169172

170173
def intensify(color: str, dark_bg: bool, amount: int = 64) -> str:
171174
if not dark_bg:
@@ -267,4 +270,26 @@ def get_styles(
267270
css.append(Rule(".ansi48-%s" % index2(grey), background=level(grey)))
268271
css.append(Rule(".inv48-%s" % index2(grey), color=level(grey)))
269272

273+
css.extend(truecolor_rules)
274+
270275
return css
276+
277+
278+
# as truecolor encoding has 16 millions colors, adding only used colors during parsing
279+
def add_truecolor_style_rule(
280+
is_foreground: bool, ansi_code: int, r: int, g: int, b: int, parameter: str
281+
) -> None:
282+
rule_name = ".ansi{}-{}".format(ansi_code, parameter)
283+
color = "#{:02X}{:02X}{:02X}".format(r, g, b)
284+
if is_foreground:
285+
rule = Rule(rule_name, color=color)
286+
else:
287+
rule = Rule(rule_name, background_color=color)
288+
truecolor_rules.append(rule)
289+
290+
291+
def pop_truecolor_styles() -> Dict[str, Rule]:
292+
global truecolor_rules # pylint: disable=global-statement
293+
styles = dict([(item.klass.strip("."), item) for item in truecolor_rules])
294+
truecolor_rules = []
295+
return styles

tests/test_ansi2html.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,55 @@ def test_latex_linkify(self) -> None:
417417
latex = Ansi2HTMLConverter(latex=True, inline=True, linkify=True).convert(ansi)
418418
assert target in latex
419419

420+
def test_truecolor(self) -> None:
421+
ansi = (
422+
"\u001b[38;2;255;102;102m 1 \u001b[0m "
423+
+ "\u001b[38;2;0;0;255m 2 \u001b[0m "
424+
+ "\u001b[48;2;65;105;225m 3 \u001b[0m"
425+
)
426+
target = (
427+
'<span class="ansi38-255102102"> 1 </span> '
428+
+ '<span class="ansi38-000000255"> 2 </span> '
429+
+ '<span class="ansi48-065105225"> 3 </span>'
430+
)
431+
html = Ansi2HTMLConverter().convert(ansi)
432+
assert target in html
433+
434+
def test_truecolor_inline(self) -> None:
435+
ansi = (
436+
"\u001b[38;2;255;102;102m 1 \u001b[0m "
437+
+ "\u001b[38;2;0;0;255m 2 \u001b[0m "
438+
+ "\u001b[48;2;65;105;225m 3 \u001b[0m"
439+
)
440+
target = (
441+
'<span style="color: #FF6666"> 1 </span> '
442+
+ '<span style="color: #0000FF"> 2 </span> '
443+
+ '<span style="background-color: #4169E1"> 3 </span>'
444+
)
445+
html = Ansi2HTMLConverter(inline=True).convert(ansi)
446+
assert target in html
447+
448+
def test_truecolor_malformed(self) -> None:
449+
ansi = "\u001b[38;2;255;102m malformed \u001b[0m "
450+
# ^ e.g. ";102" missed
451+
target = '<span class="ansi2 ansi102"> malformed </span> '
452+
html = Ansi2HTMLConverter().convert(ansi)
453+
assert target in html
454+
455+
def test_256_color_malformed(self) -> None:
456+
ansi = "\u001b[38;5m malformed \u001b[0m "
457+
# ^ e.g. ";255" missed
458+
target = '<span class="ansi5"> malformed </span> '
459+
html = Ansi2HTMLConverter().convert(ansi)
460+
assert target in html
461+
462+
def test_x_bit_color_malformed(self) -> None:
463+
ansi = "\u001b[38m malformed \u001b[0m "
464+
# ^ e.g. ";5;255m" missed
465+
target = '<span class="ansi38"> malformed </span> '
466+
html = Ansi2HTMLConverter().convert(ansi)
467+
assert target in html
468+
420469
def test_command_script(self) -> None:
421470
result = run(["ansi2html", "--version"], check=True)
422471
assert result.returncode == 0

0 commit comments

Comments
 (0)