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