Skip to content

Commit

Permalink
Merge pull request python-pillow#8721 from radarhere/justify
Browse files Browse the repository at this point in the history
Added "justify" align for multiline text
  • Loading branch information
radarhere authored Feb 4, 2025
2 parents 92eb11e + 69c9572 commit 2810d7c
Show file tree
Hide file tree
Showing 5 changed files with 146 additions and 107 deletions.
Binary file added Tests/images/multiline_text_justify.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion Tests/test_imagefont.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ def test_render_multiline_text(font: ImageFont.FreeTypeFont) -> None:


@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(
font: ImageFont.FreeTypeFont, align: str, ext: str
Expand Down
28 changes: 20 additions & 8 deletions docs/reference/ImageDraw.rst
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,11 @@ Methods
the number of pixels between lines.
:param align: If the text is passed on to
:py:meth:`~PIL.ImageDraw.ImageDraw.multiline_text`,
``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines.
Use the ``anchor`` parameter to specify the alignment to ``xy``.
``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
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
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
Expand Down Expand Up @@ -455,8 +458,11 @@ Methods
of Pillow, but implemented only in version 8.0.0.

:param spacing: The number of pixels between lines.
:param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines.
Use the ``anchor`` parameter to specify the alignment to ``xy``.
:param align: ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
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
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
Expand Down Expand Up @@ -599,8 +605,11 @@ Methods
the number of pixels between lines.
:param align: If the text is passed on to
:py:meth:`~PIL.ImageDraw.ImageDraw.multiline_textbbox`,
``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines.
Use the ``anchor`` parameter to specify the alignment to ``xy``.
``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
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
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
Expand Down Expand Up @@ -650,8 +659,11 @@ Methods
vertical text. See :ref:`text-anchors` for details.
This parameter is ignored for non-TrueType fonts.
:param spacing: The number of pixels between lines.
:param align: ``"left"``, ``"center"`` or ``"right"``. Determines the relative alignment of lines.
Use the ``anchor`` parameter to specify the alignment to ``xy``.
:param align: ``"left"``, ``"center"``, ``"right"`` or ``"justify"``. Determines
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
left), ``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
Requires libraqm.
Expand Down
12 changes: 12 additions & 0 deletions docs/releasenotes/11.2.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ TODO
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
^^^^^^^^^^^^^^^^^

Expand Down
210 changes: 112 additions & 98 deletions src/PIL/ImageDraw.py
Original file line number Diff line number Diff line change
Expand Up @@ -557,21 +557,6 @@ def _multiline_check(self, text: AnyStr) -> bool:

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(
self,
xy: tuple[float, float],
Expand Down Expand Up @@ -699,29 +684,30 @@ def draw_text(ink: int, stroke_width: float = 0) -> None:
# Only draw normal text
draw_text(ink)

def multiline_text(
def _prepare_multiline_text(
self,
xy: tuple[float, float],
text: AnyStr,
fill: _Ink | None = None,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor: str | None = None,
spacing: float = 4,
align: str = "left",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
stroke_fill: _Ink | None = None,
embedded_color: bool = False,
*,
font_size: float | None = None,
) -> 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)
Expand All @@ -740,11 +726,21 @@ def multiline_text(

widths = []
max_width: float = 0
lines = self._multiline_split(text)
line_spacing = self._multiline_spacing(font, spacing, stroke_width)
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
line,
font,
direction=direction,
features=features,
language=language,
embedded_color=embedded_color,
)
widths.append(line_width)
max_width = max(max_width, line_width)
Expand All @@ -755,6 +751,7 @@ def multiline_text(
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]
Expand All @@ -766,18 +763,81 @@ def multiline_text(
left -= width_difference

# then align by align parameter
if align == "left":
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" or "right"'
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(
self,
xy: tuple[float, float],
text: AnyStr,
fill: _Ink | None = None,
font: (
ImageFont.ImageFont
| ImageFont.FreeTypeFont
| ImageFont.TransposedFont
| None
) = None,
anchor: str | None = None,
spacing: float = 4,
align: str = "left",
direction: str | None = None,
features: list[str] | None = None,
language: str | None = None,
stroke_width: float = 0,
stroke_fill: _Ink | None = None,
embedded_color: bool = False,
*,
font_size: float | None = None,
) -> None:
font, anchor, lines = self._prepare_multiline_text(
xy,
text,
font,
anchor,
spacing,
align,
direction,
features,
language,
stroke_width,
embedded_color,
font_size,
)

for xy, line in lines:
self.text(
(left, top),
xy,
line,
fill,
font,
Expand All @@ -789,7 +849,6 @@ def multiline_text(
stroke_fill=stroke_fill,
embedded_color=embedded_color,
)
top += line_spacing

def textlength(
self,
Expand Down Expand Up @@ -891,69 +950,26 @@ def multiline_textbbox(
*,
font_size: float | None = None,
) -> tuple[float, float, float, float]:
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 = 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
font, anchor, lines = self._prepare_multiline_text(
xy,
text,
font,
anchor,
spacing,
align,
direction,
features,
language,
stroke_width,
embedded_color,
font_size,
)

bbox: tuple[float, float, float, float] | None = None

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:
bbox_line = self.textbbox(
(left, top),
xy,
line,
font,
anchor,
Expand All @@ -973,8 +989,6 @@ def multiline_textbbox(
max(bbox[3], bbox_line[3]),
)

top += line_spacing

if bbox is None:
return xy[0], xy[1], xy[0], xy[1]
return bbox
Expand Down

0 comments on commit 2810d7c

Please sign in to comment.