Skip to content

Commit d89126b

Browse files
committed
Fixed Text.from_ansi() removing trailing line break.
1 parent 9c9b011 commit d89126b

File tree

4 files changed

+60
-2
lines changed

4 files changed

+60
-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 `Text.from_ansi()` removing trailing 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's use of str.splitlines() discards trailing line breaks.
331+
# If the original string ends with recognized line break character,
332+
# then restore the missing newline.
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: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,42 @@ def test_decode():
3232
assert lines == expected
3333

3434

35+
def test_from_ansi_ending_newline():
36+
"""Ensures that Text.from_ansi() converts a trailing line break to a
37+
newline character instead of removing it.
38+
"""
39+
40+
# Line breaks recognized by str.splitlines().
41+
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
42+
line_breaks = {
43+
"\n", # Line Feed
44+
"\r", # Carriage Return
45+
"\r\n", # Carriage Return + Line Feed
46+
"\v", # Vertical Tab
47+
"\f", # Form Feed
48+
"\x1c", # File Separator
49+
"\x1d", # Group Separator
50+
"\x1e", # Record Separator
51+
"\x85", # Next Line (NEL)
52+
"\u2028", # Line Separator
53+
"\u2029", # Paragraph Separator
54+
}
55+
56+
# Test all line breaks
57+
for lb in line_breaks:
58+
input_string = f"Text{lb}"
59+
expected_output = input_string.replace(lb, "\n")
60+
assert Text.from_ansi(input_string).plain == expected_output
61+
62+
# Test string without trailing line break
63+
input_string = "No trailing\nline break"
64+
assert Text.from_ansi(input_string).plain == input_string
65+
66+
# Test empty string
67+
input_string = ""
68+
assert Text.from_ansi(input_string).plain == input_string
69+
70+
3571
def test_decode_example():
3672
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"
3773
ansi_text = ansi_bytes.decode("utf-8")
@@ -45,7 +81,7 @@ def test_decode_example():
4581
console.print(text)
4682
result = capture.get()
4783
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"
84+
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"
4985
assert result == expected
5086

5187

@@ -55,7 +91,7 @@ def test_decode_example():
5591
# https://github.com/Textualize/rich/issues/2688
5692
(
5793
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)",
94+
"Found 4 errors in 2 files (checked 18 source files)\n",
5995
),
6096
# https://mail.python.org/pipermail/python-list/2007-December/424756.html
6197
(b"Hallo", "Hallo"),

0 commit comments

Comments
 (0)