Source code for opengl.fonttexture

#
##
##  SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
##  SPDX-License-Identifier: GPL-3.0-or-later
##
##  This file is part of pyFormex 3.5  (Thu Feb  8 19:11:13 CET 2024)
##  pyFormex is a tool for generating, manipulating and transforming 3D
##  geometrical models by sequences of mathematical operations.
##  Home page: https://pyformex.org
##  Project page: https://savannah.nongnu.org/projects/pyformex/
##  Development: https://gitlab.com/bverheg/pyformex
##  Distributed under the GNU General Public License version 3 or later.
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see http://www.gnu.org/licenses/.
##
"""Textures for rendering text fonts.

This module defines the FontTexture class which is a Texture specialized for
rendering fonts. It forms the basis for text drawing on the OpenGL canvas.
uses textures on quads to render text on an OpenGL canvas.
It is dependent on freetype and the Python bindings freetype-py. These
bindings come included with pyFormex.
"""
from PIL import Image
import numpy as np

import pyformex as pf
from pyformex.opengl.texture import Texture
from pyformex.opengl.gl import GL
from pyformex.plugins.imagearray import array2image, image2array

__all__ = ['FontTexture', 'font2texture']

ASCII = ''.join(chr(i) for i in range(32, 128))


[docs]def font2texture(filename, size=24, charset=None, saveto=None): """Create a texture image from a ttf font file Parameters ---------- filename: :term:`path_like` The path to a TTF font file size: int The intended font size charset: string The set of characters to include in the texture. saveto: :term:`path_like`, optional The name of a .png file where the texture image will be stored, so it can subsequently be reloaded with the need to generate it from the TTF font. Returns ------- ndarray A texture image with the rendered (ASCII) characters from the font. Examples -------- >>> font = pf.utils.defaultMonoFont() >>> pngfile = pf.cfg['tmpdir'] / 'testfont.png' >>> im, info = font2texture(font, 24, charset='abcé°€', saveto=pngfile) >>> rim = Image.open(pngfile) >>> print(rim.text) {'size': '24', 'nrows': '1', 'ncols': '6', 'charset': 'abcé°€'} """ from pyformex import freetype as ft # lazy loading if pf.debugon(pf.DEBUG.FONT): print("Creating FontTexture({filename}) in size {size}") # Load font and check it is monospace face = ft.Face(str(filename)) try: face.set_char_size(0, int(size*64), 0, 0) except Exception: raise RuntimeError( f"Can not load font '{filename}' at size {size}") if not face.is_fixed_width: raise RuntimeError(f"Font is not fixed width: {filename}") # Define character set and row length if charset is None: # default is ascii characters >= 32 charset = ASCII ncols = 32 else: # keep only unique characters charset = ''.join(sorted(set(charset))) ncols = min(len(charset), 32) # Determine largest glyph size width, height, ascender, descender = 0, 0, 0, 0 for c in charset: face.load_char(c, ft.FT_LOAD_RENDER | ft.FT_LOAD_FORCE_AUTOHINT) bitmap = face.glyph.bitmap width = max(width, bitmap.width) ascender = max(ascender, face.glyph.bitmap_top) descender = max(descender, bitmap.rows-face.glyph.bitmap_top) height = ascender+descender # Generate texture data nrows = (len(charset)-1) // ncols + 1 imarr = np.zeros((height*nrows, width*ncols), dtype=np.ubyte) if pf.debugon(pf.DEBUG.FONT): print(f"Font Texture size: {nrows}x{ncols}, Data size: imarr.shape") info = { 'size': size, 'nrows': nrows, 'ncols': ncols, } if charset is not ASCII: info['charset'] = charset for k, c in enumerate(charset): j, i = divmod(k, ncols) face.load_char(c, ft.FT_LOAD_RENDER | ft.FT_LOAD_FORCE_AUTOHINT) bitmap = face.glyph.bitmap x = i*width + face.glyph.bitmap_left y = j*height + ascender - face.glyph.bitmap_top imarr[y:y+bitmap.rows, x:x+bitmap.width].flat = bitmap.buffer # Save the texture if requested if saveto: if pf.verbosity(2): print(f"Saving Font Texture: {saveto}") array2image(imarr, saveto, pnginfo=info) return imarr, info
[docs]def read_texturefont(filename): """Read a pyFormex Texture Font File Parameters ---------- filename: :term:`path_like` The path of a pyFormex Texture Font file. This is a .png file with a FontTexture in PNG image format and containing the appropriate metadata as generated by the :class:`FontTexture` class. """ tim = Image.open(filename) text = tim.text if text: try: info = { 'size': int(text['size']), 'nrows': int(text['nrows']), 'ncols': int(text['ncols']), 'charset': text.get('charset', None), } except KeyError: raise ValueError( f"{filename} does not have proper TextureFont metadata") else: info = { 'size': 24, 'nrows': 3, 'ncols': 32, 'charset': None, } imarr = image2array(tim, flip=False) return imarr, info
[docs]class FontTexture(Texture): """A Texture class for text rendering. The FontTexture class is a :class:`Texture` containing a bitmap of the important characters in a font. The Texture can then be used to draw text on the OpenGL canvas or on some geometry. The FontTexture can be initialized from one of two sources: - A monospace .ttf font file. Additionally a characterset may be defined to limit the FontTexture to the important characters. The created FontTexture can then be saved in a .png image file, for faster reload. - A .png image file containing a saved image of a FontTexture. pyFormex comes with a large set of pregenerated .png files containing all ASCII characters for most monospace fonts on typical Linux installations. The FontTexture class supports the full unicode character sets available in the .ttf files, but is currently limited to monospace fonts. Parameters ---------- filename: :term:`path_like` The path of the font file to be used. This should be either an existing monospace .ttf font or a texture font .png file generated by pyFormex. size: float Intended font height. The actual height might differ a bit. Different texture font heights can be generated from a single .ttf font file. A higher font height provides a higher resolution and nicer text, at a larger cost. charset: str, optional Only useful if ``filename`` is a .ttf file: the set of characters to be included in the FontTexture. If not provided, the set of ASCII characters 32..128 is used. PNG texture font files contain the character set used when they were generated. saveto: :term:`path_like`, optional The name of a .png file where the texture image will be stored, so it can subsequently be reloaded with the need to generate it from the TTF font. """ def __init__(self, filename=None, size=24, *, charset=None, saveto=None): """Initialize a FontTexture""" if filename is None: filename = pf.utils.defaultMonoFont() else: filename = pf.Path(filename) suffix = filename.suffix if suffix == '.ttf': # generate texture from .ttf file if not filename.exists(): raise ValueError(f"Font file {filename} does not exist") imarr, info = font2texture(filename, size, charset, saveto) elif suffix == '.png': # load from a .png file if not filename.exists(): if not filename.is_absolute(): for dirname in (pf.cfg['userconfdir'] / 'fonts', pf.cfg['fontsdir']): fn = dirname / filename if fn.exists(): filename = fn break if not filename.exists(): raise ValueError(f"Could not find font file {filename}") imarr, info = read_texturefont(filename) else: raise ValueError("Invalid filename {filename} for TextureFont") self.__dict__.update(info) self.height = imarr.shape[0] / self.nrows self.width = imarr.shape[1] / self.ncols if pf.debugon(pf.DEBUG.FONT): print(f"Font: {filename=}, {imarr.dtype=}, {imarr.shape=}, " f"{self.height=}, {self.width=}") Texture.__init__(self, imarr, format=GL.GL_ALPHA, texformat=GL.GL_ALPHA)
[docs] def activate(self, mode=None): """Bind the texture and make it ready for use. Returns the texture id. """ GL.glEnable(GL.GL_BLEND) Texture.activate(self, filtr=1)
[docs] def texCoords(self, s): """Return the texture coordinates for the character in a string. Parameters ---------- char: str If an integer, it should be in the range 32..127 (printable ASCII characters). If a string, all its characters should be ASCII printable characters (have an ordinal value in the range 32..127). Returns ------- array The texture coordinates needed to display the given string on a grid of quad4 elements. It is a float array with shape (len(s), 4, 2). For each character from s, this contains four (x,y) pairs corresponding respectively with the lower left, lower right, upper right, upper left corners of the character in the texture. Note that values for the lower corners are higher than those for the upper corners, because the FontTextures are stored from top to bottom as in images files. """ dx, dy = 1./self.ncols, 1./self.nrows # get character indices if self.charset is None: indices = list(ord(c) - 32 for c in s) else: indices = list(self.charset.find(c) for c in s) print(f"{indices=}") # TODO: ?? on loading, construct the tex_coords table, then select # ?? or use a dict type cache char -> texcoords(char) def tex_coords(k): """Texture coordinates for the character with index k""" if k < 0: return ((0,0),) * 4 x0, y0 = (k % self.ncols)*dx, (k // self.ncols)*dy return (x0, y0+dy), (x0+dx, y0+dy), (x0+dx, y0), (x0, y0) return np.array([tex_coords(k) for k in indices])
default_font = None
[docs] @classmethod def default(clas, size=24): """Set and return the default FontTexture. """ if clas.default_font is None: default_font_file = 'NotoSansMono-Condensed.3x32.png' default_font_size = 24 clas.default_font = FontTexture(default_font_file, default_font_size) return clas.default_font
# End