'Multicolored text with PIL

I'm creating a web-app that serves a dynamic image, with text. Each string drawn may be in multiple colors.

So far I've created a parse method, and a render method. The parse method just takes the string, and parses colors from it, they are in format like this: "§aThis is green§rthis is white" (Yeah, it is Minecraft). So this is how my font module looks like:

# Imports from pillow
from PIL import Image, ImageDraw, ImageFont

# Load the fonts
font_regular = ImageFont.truetype("static/font/regular.ttf", 24)
font_bold = ImageFont.truetype("static/font/bold.ttf", 24)
font_italics = ImageFont.truetype("static/font/italics.ttf", 24)
font_bold_italics = ImageFont.truetype("static/font/bold-italics.ttf", 24)

max_height = 21 # 9, from FONT_HEIGHT in FontRederer in MC source, multiplied by
                # 3, because each virtual pixel in the font is 3 real pixels
                # This number is also returned by:
                # font_regular.getsize("ABCDEFGHIJKLMNOPQRSTUVWXYZ")[1] 

# Create the color codes
colorCodes = [0] * 32 # Empty array, 32 slots
# This is ported from the original MC java source:
for i in range(0, 32):
    j = int((i >> 3 & 1) * 85)
    k = int((i >> 2 & 1) * 170 + j)
    l = int((i >> 1 & 1) * 170 + j)
    i1 = int((i >> 0 & 1) * 170 + j)
    if i == 6:
        k += 85
    if i >= 16:
        k = int(k/4)
        l = int(l/4)
        i1 = int(i1/4)
    colorCodes[i] = (k & 255) << 16 | (l & 255) << 8 | i1 & 255

def _get_colour(c):
    ''' Get the RGB-tuple for the color
    Color can be a string, one of the chars in: 0123456789abcdef
    or an int in range 0 to 15, including 15
    '''
    if type(c) == str:
        if c == 'r':
            c = int('f', 16)
        else:
            c = int(c, 16)
    c = colorCodes[c]
    return ( c >> 16 , c >> 8 & 255 , c & 255 )

def _get_shadow(c):
    ''' Get the shadow RGB-tuple for the color
    Color can be a string, one of the chars in: 0123456789abcdefr
    or an int in range 0 to 15, including 15
    '''
    if type(c) == str:
        if c == 'r':
            c = int('f', 16)
        else:
            c = int(c, 16)
    return _get_colour(c+16)

def _get_font(bold, italics):
    font = font_regular
    if bold and italics:
        font = font_bold_italics
    elif bold:
        font = font_bold
    elif italics:
        font = font_italics
    return font

def parse(message):
    ''' Parse the message in a format readable by render
    this will return a touple like this:
    [((int,int),str,str)]
    so if you where to send it directly to the rederer you have to do this:
    render(pos, parse(message), drawer)
    '''
    result = []
    lastColour = 'r'
    total_width = 0
    bold = False
    italics = False
    for i in range(0,len(message)):
        if message[i] == '§':
            continue
        elif message[i-1] == '§':
            if message[i] in "01234567890abcdef":
                lastColour = message[i]
            if message[i] == 'l':
                bold = True
            if message[i] == 'o':
                italics = True
            if message[i] == 'r':
                bold = False
                italics = False
                lastColour = message[i]  
            continue
        width, height = _get_font(bold, italics).getsize(message[i])
        total_width += width
        result.append(((width, height), lastColour, bold, italics, message[i]))
    return result

def get_width(message):
    ''' Calculate the width of the message
    The message has to be in the format returned by the parse function
    '''
    return sum([i[0][0] for i in message])


def render(pos, message, drawer):
    ''' Render the message to the drawer
    The message has to be in the format returned by the parse function
    '''
    x = pos[0]
    y = pos[1]
    for i in message:
        (width, height), colour, bold, italics, char = i
        font = _get_font(bold, italics)
        drawer.text((x+3, y+3+(max_height-height)), char, fill=_get_shadow(colour), font=font)
        drawer.text((x, y+(max_height-height)), char, fill=_get_colour(colour), font=font)
        x += width

And it does work, but characters who are supposed to go below the ground line of the font, like g, y and q, are rendered on the ground line, so it looks strange, Here's an example:

Any ideas on how I can make them display corectly? Or do I have to make my own offset table, where I manually put them?



Solution 1:[1]

Given that you can't get the offsets from PIL, you could do this by slicing up images since PIL combines multiple characters appropriately. Here I have two approaches, but I think the first presented is better, though both are just a few lines. The first approach gives this result (it's also a zoom in on a small font which is why it's pixelated):

enter image description here

To explain the idea here, say I want the letter 'j', and instead of just making an image of just 'j', I make an image of ' o j' since that will keep the 'j' aligned correctly. Then I crop of the part I don't want and just keep the 'j' (by using textsize on both ' o ' and ' o j').

import Image, ImageDraw
from random import randint
make_color = lambda : (randint(50, 255), randint(50, 255), randint(50,255))

image = Image.new("RGB", (1200,20), (0,0,0)) # scrap image
draw = ImageDraw.Draw(image)
image2 = Image.new("RGB", (1200, 20), (0,0,0)) # final image

fill = " o "
x = 0
w_fill, y = draw.textsize(fill)
x_draw, x_paste = 0, 0
for c in "The quick brown fox jumps over the lazy dog.":
    w_full = draw.textsize(fill+c)[0]
    w = w_full - w_fill     # the width of the character on its own
    draw.text((x_draw,0), fill+c, make_color())
    iletter = image.crop((x_draw+w_fill, 0, x_draw+w_full, y))
    image2.paste(iletter, (x_paste, 0))
    x_draw += w_full
    x_paste += w
image2.show()

Btw, I use ' o ', rather than just 'o' since adjacent letters seem to slightly corrupt each other.

The second way is to make an image of the whole alphabet, slice it up, and then repaste this together. It's easier than it sounds. Here's an example, and both building the dictionary and concatenating into the images is only a few lines of code each:

import Image, ImageDraw
import string

A = " ".join(string.printable)

image = Image.new("RGB", (1200,20), (0,0,0))
draw = ImageDraw.Draw(image)

# make a dictionary of character images
xcuts = [draw.textsize(A[:i+1])[0] for i in range(len(A))]
xcuts = [0]+xcuts
ycut = draw.textsize(A)[1]
draw.text((0,0), A, (255,255,255))
# ichars is like {"a":(width,image), "b":(width,image), ...}
ichars = dict([(A[i], (xcuts[i+1]-xcuts[i]+1, image.crop((xcuts[i]-1, 0, xcuts[i+1], ycut)))) for i in range(len(xcuts)-1)])

# Test it...
image2 = Image.new("RGB", (400,20), (0,0,0))
x = 0
for c in "This is just a nifty text string":
    w, char_image = ichars[c]
    image2.paste(char_image, (x, 0))
    x += w

Here's a (zoomed in) image of the resulting string:

enter image description here

Here's an image of the whole alphabet:

enter image description here

One trick here was that I had to put a space in between each character in my original alphabet image or I got the neighboring characters affecting each other.

I guess if you needed to do this a for a finite range of fonts and characters, it would be a good idea precalculate a the alphabet image dictionary.

Or, for a different approach, using a tool like numpy you could easily determine the yoffset of each character in the ichar dictionary above (eg, take the max along each horizontal row, and then find the max and min on the nonzero indices).

Solution 2:[2]

I simply solved this problem like this:

    image = Image.new("RGB", (1000,1000), (255,255,255)) # 1000*1000 white empty image 
    # image = Image.fromarray(frame) # or u can get image from cv2 frame
    draw = ImageDraw.Draw(image)
    fontpath = "/etc/fonts/bla/bla/comic-sans.ttf"
    font = ImageFont.truetype(fontpath, 35) # U can use default fonts
    x = 20 # image draw start pixel x position
    y = 100 # image draw start pixel y position

    xDescPxl = draw.textsize("Descrition", font= font)[0]
    draw.text((x, y), "Descrition" , font = font, fill = (0, 255, 0, 0)) # Green Color
    draw.text((x + xDescPxl, y),  ": u can do bla bla", font = font, fill = (0, 0, 0, 0)) # Black Color

Result:

             Description: u can do bla bla

(20px space)---(Green Part)--(Black Part)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2