Source code for bdflib.model

# bdflib, a library for working with BDF font files
# Copyright (C) 2009-2022, Timothy Allen
#
# 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/>.

"""
Classes that represent a bitmap font, with its glyphs and metadata.

.. testsetup::

    from bdflib.model import *
"""
from typing import Union, Optional, Dict, List, Iterable, Tuple, overload

PropertyValue = Union[bytes, int]
Properties = Dict[bytes, PropertyValue]


[docs] class GlyphExists(Exception): """ Raised when creating a new glyph for a codepoint that already has one. """
[docs] class Glyph(object): """ Represents a font glyph and associated properties. :param name: The name of this glyph, ASCII encoded. :param data: If provided, gives the initial bitmap for the glyph, see the :attr:`data` attribute below. :param bbX: The left-most edge of the glyph's bounding box, in pixels. :param bbY: The bottom-most edge of the glyph's bounding box, in pixels. :param bbW: The glyph's bounding-box extends this many pixels right of ``bbX`` (must be >= 0). If `data` is provided, each integer should be at most this many bits wide. :param bbH: The glyph's bounding-box extends this many pixels upward from ``bbY`` (must be >= 0). If `data` is provided, it should yield this many rows. :param advance: After drawing this glyph, the next glyph will be drawn this many pixels to the right. :param codepoint: The Unicode codepoint that this glyph represents. """ #: How far to the right the next glyph should be drawn, in pixels. advance: int #: Each item of the ``.data`` property #: is a :class:`int` `bbW` bits wide, #: representing the pixels of a single row. #: the first item in ``.data`` is #: the lowest row in the glyph, #: so that list indices increase in the same #: direction as pixel coordinates. #: #: >>> my_glyph = Glyph( #: ... name=b"capital A", #: ... data=[ #: ... 0b10001, #: ... 0b11111, #: ... 0b10001, #: ... 0b01110, #: ... ], #: ... bbW=5, #: ... bbH=4, #: ... ) #: >>> for row in reversed(my_glyph.data): #: ... print("{:05b}".format(row)) #: 01110 #: 10001 #: 11111 #: 10001 #: #: If you want to get the actual coordinates #: of the glyph's drawn pixels, #: look at the :meth:`iter_pixels` method. data: List[int] def __init__( self, name: bytes, data: Optional[List[int]] = None, bbX: int = 0, bbY: int = 0, bbW: int = 0, bbH: int = 0, advance: int = 0, codepoint: Optional[int] = None, ): self.name = name self.bbX = bbX self.bbY = bbY self.bbW = bbW self.bbH = bbH if data is None: self.data = [] else: self.data = data self.advance = advance if codepoint is None: self.codepoint = -1 else: self.codepoint = codepoint def __eq__(self, other: object) -> bool: return ( isinstance(other, Glyph) and self.name == other.name and self.bbX == other.bbX and self.bbY == other.bbY and self.bbW == other.bbW and self.bbH == other.bbH and self.data == other.data and self.advance == other.advance ) def __str__(self) -> str: def padding_char(x: int, y: int) -> str: if x == 0 and y == 0: return "+" elif x == 0: return "|" elif y == 0: return "-" else: return "." # What are the extents of this bitmap, given that we always want to # include the origin? bitmap_min_X = min(0, self.bbX) bitmap_max_X = max(0, self.bbX + self.bbW - 1) bitmap_min_Y = min(0, self.bbY) bitmap_max_Y = max(0, self.bbY + self.bbH - 1) res = [] for y in range(bitmap_max_Y, bitmap_min_Y - 1, -1): res_row = [] # Find the data row associated with this output row. if self.bbY <= y < self.bbY + self.bbH: data_row = self.data[y - self.bbY] else: data_row = 0 for x in range(bitmap_min_X, bitmap_max_X + 1): # Figure out which bit controls (x,y) bit_number = self.bbW - (x - self.bbX) - 1 # If we're in a cell covered by the bitmap and this particular # bit is set... if self.bbX <= x < self.bbX + self.bbW and ( data_row >> bit_number & 1 ): res_row.append("#") else: res_row.append(padding_char(x, y)) res.append("".join(res_row)) return "\n".join(res)
[docs] def get_bounding_box(self) -> Tuple[int, int, int, int]: """ Returns the position and dimensions of the glyph's bounding box. :returns: The left, bottom, width and height of the bounding box, as passed to the constructor. """ return (self.bbX, self.bbY, self.bbW, self.bbH)
[docs] def merge_glyph(self, other: "Glyph", atX: int, atY: int) -> None: """ Draw another glyph onto this one at the given coordinates. :param other: The other glyph to draw onto this one. :param atX: The other glyph's origin will be placed at this X offset in this glyph. :param atY: The other glyph's origin will be placed at this Y offset in this glyph. This glyph's bounding box will be stretch to include the area of the added glyph, but the :attr:`advance` will not be modified. """ # Calculate the new metrics new_bbX = min(self.bbX, atX + other.bbX) new_bbY = min(self.bbY, atY + other.bbY) new_bbW = ( max(self.bbX + self.bbW, atX + other.bbX + other.bbW) - new_bbX ) new_bbH = ( max(self.bbY + self.bbH, atY + other.bbY + other.bbH) - new_bbY ) # Calculate the new data new_data = [] for y in range(new_bbY, new_bbY + new_bbH): # If the old glyph has a row here... if self.bbY <= y < self.bbY + self.bbH: old_row = self.data[y - self.bbY] # If the right-hand edge of the bounding box has moved right, # we'll need to left shift the old-data to get more empty space # to draw the new glyph into. right_edge_delta = (new_bbX + new_bbW) - (self.bbX + self.bbW) if right_edge_delta > 0: old_row <<= right_edge_delta else: old_row = 0 # If the new glyph has a row here... if atY + other.bbY <= y < atY + other.bbY + other.bbH: new_row = other.data[y - other.bbY - atY] # If the new right-hand-edge ofthe bounding box if atX + other.bbX + other.bbW < new_bbX + new_bbW: new_row <<= (new_bbX + new_bbW) - ( atX + other.bbX + other.bbW ) else: new_row = 0 new_data.append(old_row | new_row) # Update our properties with calculated values self.bbX = new_bbX self.bbY = new_bbY self.bbW = new_bbW self.bbH = new_bbH self.data = new_data
[docs] def get_ascent(self) -> int: """ Returns the distance from the Y axis to the highest point of the glyph. This is zero if no part of the glyph is above the Y axis. :returns: The ascent of this glyph. """ res = self.bbY + self.bbH # Each empty row at the top of the bitmap should not be counted as part # of the ascent. for row in self.data[::-1]: if row != 0: break else: res -= 1 return res
[docs] def get_descent(self) -> int: """ Returns the distance from the Y axis to the lowest point of the glyph. This is zero if no part of the glyph is below the Y axis. :returns: The descent of this glyph. """ res = -1 * self.bbY # Each empty row at the bottom of the bitmap should not be counted as # part of the descent. for row in self.data: if row != 0: break else: res -= 1 return res
[docs] def iter_pixels(self) -> Iterable[Iterable[bool]]: """ Yields the state of pixels within the bounding box. This method returns an iterable of ``bbH`` rows, from the top of the glyph (large X values) to the bottom (small X values). Each row is an iterable of ``bbW`` booleans, from left to right. Each boolean is ``True`` if that pixel should be drawn, and otherwise ``False``. Alternatively, you can obtain the glyph data in BDF format with :meth:`get_data()`, or access the raw bitmap via the :attr:`data` property. :returns: the state of each pixel """ return ( (bool(row & (1 << self.bbW - x - 1)) for x in range(self.bbW)) for row in (self.data[self.bbH - y - 1] for y in range(self.bbH)) )
[docs] class Font(object): """ Represents the entire font and font-global properties. :param bytes name: The human-readable name of this font, ASCII encoded. :param int ptSize: The nominal size of this font in PostScript points (1/72 of an inch). :param int xdpi: The horizontal resolution of this font in dots-per-inch. :param int ydpi: The vertical resolution of this font in dots-per-inch. Instances of this class can be used like :class:`dict` instances. :class:`bytes` keys refer to the font's properties and are associated with :class:`bytes` instances, while :class:`int` keys refer to the code-points the font supports, and are associated with :class:`Glyph` instances. >>> myfont = Font( ... b"My Font", ... ptSize=12, ... xdpi=96, ... ydpi=96, ... ) >>> myfont.ptSize 12 >>> a_glyph = myfont.new_glyph_from_data( ... b"capital A", ... codepoint=65, ... ) >>> a_glyph == myfont[65] True .. note:: Some properties (the name, point-size and resolutions) are required, and although they can be examined via the ``dict`` interface, they cannot be removed with the ``del`` statement. """ #: All the glyphs in this font, #: even the ones with no associated codepoint. glyphs: List[Glyph] #: The value of the FONT field in the BDF file name: bytes = b"" #: The font's nominal size in PostScript points (1/72 of an inch), #: the first value in the SIZE field in the BDF file ptSize: int #: The font's horizontal resolution in dots-per-inch, #: the second value in the SIZE field in the BDF file xdpi: int #: The font's vertical resolution in dots-per-inch, #: the third value in the SIZE field in the BDF file ydpi: int def __init__(self, name: bytes, ptSize: int, xdpi: int, ydpi: int) -> None: """ Initialise this font object. """ self.properties: Properties = {} self.name = name self.ptSize = ptSize self.xdpi = xdpi self.ydpi = ydpi self.glyphs = [] self.glyphs_by_codepoint: Dict[int, Glyph] = {} self.comments: List[bytes] = []
[docs] def add_comment(self, comment: bytes) -> None: """ Add one or more lines of text to the font's comment field. :param comment: Human-readable text to add to the comment, ASCII encoded. It may include newline characters. The added text will begin on a new line. """ lines = comment.split(b"\n") self.comments.extend(lines)
[docs] def get_comments(self) -> List[bytes]: """ Retrieve the lines of the font's comment field. :returns: The comment text, ASCII encoded. """ return self.comments
def __setitem__(self, name: bytes, value: PropertyValue) -> None: self.properties[name] = value @overload def __getitem__(self, key: bytes) -> PropertyValue: ... @overload def __getitem__(self, key: int) -> Glyph: ... def __getitem__( self, key: Union[bytes, int] ) -> Union[PropertyValue, Glyph]: if isinstance(key, bytes): return self.properties[key] elif isinstance(key, int): return self.glyphs_by_codepoint[key] def __delitem__(self, key: Union[bytes, int]) -> None: if isinstance(key, bytes): del self.properties[key] elif isinstance(key, int): g = self.glyphs_by_codepoint[key] self.glyphs.remove(g) del self.glyphs_by_codepoint[key] def __contains__(self, key: object) -> bool: if isinstance(key, bytes): return key in self.properties elif isinstance(key, int): return key in self.glyphs_by_codepoint else: return False @overload def get(self, key: bytes) -> Optional[PropertyValue]: ... @overload def get(self, key: int) -> Optional[Glyph]: ...
[docs] def get( self, key: Union[bytes, int] ) -> Optional[Union[PropertyValue, Glyph]]: if isinstance(key, bytes): return self.properties.get(key) elif isinstance(key, int): return self.glyphs_by_codepoint.get(key)
[docs] def new_glyph_from_data( self, name: bytes, data: Optional[List[int]] = None, bbX: int = 0, bbY: int = 0, bbW: int = 0, bbH: int = 0, advance: int = 0, codepoint: Optional[int] = None, ) -> Glyph: """ Add a new :class:`Glyph` to this font. This method's arguments are passed to the :class:`Glyph` constructor. If you include the ``codepoint`` parameter, the codepoint will be included in the result of :meth:`codepoints` and you will be able to look up the glyph by codepoint later. Otherwise, the glyph will only be available via the :attr:`glyphs` property. :returns: the newly-created Glyph :raises GlyphExists: if an existing glyph is already associated with the requested codepoint. """ g = Glyph(name, data, bbX, bbY, bbW, bbH, advance, codepoint) if codepoint is not None and codepoint >= 0: if codepoint in self.glyphs_by_codepoint: old_glyph = self.glyphs_by_codepoint[codepoint] if g == old_glyph: # We've already got one! It's very nice. return old_glyph else: # Two different glyphs with the same codepoint? # Must be a mistake. raise GlyphExists( "A glyph already exists for codepoint %r" % codepoint ) else: # This is a genuinely new glyph, let's use it. self.glyphs_by_codepoint[codepoint] = g self.glyphs.append(g) return g
[docs] def copy(self) -> "Font": """ Returns a deep copy of this font. The new font, along with all of its properties and glyphs, may be modified without affecting this font. :returns: A new, independent copy of this Font """ # Create a new font object. res = Font(self.name, self.ptSize, self.xdpi, self.ydpi) # Copy the comments across. for c in self.comments: res.add_comment(c) # Copy the properties across. for p in self.properties: res[p] = self[p] # Copy the glyphs across. for g in self.glyphs: res.new_glyph_from_data( g.name, g.data, g.bbX, g.bbY, g.bbW, g.bbH, g.advance, g.codepoint, ) return res
[docs] def property_names(self) -> Iterable[bytes]: """ Returns the names of this font's properties. These names can be used with the regular dict syntax to retrieve the associated value. :returns: Property names """ return self.properties.keys()
[docs] def codepoints(self) -> Iterable[int]: """ Returns the codepoints that this font has glyphs for. These codepoints can be used with the regular dict syntax to retrieve the associated glyphs :returns: Supported codepoints """ return self.glyphs_by_codepoint.keys()