54

I try to add text at the bottom of image and actually I've done it, but in case of my text is longer then image width it is cut from both sides, to simplify I would like text to be in multiple lines if it is longer than image width. Here is my code:

FOREGROUND = (255, 255, 255)
WIDTH = 375
HEIGHT = 50
TEXT = 'Chyba najwyższy czas zadać to pytanie na śniadanie \n Chyba najwyższy czas zadać to pytanie na śniadanie'
font_path = '/Library/Fonts/Arial.ttf'
font = ImageFont.truetype(font_path, 14, encoding='unic')
text = TEXT.decode('utf-8')
(width, height) = font.getsize(text)

x = Image.open('media/converty/image.png')
y = ImageOps.expand(x,border=2,fill='white')
y = ImageOps.expand(y,border=30,fill='black')

w, h = y.size
bg = Image.new('RGBA', (w, 1000), "#000000")

W, H = bg.size
xo, yo = (W-w)/2, (H-h)/2
bg.paste(y, (xo, 0, xo+w, h))
draw = ImageDraw.Draw(bg)
draw.text(((w - width)/2, w), text, font=font, fill=FOREGROUND)


bg.show()
bg.save('media/converty/test.png')

9 Answers 9

74

You could use textwrap.wrap to break text into a list of strings, each at most width characters long:

import textwrap
lines = textwrap.wrap(text, width=40)
y_text = h
for line in lines:
    width, height = font.getsize(line)
    draw.text(((w - width) / 2, y_text), line, font=font, fill=FOREGROUND)
    y_text += height
Sign up to request clarification or add additional context in comments.

5 Comments

Thanks a lot! just copy and paste and it works like a charm. You are the best :)
What does 40 represent?
@User 40 represents maximum characters. Meaning it will allow a maximum or 40 characters before it wraps to a new line. But if one word is 10 characters and then next is 31, it will wrap right after the first word since it cannot fit the first and second word on the line.
@teewuane how about doing this based on pixels? We are not always dealing with monospace fonts
@User If you want to get the size of an string of text you can use PIL.ImageDraw.ImageDraw.textsize which returns a tuple of (width, height) in pixels. You could use this implement a solution that tries the longest possible string before the width is over some threshold
27

The accepted answer wraps text without measuring the font (max 40 characters, no matter what the font size and box width is), so the results are only approximate and may easily overfill or underfill the box.

Here is a simple library which solves the problem correctly: https://gist.github.com/turicas/1455973

1 Comment

this is the best answer. We should try to change accepted answer because it was accepted 6 years ago..
19

For a complete working example using unutbu's trick (tested with Python 3.6 and Pillow 5.3.0):

from PIL import Image, ImageDraw, ImageFont
import textwrap

def draw_multiple_line_text(image, text, font, text_color, text_start_height):
    '''
    From unutbu on [python PIL draw multiline text on image](https://stackoverflow.com/a/7698300/395857)
    '''
    draw = ImageDraw.Draw(image)
    image_width, image_height = image.size
    y_text = text_start_height
    lines = textwrap.wrap(text, width=40)
    for line in lines:
        line_width, line_height = font.getsize(line)
        draw.text(((image_width - line_width) / 2, y_text), 
                  line, font=font, fill=text_color)
        y_text += line_height


def main():
    '''
    Testing draw_multiple_line_text
    '''
    #image_width
    image = Image.new('RGB', (800, 600), color = (0, 0, 0))
    fontsize = 40  # starting font size
    font = ImageFont.truetype("arial.ttf", fontsize)
    text1 = "I try to add text at the bottom of image and actually I've done it, but in case of my text is longer then image width it is cut from both sides, to simplify I would like text to be in multiple lines if it is longer than image width."
    text2 = "You could use textwrap.wrap to break text into a list of strings, each at most width characters long"

    text_color = (200, 200, 200)
    text_start_height = 0
    draw_multiple_line_text(image, text1, font, text_color, text_start_height)
    draw_multiple_line_text(image, text2, font, text_color, 400)
    image.save('pil_text.png')

if __name__ == "__main__":
    main()
    #cProfile.run('main()') # if you want to do some profiling

Result:

enter image description here

1 Comment

should be top answer
12

All recommendations about textwrap usage fail to determine correct width for non-monospaced fonts (as Arial, used in topic example code).

I've wrote simple helper class to wrap text regarding to real font letters sizing:

from PIL import Image, ImageDraw

class TextWrapper(object):
    """ Helper class to wrap text in lines, based on given text, font
        and max allowed line width.
    """

    def __init__(self, text, font, max_width):
        self.text = text
        self.text_lines = [
            ' '.join([w.strip() for w in l.split(' ') if w])
            for l in text.split('\n')
            if l
        ]
        self.font = font
        self.max_width = max_width

        self.draw = ImageDraw.Draw(
            Image.new(
                mode='RGB',
                size=(100, 100)
            )
        )

        self.space_width = self.draw.textsize(
            text=' ',
            font=self.font
        )[0]

    def get_text_width(self, text):
        return self.draw.textsize(
            text=text,
            font=self.font
        )[0]

    def wrapped_text(self):
        wrapped_lines = []
        buf = []
        buf_width = 0

        for line in self.text_lines:
            for word in line.split(' '):
                word_width = self.get_text_width(word)

                expected_width = word_width if not buf else \
                    buf_width + self.space_width + word_width

                if expected_width <= self.max_width:
                    # word fits in line
                    buf_width = expected_width
                    buf.append(word)
                else:
                    # word doesn't fit in line
                    wrapped_lines.append(' '.join(buf))
                    buf = [word]
                    buf_width = word_width

            if buf:
                wrapped_lines.append(' '.join(buf))
                buf = []
                buf_width = 0

        return '\n'.join(wrapped_lines)

Example usage:

wrapper = TextWrapper(text, image_font_intance, 800)
wrapped_text = wrapper.wrapped_text()

It's probably not super-fast, because it renders whole text word by word, to determine words width. But for most cases it should be OK.

Comments

1

Easiest solution is to use textwrap + multiline_text function

from PIL import Image, ImageDraw
import textwrap

lines = textwrap.wrap("your long text", width=20)
draw.multiline_text((x,y), '\n'.join(lines))

Comments

0

This function will split the text into rows that are at most max length long when made in font font, then it creates a transparent image with the text on it.

def split_text(text, font, max)
    text=text.split(" ")
    total=0
    result=[]
    line=""
    for part in text:
        if total+font.getsize(f"{part} ")[0]<max:
            line+=f"{part} "
            total+=font.getsize(part)[0]
        else:
            line=line.rstrip()
            result.append(line)
            line=f"{part} "
            total=font.getsize(f"{part} ")[0]
    line=line.rstrip()
    result.append(line)
    image=new("RGBA", (max, font.getsize("gL")[1]*len(result)), (0, 0, 0, 0))
    imageDrawable=Draw(image)
    position=0
    for line in result:
        imageDrawable.text((0, position), line, font)
        position+=font.getsize("gL")[1]
    return image

Comments

0

A minimal example, keep adding words until it exceeds the maximum width limit. The function get_line returns the current line and remaining words, which can again be used in loop, as in draw_lines function below.

def get_line(words, width_limit):
    # get text which can fit in one line, remains is list of words left over
    line_width = 0
    line = ''
    i = 0
    while i < len(words) and (line_width + FONT.getsize(words[i])[0]) < width_limit:
        if i == 0:
            line = line + words[i]
        else:
            line = line + ' ' + words[i]
        i = i + 1
        line_width = FONT.getsize(line)[0]
    remains = []
    if i < len(words):
        remains = words[i:len(words)]
    return line, remains


def draw_lines(text, text_box):
    # add some margin to avoid touching borders
    box_width = text_box[1][0] - text_box[0][0] - (2*MARGIN)
    text_x = text_box[0][0] + MARGIN
    text_y = text_box[0][1] + MARGIN
    words = text.split(' ')
    while words:
        line, words = get_line(words, box_width)
        width, height = FONT.getsize(line)
        im_draw.text((text_x, text_y), line, font=FONT, fill=FOREGROUND)
        text_y += height

Comments

0

You could use PIL.ImageDraw.Draw.multiline_text().

draw.multiline_text((WIDTH, HEIGHT), TEXT, fill=FOREGROUND, font=font)

You even set spacing or align using the same param names.

NOTE: You need to wrap the text according to your image size vs desired font size.

5 Comments

Doesn't work, I just tried it. Can you elaborate the use case? I passed a really long string to this as text, and it didn't wrap to the next line.
Image
@HassanBaig did you use breaks in string? For example: "Lorem Ipsum is simply dummy \n text of the printing \n and typesetting industry."
The text is coming from user input, and as such they're not using breaks to specify next line. I'll have to handle overflow myself.
The first argument of multiline_text is documented as "xy – Top left corner of the text." You have to do line wrapping yourself.
'\n'.join(textwrap.wrap(TEXT, width=15)) will give you text with new lines - even from user input.
-2
text = textwrap.fill("test ",width=35)
self.draw.text((x, y), text, font=font, fill="Black")

2 Comments

Welcome to SO! Can you please be so kind to explain how this solves the question? A bit of extra text would go a long way for other users.
This is a more concise version of @unutbu's answer. It is the best code currently suggested, and with a bit more explanation ought to be the accepted answer.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.