Source code for foundrytools.core.font

import contextlib
import math
from collections.abc import Generator
from io import BytesIO
from pathlib import Path
from types import TracebackType
from typing import Any, Literal, TypedDict

import defcon
from cffsubr import desubroutinize, subroutinize
from extractor import extractUFO
from fontTools.misc.cliTools import makeOutputFileName
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.statisticsPen import StatisticsPen
from fontTools.subset import Options, Subsetter
from fontTools.ttLib import TTFont
from fontTools.ttLib.scaleUpem import scale_upem
from ufo2ft.postProcessor import PostProcessor

from foundrytools import constants as const
from foundrytools.core.tables import (
    TABLES_LOOKUP,
    CFFTable,
    CmapTable,
    FvarTable,
    GdefTable,
    GlyfTable,
    GsubTable,
    HeadTable,
    HheaTable,
    HmtxTable,
    KernTable,
    NameTable,
    OS2Table,
    PostTable,
)
from foundrytools.lib.otf_builder import build_otf
from foundrytools.lib.qu2cu import quadratics_to_cubics
from foundrytools.lib.ttf_builder import build_ttf
from foundrytools.lib.unicode import prod_name_from_glyph_name
from foundrytools.utils.misc import restore_flavor

__all__ = ["Font", "FontConversionError", "FontError"]


SUBSETTER_DEFAULTS = {
    "drop_tables": [],
    "passthrough_tables": True,
    "hinting_tables": ["*"],
    "layout_features": ["*"],
    "legacy_kern": True,
    "layout_closure": True,
    "layout_scripts": ["*"],
    "ignore_missing_unicodes": True,
    "hinting": True,
    "glyph_names": True,
    "legacy_cmap": True,
    "symbol_cmap": True,
    "name_IDs": ["*"],
    "name_legacy": True,
    "name_languages": ["*"],
    "retain_gids": False,
    "notdef_glyph": True,
    "notdef_outline": True,
    "recalc_bounds": True,
    "recalc_timestamp": False,
    "prune_unicode_ranges": True,
    "prune_codepage_ranges": True,
    "recalc_average_width": True,
    "recalc_max_context": True,
    "canonical_order": False,
}


[docs] class FontError(Exception): """The ``FontError`` class is a custom exception class for font-related errors."""
[docs] class FontConversionError(Exception): """The ``FontConversionError`` class is a custom exception class for font conversion errors."""
class GlyphBounds(TypedDict): """ A type representing the bounds of a glyph. """ x_min: float y_min: float x_max: float y_max: float class StyleFlags: """ The ``Flags`` class is a helper class for working with font flags (e.g., bold, italic, oblique). :Example: .. code-block:: python from foundrytools import Font, Flags font = Font("path/to/font.ttf") flags = Flags(font) # Check if the font is bold if flags.is_bold: print("The font is bold.") # Set the font as italic flags.is_italic = True """ def __init__(self, font: "Font"): """ Initialize the ``Flags`` class. :param font: The ``Font`` object. :type font: Font """ self._font = font def __repr__(self) -> str: return ( f"<Flags is_bold={self.is_bold}, is_italic={self.is_italic}, " f"is_oblique={self.is_oblique}, is_regular={self.is_regular}>" ) def __str__(self) -> str: return ( f"Flags(is_bold={self.is_bold}, is_italic={self.is_italic}, " f"is_oblique={self.is_oblique}, is_regular={self.is_regular})" ) def __eq__(self, other: Any) -> bool: if not isinstance(other, StyleFlags): return False return all( getattr(self, attr) == getattr(other, attr) for attr in ("is_bold", "is_italic", "is_oblique", "is_regular") ) def __ne__(self, other: Any) -> bool: return not self.__eq__(other) @property def font(self) -> "Font": """ Gets the font used in the instance. This property returns the Font object associated with the instance, which can be used to modify text displays. :return: Font object associated with the instance. :rtype: Font """ return self._font @font.setter def font(self, value: "Font") -> None: """ Sets the font property with a Font object. :param value: Font object to set the font property :type value: Font """ self._font = value @contextlib.contextmanager def _update_font_properties(self) -> Generator: try: yield except Exception as e: raise FontError("An error occurred while updating font properties") from e def _set_font_style( self, bold: bool | None = None, italic: bool | None = None, regular: bool | None = None, ) -> None: if bold is not None: self.font.t_os_2.fs_selection.bold = bold self.font.t_head.mac_style.bold = bold if italic is not None: self.font.t_os_2.fs_selection.italic = italic self.font.t_head.mac_style.italic = italic if regular is not None: self.font.t_os_2.fs_selection.regular = regular @property def is_bold(self) -> bool: """ A property for getting and setting the bold bits of the font. The font is considered bold if bit 5 of the ``fsSelection`` field in the ``OS/2`` table is set to 1 and bit 0 of the ``macStyle`` field in the ``head`` table is set to 1. At the same time, bit 0 of the ``fsSelection`` field in the ``OS/2`` table is set to 0. :return: ``True`` if the font is bold, ``False`` otherwise. :rtype: bool """ try: return self.font.t_os_2.fs_selection.bold and self.font.t_head.mac_style.bold except Exception as e: raise FontError("An error occurred while checking if the font is bold") from e @is_bold.setter def is_bold(self, value: bool) -> None: with self._update_font_properties(): self._set_font_style(bold=value, regular=not value if not self.is_italic else False) @property def is_italic(self) -> bool: """ A property for getting and setting the italic bits of the font. The font is considered italic when bit 0 of the ``fsSelection`` field in the ``OS/2`` table is set to 1 and bit 0 of the ``macStyle`` field in the ``head`` table is set to 1. At the same time, bit 0 of the ``fsSelection`` field in the ``OS/2`` table is set to 0. :return: ``True`` if the font is italic, ``False`` otherwise. :rtype: bool """ try: return self.font.t_os_2.fs_selection.italic and self.font.t_head.mac_style.italic except Exception as e: raise FontError("An error occurred while checking if the font is italic") from e @is_italic.setter def is_italic(self, value: bool) -> None: with self._update_font_properties(): self._set_font_style(italic=value, regular=not value if not self.is_bold else False) @property def is_oblique(self) -> bool: """ A property for getting and setting the oblique bit of the font. :return: ``True`` if the font is oblique, ``False`` otherwise. :rtype: bool """ try: return self.font.t_os_2.fs_selection.oblique except Exception as e: raise FontError("An error occurred while checking if the font is oblique") from e @is_oblique.setter def is_oblique(self, value: bool) -> None: """Set the oblique bit in the OS/2 table.""" try: self.font.t_os_2.fs_selection.oblique = value except Exception as e: raise FontError("An error occurred while setting the oblique bit") from e @property def is_regular(self) -> bool: """ A property for getting the regular bit of the font. :return: ``True`` if the font is regular, ``False`` otherwise. :rtype: bool """ try: return self.font.t_os_2.fs_selection.regular except Exception as e: raise FontError("An error occurred while checking if the font is regular") from e def set_regular(self) -> None: """Set the regular bit in the OS/2 table.""" with self._update_font_properties(): self._set_font_style(regular=True, bold=False, italic=False)
[docs] class Font: # pylint: disable=too-many-public-methods, too-many-instance-attributes """ The ``Font`` class is a high-level wrapper around the ``TTFont`` class from the fontTools library, providing a user-friendly interface for working with font files and their data. """ def __init__( self, font_source: str | Path | BytesIO | TTFont, lazy: bool | None = None, recalc_bboxes: bool = True, recalc_timestamp: bool = False, ) -> None: """ Initialize a ``Font`` object. :param font_source: A path to a font file (``str`` or ``Path`` object), a ``BytesIO`` object or a ``TTFont`` object. :type font_source: Union[str, Path, BytesIO, TTFont] :param lazy: If ``True``, many data structures are loaded lazily, upon access only. If ``False``, many data structures are loaded immediately. The default is ``None`` which is somewhere in between. :type lazy: Optional[bool] :param recalc_bboxes: If ``True`` (the default), recalculates ``glyf``, ``CFF``, ``head`` bounding box values and ``hhea``/``vhea`` min/max values on save. Also compiles the glyphs on importing, which saves memory consumption and time. :type recalc_bboxes: bool :param recalc_timestamp: If ``True``, set the ``modified`` timestamp in the ``head`` table on save. Defaults to ``False``. :type recalc_timestamp: bool """ self._file: Path | None = None self._bytesio: BytesIO | None = None self._ttfont: TTFont | None = None self._init_font(font_source, lazy, recalc_bboxes, recalc_timestamp) self._init_tables() # Ensure tables are initialized before flags self.flags = StyleFlags(self)
[docs] def _init_font( self, font_source: str | Path | BytesIO | TTFont, lazy: bool | None, recalc_bboxes: bool, recalc_timestamp: bool, ) -> None: if isinstance(font_source, (str, Path)): self._init_from_file(font_source, lazy, recalc_bboxes, recalc_timestamp) elif isinstance(font_source, BytesIO): self._init_from_bytesio(font_source, lazy, recalc_bboxes, recalc_timestamp) elif isinstance(font_source, TTFont): self._init_from_ttfont(font_source, lazy, recalc_bboxes, recalc_timestamp) else: raise FontError( f"Invalid source type {type(font_source)}. Expected str, Path, BytesIO, or TTFont." )
[docs] def _init_from_file( self, path: str | Path, lazy: bool | None, recalc_bboxes: bool, recalc_timestamp: bool, ) -> None: self._file = Path(path).resolve() self._ttfont = TTFont( path, lazy=lazy, recalcBBoxes=recalc_bboxes, recalcTimestamp=recalc_timestamp )
[docs] def _init_from_bytesio( self, bytesio: BytesIO, lazy: bool | None, recalc_bboxes: bool, recalc_timestamp: bool ) -> None: self._bytesio = bytesio self._ttfont = TTFont( bytesio, lazy=lazy, recalcBBoxes=recalc_bboxes, recalcTimestamp=recalc_timestamp ) bytesio.close()
[docs] def _init_from_ttfont( self, ttfont: TTFont, lazy: bool | None, recalc_bboxes: bool, recalc_timestamp: bool ) -> None: self._bytesio = BytesIO() ttfont.save(self._bytesio, reorderTables=False) self._bytesio.seek(0) self._ttfont = TTFont( self._bytesio, lazy=lazy, recalcBBoxes=recalc_bboxes, recalcTimestamp=recalc_timestamp )
[docs] def _init_tables(self) -> None: """ Initialize all font table attributes to None. This method sets up the initial state for each table in the font, ensuring that they are ready to be loaded when accessed. """ self._cff: CFFTable | None = None self._cmap: CmapTable | None = None self._fvar: FvarTable | None = None self._gdef: GdefTable | None = None self._glyf: GlyfTable | None = None self._gsub: GsubTable | None = None self._head: HeadTable | None = None self._hhea: HheaTable | None = None self._hmtx: HmtxTable | None = None self._kern: KernTable | None = None self._name: NameTable | None = None self._os_2: OS2Table | None = None self._post: PostTable | None = None
[docs] def _get_table(self, table_tag: str): # type: ignore table_attr, table_cls = TABLES_LOOKUP[table_tag] if getattr(self, table_attr) is None: if self.ttfont.get(table_tag) is None: raise KeyError(f"The '{table_tag}' table is not present in the font") setattr(self, table_attr, table_cls(self.ttfont)) table = getattr(self, table_attr) if table is None: raise KeyError(f"An error occurred while loading the '{table_tag}' table") return table
def __enter__(self) -> "Font": return self def __exit__( self, exc_type: type | None, exc_value: BaseException | None, traceback: TracebackType | None, ) -> None: self.close() def __repr__(self) -> str: return f"<Font: ttfont={self.ttfont}, file={self.file}, bytesio={self.bytesio}>" @property def file(self) -> Path | None: """ A property with both getter and setter methods for the file path of the font. If the font was loaded from a file, this property will return the file path. If the font was loaded from a ``BytesIO`` object or a ``TTFont`` object, this property will return ``None``. :return: The file path of the font, if any. :rtype: Optional[Path] """ return self._file @file.setter def file(self, value: str | Path) -> None: """ Set the file path of the font. :param value: The file path of the font. :type value: Path """ if isinstance(value, str): value = Path(value) self._file = value @property def bytesio(self) -> BytesIO | None: """ A property with both getter and setter methods for the ``BytesIO`` object of the font. If the font was loaded from a ``BytesIO`` object, this property will return the ``BytesIO`` object. If the font was loaded from a file or a ``TTFont`` object, this property will return ``None``. :return: The ``BytesIO`` object of the font, if any. :rtype: Optional[BytesIO] """ return self._bytesio @bytesio.setter def bytesio(self, value: BytesIO) -> None: """ Set the ``BytesIO`` object of the font. :param value: The ``BytesIO`` object of the font. :type value: BytesIO """ self._bytesio = value @property def ttfont(self) -> TTFont: """ A property with both getter and setter methods for the underlying ``TTFont`` object of the font. :return: The ``TTFont`` object of the font. :rtype: TTFont """ return self._ttfont @ttfont.setter def ttfont(self, value: TTFont) -> None: """ Set the underlying ``TTFont`` object of the font. Args: value: The ``TTFont`` object of the font. """ self._ttfont = value @property def t_cff_(self) -> CFFTable: """ The ``CFF`` table wrapper. :return: The loaded ``CFFTable``. :rtype: CFFTable """ return self._get_table(const.T_CFF) @property def t_cmap(self) -> CmapTable: """ The ``cmap`` table wrapper. :return: The loaded ``CmapTable``. :rtype: CmapTable """ return self._get_table(const.T_CMAP) @property def t_fvar(self) -> FvarTable: """ The ``fvar`` table wrapper. :return: The loaded ``FvarTable``. :rtype: FvarTable """ return self._get_table(const.T_FVAR) @property def t_gdef(self) -> GdefTable: """ The ``GDEF`` table wrapper. :return: The loaded ``GdefTable``. :rtype: GdefTable """ return self._get_table(const.T_GDEF) @property def t_glyf(self) -> GlyfTable: """ The ``glyf`` table wrapper. :return: The loaded ``GlyfTable``. :rtype: GlyfTable """ return self._get_table(const.T_GLYF) @property def t_gsub(self) -> GsubTable: """ The ``GSUB`` table wrapper. :return: The loaded ``GsubTable``. :rtype: GsubTable """ return self._get_table(const.T_GSUB) @property def t_head(self) -> HeadTable: """ The ``head`` table wrapper. :return: The loaded ``HeadTable``. :rtype: HeadTable """ return self._get_table(const.T_HEAD) @property def t_hhea(self) -> HheaTable: """ The ``hhea`` table wrapper. :return: The loaded ``HheaTable``. :rtype: HheaTable """ return self._get_table(const.T_HHEA) @property def t_hmtx(self) -> HmtxTable: """ The ``hmtx`` table wrapper. :return: The loaded ``HmtxTable``. :rtype: HmtxTable """ return self._get_table(const.T_HMTX) @property def t_kern(self) -> KernTable: """ The ``kern`` table wrapper. :return: The loaded ``KernTable``. :rtype: KernTable """ return self._get_table(const.T_KERN) @property def t_name(self) -> NameTable: """ The ``name`` table wrapper. :return: The loaded ``NameTable``. :rtype: NameTable """ return self._get_table(const.T_NAME) @property def t_os_2(self) -> OS2Table: """ The ``OS/2`` table wrapper. :return: The loaded ``OS2Table``. :rtype: OS2Table """ return self._get_table(const.T_OS_2) @property def t_post(self) -> PostTable: """ The ``post`` table wrapper. :return: The loaded ``PostTable``. :rtype: PostTable """ return self._get_table(const.T_POST) @property def is_ps(self) -> bool: """ A read-only property for checking if the font has PostScript outlines. The font has PostScript outlines if the ``sfntVersion`` attribute of the ``TTFont`` object is ``OTTO``. :return: ``True`` if the font sfntVersion is ``OTTO``, ``False`` otherwise. :rtype: bool """ return self.ttfont.sfntVersion == const.PS_SFNT_VERSION @property def is_tt(self) -> bool: """ A read-only property for checking if the font has TrueType outlines. The font has TrueType outlines if the ``sfntVersion`` attribute of the ``TTFont`` object is ``\0\1\0\0``. :return: ``True`` if the font sfntVersion is ``\0\1\0\0``, ``False`` otherwise. :rtype: bool """ return self.ttfont.sfntVersion == const.TT_SFNT_VERSION @property def is_woff(self) -> bool: """ A read-only property for checking if the font is a WOFF font. The font is a WOFF font if the ``flavor`` attribute of the ``TTFont`` object is ``woff``. :return: ``True`` if the font flavor is ``woff``, ``False`` otherwise. :rtype: bool """ return self.ttfont.flavor == const.WOFF_FLAVOR @property def is_woff2(self) -> bool: """ A read-only property for checking if the font is a WOFF2 font. The font is a WOFF2 font if the ``flavor`` attribute of the ``TTFont`` object is ``woff2``. :return: ``True`` if the font flavor is ``woff2``, ``False`` otherwise. :rtype: bool """ return self.ttfont.flavor == const.WOFF2_FLAVOR @property def is_sfnt(self) -> bool: """ A read-only property for checking if the font is an SFNT font. The font is an SFNT font if the ``flavor`` attribute of the ``TTFont`` object is ``None``. :return: ``True`` if the font flavor is ``None``, ``False`` otherwise. :rtype: bool """ return self.ttfont.flavor is None @property def is_static(self) -> bool: """ A read-only property for checking if the font is a static font. The font is a static font if the ``TTFont`` object does not have a ``fvar`` table. :return: ``True`` if the font does not have a ``fvar`` table, ``False`` otherwise. :rtype: bool """ return self.ttfont.get(const.T_FVAR) is None @property def is_variable(self) -> bool: """ A read-only property for checking if the font is a variable font. The font is a variable font if the ``TTFont`` object has a ``fvar`` table. :return: ``True`` if the font has a ``fvar`` table, ``False`` otherwise. :rtype: bool """ return self.ttfont.get(const.T_FVAR) is not None
[docs] def save( self, file: str | Path | BytesIO, reorder_tables: bool | None = True, ) -> None: """ Save the font to a file. :param file: The file path or ``BytesIO`` object to save the font to. :type file: Union[str, Path, BytesIO] :param reorder_tables: If ``True`` (the default), reorder the tables, sorting them by tag (recommended by the OpenType specification). If ``False``, retain the original order. If ``None``, reorder by table dependency (fastest). :type reorder_tables: Optional[bool] """ self.ttfont.save(file, reorderTables=reorder_tables)
[docs] def close(self) -> None: """Close the font and delete the temporary file.""" self.ttfont.close() if self.bytesio: self.bytesio.close()
[docs] def reload(self) -> None: """Reload the font by saving it to a temporary stream and then loading it back.""" recalc_bboxes = self.ttfont.recalcBBoxes recalc_timestamp = self.ttfont.recalcTimestamp buf = BytesIO() self.ttfont.save(buf) buf.seek(0) self.ttfont = TTFont(buf, recalcBBoxes=recalc_bboxes, recalcTimestamp=recalc_timestamp) self._init_tables() self.flags = StyleFlags(self) buf.close()
[docs] def rebuild(self) -> None: """Rebuild the font by saving it as XML to a temporary stream and then loading it back.""" recalc_bboxes = self.ttfont.recalcBBoxes recalc_timestamp = self.ttfont.recalcTimestamp buf = BytesIO() self.ttfont.saveXML(buf) buf.seek(0) self.ttfont = TTFont(recalcBBoxes=recalc_bboxes, recalcTimestamp=recalc_timestamp) self.ttfont.importXML(buf) self._init_tables() self.flags = StyleFlags(self) buf.close()
[docs] def get_file_ext(self) -> str: """ Get the real extension of the font (e.g., ``.otf``, ``.ttf``, ``.woff``, ``.woff2``). :return: The real extension of the font. :rtype: str """ # Order of if statements is important. # WOFF and WOFF2 must be checked before OTF and TTF. if self.is_woff: return const.WOFF_EXTENSION if self.is_woff2: return const.WOFF2_EXTENSION if self.is_ps: return const.OTF_EXTENSION if self.is_tt: return const.TTF_EXTENSION raise ValueError("Unknown font type.")
[docs] def get_file_path( self, file: Path | None = None, output_dir: Path | None = None, overwrite: bool = True, extension: str | None = None, suffix: str = "", ) -> Path: """ Get the output file for a ``Font`` object. If ``output_dir`` is not specified, the output file will be saved in the same directory as the input file. It the output file exists and ``overwrite`` is ``False``, file name will be incremented by adding a number preceded by '#' before the extension until a non-existing file name is found. If ``suffix`` is specified, it will be appended to the file name. If the suffix is already present, it will be removed before adding it again. :param file: The file name to use for the output file. :type file: Optional[Path] :param output_dir: The output directory. :type output_dir: Optional[Path] :param overwrite: If ``True``, overwrite the output file if it exists. If ``False``, increment the file name until a non-existing file name is found. :type overwrite: bool :param extension: The extension of the output file. If not specified, the extension of the input file will be used. :type extension: Optional[str] :param suffix: The suffix to add to the file name. :type suffix: str :return: The output file. :rtype: Path """ if file is None and self.file is None: raise ValueError( "Cannot get output file for a BytesIO object without providing a file name." ) file = file or self.file if not isinstance(file, Path): raise ValueError("File must be a Path object.") out_dir = output_dir or file.parent extension = extension or self.get_file_ext() file_name = file.stem + extension # Clean up the file name by removing the extensions used as file name suffix as added by # possible previous conversions. This is necessary to avoid adding the suffix multiple # times, like in the case of a file name like 'font.woff2.ttf.woff2'. It may happen when # converting a WOFF2 font to TTF and then to WOFF2 again. if suffix != "": for ext in [ const.OTF_EXTENSION, const.TTF_EXTENSION, const.WOFF2_EXTENSION, const.WOFF_EXTENSION, ]: file_name = file_name.replace(ext, "") out_file = Path( makeOutputFileName( input=file_name, outputDir=out_dir, extension=extension, overWrite=overwrite, suffix=suffix, ) ) return out_file
[docs] def to_woff(self) -> None: """ Convert a font to WOFF. :raises FontConversionError: If the font is already a WOFF font. """ if self.is_woff: raise FontConversionError("Font is already a WOFF font.") self.ttfont.flavor = const.WOFF_FLAVOR
[docs] def to_woff2(self) -> None: """ Convert a font to WOFF2. :raises FontConversionError: If the font is already a WOFF2 font. """ if self.is_woff2: raise FontConversionError("Font is already a WOFF2 font.") self.ttfont.flavor = const.WOFF2_FLAVOR
[docs] def to_ttf(self, max_err: float = 1.0, reverse_direction: bool = True) -> None: """ Converts a PostScript font to TrueType. :param max_err: The maximum error allowed when converting the font to TrueType. Defaults to 1.0. :type max_err: float :param reverse_direction: If ``True``, reverse the direction of the contours. Defaults to ``True``. :type reverse_direction: bool :raises FontConversionError: If the font is already a TrueType font or if the font is a variable font. """ if self.is_tt: raise FontConversionError("Font is already a TrueType font.") if self.is_variable: raise FontConversionError("Conversion to TrueType is not supported for variable fonts.") build_ttf(font=self.ttfont, max_err=max_err, reverse_direction=reverse_direction)
[docs] def to_otf(self, tolerance: float = 1.0, correct_contours: bool = True) -> None: """ Converts a TrueType font to PostScript. :param tolerance: The tolerance value used to convert quadratic curves to cubic curves. Defaults to 1.0. :type tolerance: float :param correct_contours: If ``True``, correct the contours of the font by removing overlaps and tiny paths and correcting the direction of contours. Defaults to ``True``. :type correct_contours: bool :raises FontConversionError: If the font is already a PostScript font or if the font is a variable font. """ if self.is_ps: raise FontConversionError("Font is already a PostScript font.") if self.is_variable: raise FontConversionError( "Conversion to PostScript is not supported for variable fonts." ) self.t_glyf.decompose_all() charstrings = quadratics_to_cubics( font=self.ttfont, tolerance=tolerance, correct_contours=correct_contours ) build_otf(font=self.ttfont, charstrings_dict=charstrings) self.t_os_2.recalc_avg_char_width()
[docs] def to_sfnt(self) -> None: """ Convert a font to SFNT. :raises FontConversionError: If the font is already a SFNT font. """ if self.is_sfnt: raise FontConversionError("Font is already a SFNT font.") self.ttfont.flavor = None
[docs] def calc_italic_angle(self, min_slant: float = 2.0) -> float: """ Calculates the italic angle of a font by measuring the slant of the glyph 'H' or 'uni0048'. :param min_slant: The minimum slant value to consider the font italic. Defaults to 2.0. :type min_slant: float :return: The italic angle of the font. :rtype: float :raises FontError: If the font does not contain the glyph 'H' or 'uni0048' or if an error occurs while calculating the italic angle. """ glyph_set = self.ttfont.getGlyphSet() pen = StatisticsPen(glyphset=glyph_set) for g in ("H", "uni0048"): with contextlib.suppress(KeyError): glyph_set[g].draw(pen) italic_angle = -1 * math.degrees(math.atan(pen.slant)) if abs(italic_angle) >= abs(min_slant): return italic_angle return 0.0 raise FontError("The font does not contain the glyph 'H' or 'uni0048'.")
[docs] def get_glyph_bounds(self, glyph_name: str) -> GlyphBounds: """ Get the bounding box of a glyph. :param glyph_name: The glyph name. :type glyph_name: str :return: The bounding box of the glyph. :rtype: dict[str, float] """ glyph_set = self.ttfont.getGlyphSet() if glyph_name not in glyph_set: raise ValueError(f"Glyph '{glyph_name}' does not exist in the font.") bounds_pen = BoundsPen(glyphSet=glyph_set) glyph_set[glyph_name].draw(bounds_pen) bounds = GlyphBounds( x_min=bounds_pen.bounds[0], y_min=bounds_pen.bounds[1], x_max=bounds_pen.bounds[2], y_max=bounds_pen.bounds[3], ) return bounds
[docs] def get_glyph_bounds_many(self, glyph_names: set[str]) -> dict[str, GlyphBounds]: """ Get the bounding box of multiple glyphs. :param glyph_names: A set of glyph names. :type glyph_names: set[str] :return: A dictionary of glyph names and their bounding boxes. :rtype: dict[str, GlyphBounds] """ bounds_dict = {} for glyph_name in glyph_names: bounds_dict[glyph_name] = self.get_glyph_bounds(glyph_name) return bounds_dict
[docs] def scale_upm(self, target_upm: int) -> None: """ Scale the font to the specified Units Per Em (UPM) value. :param target_upm: The target UPM value. Must be in the range 16 to 16384. :type target_upm: int """ if target_upm < const.MIN_UPM or target_upm > const.MAX_UPM: raise ValueError( f"units_per_em must be in the range {const.MAX_UPM} to {const.MAX_UPM}." ) if self.t_head.units_per_em == target_upm: return scale_upem(self.ttfont, new_upem=target_upm)
[docs] def correct_contours( self, remove_hinting: bool = True, ignore_errors: bool = True, remove_unused_subroutines: bool = True, min_area: int = 25, ) -> set[str]: """ Correct the contours of a font by removing overlaps and tiny paths and correcting the direction of contours. This tool is an implementation of the ``removeOverlaps`` function in the ``fontTools`` library to add support for correcting contours winding and removing tiny paths. If one or more contours are modified, the glyf or CFF table will be rebuilt. If no contours are modified, the font will remain unchanged and the method will return an empty list. The minimum area default value, 25, is the same as ``afdko.checkoutlinesufo``. All subpaths with a bounding box less than this area will be deleted. To prevent the deletion of small subpaths, set this value to 0. :param remove_hinting: If ``True``, remove hinting instructions from the font if one or more contours are modified. Defaults to ``True``. :type remove_hinting: bool :param ignore_errors: If ``True``, ignore skia pathops errors during the correction process. Defaults to ``True``. :type ignore_errors: bool :param remove_unused_subroutines: If ``True``, remove unused subroutines from the font. Defaults to ``True``. :type remove_unused_subroutines: bool :param min_area: The minimum area expressed in square units. Subpaths with a bounding box less than this area will be deleted. Defaults to 25. :type min_area: int :return: A set of glyph names that have been modified. :rtype: set[str] """ if self.is_variable: raise NotImplementedError("Contour correction is not supported for variable fonts.") if self.is_ps: return self.t_cff_.correct_contours( remove_hinting=remove_hinting, ignore_errors=ignore_errors, remove_unused_subroutines=remove_unused_subroutines, min_area=min_area, ) if self.is_tt: return self.t_glyf.correct_contours( remove_hinting=remove_hinting, ignore_errors=ignore_errors, min_area=min_area, ) raise FontError("Unknown font type.")
[docs] def remove_glyphs( self, glyph_names_to_remove: set[str] | None = None, glyph_ids_to_remove: set[int] | None = None, ) -> set[str]: """ Removes glyphs from the font using the fontTools subsetter. :param glyph_names_to_remove: A set of glyph names to remove. :type glyph_names_to_remove: Optional[set[str]] :param glyph_ids_to_remove: A set of glyph IDs to remove. :type glyph_ids_to_remove: Optional[set[int]] :return: A set of glyph names that were removed. :rtype: set[str] """ old_glyph_order = self.ttfont.getGlyphOrder() if not glyph_names_to_remove and not glyph_ids_to_remove: raise ValueError("No glyph names or glyph IDs provided to remove.") glyph_names_to_remove = glyph_names_to_remove or set() # Convert glyph IDs to glyph names to populate the subsetter with only one parameter. if glyph_ids_to_remove: for glyph_id in glyph_ids_to_remove: if glyph_id < 0 or glyph_id >= len(old_glyph_order): continue glyph_names_to_remove.add(old_glyph_order[glyph_id]) if not glyph_names_to_remove: return set() remaining_glyphs = {gn for gn in old_glyph_order if gn not in glyph_names_to_remove} options = Options(**SUBSETTER_DEFAULTS) options.recalc_timestamp = self.ttfont.recalcTimestamp subsetter = Subsetter(options=options) subsetter.populate(glyphs=remaining_glyphs) subsetter.subset(self.ttfont) new_glyph_order = self.ttfont.getGlyphOrder() return set(old_glyph_order).difference(new_glyph_order)
[docs] def remove_unused_glyphs(self) -> set[str]: """ Remove glyphs that are not reachable by Unicode values or by substitution rules in the font. :return: A set of glyph names that were removed. :rtype: set[str] """ options = Options(**SUBSETTER_DEFAULTS) options.recalc_timestamp = self.ttfont.recalcTimestamp old_glyph_order = self.ttfont.getGlyphOrder() unicodes = self.t_cmap.get_all_codepoints() subsetter = Subsetter(options=options) subsetter.populate(unicodes=unicodes) subsetter.subset(self.ttfont) new_glyph_order = self.ttfont.getGlyphOrder() return set(old_glyph_order) - set(new_glyph_order)
[docs] def rename_glyph(self, old_name: str, new_name: str) -> bool: """ Rename a single glyph in the font. :param old_name: The old glyph name. :type old_name: str :param new_name: The new glyph name. :type new_name: str :return: ``True`` if the glyph was renamed, ``False`` otherwise. :rtype: bool """ old_glyph_order = self.ttfont.getGlyphOrder() new_glyph_order = [] if old_name not in old_glyph_order: raise ValueError(f"Glyph '{old_name}' not found in the font.") if new_name in old_glyph_order: raise ValueError(f"Glyph '{new_name}' already exists in the font.") for glyph_name in old_glyph_order: if glyph_name == old_name: new_glyph_order.append(new_name) else: new_glyph_order.append(glyph_name) rename_map = dict(zip(old_glyph_order, new_glyph_order)) PostProcessor.rename_glyphs(otf=self.ttfont, rename_map=rename_map) self.t_cmap.rebuild_character_map(remap_all=True) return new_glyph_order != old_glyph_order
[docs] def rename_glyphs(self, new_glyph_order: list[str]) -> bool: """ Rename the glyphs in the font based on the new glyph order. :param new_glyph_order: The new glyph order. :type new_glyph_order: List[str] :return: ``True`` if the glyphs were renamed, ``False`` otherwise. :rtype: bool """ old_glyph_order = self.ttfont.getGlyphOrder() if new_glyph_order == old_glyph_order: return False rename_map = dict(zip(old_glyph_order, new_glyph_order)) PostProcessor.rename_glyphs(otf=self.ttfont, rename_map=rename_map) self.t_cmap.rebuild_character_map(remap_all=True) return True
[docs] def set_production_names(self) -> list[tuple[str, str]]: """ Set the production names for the glyphs in the font. The method iterates through each glyph in the old glyph order and determines its production name based on its assigned or calculated Unicode value. If the production name is already assigned, the glyph is skipped. If the production name is different from the original glyph name and is not yet assigned, the glyph is renamed and added to the new glyph order list. Finally, the font is updated with the new glyph order, the cmap table is rebuilt, and the list of renamed glyphs is returned. :return: A list of tuples containing the old and new glyph names. :rtype: List[Tuple[str, str]] :raises SetProdNamesError: If an error occurs during the process. """ old_glyph_order: list[str] = self.ttfont.getGlyphOrder() new_glyph_order: list[str] = [] renamed_glyphs: list[tuple[str, str]] = [] for glyph_name in old_glyph_order: # In case the production name could not be found, the glyph is already named with # the production name, or the production name is already assigned, we skip the # renaming process. production_name = prod_name_from_glyph_name(glyph_name) if ( not production_name or production_name == glyph_name or production_name in old_glyph_order ): new_glyph_order.append(glyph_name) continue new_glyph_order.append(production_name) renamed_glyphs.append((glyph_name, production_name)) if not renamed_glyphs: return [] rename_map = dict(zip(old_glyph_order, new_glyph_order)) PostProcessor.rename_glyphs(otf=self.ttfont, rename_map=rename_map) self.t_cmap.rebuild_character_map(remap_all=True) return renamed_glyphs
[docs] def sort_glyphs( self, sort_by: Literal["unicode", "alphabetical", "cannedDesign"] = "unicode" ) -> bool: """ Reorder the glyphs based on the Unicode values, alphabetical order, or canned design order. :param sort_by: The sorting method. Can be one of the following values: 'unicode', 'alphabetical', or 'cannedDesign'. Defaults to 'unicode'. :type sort_by: Literal['unicode', 'alphabetical', 'cannedDesign'] :return: ``True`` if the glyphs were reordered, ``False`` otherwise. :rtype: bool """ ufo = defcon.Font() extractUFO(self.file, destination=ufo, doFeatures=False, doInfo=False, doKerning=False) old_glyph_order = self.ttfont.getGlyphOrder() new_glyph_order = ufo.unicodeData.sortGlyphNames( glyphNames=old_glyph_order, sortDescriptors=[{"type": sort_by}], ) # Ensure that the '.notdef' glyph is always the first glyph in the font as required by # the OpenType specification. If the '.notdef' glyph is not the first glyph, compiling # the CFF table will fail. # https://learn.microsoft.com/en-us/typography/opentype/spec/recom#glyph-0-the-notdef-glyph if ".notdef" in new_glyph_order: new_glyph_order.remove(".notdef") new_glyph_order.insert(0, ".notdef") if old_glyph_order == new_glyph_order: return False self.ttfont.reorderGlyphs(new_glyph_order=new_glyph_order) return True
[docs] def subroutinize(self) -> bool: """ Subroutinize the CFF table of a font. A context manager is used to allow subroutinization of WOFF and WOFF2 fonts. The context manager temporarily sets the flavor of the font to 'None' before subroutinizing the font. Then restores the original flavor after the subroutinization process. :return: True if the subroutinization process was successful. :rtype: bool """ if not self.is_ps: raise NotImplementedError("Not a PostScript font.") with restore_flavor(self.ttfont): subroutinize(self.ttfont) return True
[docs] def desubroutinize(self) -> bool: """ Desubroutinize the CFF table of a font. As with the subroutinize method, a context manager is used to allow desubroutinization of WOFF and WOFF2 fonts. :return: True if the font was desubroutinized successfully. :rtype: bool """ if not self.is_ps: raise NotImplementedError("Not a PostScript font.") with restore_flavor(self.ttfont): desubroutinize(self.ttfont) return True
[docs] def del_table(self, table_tag: str) -> bool: """ Delete a table from the font. :param table_tag: The table tag. :type table_tag: str """ if table_tag not in self.ttfont.reader.tables: return False self.ttfont.reader.tables.pop(table_tag, None) return True