#
##
## 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