diff --git a/CHANGELOG.md b/CHANGELOG.md index e7583d893..7a37ce677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 4b04786b9..470d78d32 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -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) diff --git a/rich/text.py b/rich/text.py index b57d77c27..5e629be44 100644 --- a/rich/text.py +++ b/rich/text.py @@ -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 diff --git a/tests/test_ansi.py b/tests/test_ansi.py index d81f6459f..30df46319 100644 --- a/tests/test_ansi.py +++ b/tests/test_ansi.py @@ -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") @@ -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 @@ -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"),