Merge pull request #8721 from radarhere/justify

Added "justify" align for multiline text
This commit is contained in:
Andrew Murray 2025-02-04 20:10:39 +11:00 committed by GitHub
commit 2810d7c6ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 179 additions and 140 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -254,7 +254,8 @@ def test_render_multiline_text(font: ImageFont.FreeTypeFont) -> None:
@pytest.mark.parametrize( @pytest.mark.parametrize(
"align, ext", (("left", ""), ("center", "_center"), ("right", "_right")) "align, ext",
(("left", ""), ("center", "_center"), ("right", "_right"), ("justify", "_justify")),
) )
def test_render_multiline_text_align( def test_render_multiline_text_align(
font: ImageFont.FreeTypeFont, align: str, ext: str font: ImageFont.FreeTypeFont, align: str, ext: str

View File

@ -387,8 +387,11 @@ Methods
the number of pixels between lines. the number of pixels between lines.
:param align: If the text is passed on to :param align: If the text is passed on to
:py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`, :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`,
``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
Use the ``anchor`` parameter to specify the alignment to ``xy``. the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to :param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm. Requires libraqm.
@ -455,8 +458,11 @@ Methods
of Pillow, but implemented only in version 8.0.0. of Pillow, but implemented only in version 8.0.0.
:param spacing: The number of pixels between lines. :param spacing: The number of pixels between lines.
:param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. :param align: ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
Use the ``anchor`` parameter to specify the alignment to ``xy``. the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to :param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm. Requires libraqm.
@ -599,8 +605,11 @@ Methods
the number of pixels between lines. the number of pixels between lines.
:param align: If the text is passed on to :param align: If the text is passed on to
:py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`, :py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`,
``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
Use the ``anchor`` parameter to specify the alignment to ``xy``. the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to :param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm. Requires libraqm.
@ -650,8 +659,11 @@ Methods
vertical text. See :ref:`text-anchors` for details. vertical text. See :ref:`text-anchors` for details.
This parameter is ignored for non-TrueType fonts. This parameter is ignored for non-TrueType fonts.
:param spacing: The number of pixels between lines. :param spacing: The number of pixels between lines.
:param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines. :param align: ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
Use the ``anchor`` parameter to specify the alignment to ``xy``. the relative alignment of lines. Use the ``anchor`` parameter to
specify the alignment to ``xy``.
.. versionadded:: 11.2.0 ``"justify"``
:param direction: Direction of the text. It can be ``"rtl"`` (right to :param direction: Direction of the text. It can be ``"rtl"`` (right to
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom). left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm. Requires libraqm.

View File

@ -44,6 +44,18 @@ TODO
API Additions API Additions
============= =============
``"justify"`` multiline text alignment
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
In addition to ``"left"``, ``"center"`` and ``"right"``, multiline text can also be
aligned using ``"justify"`` in :py:mod:`~PIL.ImageDraw`::
from PIL import Image, ImageDraw
im = Image.new("RGB", (50, 25))
draw = ImageDraw.Draw(im)
draw.multiline_text((0, 0), "Multiline\ntext 1", align="justify")
draw.multiline_textbbox((0, 0), "Multiline\ntext 2", align="justify")
Check for MozJPEG Check for MozJPEG
^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^

View File

@ -557,21 +557,6 @@ class ImageDraw:
return split_character in text return split_character in text
def _multiline_split(self, text: AnyStr) -> list[AnyStr]:
return text.split("\n" if isinstance(text, str) else b"\n")
def _multiline_spacing(
self,
font: ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
spacing: float,
stroke_width: float,
) -> float:
return (
self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3]
+ stroke_width
+ spacing
)
def text( def text(
self, self,
xy: tuple[float, float], xy: tuple[float, float],
@ -699,6 +684,119 @@ class ImageDraw:
# Only draw normal text # Only draw normal text
draw_text(ink) draw_text(ink)
def _prepare_multiline_text(
self,
xy: tuple[float, float],
text: AnyStr,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
),
anchor: str | None,
spacing: float,
align: str,
direction: str | None,
features: list[str] | None,
language: str | None,
stroke_width: float,
embedded_color: bool,
font_size: float | None,
) -> tuple[
ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont,
str,
list[tuple[tuple[float, float], AnyStr]],
]:
if direction == "ttb":
msg = "ttb direction is unsupported for multiline text"
raise ValueError(msg)
if anchor is None:
anchor = "la"
elif len(anchor) != 2:
msg = "anchor must be a 2 character string"
raise ValueError(msg)
elif anchor[1] in "tb":
msg = "anchor not supported for multiline text"
raise ValueError(msg)
if font is None:
font = self._getfont(font_size)
widths = []
max_width: float = 0
lines = text.split("\n" if isinstance(text, str) else b"\n")
line_spacing = (
self.textbbox((0, 0), "A", font, stroke_width=stroke_width)[3]
+ stroke_width
+ spacing
)
for line in lines:
line_width = self.textlength(
line,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
widths.append(line_width)
max_width = max(max_width, line_width)
top = xy[1]
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
parts = []
for idx, line in enumerate(lines):
left = xy[0]
width_difference = max_width - widths[idx]
# first align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
# then align by align parameter
if align in ("left", "justify"):
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center", "right" or "justify"'
raise ValueError(msg)
if align == "justify" and width_difference != 0:
words = line.split(" " if isinstance(text, str) else b" ")
word_widths = [
self.textlength(
word,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
for word in words
]
width_difference = max_width - sum(word_widths)
for i, word in enumerate(words):
parts.append(((left, top), word))
left += word_widths[i] + width_difference / (len(words) - 1)
else:
parts.append(((left, top), line))
top += line_spacing
return font, anchor, parts
def multiline_text( def multiline_text(
self, self,
xy: tuple[float, float], xy: tuple[float, float],
@ -722,62 +820,24 @@ class ImageDraw:
*, *,
font_size: float | None = None, font_size: float | None = None,
) -> None: ) -> None:
if direction == "ttb": font, anchor, lines = self._prepare_multiline_text(
msg = "ttb direction is unsupported for multiline text" xy,
raise ValueError(msg) text,
font,
if anchor is None: anchor,
anchor = "la" spacing,
elif len(anchor) != 2: align,
msg = "anchor must be a 2 character string" direction,
raise ValueError(msg) features,
elif anchor[1] in "tb": language,
msg = "anchor not supported for multiline text" stroke_width,
raise ValueError(msg) embedded_color,
font_size,
if font is None: )
font = self._getfont(font_size)
widths = []
max_width: float = 0
lines = self._multiline_split(text)
line_spacing = self._multiline_spacing(font, spacing, stroke_width)
for line in lines:
line_width = self.textlength(
line, font, direction=direction, features=features, language=language
)
widths.append(line_width)
max_width = max(max_width, line_width)
top = xy[1]
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
for idx, line in enumerate(lines):
left = xy[0]
width_difference = max_width - widths[idx]
# first align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
# then align by align parameter
if align == "left":
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center" or "right"'
raise ValueError(msg)
for xy, line in lines:
self.text( self.text(
(left, top), xy,
line, line,
fill, fill,
font, font,
@ -789,7 +849,6 @@ class ImageDraw:
stroke_fill=stroke_fill, stroke_fill=stroke_fill,
embedded_color=embedded_color, embedded_color=embedded_color,
) )
top += line_spacing
def textlength( def textlength(
self, self,
@ -891,69 +950,26 @@ class ImageDraw:
*, *,
font_size: float | None = None, font_size: float | None = None,
) -> tuple[float, float, float, float]: ) -> tuple[float, float, float, float]:
if direction == "ttb": font, anchor, lines = self._prepare_multiline_text(
msg = "ttb direction is unsupported for multiline text" xy,
raise ValueError(msg) text,
font,
if anchor is None: anchor,
anchor = "la" spacing,
elif len(anchor) != 2: align,
msg = "anchor must be a 2 character string" direction,
raise ValueError(msg) features,
elif anchor[1] in "tb": language,
msg = "anchor not supported for multiline text" stroke_width,
raise ValueError(msg) embedded_color,
font_size,
if font is None: )
font = self._getfont(font_size)
widths = []
max_width: float = 0
lines = self._multiline_split(text)
line_spacing = self._multiline_spacing(font, spacing, stroke_width)
for line in lines:
line_width = self.textlength(
line,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
widths.append(line_width)
max_width = max(max_width, line_width)
top = xy[1]
if anchor[1] == "m":
top -= (len(lines) - 1) * line_spacing / 2.0
elif anchor[1] == "d":
top -= (len(lines) - 1) * line_spacing
bbox: tuple[float, float, float, float] | None = None bbox: tuple[float, float, float, float] | None = None
for idx, line in enumerate(lines): for xy, line in lines:
left = xy[0]
width_difference = max_width - widths[idx]
# first align left by anchor
if anchor[0] == "m":
left -= width_difference / 2.0
elif anchor[0] == "r":
left -= width_difference
# then align by align parameter
if align == "left":
pass
elif align == "center":
left += width_difference / 2.0
elif align == "right":
left += width_difference
else:
msg = 'align must be "left", "center" or "right"'
raise ValueError(msg)
bbox_line = self.textbbox( bbox_line = self.textbbox(
(left, top), xy,
line, line,
font, font,
anchor, anchor,
@ -973,8 +989,6 @@ class ImageDraw:
max(bbox[3], bbox_line[3]), max(bbox[3], bbox_line[3]),
) )
top += line_spacing
if bbox is None: if bbox is None:
return xy[0], xy[1], xy[0], xy[1] return xy[0], xy[1], xy[0], xy[1]
return bbox return bbox