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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [Unreleased]

### Fixed
- Fixed `Text.from_ansi()` removing trailing line break. https://github.com/Textualize/rich/issues/3577

## [14.1.0] - 2025-06-25

### Changed
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,4 @@ The following people have contributed to the development of Rich:
- [Jonathan Helmus](https://github.com/jjhelmus)
- [Brandon Capener](https://github.com/bcapener)
- [Alex Zheng](https://github.com/alexzheng111)
- [Kevin Van Brunt](https://github.com/kmvanbrunt)
20 changes: 20 additions & 0 deletions rich/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,26 @@ def from_ansi(
)
decoder = AnsiDecoder()
result = joiner.join(line for line in decoder.decode(text))

# AnsiDecoder's use of str.splitlines() discards a trailing line break.
# If the original string ends with a recognized line break character,
# then restore the missing newline.
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
line_break_chars = {
"\n", # Line Feed
"\r", # Carriage Return
"\v", # Vertical Tab
"\f", # Form Feed
"\x1c", # File Separator
"\x1d", # Group Separator
"\x1e", # Record Separator
"\x85", # Next Line (NEL)
"\u2028", # Line Separator
"\u2029", # Paragraph Separator
}
if text and text[-1] in line_break_chars:
result.append("\n")

return result

@classmethod
Expand Down
40 changes: 38 additions & 2 deletions tests/test_ansi.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,42 @@ def test_decode():
assert lines == expected


def test_from_ansi_ending_newline():
"""Ensures that Text.from_ansi() converts a trailing line break to a
newline character instead of removing it.
"""

# Line breaks recognized by str.splitlines().
# Source: https://docs.python.org/3/library/stdtypes.html#str.splitlines
line_breaks = {
"\n", # Line Feed
"\r", # Carriage Return
"\r\n", # Carriage Return + Line Feed
"\v", # Vertical Tab
"\f", # Form Feed
"\x1c", # File Separator
"\x1d", # Group Separator
"\x1e", # Record Separator
"\x85", # Next Line (NEL)
"\u2028", # Line Separator
"\u2029", # Paragraph Separator
}

# Test all line breaks
for lb in line_breaks:
input_string = f"Text{lb}"
expected_output = input_string.replace(lb, "\n")
assert Text.from_ansi(input_string).plain == expected_output

# Test string without trailing line break
input_string = "No trailing\nline break"
assert Text.from_ansi(input_string).plain == input_string

# Test empty string
input_string = ""
assert Text.from_ansi(input_string).plain == input_string


def test_decode_example():
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"
ansi_text = ansi_bytes.decode("utf-8")
Expand All @@ -45,7 +81,7 @@ def test_decode_example():
console.print(text)
result = capture.get()
print(repr(result))
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"
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"
assert result == expected


Expand All @@ -55,7 +91,7 @@ def test_decode_example():
# https://github.com/Textualize/rich/issues/2688
(
b"\x1b[31mFound 4 errors in 2 files (checked 18 source files)\x1b(B\x1b[m\n",
"Found 4 errors in 2 files (checked 18 source files)",
"Found 4 errors in 2 files (checked 18 source files)\n",
),
# https://mail.python.org/pipermail/python-list/2007-December/424756.html
(b"Hallo", "Hallo"),
Expand Down