Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 27 additions & 20 deletions tabulate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,7 +771,9 @@ def escape_empty(val):
# Also include the terminal hyperlink sequences as described here:
# https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
#
# OSC 8 ; params ; uri ST display_text OSC 8 ;; ST
# OSC 8 ; params ; uri ST
# Each sequence changes the active hyperlink; the URI is empty when closing it.
# Link text may contain other escape sequences or switch directly to another URI.
#
# Example: \x1b]8;;https://example.com\x5ctext to show\x1b]8;;\x5c
#
Expand All @@ -786,7 +788,7 @@ def escape_empty(val):
_st = rf"{_esc}\\"

_ansi_escape_pat = rf"""
(
(?:
# terminal colors, etc
{_csi} # CSI
[\x30-\x3f]* # parameter bytes
Expand All @@ -795,12 +797,10 @@ def escape_empty(val):
|
# terminal hyperlinks
{_osc}8; # OSC opening
(\w+=\w+:?)* # key=value params list (submatch 2)
[^;{_esc}]* # optional params, up to the next delimiter
; # delimiter
([^{_esc}]+) # URI - anything but ESC (submatch 3)
[^{_esc}]* # URI, or empty when closing a hyperlink
{_st} # ST
([^{_esc}]+) # link text - anything but ESC (submatch 4)
{_osc}8;;{_st} # "closing" OSC sequence
)
"""
_ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE)
Expand Down Expand Up @@ -1077,9 +1077,8 @@ def _padnone(ignore_width, s):
def _strip_ansi(s):
r"""Remove ANSI escape sequences, both CSI (color codes, etc) and OSC hyperlinks.

CSI sequences are simply removed from the output, while OSC hyperlinks are replaced
with the link text. Note: it may be desirable to show the URI instead but this is not
supported.
CSI and OSC hyperlink control sequences are removed individually, preserving link
text even when it contains other escape sequences. The URI is not included.

>>> repr(_strip_ansi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\'))
"'This is a link'"
Expand All @@ -1089,9 +1088,9 @@ def _strip_ansi(s):

"""
if isinstance(s, str):
return _ansi_codes.sub(r"\4", s)
return _ansi_codes.sub("", s)
else: # a bytestring
return _ansi_codes_bytes.sub(r"\4", s)
return _ansi_codes_bytes.sub(b"", s)


def _visible_width(s):
Expand Down Expand Up @@ -2697,6 +2696,7 @@ class _CustomTextWrap(textwrap.TextWrapper):

def __init__(self, *args, **kwargs):
self._active_codes = []
self._active_hyperlink = ""
self.max_lines = None # For python2 compatibility
textwrap.TextWrapper.__init__(self, *args, **kwargs)

Expand All @@ -2712,18 +2712,21 @@ def _len(item):

def _update_lines(self, lines, new_line):
"""Adds a new line to the list of lines the text is being wrapped into
This function will also track any ANSI color codes in this string as well
as add any colors from previous lines order to preserve the same formatting
as a single unwrapped string.
Track ANSI color codes and hyperlinks so wrapped lines preserve the formatting
of a single unwrapped string without leaking it into adjacent cells.
"""
code_matches = list(_ansi_codes.finditer(new_line))
color_codes = [code.string[code.span()[0] : code.span()[1]] for code in code_matches]
escape_codes = [code.group() for code in code_matches]

# Add color codes from earlier in the unwrapped line, and then track any new ones we add.
new_line = "".join(self._active_codes) + new_line

for code in color_codes:
if code != _ansi_color_reset_code:
new_line = "".join(self._active_codes) + self._active_hyperlink + new_line

for code in escape_codes:
if code.startswith("\x1b]8;"):
# A color reset does not close a hyperlink, and a new URI replaces the old one.
uri = code[4:-2].partition(";")[2]
self._active_hyperlink = code if uri else ""
elif code != _ansi_color_reset_code:
self._active_codes.append(code)
else: # A single reset code resets everything
self._active_codes = []
Expand All @@ -2732,6 +2735,9 @@ def _update_lines(self, lines, new_line):
# still active, otherwise colors will bleed into other cells on the console
if len(self._active_codes) > 0:
new_line = new_line + _ansi_color_reset_code
if self._active_hyperlink:
# Close at the cell boundary and reopen on the next wrapped line.
new_line += "\x1b]8;;\x1b\\"

lines.append(new_line)

Expand Down Expand Up @@ -2768,7 +2774,8 @@ def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
total_escape_len = 0
last_group = 0
if _ansi_codes.search(chunk) is not None:
for group, _, _, _ in _ansi_codes.findall(chunk):
for match in _ansi_codes.finditer(chunk):
group = match.group()
escape_len = len(group)
if group in chunk[last_group : i + total_escape_len + escape_len - 1]:
total_escape_len += escape_len
Expand Down
39 changes: 39 additions & 0 deletions test/test_internal.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,49 @@
"""Tests of the internal tabulate functions."""

from pytest import mark

import tabulate as T

from common import assert_equal, cols_to_pipe_str, rows_to_pipe_table_str, skip


@mark.parametrize("as_bytes", [False, True])
@mark.parametrize(
"text, expected",
[
(
"\x1b]8;;https://example.com\x1b\\\x1b[31mred\x1b[0m link\x1b]8;;\x1b\\",
"red link",
),
(
"\x1b]8;;https://example.com/one\x1b\\one"
"\x1b]8;;https://example.com/two\x1b\\two\x1b]8;;\x1b\\",
"onetwo",
),
("\x1b]8;;\x1b\\plain", "plain"),
("\x1b]8;;https://example.com\x1b\\open", "open"),
("\x1b]8;id=link-1:foo=bar;https://example.com\x1b\\text\x1b]8;;\x1b\\", "text"),
],
)
def test_strip_ansi_hyperlink_sequences(text, expected, as_bytes):
"Hyperlink controls are independent of the text between them (issue #273)."
if as_bytes:
text = text.encode()
expected = expected.encode()
assert_equal(T._strip_ansi(text), expected)


@mark.parametrize("wide_chars_mode", [False, True])
def test_colored_hyperlink_alignment(monkeypatch, wide_chars_mode):
"Color controls inside link text must not change table column widths."
monkeypatch.setattr(T, "WIDE_CHARS_MODE", wide_chars_mode)
link = "\x1b]8;;https://example.com\x1b\\\x1b[31mred\x1b[0m link\x1b]8;;\x1b\\"
result = T.tabulate([[link, "x"], ["normal", "y"]], tablefmt="grid")
expected = T.tabulate([["red link", "x"], ["normal", "y"]], tablefmt="grid")
assert_equal(result.replace(link, "red link"), expected)
assert link in result


def test_multiline_width():
"Internal: _multiline_width()"
multiline_string = "\n".join(["foo", "barbaz", "spam"])
Expand Down
18 changes: 18 additions & 0 deletions test/test_textwrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,29 @@
import datetime
from textwrap import TextWrapper as OTW

from pytest import mark

from tabulate import _CustomTextWrap as CTW, _strip_ansi, tabulate

from common import assert_equal, skip


@mark.parametrize("width", [4, 7])
@mark.parametrize("colored", [False, True])
def test_wrap_hyperlink_long_word(width, colored):
"Wrapping must not duplicate link text or split a hyperlink control sequence."
text = "longhyperlink"
link_text = "\x1b[31mlong\x1b[0mhyperlink" if colored else text
opening = "\x1b]8;;https://example.com\x1b\\"
closing = "\x1b]8;;\x1b\\"
link = opening + link_text + closing
result = CTW(width=width).wrap(link)
assert_equal([_strip_ansi(line) for line in result], OTW(width=width).wrap(text))
for line in result:
assert opening in line
assert line.rfind(closing) > line.rfind(opening)


def test_wrap_multiword_non_wide():
"""TextWrapper: non-wide character regression tests"""
data = "this is a test string for regression splitting"
Expand Down