DEV Community

Cover image for Wrap and Render Multiline Text on Images Using Python's Pillow Library
Edun Rilwan
Edun Rilwan

Posted on

Wrap and Render Multiline Text on Images Using Python's Pillow Library

Python takes the lead as a top programming language used for image manipulation. This high-level programming language boasts a vast collection of open-source libraries used in image manipulation and processing. One of such libraries is Pillow.

Pillow is an image processing library in Python commonly used to resize, crop, brighten, or annotate an image. It is popular among developers because of its simplicity and extensive documentation.

However, there is a particular problem with adding text annotations or labels on images using Pillow. Texts often do not wrap to the next line when it gets to the end of the text box. Also, there is no function or
module within the Pillow library that allows you to do this. The only choice is to write the logic to achieve this functionality.

This tutorial will show you how to add a text box where the text wraps to the next line automatically when it gets to the end of the box using Pillow in Python. This functionality will allow you to add text annotations or labels correctly to your images. Below is a final look of what you will build by the end of this tutorial:

Screenshot of annotated Dev.to profile

The image above is a screenshot of my Dev.to profile which will be used throughout this tutorial. The green text box is the text annotation

Requirements

This tutorial requires you to have a knowledge of basic Python concepts such as conditional statements(if, else), for loops, etc. You also need the following tools and software:

  1. Python3+: A python interpreter for running Python scripts
  2. Pillow: An image manipulation library in Python.
  3. A code editor: A code editor like Pycharm, VScode, etc. to write code.

Create a new project

Follow the steps below to create a new project:

A. Create a new folder using the terminal/command line with the command below:

mkdir image_annotation
Enter fullscreen mode Exit fullscreen mode

B. Install virtualenv with pip:
Run the command below to install virtualenv (skip the process if you have it installed):

pip install virtualenv
Enter fullscreen mode Exit fullscreen mode

C. Change your working folder to the image_annotation folder:

cd image_annotation
Enter fullscreen mode Exit fullscreen mode

D. Create a new virtual environment:

virtualenv env
Enter fullscreen mode Exit fullscreen mode

E. Activate the virtual environment with any of the commands below:
On Windows (Command prompt):

.\env\Scripts\activate
Enter fullscreen mode Exit fullscreen mode

On Linux/macOS:

source env/bin/activate
Enter fullscreen mode Exit fullscreen mode

F. Install the Pillow library with the command below:

pip install pillow
Enter fullscreen mode Exit fullscreen mode

Open the project in a code editor, then create a new python file named script.py in the project folder.

Prepare base image

The image you want to annotate is the base image. Open the image and prepare it for annotation using Pillow's ImageDraw module. Write the code below to the script.py file to prepare the base image:

from PIL import Image, ImageDraw, ImageFont
image_file = "path_to_image"

# Open image
image = Image.open(image_file)

# Initialize ImageDraw
draw = ImageDraw.Draw(image)
Enter fullscreen mode Exit fullscreen mode

Annotate the image

Pillow can be used to add both plain text and a text box with a background fill. Also, the text can either be single line or multiline. This tutorial focuses on how to add a multiline text box to an image.

The ImageDraw.multiline_text() method is used to add a multiline text to an image using Pillow. However, this only adds a plain text without a background fill. The ImageDraw.rectangle() method is used to add a text box with a background fill that a body of text can be written on.

Add the code below to the script.py file to draw a multiline text box on your image:

...

# Set text, font, and max width
text = "This is a user profile on dev.to.\n"\
       "It contains the user name, profile picture, and bio"
font = ImageFont.truetype("arial.ttf", 16)  # Use default font if you don't have this
max_width = 200 # Width for the text box

# Calculate positions
x, y = 780, 300  # Starting position for the text box
end_x, end_y = x + max_width, y + 50 # Ending position for the text box


# Dimensions for the background box
background_box = [(x, y), (end_x, end_y)]

# Draw background box
draw.rectangle(background_box, fill="green")


# Draw multiline text
draw.multiline_text((x, y), text, font=font, fill="black", spacing=6)

image.show()

Enter fullscreen mode Exit fullscreen mode

The code above sets the text, text-font, and width for the text box. Also, the x and y variable represents the point from where the drawing should start, while end_x and end_y represents the bottom-right edge of the box. The width and height of the text box are 200 and 50 respectively.

Finally, the ImageDraw.rectangle() and ImageDraw.multiline_text() method are used to draw the text box and a multiline text on the image respectively. The Image.show() method is used to display the processed image. You can save it instead with: image.save("new_image.png"). Below is the result:

Image description

There are still some errors with the annotation above. The multiline text should automatically wrap to the next line when it gets to the end of the text box. See how to do this in the next section.

Enable automatic text wrapping

The new line (\n) symbol is used to determine when to move the text to a new line. As seen in the previous example, the body of text after the newline (\n) is moved to the next line. However, in a real life use case where the text length is usually dynamic, it can be difficult to determine where and when to add a newline (\n).

Pillow's ImageDraw module has a .textlength() attribute that can be used to calculate the length of a text. The value can then be compared against the width of the text box to determine where to add a
newline (\n).

Create a new function named wrap_text() at the top of the script.py file (immediately after the import statements). It will contain the logic for the automatic text wrapping. Below is the code for this function:

...

def wrap_text(text, font, max_width, draw):
    """
    Wraps text to fit within the max_width.
    """
    words = text.split()
    lines = [] # Holds each line in the text box
    current_line = [] # Holds each word in the current line under evaluation.

    for word in words:
        # Check the width of the current line with the new word added
        test_line = ' '.join(current_line + [word])
        width = draw.textlength(test_line, font=font)
        if width <= max_width:
            current_line.append(word)
        else:
            # If the line is too wide, finalize the current line and start a new one
            lines.append(' '.join(current_line))
            current_line = [word]

    # Add the last line
    if current_line:
        lines.append(' '.join(current_line))

    return lines

...

Enter fullscreen mode Exit fullscreen mode

In the wrap_text() function, the body of text is split into its individual words. The length of the first word is checked against the text-box's width. If the word length is lesser, it is added to
the current_line list. Subsequently, each word is added to this list, combined with the existing ones, then everything is checked against the text-box's width.

If a new word causes the entire text length to be greater than the box's width, the word is moved to the next line. The same logic is applied to the next line until the entire body of text is displayed on screen.

Finally, the lines list is returned after the last line has been added. Add the code below immediately after the text, font, and max_width variables:

...
# Wrap text into lines
wrapped_lines = wrap_text(text, font, max_width, draw)
...
Enter fullscreen mode Exit fullscreen mode

Where text is the body of text to be added, font is the text font, max width is the box's width, and draw is an instance of the ImageDraw module.

Replace the draw.multiline_text() method with the code below:

...

description = ""
for line in wrapped_lines:
    description += line + "\n"


# Draw multiline text.
draw.multiline_text((x, y), description, font=font, fill="white", spacing=6)

...
Enter fullscreen mode Exit fullscreen mode

Remove the new line* (\n)* symbol from the text and run the code:

text = "This is a user profile on dev.to. It contains the user name, profile picture, and bio"
Enter fullscreen mode Exit fullscreen mode

The result of this code will be a text annotation that still overflows the box's height. While the text can now automatically adjust with the text box's width, the box's height remains fixed which causes the text to overflow outside the box.

Set up a dynamic box height

A dynamic box height is one that is determined by the number of lines occupied by the text. The first step is to change the box's end_y variable to a dynamic value as seen in the code below:

...
end_x, end_y = x + max_width, y + (24 * int(len(wrapped_lines))) # Ending position for the text box
...
Enter fullscreen mode Exit fullscreen mode

The equation - y + (24 * int(len(wrapped_lines))) was arrived at after a couple of trials. It appeared to be the best solution to get a dynamic box height for this use case. The wrapped_lines list contains all the lines to be added to the text box, therefore, the length of this list equals the total lines of text for the text box.

Below is the result:

Image description

You may need to multiply the total lines by different values to get a perfect solution for your use case.

NOTE: Difference in spacing, font size, etc. affects the equation. For example, this tutorial uses a font size of 16. If your project uses a different font-size, you will need to tweak the values used in the equation to get a dynamic box height that fits your use case.

Add text padding

Also, the text is too close to the box's corners affecting its readability and style. This can be corrected by padding the text within the box. Add a new padding variable to the script.py file and change the text box dimension. Here is the code below:

...
padding = 10

background_box = [(x - padding, y - padding), (end_x + padding, end_y + padding)]
...
Enter fullscreen mode Exit fullscreen mode

This code allows for spacing between the text and the box's corners as seen in the image below:

Image description

Add a pointer

A pointer makes it easy to determine which part of the image the annotation/label is meant for. The pointer should come before the annotation. This means the pointer will be drawn on the text box's current position, while the text box will shift to the right.

Therefore, the x axis for the text box will be tied to a new box_x variable. This change must also reflect in other variables where the box's x axis is used. Here is the updated code below:

# Calculate positions
x, y = 780, 300  # Pointer position
box_x = 780 + 30 # Box starting x position
padding = 10 # Text padding
end_x, end_y = box_x + max_width, y + (24 * int(len(wrapped_lines))) # Ending position for the text box


# Calculate total height for the background
background_box = [(box_x - padding, y - padding), (end_x + padding, end_y + padding)]


# Draw Pointer
draw.circle((x, y), 10, fill="green")

# Draw background rectangle
draw.rectangle(background_box, fill="green")

description = ""
for line in wrapped_lines:
    description += line + "\n"


draw.multiline_text((box_x, y), description, font=font, fill="white", spacing=6)

image.show()
Enter fullscreen mode Exit fullscreen mode

In the code above, the ImageDraw.circle() method, where 10 is the radius, is used to draw the pointer at the specified point. The box_x variable is the new value for the box's x axis.

Below is the final code for the script.py file:

from PIL import Image, ImageDraw, ImageFont


def wrap_text(text, font, max_width, draw):
    """
    Wraps text to fit within the max_width.
    """
    words = text.split()
    lines = [] # Holds each line in the text box
    current_line = [] # Holds the current line under evaluation.

    for word in words:
        # Check the width of the current line with the new word added
        test_line = ' '.join(current_line + [word])
        width = draw.textlength(test_line, font=font)
        if width <= max_width:
            current_line.append(word)
        else:
            # If the line is too wide, finalize the current line and start a new one
            lines.append(' '.join(current_line))
            current_line = [word]

    # Add the last line
    if current_line:
        lines.append(' '.join(current_line))

    return lines



image = Image.open("practise.png")
draw = ImageDraw.Draw(image)


# Set text, font, and max width
text = "This is a user profile on dev.to. It contains the user name, profile picture, and bio"
font = ImageFont.truetype("arial.ttf", 16)  # Use default font if you don't have this
max_width = 200

wrapped_lines = wrap_text(text, font, max_width, draw)

# Calculate positions
x, y = 780, 300  # Pointer position
box_x = 780 + 30 # Box starting x position
padding = 10 # Text padding
end_x, end_y = box_x + max_width, y + (24 * int(len(wrapped_lines))) # Ending position for the text box


# Calculate total height for the background
background_box = [(box_x - padding, y - padding), (end_x + padding, end_y + padding)]


# Draw Pointer
draw.circle((x, y), 10, fill="green")

# Draw background rectangle
draw.rectangle(background_box, fill="green")

description = ""
for line in wrapped_lines:
    description += line + "\n"


draw.multiline_text((box_x, y), description, font=font, fill="white", spacing=6)

image.show()
Enter fullscreen mode Exit fullscreen mode

Conclusion

Image manipulation is not always as difficult as it may seem. While some image manipulation libraries cannot directly solve your problem with their modules, you can implement a specific solution for your use case using the available modules. That is the beauty of coding - The ability to solve problems with custom and specific solutions.

In this tutorial, you learnt how to use Python's Pillow library to annotate an image, add a multiline text box that automatically wraps to the next line, etc. You also learnt how to write mathematical equations that can help you with image manipulation.

Refer to the Pillow documentation for detailed explanation on each module used.

Top comments (0)