diff --git a/Doc/library/textwrap.rst b/Doc/library/textwrap.rst index d12968dee91f3c3..e8802fa0d4bb04f 100644 --- a/Doc/library/textwrap.rst +++ b/Doc/library/textwrap.rst @@ -66,6 +66,11 @@ functions should be good enough; otherwise, you should use an instance of >>> textwrap.shorten("Hello world", width=10, placeholder="...") 'Hello...' + If no words can be kept before the *placeholder* (for example, when + *text* is a single word longer than *width*) and *break_long_words* is + true, the word is broken so that part of it is kept before the + *placeholder*. + Optional keyword arguments correspond to the instance attributes of :class:`TextWrapper`, documented below. Note that the whitespace is collapsed before the text is passed to the :class:`TextWrapper` :meth:`fill` diff --git a/Lib/test/test_textwrap.py b/Lib/test/test_textwrap.py index aca1f427656bb50..fc0fab7cbc0f9d9 100644 --- a/Lib/test/test_textwrap.py +++ b/Lib/test/test_textwrap.py @@ -636,9 +636,28 @@ def test_max_lines_long(self): ['Did you say', '"supercalifr', 'agilisticexp', - '[...]'], + 'ialido [...]'], max_lines=4) + def test_max_lines_long_word_broken(self): + # A long word broken across lines is broken further on the last + # line to make room for the placeholder, instead of dropping the + # whole remaining piece. + self.check_wrap('x' * 30, 10, + ['xxxxxxxxxx', 'xxxx [...]'], + max_lines=2) + self.check_wrap('x' * 30, 10, + ['xxxx [...]'], + max_lines=1) + # Not enough room for the placeholder plus any text: the + # placeholder-only line is kept. + self.check_wrap('x' * 30, 5, + ['xxxxx', '[...]'], + max_lines=2) + self.check_wrap('x' * 30, 10, + ['xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'], + max_lines=2, break_long_words=False) + class LongWordWithHyphensTestCase(BaseTestCase): def setUp(self): @@ -1132,6 +1151,16 @@ def test_width_too_small_for_placeholder(self): def test_first_word_too_long_but_placeholder_fits(self): self.check_shorten("Helloo", 5, "[...]") + def test_long_word_broken_to_fit_placeholder(self): + # A single word longer than the width is broken further to make + # room for the placeholder, instead of being replaced by it alone. + self.check_shorten("x" * 30, 10, "xxxx [...]") + self.check_shorten("x" * 30, 20, "xxxxxxxxxxxxxx [...]") + # Not enough room for the placeholder plus any text. + self.check_shorten("x" * 30, 5, "[...]") + # Words are only broken when break_long_words is true. + self.check_shorten("x" * 30, 10, "[...]", break_long_words=False) + if __name__ == '__main__': unittest.main() diff --git a/Lib/textwrap.py b/Lib/textwrap.py index 2f213e34c2c329f..018486a9ad8e288 100644 --- a/Lib/textwrap.py +++ b/Lib/textwrap.py @@ -324,6 +324,23 @@ def _wrap_chunks(self, chunks): cur_line.append(self.placeholder) lines.append(indent + ''.join(cur_line)) break + if (self.break_long_words and + len(cur_line) == 1 and + cur_line[-1].strip() and + len(cur_line[-1]) == width and + width - len(self.placeholder) > 0): + # The placeholder does not fit after this line, + # and the line consists of a single full-width + # chunk: a piece of a long word broken by + # _handle_long_word(), or a word exactly as wide + # as the line. Break it further instead of + # dropping it whole, or the last line (and, for + # shorten(), the whole text) would be replaced + # by the placeholder alone. + space_left = width - len(self.placeholder) + cur_line[-1] = cur_line[-1][:space_left] + cur_len = space_left + continue cur_len -= len(cur_line[-1]) del cur_line[-1] else: diff --git a/Misc/NEWS.d/next/Library/2026-10-25-13-24-45.gh-issue-160633.qiyuhuating.rst b/Misc/NEWS.d/next/Library/2026-10-25-13-24-45.gh-issue-160633.qiyuhuating.rst new file mode 100644 index 000000000000000..537dc6428062186 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-25-13-24-45.gh-issue-160633.qiyuhuating.rst @@ -0,0 +1,4 @@ +Fix :func:`textwrap.shorten` and :func:`textwrap.wrap` with ``max_lines`` +discarding a broken long word -- and with it, for :func:`textwrap.shorten`, +all remaining text -- instead of breaking it further to make room for the +placeholder.