Search code examples
pythonfontspygame

Pygame font render missing baseline information, can not blit text properly


In Pygame when rendering text with render() a surface is generated containing the text rendered. The size of the surface (get_rect()) has various heights based on the text itself. There is also the line height you can get with get_linesize(). Based just on these information there is no chance to align the text properly.

If you need to change a short text on one place you can blit() the text centered, or top pr bottom aligned. In any case the text will appear to jump, because the baseline of the font is jumping up and down.

Consider the following example:

>>> pygame.init()
(5, 0)
>>> ff=pygame.font.match_font("freesans")
>>> f=pygame.font.Font(ff,100)
>>> f.get_linesize()
110
>>> f.render("xxxx",True,(0,0,0)).get_rect()
<rect(0, 0, 192, 100)>
>>> f.render("XXXX",True,(0,0,0)).get_rect()
<rect(0, 0, 264, 100)>
>>> f.render("AAÁČ",True,(0,0,0)).get_rect()
<rect(0, 0, 272, 111)>
>>> f.render("yg",True,(0,0,0)).get_rect()
<rect(0, 0, 103, 102)>
>>> f.render("ygjý",True,(0,0,0)).get_rect()
<rect(0, 0, 175, 102)>
>>> f.render("ygjýŘ",True,(0,0,0)).get_rect()
<rect(0, 0, 246, 113)>

So the font size is 100, the line size is 110, the various texts have various heights 100, 111, 102 and 113. This is all fine because of the nature of True Type Fonts.

The problem is that there is no clue how to align those rectangles. look at the next image:

enter image description here

The yellow rectangles are the generated surfaces saved to image files. I aligned them manually based on the "baseline". It is clear that they can not be aligned either by bottom nor by top. The brownish rectangles represent the line height.

Is there any chance to get the baseline information or align the text properly by other means?


Solution

  • Finally I found out how to calculate the correct position of the surface generated with the pygame.font.Font.render() function. This information is available also in pygagame.font module, however it is somewhat hidden. The documentation is not very helpful here if you are not familiar with at least basics of typography.

    So here is some basic overview. If you want to print text in pygame, you want to print it somewhere. Let's say you want to print a line of text and you want to print it to the left top corner of your screen (or window) area. The coordinates of this point are (0,0). You render your text surface with s = f.render("Some arbitrary text", True, (0,0,0)). If your font size is 100 then the surface rendered will have the height 100 pixels. This is true for even just a single dot "." or underscore "_" and remains true until you do not use some accented characters.

    With English only text you are good to go with the basic tools. But with accented characters you are lost, because the rendered surfaces will be not equally high and there will be no information how to place the rendered surface.

    The solution is in f.metrics() function. Look at the next picture: enter image description here In the above picture there are bitmaps generated with pygame.font.Font.render() function with font size set to 100 and font face to freesans. It consists of chunks of texts "ABd", "dab", "bXZy", "ygj", "jýáščẙ" and "ýŽŽÁČŮ". The neighboring characters are overlapping so the bitmaps can be adjusted to correct positions. In the texts below the bitmaps there are the results of the get_rect(), size() and metrics() functions. The first two (get_rect and size) give the same result, the width and height of the resulting bitmap (left and top values of get_rect result are 0). The metrics return value is a list...

    The list contains tuples for each character, which contain the minimum X offset, the maximum X offset, the minimum Y offset, the maximum Y offset and the advance offset (bearing plus width) of the character. [(minx, maxx, miny, maxy, advance), (minx, maxx, miny, maxy, advance), ...]. None is entered in the list for each unrecognized character.

    ... according to documentation.

    Not very clear at first sight. But the solution is in there. The numbers are as I understand them: the minimum and maximum x offset is where the character itself is printed, take the first numbers for "A" in "ABd" (1, 66, 0, 73, 67), the 1 means the a 1 pixel padding on left side, 66 means the letter goes this wide (66 - 1 = 65) and the advance (the 5th value) is where starts the next letter. The min and max y offset is that the bottom of the layer is at position 0 and the top at position 73, so the letter "A" is 73 pixels high. And most useful part is that this 0 is the baseline, the value we are after.

    To know what the baseline is look at the above picture again. It is the red line which all the capital letters "sit" on. The other lines are the ascent, the blue, which you can get with get_ascent() function, then the descent, the violet, you get it with get_descent() function.

    The get_linesize() function gives a value of 110 for the "freesans" font with size 100. This value is (I just guess here, because this is the limit of my typography knowledge) the line height which is nominal and you can make the lines higher or even smaller. And I guess the remaining space between the font size (100), which is the sum of ascent and descent (80 and -20 in this case), and the line height (110) can be divided to get the top (black line) and bottom (green line) of the text line.

    If you scan through those metrics numbers you can spot max y values bigger than 80 (the ascent) and also min y values smaller than 0 (negative 22) which are smaller than the descent (-20). The min y values are not interesting, since we are positioning the top of the rectangle. The max y values bigger than 80 are saying how much to offset the rectangle's top coordinate when blit-ing the text to the screen.

    With this piece of code you can position the text correctly

    posy = 10  # the position of text to be printed
    mxy = max([m[3] for m in f.metrics(text)])
    if mxy > f.get_ascent():
        posy = posy - (mxy - f.get_ascent())
    

    where text is any text you give the maximum value for text over the baseline. mxy is the maximum of max y values. posy is the top position of the line to be printed. If the mxy value is bigger than the ascent then the posy value will be smaller of the difference between the mxy and the ascent value.

    To further understand the advance value in metrics tuple here is another picture enter image description here where are the individual sizes of bitmaps and metrics of individual letters of the above example. The advance value is also the width of the bitmaps. Also when combined the letters add up in resulting bitmaps to the sum of individual bitmaps and thus advance values. This however is not always true and is dependent on the font itself and the combination of letters.

    From the documentation:

    size(); determine the amount of space needed to render text size(text) -> (width, height) Returns the dimensions needed to render the text. This can be used to help determine the positioning needed for text before it is rendered. It can also be used for word wrapping and other layout effects. Be aware that most fonts use kerning which adjusts the widths for specific letter pairs. For example, the width for "ae" will not always match the width for "a" + "e".

    This is not the end of the story. There is also a newer library in pygame called pygame.freetype. This module gives us the needed information on baseline. See the picture enter image description here

    The pygame.freetype.Font.render() function returns a tuple of surface and rect. And the rect contains in left property the horizontal offset to place the text and in top value there is the distance of the top of the bitmap from the baseline.

    When the pad property of the Font object is set to True, the bitmaps of text are padded with space so they look exactly as the former ones from the pygame.font module. In this case the left property is always 0.

    The huge integer numbers in metrics tuples is a bug, where there should be negative values, but the number is treated as unsigned instead of signed.

    One last picture to illustrate the difference when pad is True and False enter image description here The description in the picture describes all.

    But before you head to use the pygame.freetype module, you must know that according my simple measurements based on the following code

    #!/bin/python
    
    # typography test
    
    import pygame
    import pygame.freetype
    import time
    
    pygame.init()
    pygame.freetype.init()
    
    font1 = "freesans"
    font2 = "freemono"
    fs1 = 100
    fs2 = 50
    fs3 = 20
    fgcolor = (0,0,0)
    bgcolor = (255,255,0)
    
    ff1 = pygame.font.match_font(font1)
    ff2 = pygame.font.match_font(font2)
    
    f1 = pygame.font.Font(ff1, fs1)
    f2 = pygame.font.Font(ff1, fs2)
    f3 = pygame.font.Font(ff2, fs3)
    ft1 = pygame.freetype.Font(ff1)
    ft2 = pygame.freetype.Font(ff2)
    
    text = "Hello World! " * 10
    
    COUNT = 1000
    
    # measure pygame.font
    start = time.time()
    
    for i in range(COUNT):
        _sf = f1.render(text,True, fgcolor, bgcolor)
        _r = _sf.get_rect()
        _o = max([m[3] for m in f1.metrics(text)])
        _a = f1.get_ascent()
        _sf = f2.render(text,True, fgcolor, bgcolor)
        _r = _sf.get_rect()
        _o = max([m[3] for m in f2.metrics(text)])
        _a = f2.get_ascent()
        _sf = f3.render(text,True, fgcolor, bgcolor)
        _r = _sf.get_rect()
        _o = max([m[3] for m in f3.metrics(text)])
        _a = f3.get_ascent()
    
    t1 = time.time() - start
    
    # measure pygame.freetype
    start = time.time()
    
    for i in range(COUNT):
        _sf,_r = ft1.render(text,fgcolor=fgcolor, bgcolor=bgcolor, size=fs1)
        _sf,_r = ft1.render(text,fgcolor=fgcolor, bgcolor=bgcolor, size=fs2)
        _sf,_r = ft2.render(text,fgcolor=fgcolor, bgcolor=bgcolor, size=fs3)
    
    t2 = time.time() - start
    
    print("python.font render time: %s" % t1)
    print("python.freetype render time: %s" % t2)
    

    the pygame.freetype module is about 6.5 times slower than the older pygame.font module when rendering text. The measurement was done on pygame 2.5.2 (SDL 2.28.3, Python 3.11.2) on Raspberry Pi OS.