From 287d2185b1d3d5cf0ff8d7e4a09b03f3a07115d4 Mon Sep 17 00:00:00 2001 From: umtong Date: Tue, 27 Oct 2026 04:38:44 +0900 Subject: [PATCH 1/5] Use the scaled font for wrapped text line spacing Measure line positions with the candidate font while fitting text to a height. Cover growth and shrinkage with explicit breaks, blank lines, spacing, stroke, and automatic wrapping. --- Tests/test_imagetext.py | 36 ++++++++++++++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 8 ++++++++ src/PIL/ImageText.py | 10 ++++++---- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 699c4cba544..8fa0d669773 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -231,3 +231,39 @@ def test_wrap_grow() -> None: assert text.wrap(50, 50, ("grow", 18)) is None assert isinstance(text.font, ImageFont.FreeTypeFont) assert text.font.size == 16 + + +@skip_unless_feature("freetype2") +@pytest.mark.parametrize("scaling, size", (("grow", 10), ("shrink", 60))) +@pytest.mark.parametrize( + "data, width, spacing, stroke_width", + ( + ("A\nB", 400, 4, 0), + ("A\n\nB", 400, 6, 2), + ("A B C D", 40, 4, 0), + ), +) +def test_wrap_scaling_height( + layout_engine: ImageFont.Layout, + scaling: str, + size: int, + data: str, + width: int, + spacing: int, + stroke_width: int, +) -> None: + font = ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) + text = ImageText.Text(data, font, spacing=spacing) + text.stroke(stroke_width) + + assert text.wrap(width, 50, scaling) is None + assert text.get_bbox()[2] <= width + assert text.get_bbox()[3] <= 50 + + assert isinstance(text.font, ImageFont.FreeTypeFont) + larger = ImageText.Text( + data, text.font.font_variant(size=text.font.size + 1), spacing=spacing + ) + larger.stroke(stroke_width) + assert larger.wrap(width) is None + assert larger.get_bbox()[3] > 50 diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 5edc0d28693..aac3f568b40 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -49,6 +49,14 @@ TODO Other changes ============= +Scaling multiline text to a height limit +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.ImageText.Text.wrap` now measures line spacing using the scaled +font when growing or shrinking text. Previously, growing multiline text could +exceed the height limit, and shrinking could select a smaller font than needed +or fail to find a font size that fits. + Resizing I;16B and I;16N images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 10297d3f282..42535b0dd26 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -85,7 +85,7 @@ def __init__( def add_line(self, line: AnyStr) -> bool: lines = self.lines + [line] if self.height is not None: - last_line_y = self.text._split(lines=lines)[-1].y + last_line_y = self.text._split(lines=lines, font=self.font)[-1].y last_line_height = self.text._get_bbox(line, self.font)[3] if last_line_y + last_line_height > self.height: return False @@ -331,6 +331,7 @@ def _split( anchor: str | None = None, align: str = "left", lines: list[str] | list[bytes] | None = None, + font: ImageFont.BaseImageFont | None = None, ) -> list[_Line]: if anchor is None: anchor = "lt" if self.direction == "ttb" else "la" @@ -351,9 +352,10 @@ def _split( msg = "anchor not supported for multiline text" raise ValueError(msg) + font = font or self.font fontmode = self._get_fontmode() line_spacing = ( - self.font.getbbox( + font.getbbox( "A", fontmode, None, @@ -376,7 +378,7 @@ def _split( widths = [] max_width: float = 0 for line in lines: - line_width = self.font.getlength( + line_width = font.getlength( line, fontmode, self.direction, self.features, self.language ) widths.append(line_width) @@ -420,7 +422,7 @@ def _split( left -= max_width word_widths = [ - self.font.getlength( + font.getlength( word, fontmode, self.direction, From 9157fd95b0662a29b784164256a35603b96e3588 Mon Sep 17 00:00:00 2001 From: umtong Date: Tue, 27 Oct 2026 23:44:58 +0900 Subject: [PATCH 2/5] Stop wrapping when a blank line exceeds the height --- Tests/test_imagetext.py | 25 +++++++++++++++++++++++++ docs/releasenotes/13.1.0.rst | 3 +++ src/PIL/ImageText.py | 5 +++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 8fa0d669773..a6d965e9400 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import AnyStr + import pytest from PIL import Image, ImageDraw, ImageFont, ImageText, features @@ -170,6 +172,29 @@ def test_wrap_height() -> None: assert text.text == "Text does\nnot fit" +@skip_unless_feature("freetype2") +@pytest.mark.parametrize( + "data", + ( + "A\n\nB", + b"A\n\nB", + "A\n\n\nB", + b"A\n\n\nB", + "A\n \nB", + b"A\n \nB", + ), +) +def test_wrap_height_blank_line(layout_engine: ImageFont.Layout, data: AnyStr) -> None: + font = ImageFont.truetype(FONT_PATH, 10, layout_engine=layout_engine) + text = ImageText.Text(data, font) + + wrapped = text.wrap(100, int(font.getbbox("A")[3])) + + assert text.text == data[:1] + assert wrapped is not None + assert wrapped.text == data[2:] + + def test_wrap_scaling_unsupported() -> None: font = ImageFont.load_default_imagefont() text = ImageText.Text("Hello World!", font) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index aac3f568b40..fcf2950bb61 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -57,6 +57,9 @@ font when growing or shrinking text. Previously, growing multiline text could exceed the height limit, and shrinking could select a smaller font than needed or fail to find a font size that fits. +Wrapping also stops at a blank line that exceeds the height limit, preserving the +remaining text. This avoids ``RuntimeError: raqm_layout() failed`` when using RAQM. + Resizing I;16B and I;16N images ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 42535b0dd26..f008fd15801 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -52,7 +52,8 @@ def __init__( break for i, line in enumerate(newlines): if i != 0 and not self.add_line(emptystring): - break + self.remaining_text: AnyStr = input_text[self.position :] + return self.position += len(line) word = word[len(line) :] line = emptystring @@ -80,7 +81,7 @@ def __init__( else: if line: self.add_line(line) - self.remaining_text: AnyStr = input_text[self.position :] + self.remaining_text = input_text[self.position :] def add_line(self, line: AnyStr) -> bool: lines = self.lines + [line] From 0b1274554e45b444b8d7a81389efa1a041cd2971 Mon Sep 17 00:00:00 2001 From: umtong Date: Wed, 28 Oct 2026 00:23:43 +0900 Subject: [PATCH 3/5] Fix release note heading underline --- docs/releasenotes/13.1.0.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index fcf2950bb61..09303398b7c 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -50,7 +50,7 @@ Other changes ============= Scaling multiline text to a height limit -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:meth:`~PIL.ImageText.Text.wrap` now measures line spacing using the scaled font when growing or shrinking text. Previously, growing multiline text could From 378475b8e3af82253b55f34bb7d74a4aaad66c14 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:46:46 +1100 Subject: [PATCH 4/5] Use if --- src/PIL/ImageText.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index f008fd15801..0918394ec13 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -353,7 +353,8 @@ def _split( msg = "anchor not supported for multiline text" raise ValueError(msg) - font = font or self.font + if font is None: + font = self.font fontmode = self._get_fontmode() line_spacing = ( font.getbbox( From 2800ce03cc267a20da4d9f925a185449d84b8df4 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 30 Oct 2026 15:21:01 +1100 Subject: [PATCH 5/5] Break instead of return --- Tests/test_imagetext.py | 64 +++++------------------------------- docs/releasenotes/13.1.0.rst | 11 ------- src/PIL/ImageText.py | 9 +++-- 3 files changed, 14 insertions(+), 70 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index a6d965e9400..e55299469c2 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import AnyStr - import pytest from PIL import Image, ImageDraw, ImageFont, ImageText, features @@ -172,27 +170,14 @@ def test_wrap_height() -> None: assert text.text == "Text does\nnot fit" -@skip_unless_feature("freetype2") -@pytest.mark.parametrize( - "data", - ( - "A\n\nB", - b"A\n\nB", - "A\n\n\nB", - b"A\n\n\nB", - "A\n \nB", - b"A\n \nB", - ), -) -def test_wrap_height_blank_line(layout_engine: ImageFont.Layout, data: AnyStr) -> None: +def test_wrap_height_blank_line(layout_engine: ImageFont.Layout) -> None: font = ImageFont.truetype(FONT_PATH, 10, layout_engine=layout_engine) - text = ImageText.Text(data, font) - - wrapped = text.wrap(100, int(font.getbbox("A")[3])) + text = ImageText.Text("A\n\nB", font) - assert text.text == data[:1] + wrapped = text.wrap(100, 10) assert wrapped is not None - assert wrapped.text == data[2:] + assert wrapped.text == "\nB" + assert text.text == "A" def test_wrap_scaling_unsupported() -> None: @@ -252,43 +237,10 @@ def test_wrap_grow() -> None: with pytest.raises(ValueError, match="Text could not be scaled"): text.wrap(50, 10, "grow") + text.wrap(50, 30, "grow") + assert text.get_bbox()[3] <= 30 + text = ImageText.Text("Hello World!") assert text.wrap(50, 50, ("grow", 18)) is None assert isinstance(text.font, ImageFont.FreeTypeFont) assert text.font.size == 16 - - -@skip_unless_feature("freetype2") -@pytest.mark.parametrize("scaling, size", (("grow", 10), ("shrink", 60))) -@pytest.mark.parametrize( - "data, width, spacing, stroke_width", - ( - ("A\nB", 400, 4, 0), - ("A\n\nB", 400, 6, 2), - ("A B C D", 40, 4, 0), - ), -) -def test_wrap_scaling_height( - layout_engine: ImageFont.Layout, - scaling: str, - size: int, - data: str, - width: int, - spacing: int, - stroke_width: int, -) -> None: - font = ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) - text = ImageText.Text(data, font, spacing=spacing) - text.stroke(stroke_width) - - assert text.wrap(width, 50, scaling) is None - assert text.get_bbox()[2] <= width - assert text.get_bbox()[3] <= 50 - - assert isinstance(text.font, ImageFont.FreeTypeFont) - larger = ImageText.Text( - data, text.font.font_variant(size=text.font.size + 1), spacing=spacing - ) - larger.stroke(stroke_width) - assert larger.wrap(width) is None - assert larger.get_bbox()[3] > 50 diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 67b623c1492..7782570bba8 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -49,17 +49,6 @@ TODO Other changes ============= -Scaling multiline text to a height limit -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:py:meth:`~PIL.ImageText.Text.wrap` now measures line spacing using the scaled -font when growing or shrinking text. Previously, growing multiline text could -exceed the height limit, and shrinking could select a smaller font than needed -or fail to find a font size that fits. - -Wrapping also stops at a blank line that exceeds the height limit, preserving the -remaining text. This avoids ``RuntimeError: raqm_layout() failed`` when using RAQM. - Writing to read-only images ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 0918394ec13..385491771ce 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -50,12 +50,15 @@ def __init__( if newlines: if not self.add_line(line): break + newlines_all_fit = True for i, line in enumerate(newlines): if i != 0 and not self.add_line(emptystring): - self.remaining_text: AnyStr = input_text[self.position :] - return + newlines_all_fit = False + break self.position += len(line) word = word[len(line) :] + if not newlines_all_fit: + break line = emptystring new_line = line + word @@ -81,7 +84,7 @@ def __init__( else: if line: self.add_line(line) - self.remaining_text = input_text[self.position :] + self.remaining_text: AnyStr = input_text[self.position :] def add_line(self, line: AnyStr) -> bool: lines = self.lines + [line]