4
$\begingroup$

I have a frame, and I want to draw text for it, which will be in the very center:

a

Oh no. It works fine, but I'm still not happy

b

import bpy, blf, gpu, gpu_extras.batch
from mathutils import Vector as Vec

def DrawRect(pos1, pos2, col):
    gpu.state.blend_set('ALPHA')
    gpuShader.bind()
    gpuShader.uniform_float('color', col)
    vpos = (pos1[0],pos1[1]), (pos2[0],pos1[1]), (pos2[0],pos2[1]), (pos1[0],pos2[1])
    gpu_extras.batch.batch_for_shader(gpuShader, type='TRI_FAN', content={'pos':vpos}).draw(gpuShader)

def CallbackDraw():
    #Frame for centered text:
    pos1 = Vec((200,200))
    pos2 = Vec((430,260))
    DrawRect(pos1, pos2, (.75,.75,1,.15))
    col = (1,1,1,.5)
    DrawRect(pos1, (pos2.x, pos1.y+1), col)
    DrawRect(pos1, (pos1.x+1, pos2.y), col)
    DrawRect((pos2.x-1, pos1.y), pos2, col)
    DrawRect((pos1.x, pos2.y-1), pos2, col)
    #Visualisation:
    blf.size(fontId, 28)
    dim = Vec(blf.dimensions(fontId, txtForTest))
    cen = (pos1+pos2)/2
    bou1 = cen-dim/2
    bou2 = cen+dim/2
    col = (1,1,1,.5)
    DrawRect((0, bou2.y), (1000, bou2.y+1), col)
    DrawRect((0, bou1.y), (1000, bou1.y+1), col)
    DrawRect((cen.x+dim.x/2, 0), (cen.x+dim.x/2+1, 1000), col)
    DrawRect((cen.x-dim.x/2, 0), (cen.x-dim.x/2+1, 1000), col)
    #Text:
    blf.position(fontId, cen.x-dim.x/2, cen.y-dim.y/2, 0)
    #blf.position(fontId, bou1.x, bou1.y, 0)
    #blf.position(fontId, cen.x, cen.y, 0)
    blf.enable(fontId, blf.SHADOW)
    blf.shadow_offset(fontId, 1, -1)
    blf.shadow(fontId, 5, .1, .1, .1, 1)
    blf.color(fontId, .9, .9, 1, 1)
    blf.draw(fontId, txtForTest)

txtForTest = "GJKLPgjklp!?"
fontId = blf.load(r"C:\Windows\Fonts\consola.ttf")
gpuShader = gpu.shader.from_builtin('UNIFORM_COLOR')

bpy.types.SpaceNodeEditor.draw_handler_add(CallbackDraw, (), 'WINDOW', 'POST_PIXEL')
bpy.types.SpaceTextEditor.draw_handler_add(CallbackDraw, (), 'WINDOW', 'POST_PIXEL')
bpy.types.SpaceView3D.draw_handler_add(CallbackDraw, (), 'WINDOW', 'POST_PIXEL')

Most fonts have this problem:

c

d

f

g

Because of this problem, the font does not "visually" look in the center:

i

Especially here
j

But I want it to be like this:

k

Of course, this is not a center either, but it will still be better.

h

^ For this font comici.ttf, you need to add offset +9 pixels on the Y axis.
But these are different values for each font and each font size. How can I calculate this automatically?

How do I get the "zero point" for a given font or individual letters, by any means?

$\endgroup$
2
  • $\begingroup$ Thanks for the bounty, how about the external library solution, have you considered it? $\endgroup$ Commented Feb 26 at 9:33
  • $\begingroup$ No, I didn't think about it. I wanted to have a solution using only the available tools. $\endgroup$
    – ugorek
    Commented Feb 26 at 17:24

3 Answers 3

6
+500
$\begingroup$

First, some clarification on the problem: the blf module draws text on a given position, by aligning the baseline of the font to the given screen $y$ coordinate:

What you're interested in, is to get the bounding box of generated text: for xxx you want to get the x-height and $0$. For S you want to get cap height and $0$ (with a caveat: it's a very common convention that rounded curves like in "S" or "O" go beyond the guidelines, so that they visually seem to be exactly limited by them; same principle as with distorted Greek columns that seem straight because of that). For p and the above font, you want to get a descender height (perhaps with a negative sign) and a little bit more than the x-height etc. This means if you use your bounding box to align vertically, the text will move as it changes, which is probably not what you actually want, but once your problem, as currently phrased, is solved, you can simply align vertically based on a static text like "x" or "S" or "h" or "Hp" (see the "recommended" comment in code).

Unfortunately the blf capability for obtaining font metrics is very poor. As I already said, it aligns the font to the baseline:

And the only metric it provides is the blf.dimensions you already use. The reported size is not some 'constant' metric, e.g. the height always being the distance between the descender height and ascender height, because it changes based on text passed (so the text is not needed just to multiply a single character dimensions by the number of columns and lines):

>>> import blf
>>> consola = blf.load('C:/Windows/Fonts/consola.ttf')
>>> blf.size(consola, 1000)
>>> blf.dimensions(consola, "GJKLPgjklp!?")
(6600.0, 901.0)

>>> blf.dimensions(consola, "GJKLP")
(2750.0, 656.0)

>>> blf.dimensions(consola, "H")
(550.0, 639.0)

>>> blf.dimensions(consola, "S")
(550.0, 656.0)

>>> blf.size(consola, 100)
>>> blf.dimensions(consola, "S")
(55.0, 66.0)

>>> blf.size(consola, 10)
>>> blf.dimensions(consola, "S")
(6.0, 6.0)

Also notice the rounding of the dimensions, perhaps because of font hinting:

I don't know if there is some better library in vanilla Blender available to measure text. In general you want to google for "font metrics" - there are Python libraries available, including solutions involving popular libraries like PIL.

I don't like relying on installing libraries through PIP, however, I find it unreliable, and if your customer has a PIP-related problem, you're in a world of hurt… On the other hand, my solution won't work in context where you can't evaluate an object, or in a tight-loop for performance reasons. If you can't optimize it by gathering required data once and storing it, then you're forced to use an external library. I don't think you need help with that, however; I might still add the answer for that for future visitors… Or you may share your solution if you go this route.

Geometry Nodes Text Curve solution

Thanks to lemon simplified the answer by removing geonodes. Read earlier revision if you want to see something silly.

  • create a temporary object
  • and a temporary "font" curve
  • set a one-line text (unless you explicitly use newline characters)
  • read the bounding box corners
  • you want the center of the bounding box in the center
  • therefore its offset from the origin=baseline is the (inverse) offset you want to apply
  • unfortunately the size of $1$ isn't the same in text object and blf
  • therefore multiply the offset by the ratio of one bounding box height to the other
  • also when obtaining the height from blf use a very big size for improved accuracy
font_size = 28
txtForTest = "GJKLPgjklp!?"


import bpy, blf, gpu, gpu_extras.batch
from mathutils import Vector as Vec
from bpy import context as C, data as D


def DrawRect(pos1, pos2, col):
    gpu.state.blend_set('ALPHA')
    gpuShader.bind()
    gpuShader.uniform_float('color', col)
    vpos = (pos1[0],pos1[1]), (pos2[0],pos1[1]), (pos2[0],pos2[1]), (pos1[0],pos2[1])
    gpu_extras.batch.batch_for_shader(gpuShader, type='TRI_FAN', content={'pos':vpos}).draw(gpuShader)


def CallbackDraw():
    #Frame for centered text:
    pos1 = Vec((200,200))
    pos2 = Vec((430,260))
    DrawRect(pos1, pos2, (.75,.75,1,.15))
    col = (1,1,1,.5)
    DrawRect(pos1, (pos2.x, pos1.y+1), col)
    DrawRect(pos1, (pos1.x+1, pos2.y), col)
    DrawRect((pos2.x-1, pos1.y), pos2, col)
    DrawRect((pos1.x, pos2.y-1), pos2, col)
    #Visualisation:
    blf.size(fontId, font_size)
    dim = Vec(blf.dimensions(fontId, txtForTest))
    cen = (pos1+pos2)/2
    bou1 = cen-dim/2
    bou2 = cen+dim/2
    col = (1,1,1,.5)
    DrawRect((0, bou2.y), (1000, bou2.y+1), col)
    DrawRect((0, bou1.y), (1000, bou1.y+1), col)
    DrawRect((cen.x+dim.x/2, 0), (cen.x+dim.x/2+1, 1000), col)
    DrawRect((cen.x-dim.x/2, 0), (cen.x-dim.x/2+1, 1000), col)
    
    DrawRect((0, cen.y-.5), (1000, cen.y+.5), col)

    #Text:
    blf.position(fontId, cen.x-dim.x/2, cen.y-centering_offset, 0)
    blf.enable(fontId, blf.SHADOW)
    blf.shadow_offset(fontId, 1, -1)
    blf.shadow(fontId, 5, .1, .1, .1, 1)
    blf.color(fontId, .9, .9, 1, 1)
    blf.draw(fontId, txtForTest)


def get_text_centering_offset(txt, font_path, blf_height):
    temp_name = "Temporary datablock - delete it"
    
    font = D.fonts.load(filepath=font_path, check_existing=True)
    txt_curve = D.curves.new(name=temp_name, type='FONT')
    txt_curve.font = font
    txt_curve.body = txt
    
    ob = D.objects.new(name=temp_name, object_data=txt_curve)
#    C.collection.objects.link(ob)
    
    # left/right, front/rear, top/bottom
    # ^    ^      ^     ^     ^   ^
    # see numpad orthographic views for reference
    lfb, lft, lrt, lrb, rfb, rft, rrt, rrb = ob.bound_box

    top_in_2D = lrb[1]
    bottom_in_2D = lfb[1]
    
    D.objects.remove(ob)
    D.curves.remove(txt_curve)
    
    height = top_in_2D - bottom_in_2D
    ratio = blf_height / height
    
    return (top_in_2D + bottom_in_2D)/2 * ratio
    

# using main() helps to avoid bugs related to namespace pollution
def main():
    global fontId, gpuShader, centering_offset
    txt_for_measurement = txtForTest
    #txt_for_measurement = "Sp"  # recommended

    font_path = r"C:\Windows\Fonts\consola.TTF"
    fontId = blf.load(font_path)
    blf.size(fontId, 1000)  # big size for accuracy; don't go too crazy, e.g. 100k doesn't work
    blf_height = blf.dimensions(fontId, txtForTest)[1]
    blf_height /= 1000  # cancel out the temporary magnification

    centering_offset = get_text_centering_offset(
        txt=txtForTest,
        font_path=font_path,
        blf_height=blf_height
    )
    print(centering_offset)
    centering_offset *= font_size  # because 1000 was a constant value, not a multiplier
    
    gpuShader = gpu.shader.from_builtin('UNIFORM_COLOR')
    
    # for easy script changes:
    s3D = bpy.types.SpaceView3D
    try:
        s3D.draw_handler_remove(s3D.my_handler, 'WINDOW')
    except (AttributeError, ValueError):
        pass
    s3D.my_handler = s3D.draw_handler_add(CallbackDraw, (), 'WINDOW', 'POST_PIXEL')
    
    
main()

$\endgroup$
6
  • $\begingroup$ I tested at a big sizes and it fails then. Looks like the multiplication by size is not enough. For the consolas you need to multiply the centering_offset by an additional 1.3, but that depends on a font. I wonder if that could maybe be dependend on the height ($y$ dimension) of the bounding box... $\endgroup$ Commented Feb 25 at 1:25
  • $\begingroup$ Fixed. At least mostly. There could be some off-by-one errors not related to the text measurement. $\endgroup$ Commented Feb 25 at 11:43
  • $\begingroup$ +1 but why the hell are you using gn for that? $\endgroup$
    – lemon
    Commented Feb 25 at 12:12
  • $\begingroup$ @lemon I edited the answer just about when you wrote this comment, maybe the new revision clarifies why. I have another idea that works in most cases for the most part, will post soon, but I think this solution is just the most reliable solution not using an external library. And as I wrote: I don't think OP needs any help on using an external Python library for font metrics, so I'm not rushing into writing an answer involving pip install or git pull $\endgroup$ Commented Feb 25 at 14:22
  • 1
    $\begingroup$ @lemon aaah haha, yes, that's a very reasonable observation. I've never used the font objects much, maybe that's why I didn't intuitively think of it... I just checked and the bound_box works (out of the box). The default text alignment is set to baseline as well. I'll rework this answer to use a text object instead soon! $\endgroup$ Commented Feb 25 at 14:42
2
$\begingroup$

Here's a much simpler answer than my other answer.

It works most of the time for the most part, that is, it relies on 2 assumptions, that most of the time are roughly right:

  • Assumption #1: the "qypg" letters have tops aligned with "x"
  • Assumption #2: nothing goes lower than "qypg" letters
font_size = 28
txtForTest = "GJKLPgjklp!?"


import bpy, blf, gpu, gpu_extras.batch
from mathutils import Vector as Vec
from bpy import context as C, data as D


def DrawRect(pos1, pos2, col):
    gpu.state.blend_set('ALPHA')
    gpuShader.bind()
    gpuShader.uniform_float('color', col)
    vpos = (pos1[0],pos1[1]), (pos2[0],pos1[1]), (pos2[0],pos2[1]), (pos1[0],pos2[1])
    gpu_extras.batch.batch_for_shader(gpuShader, type='TRI_FAN', content={'pos':vpos}).draw(gpuShader)


def CallbackDraw():
    #Frame for centered text:
    pos1 = Vec((200,200))
    pos2 = Vec((430,260))
    DrawRect(pos1, pos2, (.75,.75,1,.15))
    col = (1,1,1,.5)
    DrawRect(pos1, (pos2.x, pos1.y+1), col)
    DrawRect(pos1, (pos1.x+1, pos2.y), col)
    DrawRect((pos2.x-1, pos1.y), pos2, col)
    DrawRect((pos1.x, pos2.y-1), pos2, col)
    #Visualisation:
    blf.size(fontId, font_size)
    dim = Vec(blf.dimensions(fontId, txtForTest))
    cen = (pos1+pos2)/2
    bou1 = cen-dim/2
    bou2 = cen+dim/2
    col = (1,1,1,.5)
    DrawRect((0, bou2.y), (1000, bou2.y+1), col)
    DrawRect((0, bou1.y), (1000, bou1.y+1), col)
    DrawRect((cen.x+dim.x/2, 0), (cen.x+dim.x/2+1, 1000), col)
    DrawRect((cen.x-dim.x/2, 0), (cen.x-dim.x/2+1, 1000), col)
    
    DrawRect((0, cen.y-.5), (1000, cen.y+.5), col)

    #Text:
    blf.position(fontId, cen.x-dim.x/2, cen.y-centering_offset, 0)
    blf.enable(fontId, blf.SHADOW)
    blf.shadow_offset(fontId, 1, -1)
    blf.shadow(fontId, 5, .1, .1, .1, 1)
    blf.color(fontId, .9, .9, 1, 1)
    blf.draw(fontId, txtForTest)


def get_text_centering_offset(txt, fontId):
    txt_height = blf.dimensions(fontId, txt)[1]
    x_height = blf.dimensions(fontId, 'x')[1]
    qypg_height = blf.dimensions(fontId, 'qypg')[1]
    
    # ASSUMPTION 1: the "qypg" letters have tops aligned with "x"
    descender_height = qypg_height - x_height
    
    # ASSUMPTION 2: nothing goes lower than "qypg" letters
    offset = -descender_height  # move up so the bottom of text is spawned on the given coordinate
    offset += txt_height/2  # move down so the middle of text is spawned on the given coordinate
    return offset
    

# using main() helps to avoid bugs related to namespace pollution
def main():
    global fontId, gpuShader, centering_offset
    txt_for_measurement = txtForTest
    #txt_for_measurement = "Sp"  # recommended

    font_path = r"C:\Windows\Fonts\consola.TTF"
    fontId = blf.load(font_path)
    blf.size(fontId, font_size)
    
    centering_offset = get_text_centering_offset(
        txt=txtForTest,
        fontId=fontId,
    )
    
    gpuShader = gpu.shader.from_builtin('UNIFORM_COLOR')
    
    # for easy script changes:
    s3D = bpy.types.SpaceView3D
    try:
        s3D.draw_handler_remove(s3D.my_handler, 'WINDOW')
    except (AttributeError, ValueError):
        pass
    s3D.my_handler = s3D.draw_handler_add(CallbackDraw, (), 'WINDOW', 'POST_PIXEL')
    
    
main()
```
$\endgroup$
2
  • $\begingroup$ Cool, but if the text consists only of "-", then this method is not suitable. $\endgroup$
    – ugorek
    Commented Feb 26 at 3:54
  • 1
    $\begingroup$ @ugorek I guess there's many more assumptions this relies on, than just the 2 mentioned... If you listen to the # recommended comment, then what you get is an alignment of the "text line", such that a typical usage of the text has the boundary box centered. I recommend this for dynamic text in mind, not even an "animated" text like a counter, but think about some unpleasantness when a hyphen appears at different heights depending if there's letters on the same line or not (or maybe only I notice such things). $\endgroup$ Commented Feb 26 at 9:31
0
$\begingroup$

Not sure I understood correctly, but my thought was to just use the height of (for example) an "H" as reported by blf.dimensions as the line height.

This should center the "line", but maybe not the actual text, depending on if there are descenders, only lowercase letters, etc.

import bpy, blf, gpu, gpu_extras.batch
from mathutils import Vector as Vec


def DrawRect(pos1, pos2, col):
    gpu.state.blend_set('ALPHA')
    gpuShader.bind()
    gpuShader.uniform_float('color', col)
    vpos = (pos1[0],pos1[1]), (pos2[0],pos1[1]), (pos2[0],pos2[1]), (pos1[0],pos2[1])
    gpu_extras.batch.batch_for_shader(gpuShader, type='TRI_FAN', content={'pos':vpos}).draw(gpuShader)


def DrawTextbox(
    fontid,
    text,
    center,
    margin,
    bg_color,
):
    line_height = blf.dimensions(fontid, "H")[1]
    txt_w, txt_h = blf.dimensions(fontid, text)
    cx, cy = center
    box_w = txt_w + 2*margin
    box_h = line_height + 2*margin

    # Box
    DrawRect(
        (cx - box_w/2, cy + box_h/2),
        (cx + box_w/2, cy - box_h/2),
        bg_color,
    )

    # Text
    blf.position(fontid, cx - txt_w/2, cy - line_height/2, 0)    
    blf.draw(fontid, text)


def CallbackDraw():
    blf.size(fontId, 28)
    blf.enable(fontId, blf.SHADOW)
    blf.shadow_offset(fontId, 1, -1)
    blf.shadow(fontId, 5, .1, .1, .1, 1)
    blf.color(fontId, .9, .9, 1, 1)

    DrawTextbox(
        fontid=fontId,
        text="GJKLPgjklp!?",
        center=(400, 100),
        margin=20,
        bg_color=(.75, .75, 1, .15),
    )
    
    blf.size(fontId, 50)
    
    DrawTextbox(
        fontid=fontId,
        text="GJKLPgjklp!?",
        center=(400, 300),
        margin=40,
        bg_color=(1, .75, .75, .15),
    )


fontId = blf.load(r"C:\Windows\Fonts\consola.ttf")
gpuShader = gpu.shader.from_builtin('UNIFORM_COLOR')

bpy.types.SpaceView3D.draw_handler_add(CallbackDraw, (), 'WINDOW', 'POST_PIXEL')

$\endgroup$
1
  • 1
    $\begingroup$ The problem is: when you position the text on $y$, it's not the top of the text's boundary, nor the bottom of it, positioned on this coordinate. It's the baseline. So knowing the height is not enough to center the text. See here: ~38 vs ~30 px distance to border: i.imgur.com/dBJ7i2b.gif Of course you can position the baseline in the middle easily, because that's the position of text drawing, though that's also not what you achieve above: i.imgur.com/btlxPBb.gif Finally, is the center of the text in the vertical middle? Not quite: i.imgur.com/VEzPap4.gif $\endgroup$ Commented Feb 25 at 14:19

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .