Skip to main content
3 of 4
added theory, fixed a bug, reworked existing part of the answer for clarity
Markus von Broady
  • 42.4k
  • 3
  • 37
  • 107

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 solution

  • create a temporary object
  • and a temporary node tree
  • draw a one-line text (unless you explicitly use newline characters)
  • align the baseline to origin
  • convert text to a bounding box
  • evaluate the geonodes modifier
  • 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 geonodes 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)
    me = D.meshes.new(name=temp_name)
    ob = D.objects.new(name=temp_name, object_data=me)
    mod = ob.modifiers.new(name=temp_name, type='NODES')

    tree = D.node_groups.new(name=temp_name, type='GeometryNodeTree')
    tree.interface.new_socket(
        name='Geometry', in_out='OUTPUT', socket_type='NodeSocketGeometry')

    node_string_to_curves = tree.nodes.new(type='GeometryNodeStringToCurves')
    node_string_to_curves.align_y = 'BOTTOM_BASELINE'
    node_string_to_curves.inputs['String'].default_value = txt
    node_string_to_curves.font = font

    node_realize_instances = tree.nodes.new(type='GeometryNodeRealizeInstances')
    node_bounding_box = tree.nodes.new(type='GeometryNodeBoundBox')
    node_out = tree.nodes.new(type='NodeGroupOutput')

    tree.links.new(
        node_string_to_curves.outputs['Curve Instances'],
        node_realize_instances.inputs['Geometry']
    )
    tree.links.new(
        node_realize_instances.outputs['Geometry'],
        node_bounding_box.inputs['Geometry']
    )
    tree.links.new(
        node_bounding_box.outputs['Bounding Box'],
        node_out.inputs['Geometry']
    )

    mod.node_group = tree
    C.collection.objects.link(ob)
    dg = C.evaluated_depsgraph_get()
    ev = ob.evaluated_get(dg)

    # left/right, front/rear, top/bottom
    # ^    ^      ^     ^     ^   ^
    # see numpad orthographic views for reference
    lfb, lft, lrt, lrb, rfb, rft, rrt, rrb = ev.bound_box
    
    D.node_groups.remove(tree)
    D.objects.remove(ob)
    D.meshes.remove(me)

    top_in_2D = lrb[1]
    bottom_in_2D = lfb[1]
    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
    )
    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()

Markus von Broady
  • 42.4k
  • 3
  • 37
  • 107