Fitting Wrapped Text & Custom Fonts to Images in Python

Image processing is a breeze in Python when using the Pillow library. However, getting text to play nicely isn't always an easy task. Learn how to wrap text like a pro and start churning out memes by the thousand in no time flat!
Custom Font Text Wrapping Python overcoded

Python provides a medley of string manipulation tools via the standard library. Combine with the text manipulation tools of imaging libraries like Pillow, Python can be an absolute workhorse for creating text-heavy visual media. Even with these tools, text-wrapping can be a challenge. In this article, we’ll cover how to fit wrapped text to an image using Python and Pillow.

Python offers some excellent text-wrapping utilities via the standard textwrap module. Pillow is a third-party Python imaging library that provides visual text tools—such as loading custom fonts and overlaying text on images. By using both of these toolsets in concert one can manage to overlay wrapped text onto an image and ensure the text is scaled appropriately. The best part? It takes some creative problem-solving!

Step 1: Creating a Background Image

The Pillow library is an imaging library first and a text manipulation tool second. As such, to add the text we first need an Image class object onto which our text will be added. Think of this as a blank sheet of paper on which we’ll be writing our text.

To create a canvas (background) Image there are two options: create a new image specifying attributes like color, size, and image mode; or simply load an existing image file.

For this tutorial, I’m simply loading an image that I created in Photoshop named background.jpg. I’ll also be using a custom font I downloaded from Google Fonts named Source Code Pro. Here’s what the project structure looks like:

ProjectRoot
├── wrapper.py
├── background.jpg
└── SourceCodePro-Bold.ttf

To load the background image in preparation use the following code replacing the fp (file path) argument of background.jpg with the full file path to whichever image you use. The mode argument specifies what file mode to open the file; the default is r for read-only.

from PIL import Image, ImageDraw, ImageFont

# Open image
img = Image.open(fp='background.jpg', mode='r')

>>> <PIL.JpegImagePlugin.JpegImageFile image mode=RGB size=1024x683 at 0x1958734C1F0>

# View image to ensure it loads properly
img.show()

This code loads our initial background image which will be our starting point for creating wrapped text on an image in Python.

Note: this returns a JpegImageFile object in RBG mode.

We won’t need to know that for our purposes but, depending on the use case, one might have to specify mode and image type when using Pillow. Below is our background image on which we’ll be adding text.

text background overcoded
The plain background.jpg image loaded via Pillow’s Image class

Step 2: Loading a Custom Font & Text

Our background image provides a canvas on which to place text. I’m using a fancy yellow image for visual purposes only—a plain white, transparent, or any other color would work as well. Using Pillow’s ImageFont class we can load the SourceCode Pro font we downloaded from Google Fonts earlier:

# Load custom font
font = ImageFont.truetype(font='SourceCodePro-Bold.ttf', size=42)

>>> <PIL.ImageFont.FreeTypeFont object at 0x000001BECB5005E0>

To begin the process of adding text to our background we’ll be using Pillow’s ImageDraw module. This module provides a range of tools that allow one to draw arcs, lines, points, lines, and many more geometric constructs.

Check out the official documentation for more on the available methods.

For now, we’ll be restricting use to the text method to add some text. The following code will create a custom ImageDraw object, load our custom font, and add text to our background image.

# Create DrawText object
draw = ImageDraw.Draw(im=img)

>>> <PIL.ImageDraw.ImageDraw object at 0x000001EEB39991F0>

# Define our text
text = """Simplicity--the art of maximizing the amount of work not done--is essential."""

# Add text to the image
draw.text(xy=(0, 0), text=text, font=font, fill='#000000')

# View the updated image
img.show()

Before the grand reveal, let me explain some arguments that I’ve added:

  • xy: The x + y coordinates, as a tuple of pixel values, relative to our image, at which the text placement will start. The (0, 0) value specifies the top-left corner.
  • font: The ImageFont object referencing our custom SourceCode Pro font.
  • fill: The color, as a hexadecimal value, assigned to our text.

There are a number of other optional arguments that can be provided. These are outlined well in the official Pillow ImageDraw documentation. We won’t be touching on most of them, but the anchor argument will be used later in this tutorial. For now, let’s see what our image looks like with the text added:

step two image
The background image with the text added, using the specified font, at the specified size, starting at the (0, 0) location.

Note: This quote comes from the AGILE manifesto which outlines the core philosophy of software development practices such as SCRUM, FURPS, and SOLID design principles.

Step 3: Text Wrapping

The next few steps can be done in any sequence—they amount to the same effect. I’m starting with text-wrapping because it’s the trickest and I like to get the tough jobs done first. For this task, we’ll be utilizing some of the Python standard library’s text-manipulation tools. Namely, the textwrap module’s wrap and fill functions. For a detailed look at the textwrap module read this article. For our purposes here, just know the following:

  • wrap: takes a body of text and returns as a collection of lines less than or equal to in character count to that of a specified maximum.
  • fill: Returns a block of solid text having used the wrap function to split lines into widths of a character maximum.

We have roughly 41 characters displayed, including whitespace and special characters, in our image so far. I’m going for a little extra padding for visual effect, so I’m going to wrap our text to a 35 character maximum. To do this, I use the textrwap.fill() method as follows:

import textwrap

# Define the text to be used on our image
text = """Simplicity--the art of maximizing the amount of work not done--is essential."""

# Create a new version of our text with maximum of 35 chars per line
text = textwrap.fill(text=text, width=35)

>>> 
Simplicity--the art of maximizing
the amount of work not done--is
essential.

# Get some line counts for fun
"\n".join(f"{str(len(x))} - {x}" for x in text.splitlines())

>>>
33 - Simplicity--the art of maximizing
31 - the amount of work not done--is
10 - essential.

You’ll note that the textwrap.fill() function splits on whole-words by default. This leaves us with three lines of total widths, measured by character count, of 33, 31, and 10 respectively. Here’s what our image looks like now:

text wrapping step 3 wrapping overcoded
The image displaying our text wrapped on whole-word breaks of 35 characters or less.

This is definitely a step in the right direction but still far from what I’m after. There are several issues that still have to be ironed out here. The following are the most immediate:

  • Adjusting for font and font size changes
  • Text positioning
  • Scaling the text to fit the image
  • Text alignment (centered, left-aligned, justified, etc.)

Each of these will help ensure our final solution offers us control to create different presentations of text and also dynamic and flexible enough to allow the exchange of different texts and fonts. After all, what good is our solution if it only works for a single font, on a single image, using a single string of text? Not much! Let’s start by addressing the issue of font sizes.

Step 4: Accounting for Font Size

Python’s textwrap module has already demonstrated its utility. However, it has the shortcoming of using character count in determining the width of line breaks. This makes complete sense—given it isn’t an imaging focused module. We’re still going to use this module but we’ll have to convert our width parameter from pixel size to character count dynamically by completing the following actions

  1. Calculate an average character width given font and size
  2. Calculate the maximum number of characters, using our average width value, that can fit across a specified pixel distance.
  3. Use that calculated value to wrap the text

I’ve wrapped all this up into a tidy function but for now, we’ll break things down step-by-step to visualize the benefits of this step:

from string import ascii_letters

# Calculate the average length of a single character of our font.
# Note: this takes into account the specific font and font size.
avg_char_width = sum(font.getsize(char)[0] for char in ascii_letters) / len(ascii_letters)

>>> 25.03846153846154

# Translate this average length into a character count
# to fill 95% of our image's total width
max_char_count = int( (image.size[0] * .95) / avg_char_width )

>>> 38

# Create a wrapped text object using scaled character count
scaled_wrapped_text = textwrap.fill(text=text, width=)

>>> 
Simplicity--the art of maximizing the
amount of work not done--is essential.

This uses the string.ascii_letters variable along with our ImageFont object to calculate the average width, as a pixel value, of each letter in our font. We then convert this value into a maximum number of characters per line by dividing our image width (scaled by a factor of .95 for visual aesthetics) by our average font character length.

We then use the resulting value as the width argument for the textwrap.fill() function’s width argument (as character count) to create a wrapped text object that is scaled to the image. Here’s our image now:

step 4 scaled font text wrapped image python pillow overcoded
Our text has now been scaled relative to the font size to span a maximum percentage of our image’s width as a pixel value rather than character count.

Overall I think things are looking much better — we can use a custom font, of a custom size, and not have to worry about our text overflowing our image horizontally. There are still several needed tweaks including text justification and placement.

Step 5: Text Placement

Previous versions of Pillow made little accommodation for the control of where a TextDraw object was placed on an image. At some point [citation needed] the Pillow library matured and Text Anchors were implemented. This allows one to specifiy standardized locations to which a text object is aligned to an image.

There are several possible alignment options outlined in the official Text Anchors documentation. For our purposes here, I’ll be positioning our text to the vertical and horizontal center using the mm parameter for the anchor argument. Having already scaled out text appropriately, the text can be centered as such:

# Add the 'mm' argument to the anchor parameter to center horizontally and vertically
draw.text(xy=(0, 0), text=text, font=font, fill='#000000', anchor='mm')

As you can see, I’ve added the mm argument as the anchor parameter here. Also, I changed the width of our text to be 61.8% of our image’s total width (ever drawn to the aesthetics of the golden ratio!).

step 5 error centering without xy location overcoded
Our image’s text is now “centered” but that center is still at the (0, 0) point!

Yikes. I made a big mistake here by forgetting to update the position at which our text was being added to the image. Up to this point, we’ve been placing our text starting at the top-left corner (coordinates (0, 0) ) of our image. Our text is technically centered now, but it’s centered at the (0, 0) location which causes unwanted overflow. Let’s use the coordinates of our image and see what happens:

step 5 text placement correction overcoded
By starting our text placement at the middle of our image we find the placement much more visually pleasing.

This is much more along the lines of what I was hoping to achieve. Our text is centered, scaled, and will dynamically adjust should we choose to update the font or the font size. Let’s update some parameters and double-check things work as expected.

Increased Font Size

Here I’ve increased the font size from a modest 42 to an imposing 72 suited for bold, captivating memes and banners:

text wrapping centered text increased font size overcoded
The text is still centered after being updated from 42 to 72

Decreased Font Size & Longer Text

Here I’ll add a significant number of additional characters—using a quote from Alan Turing—and also decrease the font size from the larger 72 to a meager 28.

text wrapping smaller text alan turning quote overcoded
This quote from Alan Turing shows a significant increase in total character count and a decrease in font size still allows for proper centering of the text.

Putting it All Together

Below is all the code that we’ve put together so far. Note that this code assumes an image named background.jpg and a valid font file named SourceCodePro-Bold.ttf are present in the current working directory.

# Open image
img = Image.open(fp='background.jpg', mode='r')

# Load custom font
font = ImageFont.truetype(font='SourceCodePro-Bold.ttf', size=72)

# Create DrawText object
draw = ImageDraw.Draw(im=img)

# Define our text
text = """Simplicity--the art of maximizing the amount of work not done--is essential."""

# Calculate the average length of a single character of our font.
# Note: this takes into account the specific font and font size.
avg_char_width = sum(font.getsize(char)[0] for char in ascii_letters) / len(ascii_letters)

# Translate this average length into a character count
max_char_count = int(img.size[0] * .618 / avg_char_width)

# Create a wrapped text object using scaled character count
text = textwrap.fill(text=text, width=max_char_count)

# Add text to the image
draw.text(xy=(img.size[0]/2, img.size[1] / 2), text=text, font=font, fill='#000000', anchor='mm')

# view the result
img.show()

This will result in the image that we saw last. I’m a fan of object-oriented programming styles and have created a class to handle all this with a little more stylistic grace. It’s a work-in-progress and is available on Github here.

@TODO

Address the issues of vertical overflow

when there are too many lines of text to adequately fit on the background. There would be tradeoffs here to balance an increase in overall text width, a possible decrease in font size, and the possible case such that no practical adjustment to either would result in enough real estate to accommodate a text. For example, one couldn’t fit the entirety of Python documentation onto a single 1024 x 1024 image and expect the text to be legible.

Proposed: force a maximum font size proportional to a fixed percentage of the image height.

Final Thoughts

Python + Pillow affords a powerful set of text-manipulating tools. With a little bit of translation between the textwrap and ImageDraw features, one can create visually stunning images with text overlay in Python. These tools are one of the many aspects of Python that make it one of the most popular programming languages in the world. The tools and approaches outlined here are far from robust, fall short of completeness, and come with no warranty. I know I learned a lot while writing this article and hope that you have too!

Zαck West
Full-Stack Software Engineer with 10+ years of experience. Expertise in developing distributed systems, implementing object-oriented models with a focus on semantic clarity, driving development with TDD, enhancing interfaces through thoughtful visual design, and developing deep learning agents.