diff --git a/django/utils/text.py b/django/utils/text.py index 05b781b011..26edde99e3 100644 --- a/django/utils/text.py +++ b/django/utils/text.py @@ -54,10 +54,19 @@ def wrap(text, width): width=width, break_long_words=False, break_on_hyphens=False, + replace_whitespace=False, ) result = [] - for line in text.splitlines(True): - result.extend(wrapper.wrap(line)) + for line in text.splitlines(): + wrapped = wrapper.wrap(line) + if not wrapped: + # If `line` contains only whitespaces that are dropped, restore it. + result.append(line) + else: + result.extend(wrapped) + if text.endswith("\n"): + # If `text` ends with a newline, preserve it. + result.append("") return "\n".join(result) diff --git a/docs/releases/5.2.1.txt b/docs/releases/5.2.1.txt index caebde7f02..82df478d96 100644 --- a/docs/releases/5.2.1.txt +++ b/docs/releases/5.2.1.txt @@ -44,3 +44,7 @@ Bugfixes * Fixed a regression in Django 5.2 that caused the ``object-tools`` block to be rendered twice when using custom admin templates with overridden blocks due to changes in the base admin page block structure (:ticket:`36331`). + +* Fixed a regression in Django 5.2, introduced when fixing :cve:`2025-26699`, + where the :tfilter:`wordwrap` template filter did not preserve empty lines + between paragraphs after wrapping text (:ticket:`36341`). diff --git a/tests/template_tests/filter_tests/test_wordwrap.py b/tests/template_tests/filter_tests/test_wordwrap.py index 4afa1dd234..1692332e1e 100644 --- a/tests/template_tests/filter_tests/test_wordwrap.py +++ b/tests/template_tests/filter_tests/test_wordwrap.py @@ -89,3 +89,44 @@ class FunctionTests(SimpleTestCase): "I'm afraid", wordwrap(long_text, 10), ) + + def test_wrap_preserve_newlines(self): + cases = [ + ( + "this is a long paragraph of text that really needs to be wrapped\n\n" + "that is followed by another paragraph separated by an empty line\n", + "this is a long paragraph of\ntext that really needs to be\nwrapped\n\n" + "that is followed by another\nparagraph separated by an\nempty line\n", + 30, + ), + ("\n\n\n", "\n\n\n", 5), + ("\n\n\n\n\n\n", "\n\n\n\n\n\n", 5), + ] + for text, expected, width in cases: + with self.subTest(text=text): + self.assertEqual(wordwrap(text, width), expected) + + def test_wrap_preserve_whitespace(self): + width = 5 + width_spaces = " " * width + cases = [ + ( + f"first line\n{width_spaces}\nsecond line", + f"first\nline\n{width_spaces}\nsecond\nline", + ), + ( + "first line\n \t\t\t \nsecond line", + "first\nline\n \t\t\t \nsecond\nline", + ), + ( + f"first line\n{width_spaces}\nsecond line\n\nthird{width_spaces}\n", + f"first\nline\n{width_spaces}\nsecond\nline\n\nthird\n", + ), + ( + f"first line\n{width_spaces}{width_spaces}\nsecond line", + f"first\nline\n{width_spaces}{width_spaces}\nsecond\nline", + ), + ] + for text, expected in cases: + with self.subTest(text=text): + self.assertEqual(wordwrap(text, width), expected)