From 4b2d4811e18516469793210e41080d21bf5d33c6 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Nov 2025 20:30:16 +1100 Subject: [PATCH] Added scaling argument to wrap() --- Tests/test_imagetext.py | 113 ++++++++++++++-- src/PIL/ImageDraw.py | 2 +- src/PIL/ImageText.py | 282 ++++++++++++++++++++++------------------ 3 files changed, 258 insertions(+), 139 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 9b3c711f4..507d82409 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -111,27 +111,120 @@ def test_stroke() -> None: @pytest.mark.parametrize( - "text, width, expected", + "data, width, expected", ( ("Hello World!", 100, "Hello World!"), # No wrap required ("Hello World!", 50, "Hello\nWorld!"), # Wrap word to a new line - ("Hello World!", 25, "Hello\nWorl\nd!"), # Split word across lines # Keep multiple spaces within a line - ("Keep multiple spaces", 75, "Keep multiple\nspaces"), + ("Keep multiple spaces", 90, "Keep multiple\nspaces"), + (" Keep\n leading space", 100, " Keep\n leading space"), ), ) @pytest.mark.parametrize("string", (True, False)) -def test_wrap(text: str, width: int, expected: str, string: bool) -> None: - text = ImageText.Text(text if string else text.encode()) - assert text.wrap(width) is None - assert text.text == expected if string else expected.encode() +def test_wrap(data: str, width: int, expected: str, string: bool) -> None: + if string: + text = ImageText.Text(data) + assert text.wrap(width) is None + assert text.text == expected + else: + text_bytes = ImageText.Text(data.encode()) + assert text_bytes.wrap(width) is None + assert text_bytes.text == expected.encode() + + +def test_wrap_long_word() -> None: + text = ImageText.Text("Hello World!") + with pytest.raises(ValueError, match="Word does not fit within line"): + text.wrap(25) + + +def test_wrap_unsupported(font: ImageFont.FreeTypeFont) -> None: + transposed_font = ImageFont.TransposedFont(font) + text = ImageText.Text("Hello World!", transposed_font) + with pytest.raises(ValueError, match="TransposedFont not supported"): + text.wrap(50) + + text = ImageText.Text("Hello World!", direction="ttb") + with pytest.raises(ValueError, match="Only ltr direction supported"): + text.wrap(50) def test_wrap_height() -> None: + width = 50 if features.check_module("freetype2") else 60 text = ImageText.Text("Text does not fit within height") - assert text.wrap(50, 25).text == " within height" + wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40) + assert wrapped is not None + assert wrapped.text == " within height" assert text.text == "Text does\nnot fit" - text = ImageText.Text("Text does not fit singlelongword") - assert text.wrap(50, 25).text == " singlelongword" + text = ImageText.Text("Text does not fit\nwithin height") + wrapped = text.wrap(width, 20) + assert wrapped is not None + assert wrapped.text == " not fit\nwithin height" + assert text.text == "Text does" + + text = ImageText.Text("Text does not fit\n\nwithin height") + wrapped = text.wrap(width, 25 if features.check_module("freetype2") else 40) + assert wrapped is not None + assert wrapped.text == "\nwithin height" assert text.text == "Text does\nnot fit" + + +def test_wrap_scaling_unsupported() -> None: + font = ImageFont.load_default_imagefont() + text = ImageText.Text("Hello World!", font) + with pytest.raises(ValueError, match="'scaling' only supports FreeTypeFont"): + text.wrap(50, scaling="shrink") + + if features.check_module("freetype2"): + text = ImageText.Text("Hello World!") + with pytest.raises(ValueError, match="'scaling' requires 'height'"): + text.wrap(50, scaling="shrink") + + +@skip_unless_feature("freetype2") +def test_wrap_shrink() -> None: + # No scaling required + text = ImageText.Text("Hello World!") + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + assert text.wrap(50, 50, "shrink") is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + + with pytest.raises(ValueError, match="Text could not be scaled"): + text.wrap(50, 15, ("shrink", 9)) + + assert text.wrap(50, 15, "shrink") is None + assert text.font.size == 8 + + text = ImageText.Text("Hello World!") + assert text.wrap(50, 15, ("shrink", 7)) is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 8 + + +@skip_unless_feature("freetype2") +def test_wrap_grow() -> None: + # No scaling required + text = ImageText.Text("Hello World!") + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + assert text.wrap(58, 10, "grow") is None + assert isinstance(text.font, ImageFont.FreeTypeFont) + assert text.font.size == 10 + + with pytest.raises(ValueError, match="Text could not be scaled"): + text.wrap(50, 50, ("grow", 12)) + + assert text.wrap(50, 50, "grow") is None + assert text.font.size == 16 + + text = ImageText.Text("A\nB") + with pytest.raises(ValueError, match="Text could not be scaled"): + text.wrap(50, 10, "grow") + + 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 diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index dfdbb622d..07fa43b06 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -538,7 +538,7 @@ class ImageDraw: def text( self, xy: tuple[float, float], - text: AnyStr | ImageText.Text, + text: AnyStr | ImageText.Text[AnyStr], fill: _Ink | None = None, font: ( ImageFont.ImageFont diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 7cd5f957e..723ab9f8c 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -1,10 +1,14 @@ from __future__ import annotations -from typing import NamedTuple, cast +import math +import re +from typing import AnyStr, Generic, NamedTuple from . import ImageFont from ._typing import _Ink +Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont + class _Line(NamedTuple): x: float @@ -13,16 +17,87 @@ class _Line(NamedTuple): text: str | bytes -class Text: +class _Wrap(Generic[AnyStr]): + lines: list[AnyStr] = [] + position = 0 + offset = 0 + def __init__( self, - text: str | bytes, - font: ( - ImageFont.ImageFont - | ImageFont.FreeTypeFont - | ImageFont.TransposedFont - | None - ) = None, + text: Text[AnyStr], + width: int, + height: int | None = None, + font: Font | None = None, + ) -> None: + self.text: Text[AnyStr] = text + self.width = width + self.height = height + self.font = font + + input_text = self.text.text + emptystring = "" if isinstance(input_text, str) else b"" + line = emptystring + + for word in re.findall( + r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text + ): + newlines = re.findall( + r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word + ) + if newlines: + if not self.add_line(line): + break + for i, line in enumerate(newlines): + if i != 0 and not self.add_line(emptystring): + break + self.position += len(line) + word = word[len(line) :] + line = emptystring + + new_line = line + word + if self.text._get_bbox(new_line, self.font)[2] <= width: + # This word fits on the line + line = new_line + continue + + # This word does not fit on the line + if line and not self.add_line(line): + break + + original_length = len(word) + word = word.lstrip() + self.offset = original_length - len(word) + + if self.text._get_bbox(word, self.font)[2] > width: + if font is None: + msg = "Word does not fit within line" + raise ValueError(msg) + break + line = word + else: + if line: + self.add_line(line) + self.remaining_text: AnyStr = input_text[self.position :] + + 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_height = self.text._get_bbox(line, self.font)[3] + if last_line_y + last_line_height > self.height: + return False + + self.lines = lines + self.position += len(line) + self.offset + self.offset = 0 + return True + + +class Text(Generic[AnyStr]): + def __init__( + self, + text: AnyStr, + font: Font | None = None, mode: str = "RGB", spacing: float = 4, direction: str | None = None, @@ -56,7 +131,7 @@ class Text: It should be a `BCP 47 language code`_. Requires libraqm. """ - self.text = text + self.text: AnyStr = text self.font = font or ImageFont.load_default() self.mode = mode @@ -101,118 +176,67 @@ class Text: self, width: int, height: int | None = None, - ) -> Text | None: - wrapped_lines: list[str] | list[bytes] = [] - emptystring = "" if isinstance(self.text, str) else b"" - newline = "\n" if isinstance(self.text, str) else b"\n" - fontmode = self._get_fontmode() + scaling: str | tuple[str, int] | None = None, + ) -> Text[AnyStr] | None: + if isinstance(self.font, ImageFont.TransposedFont): + msg = "TransposedFont not supported" + raise ValueError(msg) + if self.direction not in (None, "ltr"): + msg = "Only ltr direction supported" + raise ValueError(msg) - def getbbox(text) -> tuple[float, float]: - _, _, right, bottom = self.font.getbbox( - text, - fontmode, - self.direction, - self.features, - self.language, - self.stroke_width, - ) - return right, bottom + if scaling is None: + wrap = _Wrap(self, width, height) + else: + if not isinstance(self.font, ImageFont.FreeTypeFont): + msg = "'scaling' only supports FreeTypeFont" + raise ValueError(msg) + if height is None: + msg = "'scaling' requires 'height'" + raise ValueError(msg) - wrapped_line = emptystring - word = emptystring - reached_end = False - remaining_position = 0 - - def join_text(a: str | bytes, b: str | bytes) -> str | bytes: - if isinstance(a, str): - return a + cast(str, b) + if isinstance(scaling, str): + limit = 1 else: - return a + cast(bytes, b) + scaling, limit = scaling - for i in range(len(self.text)): - last_character = i == len(self.text) - 1 + font = self.font + wrap = _Wrap(self, width, height, font) + if scaling == "shrink": + if not wrap.remaining_text: + return None - def add_line() -> bool: - nonlocal wrapped_lines, remaining_position - lines = cast( - list[str] | list[bytes], wrapped_lines + [wrapped_line.rstrip()] - ) - if height is not None: - last_line_y = self._split(lines=lines)[-1].y - last_line_height = getbbox(wrapped_line)[1] - if last_line_y + last_line_height > height: - return False + size = math.ceil(font.size) + while wrap.remaining_text: + if size == max(limit, 1): + msg = "Text could not be scaled" + raise ValueError(msg) + size -= 1 + font = self.font.font_variant(size=size) + wrap = _Wrap(self, width, height, font) + self.font = font + else: + if wrap.remaining_text: + msg = "Text could not be scaled" + raise ValueError(msg) - wrapped_lines = lines - remaining_position = i - len(word) - if last_character: - remaining_position += 1 - return True + size = math.floor(font.size) + while not wrap.remaining_text: + if size == limit: + msg = "Text could not be scaled" + raise ValueError(msg) + size += 1 + font = self.font.font_variant(size=size) + last_wrap = wrap + wrap = _Wrap(self, width, height, font) + size -= 1 + if size != self.font.size: + self.font = self.font.font_variant(size=size) + wrap = last_wrap - character = self.text[i : i + 1] - if last_character: - word = join_text(word, character) - character = newline - if character.isspace(): - if not word or word.isspace(): - # Do not use whitespace until a non-whitespace character is reached - # Trimming whitespace from the end of the line - word = join_text(word, character) - else: - # Append the word to the current line - if not wrapped_line: - word = word.lstrip() - new_wrapped_line = join_text(wrapped_line, word) - if getbbox(new_wrapped_line)[0] > width: - - def split_word(): - nonlocal wrapped_line, word, reached_end - # This word is too long for a single line, so split the word - j = len(word) - while j > 1 and getbbox(word[:j])[0] > width: - j -= 1 - wrapped_line = word[:j] - if not add_line(): - reached_end = True - return - word = word[j:] - wrapped_line = word - if getbbox(wrapped_line)[0] > width: - split_word() - - if wrapped_line: - # This word does not fit on the line - if not add_line(): - reached_end = True - break - word = word.lstrip() - if getbbox(word)[0] > width: - split_word() - else: - wrapped_line = word - else: - split_word() - if reached_end: - break - else: - # This word fits on the line - wrapped_line = new_wrapped_line - word = emptystring - - word = emptystring if character == newline else character - - if character == newline: - if not add_line(): - break - wrapped_line = emptystring - elif not character.isspace(): - # Word is not finished yet - word = join_text(word, character) - - remaining_text = self.text[remaining_position:] - if remaining_text: + if wrap.remaining_text: text = Text( - text=remaining_text, + text=wrap.remaining_text, font=self.font, mode=self.mode, spacing=self.spacing, @@ -226,10 +250,8 @@ class Text: else: text = None - if isinstance(self.text, str): - self.text = "\n".join(cast(list[str], wrapped_lines)) - else: - self.text = b"\n".join(cast(list[bytes], wrapped_lines)) + newline = "\n" if isinstance(self.text, str) else b"\n" + self.text = newline.join(wrap.lines) return text def get_length(self) -> float: @@ -413,6 +435,19 @@ class Text: return parts + def _get_bbox( + self, text: str | bytes, font: Font | None = None, anchor: str | None = None + ) -> tuple[float, float, float, float]: + return (font or self.font).getbbox( + text, + self._get_fontmode(), + self.direction, + self.features, + self.language, + self.stroke_width, + anchor, + ) + def get_bbox( self, xy: tuple[float, float] = (0, 0), @@ -438,17 +473,8 @@ class Text: :return: ``(left, top, right, bottom)`` bounding box """ bbox: tuple[float, float, float, float] | None = None - fontmode = self._get_fontmode() for x, y, anchor, text in self._split(xy, anchor, align): - bbox_line = self.font.getbbox( - text, - fontmode, - self.direction, - self.features, - self.language, - self.stroke_width, - anchor, - ) + bbox_line = self._get_bbox(text, anchor=anchor) bbox_line = ( bbox_line[0] + x, bbox_line[1] + y,