From fb90f95eec297c04c5ea4df2ef402946355fae9a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Fri, 24 Oct 2025 07:29:51 +1100 Subject: [PATCH 1/3] Added wrap() --- Tests/test_imagetext.py | 17 +++++++++++ src/PIL/ImageText.py | 66 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 46afea0645a..447c642eaad 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -81,3 +81,20 @@ def test_stroke() -> None: assert_image_similar_tofile( im, "Tests/images/imagedraw_stroke_" + suffix + ".png", 3.1 ) + + +def test_wrap() -> None: + # No wrap required + text = ImageText.Text("Hello World!") + text.wrap(100) + assert text.text == "Hello World!" + + # Wrap word to a new line + text = ImageText.Text("Hello World!") + text.wrap(50) + assert text.text == "Hello\nWorld!" + + # Split word across lines + text = ImageText.Text("Hello World!") + text.wrap(25) + assert text.text == "Hello\nWorl\nd!" diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index e6ccd824332..34c0336c8be 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import cast + from . import ImageFont from ._typing import _Ink @@ -88,6 +90,70 @@ def _get_fontmode(self) -> str: else: return "L" + def wrap(self, width: int) -> None: + str_type = isinstance(self.text, str) + wrapped_lines = [] + emptystring = "" if str_type else b"" + fontmode = self._get_fontmode() + for line in self.text.splitlines(): + wrapped_line = emptystring + words = line.split() + while words: + word = words[0] + + new_wrapped_line: str | bytes + if wrapped_line: + if str_type: + new_wrapped_line = ( + cast(str, wrapped_line) + " " + cast(str, word) + ) + else: + new_wrapped_line = ( + cast(bytes, wrapped_line) + b" " + cast(bytes, word) + ) + else: + new_wrapped_line = word + + def get_width(text) -> float: + left, _, right, _ = self.font.getbbox( + text, + fontmode, + self.direction, + self.features, + self.language, + self.stroke_width, + ) + return right - left + + if get_width(new_wrapped_line) > width: + if wrapped_line: + wrapped_lines.append(wrapped_line) + wrapped_line = emptystring + else: + # This word is too long for a single line, so split the word + characters = word + i = len(characters) + while i > 1 and get_width(characters[:i]) > width: + i -= 1 + wrapped_line = characters[:i] + if str_type: + cast(list[str], words)[0] = cast(str, characters[i:]) + else: + cast(list[bytes], words)[0] = cast(bytes, characters[i:]) + else: + words.pop(0) + wrapped_line = new_wrapped_line + if wrapped_line: + wrapped_lines.append(wrapped_line) + if str_type: + self.text = "\n".join( + [line for line in wrapped_lines if isinstance(line, str)] + ) + else: + self.text = b"\n".join( + [line for line in wrapped_lines if isinstance(line, bytes)] + ) + def get_length(self) -> float: """ Returns length (in pixels with 1/64 precision) of text. From e5fc2d3203daa514bbe3f9bad806a74baf1a2e25 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 1 Nov 2025 00:00:50 +1100 Subject: [PATCH 2/3] Added height argument to wrap() --- Tests/test_imagetext.py | 40 ++++--- src/PIL/ImageDraw.py | 25 ++--- src/PIL/ImageText.py | 235 +++++++++++++++++++++++++++------------- 3 files changed, 196 insertions(+), 104 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 447c642eaad..320820590d5 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -83,18 +83,28 @@ def test_stroke() -> None: ) -def test_wrap() -> None: - # No wrap required - text = ImageText.Text("Hello World!") - text.wrap(100) - assert text.text == "Hello World!" - - # Wrap word to a new line - text = ImageText.Text("Hello World!") - text.wrap(50) - assert text.text == "Hello\nWorld!" - - # Split word across lines - text = ImageText.Text("Hello World!") - text.wrap(25) - assert text.text == "Hello\nWorl\nd!" +@pytest.mark.parametrize( + "text, 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"), + ), +) +@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_height() -> None: + text = ImageText.Text("Text does not fit within height") + assert text.wrap(50, 25).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" + assert text.text == "Text does\nnot fit" diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 8bcf2d8ee06..dfdbb622d04 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -591,49 +591,49 @@ def getink(fill: _Ink | None) -> int: else ink ) - for xy, anchor, line in image_text._split(xy, anchor, align): + for line in image_text._split(xy, anchor, align): def draw_text(ink: int, stroke_width: float = 0) -> None: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" - coord = [] - for i in range(2): - coord.append(int(xy[i])) - start = (math.modf(xy[0])[0], math.modf(xy[1])[0]) + x = int(line.x) + y = int(line.y) + start = (math.modf(line.x)[0], math.modf(line.y)[0]) try: mask, offset = image_text.font.getmask2( # type: ignore[union-attr,misc] - line, + line.text, mode, direction=direction, features=features, language=language, stroke_width=stroke_width, stroke_filled=True, - anchor=anchor, + anchor=line.anchor, ink=ink, start=start, *args, **kwargs, ) - coord = [coord[0] + offset[0], coord[1] + offset[1]] + x += offset[0] + y += offset[1] except AttributeError: try: mask = image_text.font.getmask( # type: ignore[misc] - line, + line.text, mode, direction, features, language, stroke_width, - anchor, + line.anchor, ink, start=start, *args, **kwargs, ) except TypeError: - mask = image_text.font.getmask(line) + mask = image_text.font.getmask(line.text) if mode == "RGBA": # image_text.font.getmask2(mode="RGBA") # returns color in RGB bands and mask in A @@ -641,13 +641,12 @@ def draw_text(ink: int, stroke_width: float = 0) -> None: color, mask = mask, mask.getband(3) ink_alpha = struct.pack("i", ink)[3] color.fillband(3, ink_alpha) - x, y = coord if self.im is not None: self.im.paste( color, (x, y, x + mask.size[0], y + mask.size[1]), mask ) else: - self.draw.draw_bitmap(coord, mask, ink) + self.draw.draw_bitmap((x, y), mask, ink) if stroke_ink is not None: # Draw stroked text diff --git a/src/PIL/ImageText.py b/src/PIL/ImageText.py index 34c0336c8be..7cd5f957e75 100644 --- a/src/PIL/ImageText.py +++ b/src/PIL/ImageText.py @@ -1,11 +1,18 @@ from __future__ import annotations -from typing import cast +from typing import NamedTuple, cast from . import ImageFont from ._typing import _Ink +class _Line(NamedTuple): + x: float + y: float + anchor: str + text: str | bytes + + class Text: def __init__( self, @@ -90,69 +97,140 @@ def _get_fontmode(self) -> str: else: return "L" - def wrap(self, width: int) -> None: - str_type = isinstance(self.text, str) - wrapped_lines = [] - emptystring = "" if str_type else b"" + def wrap( + 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() - for line in self.text.splitlines(): - wrapped_line = emptystring - words = line.split() - while words: - word = words[0] - - new_wrapped_line: str | bytes - if wrapped_line: - if str_type: - new_wrapped_line = ( - cast(str, wrapped_line) + " " + cast(str, word) - ) - else: - new_wrapped_line = ( - cast(bytes, wrapped_line) + b" " + cast(bytes, word) - ) - else: - new_wrapped_line = word - - def get_width(text) -> float: - left, _, right, _ = self.font.getbbox( - text, - fontmode, - self.direction, - self.features, - self.language, - self.stroke_width, - ) - return right - left - if get_width(new_wrapped_line) > width: - if wrapped_line: - wrapped_lines.append(wrapped_line) - wrapped_line = emptystring - else: - # This word is too long for a single line, so split the word - characters = word - i = len(characters) - while i > 1 and get_width(characters[:i]) > width: - i -= 1 - wrapped_line = characters[:i] - if str_type: - cast(list[str], words)[0] = cast(str, characters[i:]) - else: - cast(list[bytes], words)[0] = cast(bytes, characters[i:]) + 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 + + 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) + else: + return a + cast(bytes, b) + + for i in range(len(self.text)): + last_character = i == len(self.text) - 1 + + 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 + + wrapped_lines = lines + remaining_position = i - len(word) + if last_character: + remaining_position += 1 + return True + + 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: - words.pop(0) - wrapped_line = new_wrapped_line - if wrapped_line: - wrapped_lines.append(wrapped_line) - if str_type: - self.text = "\n".join( - [line for line in wrapped_lines if isinstance(line, str)] + # 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: + text = Text( + text=remaining_text, + font=self.font, + mode=self.mode, + spacing=self.spacing, + direction=self.direction, + features=self.features, + language=self.language, ) + text.embedded_color = self.embedded_color + text.stroke_width = self.stroke_width + text.stroke_fill = self.stroke_fill else: - self.text = b"\n".join( - [line for line in wrapped_lines if isinstance(line, bytes)] - ) + 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)) + return text def get_length(self) -> float: """ @@ -212,21 +290,26 @@ def get_length(self) -> float: ) def _split( - self, xy: tuple[float, float], anchor: str | None, align: str - ) -> list[tuple[tuple[float, float], str, str | bytes]]: + self, + xy: tuple[float, float] = (0, 0), + anchor: str | None = None, + align: str = "left", + lines: list[str] | list[bytes] | None = None, + ) -> list[_Line]: if anchor is None: anchor = "lt" if self.direction == "ttb" else "la" elif len(anchor) != 2: msg = "anchor must be a 2 character string" raise ValueError(msg) - lines = ( - self.text.split("\n") - if isinstance(self.text, str) - else self.text.split(b"\n") - ) + if lines is None: + lines = ( + self.text.split("\n") + if isinstance(self.text, str) + else self.text.split(b"\n") + ) if len(lines) == 1: - return [(xy, anchor, self.text)] + return [_Line(xy[0], xy[1], anchor, lines[0])] if anchor[1] in "tb" and self.direction != "ttb": msg = "anchor not supported for multiline text" @@ -251,7 +334,7 @@ def _split( if self.direction == "ttb": left = xy[0] for line in lines: - parts.append(((left, top), anchor, line)) + parts.append(_Line(left, top, anchor, line)) left += line_spacing else: widths = [] @@ -314,7 +397,7 @@ def _split( width_difference = max_width - sum(word_widths) i = 0 for word in words: - parts.append(((left, top), word_anchor, word)) + parts.append(_Line(left, top, word_anchor, word)) left += word_widths[i] + width_difference / (len(words) - 1) i += 1 top += line_spacing @@ -325,7 +408,7 @@ def _split( left -= width_difference / 2.0 elif anchor[0] == "r": left -= width_difference - parts.append(((left, top), anchor, line)) + parts.append(_Line(left, top, anchor, line)) top += line_spacing return parts @@ -356,9 +439,9 @@ def get_bbox( """ bbox: tuple[float, float, float, float] | None = None fontmode = self._get_fontmode() - for xy, anchor, line in self._split(xy, anchor, align): + for x, y, anchor, text in self._split(xy, anchor, align): bbox_line = self.font.getbbox( - line, + text, fontmode, self.direction, self.features, @@ -367,10 +450,10 @@ def get_bbox( anchor, ) bbox_line = ( - bbox_line[0] + xy[0], - bbox_line[1] + xy[1], - bbox_line[2] + xy[0], - bbox_line[3] + xy[1], + bbox_line[0] + x, + bbox_line[1] + y, + bbox_line[2] + x, + bbox_line[3] + y, ) if bbox is None: bbox = bbox_line From d27ce5fd27fb6c9a63bb1d256986d8f973284d95 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 5 Nov 2025 20:30:16 +1100 Subject: [PATCH 3/3] Added scaling argument to wrap() --- Tests/test_imagetext.py | 115 ++++++++++++++-- src/PIL/ImageDraw.py | 2 +- src/PIL/ImageText.py | 290 ++++++++++++++++++++++------------------ 3 files changed, 263 insertions(+), 144 deletions(-) diff --git a/Tests/test_imagetext.py b/Tests/test_imagetext.py index 320820590d5..1d7fe341c44 100644 --- a/Tests/test_imagetext.py +++ b/Tests/test_imagetext.py @@ -2,7 +2,7 @@ import pytest -from PIL import Image, ImageDraw, ImageFont, ImageText +from PIL import Image, ImageDraw, ImageFont, ImageText, features from .helper import assert_image_similar_tofile, skip_unless_feature @@ -84,27 +84,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 dfdbb622d04..07fa43b0687 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -538,7 +538,7 @@ def draw_corners(pieslice: bool) -> None: 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 7cd5f957e75..723ab9f8c1c 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 @@ def __init__( 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 @@ def wrap( 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() - - 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 - - wrapped_line = emptystring - word = emptystring - reached_end = False - remaining_position = 0 + 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 join_text(a: str | bytes, b: str | bytes) -> str | bytes: - if isinstance(a, str): - return a + cast(str, b) + 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) + + if isinstance(scaling, str): + limit = 1 else: - return a + cast(bytes, b) - - for i in range(len(self.text)): - last_character = i == len(self.text) - 1 - - 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 - - wrapped_lines = lines - remaining_position = i - len(word) - if last_character: - remaining_position += 1 - return True - - 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) + scaling, limit = scaling + + font = self.font + wrap = _Wrap(self, width, height, font) + if scaling == "shrink": + if not wrap.remaining_text: + return None + + 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) - remaining_text = self.text[remaining_position:] - if remaining_text: + 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 + + 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 @@ def split_word(): 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 @@ def _split( 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 @@ def get_bbox( :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,