Skip to content

Commit e54f86e

Browse files
committed
Fixed issue where Text.from_ansi() was removing ending line break.
1 parent 9c9b011 commit e54f86e

File tree

4 files changed

+61
-2
lines changed

4 files changed

+61
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Fixed extraction of recursive exceptions https://github.com/Textualize/rich/pull/3772
2020
- Fixed padding applied to Syntax https://github.com/Textualize/rich/pull/3782
2121
- Fixed `Panel` title missing the panel background style https://github.com/Textualize/rich/issues/3569
22+
- Fixed issue where `Text.from_ansi()` was removing ending line break. https://github.com/Textualize/rich/issues/3577
2223

2324
### Added
2425

CONTRIBUTORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,4 @@ The following people have contributed to the development of Rich:
9494
- [Jonathan Helmus](https://github.com/jjhelmus)
9595
- [Brandon Capener](https://github.com/bcapener)
9696
- [Alex Zheng](https://github.com/alexzheng111)
97+
- [Kevin Van Brunt](https://github.com/kmvanbrunt)

rich/text.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,26 @@ def from_ansi(
326326
)
327327
decoder = AnsiDecoder()
328328
result = joiner.join(line for line in decoder.decode(text))
329+
330+
# AnsiDecoder.decode() uses str.splitlines(), which discards trailing line break characters.
331+
# If 'text' ends with one, restore the missing newline to 'result'.
332+
# Note: '\r\n' is handled as its last character is '\n'.
333+
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
334+
line_break_chars = {
335+
"\n", # Line Feed
336+
"\r", # Carriage Return
337+
"\v", # Vertical Tab
338+
"\f", # Form Feed
339+
"\x1c", # File Separator
340+
"\x1d", # Group Separator
341+
"\x1e", # Record Separator
342+
"\x85", # Next Line (NEL)
343+
"\u2028", # Line Separator
344+
"\u2029", # Paragraph Separator
345+
}
346+
if text and text[-1] in line_break_chars:
347+
result.append("\n")
348+
329349
return result
330350

331351
@classmethod

tests/test_ansi.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,43 @@ def test_decode():
3232
assert lines == expected
3333

3434

35+
def test_from_ansi_ending_newline():
36+
"""Test that ending line breaks are not removed but are restored as newlines."""
37+
# Line break characters recognized by str.splitlines()
38+
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
39+
line_break_chars = {
40+
"\n", # Line Feed
41+
"\r", # Carriage Return
42+
"\v", # Vertical Tab
43+
"\f", # Form Feed
44+
"\x1c", # File Separator
45+
"\x1d", # Group Separator
46+
"\x1e", # Record Separator
47+
"\x85", # Next Line (NEL)
48+
"\u2028", # Line Separator
49+
"\u2029", # Paragraph Separator
50+
}
51+
52+
# Test single-character line breaks
53+
for c in line_break_chars:
54+
input_string = f"Text{c}"
55+
expected_output = input_string.replace(c, "\n")
56+
assert Text.from_ansi(input_string).plain == expected_output
57+
58+
# Test '\r\n'
59+
input_string = "Text\r\n"
60+
expected_output = input_string.replace("\r\n", "\n")
61+
assert Text.from_ansi(input_string).plain == expected_output
62+
63+
# Test string without ending line break
64+
input_string = "No line break"
65+
assert Text.from_ansi(input_string).plain == input_string
66+
67+
# Test empty string
68+
input_string = ""
69+
assert Text.from_ansi(input_string).plain == input_string
70+
71+
3572
def test_decode_example():
3673
ansi_bytes = b"\x1b[01m\x1b[KC:\\Users\\stefa\\AppData\\Local\\Temp\\tmp3ydingba:\x1b[m\x1b[K In function '\x1b[01m\x1b[Kmain\x1b[m\x1b[K':\n\x1b[01m\x1b[KC:\\Users\\stefa\\AppData\\Local\\Temp\\tmp3ydingba:3:5:\x1b[m\x1b[K \x1b[01;35m\x1b[Kwarning: \x1b[m\x1b[Kunused variable '\x1b[01m\x1b[Ka\x1b[m\x1b[K' [\x1b[01;35m\x1b[K-Wunused-variable\x1b[m\x1b[K]\n 3 | int \x1b[01;35m\x1b[Ka\x1b[m\x1b[K=1;\n | \x1b[01;35m\x1b[K^\x1b[m\x1b[K\n"
3774
ansi_text = ansi_bytes.decode("utf-8")
@@ -45,7 +82,7 @@ def test_decode_example():
4582
console.print(text)
4683
result = capture.get()
4784
print(repr(result))
48-
expected = "\x1b[1mC:\\Users\\stefa\\AppData\\Local\\Temp\\tmp3ydingba:\x1b[0m In function '\x1b[1mmain\x1b[0m':\n\x1b[1mC:\\Users\\stefa\\AppData\\Local\\Temp\\tmp3ydingba:3:5:\x1b[0m \x1b[1;35mwarning: \x1b[0munused variable '\x1b[1ma\x1b[0m' \n[\x1b[1;35m-Wunused-variable\x1b[0m]\n 3 | int \x1b[1;35ma\x1b[0m=1;\n | \x1b[1;35m^\x1b[0m\n"
85+
expected = "\x1b[1mC:\\Users\\stefa\\AppData\\Local\\Temp\\tmp3ydingba:\x1b[0m In function '\x1b[1mmain\x1b[0m':\n\x1b[1mC:\\Users\\stefa\\AppData\\Local\\Temp\\tmp3ydingba:3:5:\x1b[0m \x1b[1;35mwarning: \x1b[0munused variable '\x1b[1ma\x1b[0m' \n[\x1b[1;35m-Wunused-variable\x1b[0m]\n 3 | int \x1b[1;35ma\x1b[0m=1;\n | \x1b[1;35m^\x1b[0m\n\n"
4986
assert result == expected
5087

5188

@@ -55,7 +92,7 @@ def test_decode_example():
5592
# https://github.com/Textualize/rich/issues/2688
5693
(
5794
b"\x1b[31mFound 4 errors in 2 files (checked 18 source files)\x1b(B\x1b[m\n",
58-
"Found 4 errors in 2 files (checked 18 source files)",
95+
"Found 4 errors in 2 files (checked 18 source files)\n",
5996
),
6097
# https://mail.python.org/pipermail/python-list/2007-December/424756.html
6198
(b"Hallo", "Hallo"),

0 commit comments

Comments
 (0)