Source code for luma.core.bitmap_font

# -*- coding: utf-8 -*-
# Copyright (c) 2020-2023 Richard Hull and contributors
# See LICENSE.rst for details.

from pathlib import Path
from math import ceil
from copy import deepcopy
from PIL import Image, ImageFont
import cbor2
from luma.core.util import from_16_to_8, from_8_to_16


[docs] class bitmap_font(): """ An ``PIL.Imagefont`` style font. The structure of this class was modeled after the PIL ``ImageFont`` class and is intended to be interchangable for :py:class:`PIL.ImageFont` objects. It has the following additional capabilities: * Allows fonts larger than 256 characters to be created * Font can be combined with other fonts * Font characters can be mapped to their correct unicode codepoints * Font can be initialized from a basic sprite table (with conditions) .. note: Because this font is implemented completely in Python it will be slower than a native PIL.ImageFont object. .. versionadded:: 1.16.0 """ PUA_SPACE = 0xF8000 def __init__(self): self.mappings = {} self.metrics = []
[docs] def load(self, filename): """ Load font from filename :param filename: the filename of the file containing the font data :type filename: str :return: a font object :rtype: :py:class:`luma.core.bitmap_font` """ with open(filename, 'rb') as fp: s = fp.readline() if s != b'LUMA.CORE.BITMAP_FONT\n': raise SyntaxError('Not a luma.core.bitmap_font file') fontdata = cbor2.load(fp) self._load_fontdata(fontdata) return self
[docs] def loads(self, fontdata): """ Load :py:class:`luma.core.bitmap_font` from a string of serialized data produced by the ``dumps`` method :param fontdata: The serialized font data that will be used to initialize the font. This data is produced by the :py:func:`luma.core.bitmap_font.dumps` method. :type fontdata: bytes :return: a font object :rtype: :py:class:`luma.core.bitmap_font` """ fontdata = cbor2.loads(fontdata) self._load_fontdata(fontdata) return self
[docs] def load_pillow_font(self, file, mappings=None): """ Create :py:class:`luma.core.bitmap_font` from a PIL ImageFont style font. :param file: The filename of the PIL.ImageFont to load :type file: str :param mappings: a dictionary of unicode to value pairs (optional). Mappings allow the appropriate unicode values to be provided for each character contained within the font :type mappings: dict :return: a font object :rtype: :py:class:`luma.core.bitmap_font` """ with open(file, 'rb') as fp: if fp.readline() != b"PILfont\n": raise SyntaxError("Not a PIL.ImageFont file") while True: s = fp.readline() if not s: raise SyntaxError("PIL.ImageFont file missing metric data") if s == b"DATA\n": break data = fp.read(256 * 20) if len(data) != 256 * 20: raise SyntaxError("PIL.ImageFont file metric data incomplete") sprite_table = self._get_image(file) self._populate_metrics(sprite_table, data, range(256), mappings) sprite_table.close() return self
[docs] def load_sprite_table(self, sprite_table, index, xwidth, glyph_size, cell_size, mappings=None): """ Load a font from a sprite table :param sprite_table: A PIL.Image representation of every glyph within the font :type sprite_table: PIL.Image :param index: The list of character values contained within sprite_table. This list MUST be in the same order that the glyphs for the characters appear within the sprite_table (in left to right, top to bottom order) :type index: list or other iterable :param xwidth: number of pixels between placements of each character in a line of text :type xwidth: int :param glyph_size: tuple containing the width and height of each character in the font :type glyph_size: tuple(int, int) :param cell_size: tuple containing the width and height of each cell in the sprite table. Defaults to the size of the glyphs. :type cell_size: tuple(int, int) :param mappings: a dictionary of unicode to value pairs (optional) Mappings allow the appropriate unicode values to be provided for each character contained within the font :type mappings: dict :return: a font object :rtype: :py:class:`luma.core.bitmap_font` .. note: Font contained within table must adhere to the following conditions * All glyphs must be the same size * Glyphs are contained within the sprite table in a grid arrangement * The grid is filled with glyphs placed in horizontal order * Each cell in the grid is the same size * The placement of each glyph has no offset from its origin """ table_width = sprite_table.size[0] # Each character uses the same data line = [xwidth, 0, 0, -glyph_size[1], glyph_size[0], 0] data = [] # Generate an entry for each character for c in range(len(index)): offset = c * cell_size[0] left = offset % table_width top = (offset // table_width) * cell_size[1] right = left + glyph_size[0] bottom = top + glyph_size[1] data = data + line + [left, top, right, bottom] self._populate_metrics(sprite_table, from_16_to_8(data), index, mappings) return self
[docs] def save(self, filename): """ Write :py:class:`luma.core.bitmap_font` data to a file """ with open(filename, 'wb') as fp: fontdata = self._generate_fontdata() fp.write(b'LUMA.CORE.BITMAP_FONT\n') cbor2.dump(fontdata, fp)
[docs] def dumps(self): """ Serializes the font data for transfer or storage :return: Serialized font data :rtype: bytes """ fontdata = self._generate_fontdata() return cbor2.dumps(fontdata)
def _generate_fontdata(self): """ Utility method to create an efficient serializable representation of a :py:class:`luma.core.bitmap_font` """ cell_size = (self.width, self.height) area = self.count * self.width * self.height table_width = ((ceil(area**0.5) + self.width - 1) // self.width) * self.width table_height = ((ceil(area / table_width) + self.height - 1) // self.height) * self.height image = Image.new('1', (table_width, table_height)) metrics = [] for i, v in enumerate(self.metrics): offset = i * cell_size[0] left = offset % table_width top = (offset // table_width) * cell_size[1] image.paste(v['img'], (left, top)) metrics.append((v['xwidth'], v['dst'])) fontdata = {} fontdata['type'] = 'LUMA.CORE.BITMAP_FONT' fontdata['count'] = len(self.metrics) if self.regular: fontdata['xwidth'] = self.regular[0] fontdata['glyph_size'] = (self.width, self.height) fontdata['cell_size'] = cell_size fontdata['mappings'] = self.mappings fontdata['sprite_table_dimensions'] = (table_width, table_height) fontdata['sprite_table'] = image.tobytes() if not self.regular: fontdata['metrics'] = metrics return fontdata def _load_fontdata(self, fontdata): """ Initialize font from deserialized data """ try: count = fontdata['count'] xwidth = fontdata.get('xwidth') glyph_size = fontdata.get('glyph_size') cell_size = fontdata['cell_size'] self.mappings = fontdata['mappings'] table_width, table_height = fontdata['sprite_table_dimensions'] image = Image.frombytes('1', (table_width, table_height), fontdata['sprite_table']) metrics = fontdata.get('metrics') except (KeyError, TypeError, ValueError): raise ValueError('Cannot parse fontdata. It is invalid.') self.metrics = [] for i in range(count): offset = i * cell_size[0] metric = metrics[i] if metrics else (xwidth, [0, -glyph_size[1], glyph_size[0], 0]) left = offset % table_width top = (offset // table_width) * cell_size[1] right = left + (metric[1][2] - metric[1][0]) bottom = top + (metric[1][3] - metric[1][1]) self.metrics.append({ 'xwidth': metric[0], 'dst': metric[1], 'img': image.crop((left, top, right, bottom)) }) self._calculate_font_size() def _get_image(self, filename): """ Load sprite_table associated with font """ ifs = {p.resolve() for p in Path(filename).parent.glob(Path(filename).stem + ".*") if p.suffix in (".png", ".gif", ".pbm")} for f in ifs: try: image = Image.open(f) except: pass else: if image.mode in ['1', 'L']: break image.close() else: raise OSError('cannot find glyph data file') return image def _lookup(self, val): """ Utility method to determine a characters placement within the metrics list """ if val in self.mappings: return self.mappings[val] if val + self.PUA_SPACE in self.mappings: return self.mappings[val + self.PUA_SPACE] return None def _getsize(self, text): """ Utility method to compute the rendered size of a line of text. It also computes the minimum column value for the line of text. This is needed in case the font has a negative horizontal offset which requires that the size be expanded to accomodate the extra pixels. """ min_col = max_col = cp = 0 for c in text: m = self._lookup(ord(c)) if m is None: # Ignore characters that do not exist in font continue char = self.metrics[m] min_col = min(min_col, char['dst'][0] + cp) max_col = max(max_col, char['dst'][2] + cp) cp += char['xwidth'] return (max_col - min_col, self.height, min_col)
[docs] def getsize(self, text, *args, **kwargs): """ Wrapper for _getsize to match the interface of PIL.ImageFont """ width, height, min = self._getsize(text) return (width, height)
[docs] def getmask(self, text, mode="1", *args, **kwargs): """ Implements an PIL.ImageFont compatible method to return the rendered image of a line of text """ # TODO: Test for potential character overwrite if horizontal offset is < 0 assert mode in ['1', 'L'] width, height, min = self._getsize(text) image = Image.new(mode, (width, height)) # Adjust start if any glyph is placed before origin cp = -min if min < 0 else 0 for c in text: m = self._lookup(ord(c)) if m is None: # Ignore characters that do not exist in font continue char = self.metrics[m] px = char['dst'][0] + cp py = char['dst'][1] + self.baseline image.paste(char['img'], (px, py)) cp += char['xwidth'] return image.im
def _populate_metrics(self, sprite_table, data, index, mappings): """ Populate metrics on initial font load from a sprite table or PIL ImageFont Place characters contained on the sprite_table into Unicode private use area (PUA). Create a reverse lookup from the values that are contained on the sprite_table. .. note: Arbritarily using Supplemental Private Use Area-A starting at PUA_SPACE (0xF8000) to give the raw sprite_table locations a unicode codepoint. """ self.metrics = [] self.glyph_index = {} self.data = data idx = 0 rev_map = {} if mappings is not None: for k, v in mappings.items(): if v in rev_map: rev_map[v].append(k) else: rev_map[v] = [k] self.mappings = {} for i, c in enumerate(index): metric = from_8_to_16(data[i * 20:(i + 1) * 20]) # If character position has no data, skip it if sum(metric) == 0: continue xwidth = metric[0] dst = metric[2:6] src = metric[6:10] img = sprite_table.crop((src[0], src[1], src[2], src[3])) self.metrics.append({ 'xwidth': xwidth, 'dst': dst, 'img': img }) self.mappings[c + self.PUA_SPACE] = idx if c in rev_map: for u in rev_map[c]: self.mappings[u] = idx i2b = img.tobytes() # Only add new glyphs except always add space character if i2b not in self.glyph_index or c == 0x20: self.glyph_index[i2b] = c idx += 1 self._calculate_font_size() def _calculate_font_size(self): # Calculate height and baseline of font ascent = descent = width = 0 m = self.metrics[0] regular = (m['xwidth'], m['dst']) xwidth = regular[0] regular_flag = True for m in self.metrics: if regular != (m['xwidth'], m['dst']): regular_flag = False ascent = max(ascent, -m['dst'][1]) descent = max(descent, m['dst'][3]) width = max(width, m['dst'][2] - m['dst'][0]) xwidth = max(xwidth, m['xwidth']) self.height = ascent + descent self.width = width self.baseline = ascent self.regular = regular if regular_flag else None self.count = len(self.metrics)
[docs] def combine(self, source_font, characters=None, force=False): """ Combine two :py:class:`luma.core.bitmap_font` instances. :param source_font: a :py:class:`luma.core.bitmap_font` to copy from :type source_font: :py:class:`luma.core.bitmap_font` :param characters: (optional) A list of the characters to transfer from the source_font. If not provided, all of the characters within the source_font will be transferred. :type characters: str :param force: If set, the source_font can overwrite values that already exists within this font. Default is False. :type force: bool """ if characters: for c in characters: if ord(c) in self.mappings and not force: continue m = source_font._lookup(ord(c)) if m is not None: v = source_font.metrics[m] else: raise ValueError(f'{c} is not a valid character within the source font') self.metrics.append(v) self.mappings[ord(c)] = len(self.metrics) - 1 else: # Copy source values into destination but don't overwrite existing characters unless force set for k, v in source_font.mappings.items(): if k in self.mappings and not force: continue self.metrics.append(source_font.metrics[v]) self.mappings[k] = len(self.metrics) - 1 # Recompute font size metrics self._calculate_font_size()
[docs] def load(filename): """ Load a :py:class:`luma.core.bitmap_font` file. This function creates a :py:class:`luma.core.bitmap_font` object from the given :py:class:`luma.core.bitmap_font` file, and returns the corresponding font object. :param filename: Filename of font file. :type filename: str :return: A :py:class:`luma.core.bitmap_font` object. :exception OSError: If the file could not be read. :exception SyntaxError: If the file does not contain the expected data """ f = bitmap_font() f.load(filename) return f
[docs] def loads(data): """ Load a :py:class:`luma.core.bitmap_font` from a string of serialized data. This function creates a :py:class:`luma.core.bitmap_font` object from serialized data produced from the ``dumps`` method and returns the corresponding font object. :param data: Serialized :py:class:`luma.core.bitmap_font` data. :type data: str :return: A :py:class:`luma.core.bitmap_font` object. :exception ValueError: If the data does not a valid luma.core.bitmap_font """ f = bitmap_font() f.loads(data) return f
[docs] def load_pillow_font(filename, mappings=None): """ Load a PIL font file. This function creates a luma.core.bitmap_font object from the given PIL bitmap font file, and returns the corresponding font object. :param filename: Filename of font file. :type filename: str :param mappings: a dictionary of unicode to value pairs (optional) :type mappings: dict :return: A font object. :exception OSError: If the file could not be read. :exception SyntaxError: If the file does not contain the expected data """ f = bitmap_font() f.load_pillow_font(filename, mappings) return f
[docs] def load_sprite_table(sprite_table, index, xwidth, glyph_size, cell_size=None, mappings=None): """ Create a :py:class:`luma.core.bitmap_font` from a sprite table. :param sprite_table: Filename of a sprite_table file or a PIL.Image containing the sprite_table :type sprite_table: str or PIL.Image :param index: The list of character values contained within sprite_table. This list MUST be in the same order that the glyphs for the characters appear within the sprite_table (in left to right, top to bottom order) :type index: list or other iterable :param xwidth: number of pixels between placements of each character in a line of text :type xwidth: int :param glyph_size: tuple containing the width and height of each character in the font :type glyph_size: tuple(int, int) :param cell_size: tuple containing the width and height of each cell in the sprite table. Defaults to the size of the glyphs. :type cell_size: tuple(int, int) :param mappings: a dictionary of unicode to value pairs (optional) :type mappings: dict :return: A font object. :exception OSError: If the file could not be read. :exception SyntaxError: If the file does not contain the expected data .. note: Requires a font where each character is the same size with no horizontal or vertical offset and has consistant horizontal distance between each character """ f = bitmap_font() need_to_close = False if type(sprite_table) is str: try: sprite_table = Image.open(sprite_table) need_to_close = True # Differentiate between file not found and invalid sprite table except FileNotFoundError: raise except IOError: raise ValueError(f'File {sprite_table} not a valid sprite table') if isinstance(sprite_table, Image.Image): cell_size = cell_size if cell_size is not None else glyph_size f.load_sprite_table(sprite_table, index, xwidth, glyph_size, cell_size, mappings) else: raise ValueError('Provided image is not an instance of PIL.Image') if need_to_close: sprite_table.close() return f
[docs] class embedded_fonts(ImageFont.ImageFont): """ Utility class to manage the set of fonts that are embedded within a compatible device. :param data: The font data from the device. See note below. :type data: dict :param selected_font: The font that should be loaded as this device's default. Will accept the font's index or its name. :type selected_font: str or int ..note: The class is used by devices which have embedded fonts and is not intended to be used directly. To initialize it requires providing a dictionary of font data including a `PIL.Image.tobytes` representation of a sprite_table which contains the glyphs of the font organized in consistent rows and columns, a metrics dictionary which provides the information on how to retrieve fonts from the sprite_table, and a mappings dictionary that provides unicode to table mappings. .. versionadded:: 1.16.0 """ def __init__(self, data, selected_font=0): self.data = data self.font_by_number = {} self.names_index = {} for i in range(len(data['metrics'])): name = data['metrics'][i]['name'] self.names_index[name] = i self.current = selected_font
[docs] def load(self, val): """ Load a font by its index value or name and return it :param val: The index or the name of the font to return :type val: int or str """ if type(val) is str: if val in self.names_index: index = self.names_index[val] else: raise ValueError(f'No font with name {val}') elif type(val) is int: if val in range(len(self.names_index)): index = val else: raise ValueError(f'No font with index {val}') else: raise TypeError(f'Expected int or str. Received {type(val)}') if index not in self.font_by_number: i = index index_list = self.data['metrics'][i]['index'] xwidth = self.data['metrics'][i]['xwidth'] cell_size = self.data['metrics'][i]['cell_size'] glyph_size = self.data['metrics'][i]['glyph_size'] table_size = self.data['metrics'][i]['table_size'] mappings = self.data['mappings'][i] if 'mappings' in self.data else None sprite_table = Image.frombytes('1', table_size, self.data['fonts'][i]) font = load_sprite_table(sprite_table, index_list, xwidth, glyph_size, cell_size, mappings) self.font_by_number[i] = font return self.font_by_number[index]
@property def current(self): """ Returns the currently selected font """ return self.font @current.setter def current(self, val): """ Sets the current font, loading the font if it has not previously been selected :param val: The name or index number of the selected font. :type val: str or int """ self.font = self.load(val)
[docs] def combine(self, font, characters=None, force=False): """ Combine the current font with a new one :param font: The font to combine with the current font :type font: :py:class:`luma.core.bitmap_font` :param characters: (Optional) A list of characters to move from the new font to the current font. If not provided all characters from the new font will be transferred. :type characters: list of unicode characters :param force: Determines if conflicting characters should be ignored (default) or overwritten. .. note: This does not permanently change the embedded font. If you set the value of current again even if setting it to the same font, the changes that combine has made will be lost. """ destination = deepcopy(self.font) destination.combine(font, characters, force) self.font = destination