pythonfontspython-imaging-librarytext-rendering

Properly render text with a given font in Python and accurately detect its boundaries


This might strike as something very simple, and I too thought it'd be, but it apparently isn't. I must've spent a week trying to make this work, but I for the love of me can't manage to do so.

What I need

I need to render any given string (only containing standard characters) with any given font (handwritten-like) in Python. The font must be loaded from a TTF file. I also need to be able to accurately detect its borders (get the exact start and end position of the text, vertically and horizontally), preferably before drawing it. Lastly, it'd really make my life easier if the output is an array which I can then keep processing, and not an image file written to disc.

What I've tried

Imagemagick bindings (namely Wand): Couldn't figure out how to get the text metrics before setting the image size and rendering the text on it.

Pango via Pycairo bindings: nearly inexistent documentation, couldn't figure out how to load a TrueType font from a file.

PIL (Pillow): The most promising option. I've managed to accurately calculate the height for any text (which surprisingly is not the height getsize returns), but the width seems buggy for some fonts. Not only that, but those fonts with buggy width also get rendered incorrectly. Even when making the image large enough, they get cut off.

Here are some examples, with the text "Puzzling":

Font: Lovers Quarrel

Result:

Lovers Quarrel Render

Font: Miss Fajardose

Result:

Miss Fajardose Render

This is the code I'm using to generate the images:

from PIL import Image, ImageDraw, ImageFont
import cv2
import numpy as np
import glob
import os

font_size = 75
font_paths = sorted(glob.glob('./fonts/*.ttf'))
text = "Puzzling"
background_color = 180
text_color = 50
color_variance = 60
cv2.namedWindow('display', 0)

for font_path in font_paths:

    font = ImageFont.truetype(font_path, font_size)
    text_width, text_height = font.getsize(text)

    ascent, descent = font.getmetrics()
    (width, baseline), (offset_x, offset_y) = font.font.getsize(text)

    # +100 added to see that text gets cut off
    PIL_image = Image.new('RGB', (text_width-offset_x+100, text_height-offset_y), color=0x888888)
    draw = ImageDraw.Draw(PIL_image)
    draw.text((-offset_x, -offset_y), text, font=font, fill=0)

    cv2.imshow('display', np.array(PIL_image))
    k = cv2.waitKey()
    if chr(k & 255) == 'q':
        break

Some questions

Are the fonts the problem? I've been told by some colleagues that might be it, but I don't think so, since they get rendered correctly by the Imagemagick via command line.

Is my code the problem? Am I doing something wrong which is causing the text to get cut off?

Lastly, is it a bug in PIL? In that case, which library do you recommend I use to solve my problem? Should I give Pango and Wand another try?


Solution

  • pyvips seems to do this correctly. I tried this:

    $ python3
    Python 3.7.3 (default, Apr  3 2019, 05:39:12) 
    [GCC 8.3.0] on linux
    Type "help", "copyright", "credits" or "license" for more information.
    >>> import pyvips
    >>> x = pyvips.Image.text("Puzzling", dpi=300, font="Miss Fajardose", fontfile="/home/john/pics/MissFajardose-Regular.ttf")
    >>> x.write_to_file("x.png")
    

    To make:

    enter image description here

    The pyvips docs have a quick intro to the options:

    https://libvips.github.io/pyvips/vimage.html#pyvips.Image.text

    Or the C library docs have a lot more detail:

    http://libvips.github.io/libvips/API/current/libvips-create.html#vips-text

    It makes a one-band 8-bit image of the antialiased text which you can use for further processing, pass to NumPy or PIL, etc etc. There's a section in the intro on how to convert libvips images into arrays:

    https://libvips.github.io/pyvips/intro.html#numpy-and-pil