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:
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?
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:
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
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
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
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.