From 287d2185b1d3d5cf0ff8d7e4a09b03f3a07115d4 Mon Sep 17 00:00:00 2001 From: umtong Date: Tue, 27 Oct 2026 04:38:44 +0900 Subject: [PATCH 1/3] 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/3] 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/3] 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