572 lines
22 KiB
Python
572 lines
22 KiB
Python
# SPDX-FileCopyrightText: 2020 Kevin Matocha
|
|
#
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""
|
|
`adafruit_display_text.bitmap_label`
|
|
================================================================================
|
|
|
|
Text graphics handling for CircuitPython, including text boxes
|
|
|
|
|
|
* Author(s): Kevin Matocha
|
|
|
|
Implementation Notes
|
|
--------------------
|
|
|
|
**Hardware:**
|
|
|
|
**Software and Dependencies:**
|
|
|
|
* Adafruit CircuitPython firmware for the supported boards:
|
|
https://circuitpython.org/downloads
|
|
|
|
"""
|
|
|
|
__version__ = "2.22.3"
|
|
__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_Display_Text.git"
|
|
|
|
|
|
try:
|
|
from typing import Union, Optional, Tuple
|
|
from fontio import BuiltinFont
|
|
from adafruit_bitmap_font.bdf import BDF
|
|
from adafruit_bitmap_font.pcf import PCF
|
|
except ImportError:
|
|
pass
|
|
|
|
import displayio
|
|
|
|
from adafruit_display_text import LabelBase
|
|
|
|
# pylint: disable=too-many-instance-attributes
|
|
class Label(LabelBase):
|
|
"""A label displaying a string of text that is stored in a bitmap.
|
|
Note: This ``bitmap_label.py`` library utilizes a :py:class:`~displayio.Bitmap`
|
|
to display the text. This method is memory-conserving relative to ``label.py``.
|
|
|
|
For further reduction in memory usage, set ``save_text=False`` (text string will not
|
|
be stored and ``line_spacing`` and ``font`` are immutable with ``save_text``
|
|
set to ``False``).
|
|
|
|
The origin point set by ``x`` and ``y``
|
|
properties will be the left edge of the bounding box, and in the center of a M
|
|
glyph (if its one line), or the (number of lines * linespacing + M)/2. That is,
|
|
it will try to have it be center-left as close as possible.
|
|
|
|
:param font: A font class that has ``get_bounding_box`` and ``get_glyph``.
|
|
Must include a capital M for measuring character size.
|
|
:type font: ~BuiltinFont, ~BDF, or ~PCF
|
|
:param str text: Text to display
|
|
:param int color: Color of all text in RGB hex
|
|
:param int background_color: Color of the background, use `None` for transparent
|
|
:param float line_spacing: Line spacing of text to display
|
|
:param bool background_tight: Set `True` only if you want background box to tightly
|
|
surround text. When set to 'True' Padding parameters will be ignored.
|
|
:param int padding_top: Additional pixels added to background bounding box at top
|
|
:param int padding_bottom: Additional pixels added to background bounding box at bottom
|
|
:param int padding_left: Additional pixels added to background bounding box at left
|
|
:param int padding_right: Additional pixels added to background bounding box at right
|
|
:param (float,float) anchor_point: Point that anchored_position moves relative to.
|
|
Tuple with decimal percentage of width and height.
|
|
(E.g. (0,0) is top left, (1.0, 0.5): is middle right.)
|
|
:param (int,int) anchored_position: Position relative to the anchor_point. Tuple
|
|
containing x,y pixel coordinates.
|
|
:param int scale: Integer value of the pixel scaling
|
|
:param bool save_text: Set True to save the text string as a constant in the
|
|
label structure. Set False to reduce memory use.
|
|
:param bool base_alignment: when True allows to align text label to the baseline.
|
|
This is helpful when two or more labels need to be aligned to the same baseline
|
|
:param (int,str) tab_replacement: tuple with tab character replace information. When
|
|
(4, " ") will indicate a tab replacement of 4 spaces, defaults to 4 spaces by
|
|
tab character
|
|
:param str label_direction: string defining the label text orientation. There are 5
|
|
configurations possibles ``LTR``-Left-To-Right ``RTL``-Right-To-Left
|
|
``UPD``-Upside Down ``UPR``-Upwards ``DWR``-Downwards. It defaults to ``LTR``"""
|
|
|
|
# This maps label_direction to TileGrid's transpose_xy, flip_x, flip_y
|
|
_DIR_MAP = {
|
|
"UPR": (True, True, False),
|
|
"DWR": (True, False, True),
|
|
"UPD": (False, True, True),
|
|
"LTR": (False, False, False),
|
|
"RTL": (False, False, False),
|
|
}
|
|
|
|
def __init__(
|
|
self, font: Union[BuiltinFont, BDF, PCF], save_text: bool = True, **kwargs
|
|
) -> None:
|
|
|
|
self._bitmap = None
|
|
self._tilegrid = None
|
|
self._prev_label_direction = None
|
|
|
|
super().__init__(font, **kwargs)
|
|
|
|
self._save_text = save_text
|
|
self._text = self._replace_tabs(self._text)
|
|
|
|
# call the text updater with all the arguments.
|
|
self._reset_text(
|
|
font=font,
|
|
text=self._text,
|
|
line_spacing=self._line_spacing,
|
|
scale=self.scale,
|
|
)
|
|
|
|
def _reset_text(
|
|
self,
|
|
font: Optional[Union[BuiltinFont, BDF, PCF]] = None,
|
|
text: Optional[str] = None,
|
|
line_spacing: Optional[float] = None,
|
|
scale: Optional[int] = None,
|
|
) -> None:
|
|
# pylint: disable=too-many-branches, too-many-statements, too-many-locals
|
|
|
|
# Store all the instance variables
|
|
if font is not None:
|
|
self._font = font
|
|
if line_spacing is not None:
|
|
self._line_spacing = line_spacing
|
|
|
|
# if text is not provided as a parameter (text is None), use the previous value.
|
|
if (text is None) and self._save_text:
|
|
text = self._text
|
|
|
|
if self._save_text: # text string will be saved
|
|
self._text = self._replace_tabs(text)
|
|
else:
|
|
self._text = None # save a None value since text string is not saved
|
|
|
|
# Check for empty string
|
|
if (text == "") or (
|
|
text is None
|
|
): # If empty string, just create a zero-sized bounding box and that's it.
|
|
|
|
self._bounding_box = (
|
|
0,
|
|
0,
|
|
0, # zero width with text == ""
|
|
0, # zero height with text == ""
|
|
)
|
|
# Clear out any items in the self._local_group Group, in case this is an
|
|
# update to the bitmap_label
|
|
for _ in self._local_group:
|
|
self._local_group.pop(0)
|
|
|
|
# Free the bitmap and tilegrid since they are removed
|
|
self._bitmap = None
|
|
self._tilegrid = None
|
|
|
|
else: # The text string is not empty, so create the Bitmap and TileGrid and
|
|
# append to the self Group
|
|
|
|
# Calculate the text bounding box
|
|
|
|
# Calculate both "tight" and "loose" bounding box dimensions to match label for
|
|
# anchor_position calculations
|
|
(
|
|
box_x,
|
|
tight_box_y,
|
|
x_offset,
|
|
tight_y_offset,
|
|
loose_box_y,
|
|
loose_y_offset,
|
|
) = self._text_bounding_box(
|
|
text,
|
|
self._font,
|
|
) # calculate the box size for a tight and loose backgrounds
|
|
|
|
if self._background_tight:
|
|
box_y = tight_box_y
|
|
y_offset = tight_y_offset
|
|
|
|
else: # calculate the box size for a loose background
|
|
box_y = loose_box_y
|
|
y_offset = loose_y_offset
|
|
|
|
# Calculate the background size including padding
|
|
box_x = box_x + self._padding_left + self._padding_right
|
|
box_y = box_y + self._padding_top + self._padding_bottom
|
|
|
|
# Create the Bitmap unless it can be reused
|
|
new_bitmap = None
|
|
if (
|
|
self._bitmap is None
|
|
or self._bitmap.width != box_x
|
|
or self._bitmap.height != box_y
|
|
):
|
|
new_bitmap = displayio.Bitmap(box_x, box_y, len(self._palette))
|
|
self._bitmap = new_bitmap
|
|
else:
|
|
self._bitmap.fill(0)
|
|
|
|
# Place the text into the Bitmap
|
|
self._place_text(
|
|
self._bitmap,
|
|
text if self._label_direction != "RTL" else "".join(reversed(text)),
|
|
self._font,
|
|
self._padding_left - x_offset,
|
|
self._padding_top + y_offset,
|
|
)
|
|
|
|
if self._base_alignment:
|
|
label_position_yoffset = 0
|
|
else:
|
|
label_position_yoffset = self._ascent // 2
|
|
|
|
# Create the TileGrid if not created bitmap unchanged
|
|
if self._tilegrid is None or new_bitmap:
|
|
self._tilegrid = displayio.TileGrid(
|
|
self._bitmap,
|
|
pixel_shader=self._palette,
|
|
width=1,
|
|
height=1,
|
|
tile_width=box_x,
|
|
tile_height=box_y,
|
|
default_tile=0,
|
|
x=-self._padding_left + x_offset,
|
|
y=label_position_yoffset - y_offset - self._padding_top,
|
|
)
|
|
# Clear out any items in the local_group Group, in case this is an update to
|
|
# the bitmap_label
|
|
for _ in self._local_group:
|
|
self._local_group.pop(0)
|
|
self._local_group.append(
|
|
self._tilegrid
|
|
) # add the bitmap's tilegrid to the group
|
|
|
|
# Set TileGrid properties based on label_direction
|
|
if self._label_direction != self._prev_label_direction:
|
|
tg1 = self._tilegrid
|
|
tg1.transpose_xy, tg1.flip_x, tg1.flip_y = self._DIR_MAP[
|
|
self._label_direction
|
|
]
|
|
|
|
# Update bounding_box values. Note: To be consistent with label.py,
|
|
# this is the bounding box for the text only, not including the background.
|
|
if self._label_direction in ("UPR", "DWR"):
|
|
self._bounding_box = (
|
|
self._tilegrid.x,
|
|
self._tilegrid.y,
|
|
tight_box_y,
|
|
box_x,
|
|
)
|
|
else:
|
|
self._bounding_box = (
|
|
self._tilegrid.x,
|
|
self._tilegrid.y,
|
|
box_x,
|
|
tight_box_y,
|
|
)
|
|
|
|
if (
|
|
scale is not None
|
|
): # Scale will be defined in local_group (Note: self should have scale=1)
|
|
self.scale = scale # call the setter
|
|
|
|
# set the anchored_position with setter after bitmap is created, sets the
|
|
# x,y positions of the label
|
|
self.anchored_position = self._anchored_position
|
|
|
|
@staticmethod
|
|
def _line_spacing_ypixels(
|
|
font: Union[BuiltinFont, BDF, PCF], line_spacing: float
|
|
) -> int:
|
|
# Note: Scaling is provided at the Group level
|
|
return_value = int(line_spacing * font.get_bounding_box()[1])
|
|
return return_value
|
|
|
|
def _text_bounding_box(
|
|
self, text: str, font: Union[BuiltinFont, BDF, PCF]
|
|
) -> Tuple[int, int, int, int, int, int]:
|
|
# pylint: disable=too-many-locals
|
|
|
|
ascender_max, descender_max = self._ascent, self._descent
|
|
|
|
lines = 1
|
|
|
|
xposition = (
|
|
x_start
|
|
) = yposition = y_start = 0 # starting x and y position (left margin)
|
|
|
|
left = None
|
|
right = x_start
|
|
top = bottom = y_start
|
|
|
|
y_offset_tight = self._ascent // 2
|
|
|
|
newline = False
|
|
line_spacing = self._line_spacing
|
|
|
|
for char in text:
|
|
|
|
if char == "\n": # newline
|
|
newline = True
|
|
|
|
else:
|
|
|
|
my_glyph = font.get_glyph(ord(char))
|
|
|
|
if my_glyph is None: # Error checking: no glyph found
|
|
print("Glyph not found: {}".format(repr(char)))
|
|
else:
|
|
if newline:
|
|
newline = False
|
|
xposition = x_start # reset to left column
|
|
yposition = yposition + self._line_spacing_ypixels(
|
|
font, line_spacing
|
|
) # Add a newline
|
|
lines += 1
|
|
if xposition == x_start:
|
|
if left is None:
|
|
left = my_glyph.dx
|
|
else:
|
|
left = min(left, my_glyph.dx)
|
|
xright = xposition + my_glyph.width + my_glyph.dx
|
|
xposition += my_glyph.shift_x
|
|
|
|
right = max(right, xposition, xright)
|
|
|
|
if yposition == y_start: # first line, find the Ascender height
|
|
top = min(top, -my_glyph.height - my_glyph.dy + y_offset_tight)
|
|
bottom = max(bottom, yposition - my_glyph.dy + y_offset_tight)
|
|
|
|
if left is None:
|
|
left = 0
|
|
|
|
final_box_width = right - left
|
|
|
|
final_box_height_tight = bottom - top
|
|
final_y_offset_tight = -top + y_offset_tight
|
|
|
|
final_box_height_loose = (lines - 1) * self._line_spacing_ypixels(
|
|
font, line_spacing
|
|
) + (ascender_max + descender_max)
|
|
final_y_offset_loose = ascender_max
|
|
|
|
# return (final_box_width, final_box_height, left, final_y_offset)
|
|
|
|
return (
|
|
final_box_width,
|
|
final_box_height_tight,
|
|
left,
|
|
final_y_offset_tight,
|
|
final_box_height_loose,
|
|
final_y_offset_loose,
|
|
)
|
|
|
|
def _place_text(
|
|
self,
|
|
bitmap: displayio.Bitmap,
|
|
text: str,
|
|
font: Union[BuiltinFont, BDF, PCF],
|
|
xposition: int,
|
|
yposition: int,
|
|
skip_index: int = 0, # set to None to write all pixels, other wise skip this palette index
|
|
# when copying glyph bitmaps (this is important for slanted text
|
|
# where rectangular glyph boxes overlap)
|
|
) -> Tuple[int, int, int, int]:
|
|
# pylint: disable=too-many-arguments, too-many-locals
|
|
|
|
# placeText - Writes text into a bitmap at the specified location.
|
|
#
|
|
# Note: scale is pushed up to Group level
|
|
|
|
x_start = xposition # starting x position (left margin)
|
|
y_start = yposition
|
|
|
|
left = None
|
|
right = x_start
|
|
top = bottom = y_start
|
|
line_spacing = self._line_spacing
|
|
|
|
for char in text:
|
|
|
|
if char == "\n": # newline
|
|
xposition = x_start # reset to left column
|
|
yposition = yposition + self._line_spacing_ypixels(
|
|
font, line_spacing
|
|
) # Add a newline
|
|
|
|
else:
|
|
|
|
my_glyph = font.get_glyph(ord(char))
|
|
|
|
if my_glyph is None: # Error checking: no glyph found
|
|
print("Glyph not found: {}".format(repr(char)))
|
|
else:
|
|
if xposition == x_start:
|
|
if left is None:
|
|
left = my_glyph.dx
|
|
else:
|
|
left = min(left, my_glyph.dx)
|
|
|
|
right = max(
|
|
right,
|
|
xposition + my_glyph.shift_x,
|
|
xposition + my_glyph.width + my_glyph.dx,
|
|
)
|
|
if yposition == y_start: # first line, find the Ascender height
|
|
top = min(top, -my_glyph.height - my_glyph.dy)
|
|
bottom = max(bottom, yposition - my_glyph.dy)
|
|
|
|
glyph_offset_x = (
|
|
my_glyph.tile_index * my_glyph.width
|
|
) # for type BuiltinFont, this creates the x-offset in the glyph bitmap.
|
|
# for BDF loaded fonts, this should equal 0
|
|
|
|
y_blit_target = yposition - my_glyph.height - my_glyph.dy
|
|
|
|
# Clip glyph y-direction if outside the font ascent/descent metrics.
|
|
# Note: bitmap.blit will automatically clip the bottom of the glyph.
|
|
y_clip = 0
|
|
if y_blit_target < 0:
|
|
y_clip = -y_blit_target # clip this amount from top of bitmap
|
|
y_blit_target = 0 # draw the clipped bitmap at y=0
|
|
|
|
print(
|
|
'Warning: Glyph clipped, exceeds Ascent property: "{}"'.format(
|
|
char
|
|
)
|
|
)
|
|
|
|
if (y_blit_target + my_glyph.height) > bitmap.height:
|
|
print(
|
|
'Warning: Glyph clipped, exceeds descent property: "{}"'.format(
|
|
char
|
|
)
|
|
)
|
|
|
|
self._blit(
|
|
bitmap,
|
|
xposition + my_glyph.dx,
|
|
y_blit_target,
|
|
my_glyph.bitmap,
|
|
x_1=glyph_offset_x,
|
|
y_1=y_clip,
|
|
x_2=glyph_offset_x + my_glyph.width,
|
|
y_2=my_glyph.height,
|
|
skip_index=skip_index, # do not copy over any 0 background pixels
|
|
)
|
|
|
|
xposition = xposition + my_glyph.shift_x
|
|
|
|
# bounding_box
|
|
return left, top, right - left, bottom - top
|
|
|
|
def _blit(
|
|
self,
|
|
bitmap: displayio.Bitmap, # target bitmap
|
|
x: int, # target x upper left corner
|
|
y: int, # target y upper left corner
|
|
source_bitmap: displayio.Bitmap, # source bitmap
|
|
x_1: int = 0, # source x start
|
|
y_1: int = 0, # source y start
|
|
x_2: int = None, # source x end
|
|
y_2: int = None, # source y end
|
|
skip_index: int = None, # palette index that will not be copied
|
|
# (for example: the background color of a glyph)
|
|
) -> None:
|
|
# pylint: disable=no-self-use, too-many-arguments
|
|
|
|
if hasattr(bitmap, "blit"): # if bitmap has a built-in blit function, call it
|
|
# this function should perform its own input checks
|
|
bitmap.blit(
|
|
x,
|
|
y,
|
|
source_bitmap,
|
|
x1=x_1,
|
|
y1=y_1,
|
|
x2=x_2,
|
|
y2=y_2,
|
|
skip_index=skip_index,
|
|
)
|
|
|
|
else: # perform pixel by pixel copy of the bitmap
|
|
|
|
# Perform input checks
|
|
|
|
if x_2 is None:
|
|
x_2 = source_bitmap.width
|
|
if y_2 is None:
|
|
y_2 = source_bitmap.height
|
|
|
|
# Rearrange so that x_1 < x_2 and y1 < y2
|
|
if x_1 > x_2:
|
|
x_1, x_2 = x_2, x_1
|
|
if y_1 > y_2:
|
|
y_1, y_2 = y_2, y_1
|
|
|
|
# Ensure that x2 and y2 are within source bitmap size
|
|
x_2 = min(x_2, source_bitmap.width)
|
|
y_2 = min(y_2, source_bitmap.height)
|
|
|
|
for y_count in range(y_2 - y_1):
|
|
for x_count in range(x_2 - x_1):
|
|
x_placement = x + x_count
|
|
y_placement = y + y_count
|
|
|
|
if (bitmap.width > x_placement >= 0) and (
|
|
bitmap.height > y_placement >= 0
|
|
): # ensure placement is within target bitmap
|
|
|
|
# get the palette index from the source bitmap
|
|
this_pixel_color = source_bitmap[
|
|
y_1
|
|
+ (
|
|
y_count * source_bitmap.width
|
|
) # Direct index into a bitmap array is speedier than [x,y] tuple
|
|
+ x_1
|
|
+ x_count
|
|
]
|
|
|
|
if (skip_index is None) or (this_pixel_color != skip_index):
|
|
bitmap[ # Direct index into a bitmap array is speedier than [x,y] tuple
|
|
y_placement * bitmap.width + x_placement
|
|
] = this_pixel_color
|
|
elif y_placement > bitmap.height:
|
|
break
|
|
|
|
def _set_line_spacing(self, new_line_spacing: float) -> None:
|
|
if self._save_text:
|
|
self._reset_text(line_spacing=new_line_spacing, scale=self.scale)
|
|
else:
|
|
raise RuntimeError("line_spacing is immutable when save_text is False")
|
|
|
|
def _set_font(self, new_font: Union[BuiltinFont, BDF, PCF]) -> None:
|
|
self._font = new_font
|
|
if self._save_text:
|
|
self._reset_text(font=new_font, scale=self.scale)
|
|
else:
|
|
raise RuntimeError("font is immutable when save_text is False")
|
|
|
|
def _set_text(self, new_text: str, scale: int) -> None:
|
|
self._reset_text(text=self._replace_tabs(new_text), scale=self.scale)
|
|
|
|
def _set_background_color(self, new_color: Optional[int]):
|
|
self._background_color = new_color
|
|
if new_color is not None:
|
|
self._palette[0] = new_color
|
|
self._palette.make_opaque(0)
|
|
else:
|
|
self._palette[0] = 0
|
|
self._palette.make_transparent(0)
|
|
|
|
def _set_label_direction(self, new_label_direction: str) -> None:
|
|
self._prev_label_direction = self._label_direction
|
|
self._label_direction = new_label_direction
|
|
self._reset_text(text=str(self._text)) # Force a recalculation
|
|
|
|
def _get_valid_label_directions(self) -> Tuple[str, ...]:
|
|
return "LTR", "RTL", "UPD", "UPR", "DWR"
|
|
|
|
@property
|
|
def bitmap(self) -> displayio.Bitmap:
|
|
"""
|
|
The Bitmap object that the text and background are drawn into.
|
|
|
|
:rtype: displayio.Bitmap
|
|
"""
|
|
return self._bitmap
|