From b7a7d0144ffd66340da454b6dd7fc5960669343c Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sun, 22 Jun 2025 20:43:39 +0200 Subject: [PATCH 01/22] Escape control characters in `xmlWriter` and test. --- Lib/fontTools/misc/xmlWriter.py | 6 +++ Tests/ttx/ttx_test.py | 85 ++++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/misc/xmlWriter.py b/Lib/fontTools/misc/xmlWriter.py index 9a8dc3e3b7..4a7c7667d6 100644 --- a/Lib/fontTools/misc/xmlWriter.py +++ b/Lib/fontTools/misc/xmlWriter.py @@ -3,6 +3,7 @@ from fontTools.misc.textTools import byteord, strjoin, tobytes, tostr import sys import os +import re import string INDENT = " " @@ -167,12 +168,17 @@ def stringifyattrs(self, *args, **kwargs): return data +# XML 1.0 allows only a few control characters (0x09, 0x0A, 0x0D). +_illegal_xml_chars = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") + + def escape(data): data = tostr(data, "utf_8") data = data.replace("&", "&") data = data.replace("<", "<") data = data.replace(">", ">") data = data.replace("\r", " ") + data = _illegal_xml_chars.sub(lambda m: f"&#x{ord(m.group(0)):02X};", data) return data diff --git a/Tests/ttx/ttx_test.py b/Tests/ttx/ttx_test.py index c588a1e176..bfeca0bfd1 100644 --- a/Tests/ttx/ttx_test.py +++ b/Tests/ttx/ttx_test.py @@ -1,8 +1,11 @@ from fontTools.misc.testTools import parseXML from fontTools.misc.timeTools import timestampSinceEpoch -from fontTools.ttLib import TTFont, TTLibError +from fontTools.ttLib import TTFont, TTLibError, newTable from fontTools.ttLib.tables.DefaultTable import DefaultTable +from fontTools.ttLib.tables._g_l_y_f import Glyph +from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e from fontTools import ttx + import base64 import getopt import logging @@ -69,6 +72,86 @@ def read_file(file_path): # Tests # ----- + def test_saveXML_escapes_control_characters(self): + # create font + font = TTFont() + + font.setGlyphOrder([".notdef"]) + font["head"] = newTable("head") + font["head"].tableVersion = 1.0 + font["head"].fontRevision = 1.0 + font["head"].checkSumAdjustment = 0 + font["head"].magicNumber = 0x5F0F3CF5 + font["head"].flags = 0 + font["head"].unitsPerEm = 1000 + font["head"].created = 0 + font["head"].modified = 0 + font["head"].xMin = 0 + font["head"].yMin = 0 + font["head"].xMax = 0 + font["head"].yMax = 0 + font["head"].macStyle = 0 + font["head"].lowestRecPPEM = 8 + font["head"].fontDirectionHint = 2 + font["head"].indexToLocFormat = 0 + font["head"].glyphDataFormat = 0 + + font["hhea"] = newTable("hhea") + font["hhea"].tableVersion = 0x00010000 + font["hhea"].ascent = 800 + font["hhea"].descent = -200 + font["hhea"].lineGap = 200 + font["hhea"].advanceWidthMax = 600 + font["hhea"].minLeftSideBearing = 0 + font["hhea"].minRightSideBearing = 0 + font["hhea"].xMaxExtent = 600 + font["hhea"].caretSlopeRise = 1 + font["hhea"].caretSlopeRun = 0 + font["hhea"].caretOffset = 0 + font["hhea"].reserved0 = 0 + font["hhea"].reserved1 = 0 + font["hhea"].reserved2 = 0 + font["hhea"].reserved3 = 0 + font["hhea"].metricDataFormat = 0 + font["hhea"].numberOfHMetrics = 1 + + font["maxp"] = newTable("maxp") + font["maxp"].numGlyphs = 1 + font["maxp"].tableVersion = 0x00010000 + font["maxp"].maxPoints = 0 + font["maxp"].maxContours = 0 + font["maxp"].maxCompositePoints = 0 + font["maxp"].maxCompositeContours = 0 + font["maxp"].maxZones = 1 + font["maxp"].maxTwilightPoints = 0 + font["maxp"].maxStorage = 0 + font["maxp"].maxFunctionDefs = 0 + font["maxp"].maxInstructionDefs = 0 + font["maxp"].maxStackElements = 0 + font["maxp"].maxSizeOfInstructions = 0 + font["maxp"].maxComponentElements = 0 + font["maxp"].maxComponentDepth = 0 + + font["glyf"] = newTable("glyf") + font["glyf"].glyphs = {".notdef": Glyph()} + font["glyf"].glyphOrder = [".notdef"] + + font["hmtx"] = newTable("hmtx") + font["hmtx"].metrics = {".notdef": (600, 0)} + + # Inject control character into name table + name_table = table__n_a_m_e() + name_table.setName("Control\x01Char", 1, 3, 1, 0x409) + font["name"] = name_table + + # Save to XML + self.temp_dir() + ttx_path = Path(self.tempdir) / "test.ttx" + font.saveXML(str(ttx_path)) + + xml_content = ttx_path.read_text(encoding="utf-8") + assert "" in xml_content + def test_parseOptions_no_args(self): with self.assertRaises(getopt.GetoptError) as cm: ttx.parseOptions([]) From 09468e2f76c934e2cc3e05bc943f94ae2d83b92f Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Mon, 23 Jun 2025 00:40:20 +0200 Subject: [PATCH 02/22] Update test to use `FontBuilder`. --- Tests/ttx/ttx_test.py | 103 ++++++++++++------------------------------ 1 file changed, 30 insertions(+), 73 deletions(-) diff --git a/Tests/ttx/ttx_test.py b/Tests/ttx/ttx_test.py index bfeca0bfd1..d04338a1db 100644 --- a/Tests/ttx/ttx_test.py +++ b/Tests/ttx/ttx_test.py @@ -1,9 +1,11 @@ from fontTools.misc.testTools import parseXML from fontTools.misc.timeTools import timestampSinceEpoch -from fontTools.ttLib import TTFont, TTLibError, newTable +from fontTools.ttLib import TTFont, TTLibError from fontTools.ttLib.tables.DefaultTable import DefaultTable -from fontTools.ttLib.tables._g_l_y_f import Glyph +from fontTools.fontBuilder import FontBuilder from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e +from fontTools.ttLib.tables._g_l_y_f import Glyph + from fontTools import ttx import base64 @@ -73,85 +75,40 @@ def read_file(file_path): # ----- def test_saveXML_escapes_control_characters(self): - # create font - font = TTFont() - - font.setGlyphOrder([".notdef"]) - font["head"] = newTable("head") - font["head"].tableVersion = 1.0 - font["head"].fontRevision = 1.0 - font["head"].checkSumAdjustment = 0 - font["head"].magicNumber = 0x5F0F3CF5 - font["head"].flags = 0 - font["head"].unitsPerEm = 1000 - font["head"].created = 0 - font["head"].modified = 0 - font["head"].xMin = 0 - font["head"].yMin = 0 - font["head"].xMax = 0 - font["head"].yMax = 0 - font["head"].macStyle = 0 - font["head"].lowestRecPPEM = 8 - font["head"].fontDirectionHint = 2 - font["head"].indexToLocFormat = 0 - font["head"].glyphDataFormat = 0 - - font["hhea"] = newTable("hhea") - font["hhea"].tableVersion = 0x00010000 - font["hhea"].ascent = 800 - font["hhea"].descent = -200 - font["hhea"].lineGap = 200 - font["hhea"].advanceWidthMax = 600 - font["hhea"].minLeftSideBearing = 0 - font["hhea"].minRightSideBearing = 0 - font["hhea"].xMaxExtent = 600 - font["hhea"].caretSlopeRise = 1 - font["hhea"].caretSlopeRun = 0 - font["hhea"].caretOffset = 0 - font["hhea"].reserved0 = 0 - font["hhea"].reserved1 = 0 - font["hhea"].reserved2 = 0 - font["hhea"].reserved3 = 0 - font["hhea"].metricDataFormat = 0 - font["hhea"].numberOfHMetrics = 1 - - font["maxp"] = newTable("maxp") - font["maxp"].numGlyphs = 1 - font["maxp"].tableVersion = 0x00010000 - font["maxp"].maxPoints = 0 - font["maxp"].maxContours = 0 - font["maxp"].maxCompositePoints = 0 - font["maxp"].maxCompositeContours = 0 - font["maxp"].maxZones = 1 - font["maxp"].maxTwilightPoints = 0 - font["maxp"].maxStorage = 0 - font["maxp"].maxFunctionDefs = 0 - font["maxp"].maxInstructionDefs = 0 - font["maxp"].maxStackElements = 0 - font["maxp"].maxSizeOfInstructions = 0 - font["maxp"].maxComponentElements = 0 - font["maxp"].maxComponentDepth = 0 - - font["glyf"] = newTable("glyf") - font["glyf"].glyphs = {".notdef": Glyph()} - font["glyf"].glyphOrder = [".notdef"] - - font["hmtx"] = newTable("hmtx") - font["hmtx"].metrics = {".notdef": (600, 0)} - - # Inject control character into name table + # Set up a font with one glyph and a name record containing a control char + fb = FontBuilder(unitsPerEm=1000, isTTF=True) + fb.setupGlyphOrder([".notdef"]) + fb.setupCharacterMap({}) + fb.setupGlyf({".notdef": Glyph()}) + fb.setupHorizontalMetrics({".notdef": (600, 0)}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupOS2() + fb.setupPost() + + control_string = "Control\x01Char" name_table = table__n_a_m_e() - name_table.setName("Control\x01Char", 1, 3, 1, 0x409) - font["name"] = name_table + name_table.setName(control_string, 1, 3, 1, 0x409) + fb.font["name"] = name_table - # Save to XML self.temp_dir() ttx_path = Path(self.tempdir) / "test.ttx" - font.saveXML(str(ttx_path)) + # Write to TTX + fb.font.saveXML(str(ttx_path)) + + # Ensure XML has the character escaped xml_content = ttx_path.read_text(encoding="utf-8") assert "" in xml_content + # # Read back in from TTX + # font2 = TTFont() + # font2.importXML(str(ttx_path)) + + # # Check the name table round-tripped correctly + # recovered = font2["name"].getName(1, 3, 1, 0x409) + # assert recovered is not None + # assert recovered.toUnicode() == control_string + def test_parseOptions_no_args(self): with self.assertRaises(getopt.GetoptError) as cm: ttx.parseOptions([]) From 686c5f4710eb06bead1211a6732902c7bc455bc9 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Fri, 27 Jun 2025 14:49:41 +0200 Subject: [PATCH 03/22] Add type annotations module. --- Lib/fontTools/annotations.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Lib/fontTools/annotations.py diff --git a/Lib/fontTools/annotations.py b/Lib/fontTools/annotations.py new file mode 100644 index 0000000000..6b8910fa00 --- /dev/null +++ b/Lib/fontTools/annotations.py @@ -0,0 +1,11 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Optional, Tuple, TypeVar, Union + +if TYPE_CHECKING: + from fontTools.ufoLib import UFOFormatVersion + +T = TypeVar("T") # Generic type +K = TypeVar("K") # Generic dict key type +V = TypeVar("V") # Generic dict value type + +UFOFormatVersionInput = Optional[Union[int, Tuple[int, int], UFOFormatVersion]] From a783580112f9ed5950d5a06fef5445751cfe325f Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Fri, 27 Jun 2025 14:51:25 +0200 Subject: [PATCH 04/22] Add normalizer for `ufoLib.UFOFormatVersion`. --- Lib/fontTools/ufoLib/utils.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Lib/fontTools/ufoLib/utils.py b/Lib/fontTools/ufoLib/utils.py index 45ae5e812e..cb00eb6966 100644 --- a/Lib/fontTools/ufoLib/utils.py +++ b/Lib/fontTools/ufoLib/utils.py @@ -5,9 +5,15 @@ the module. """ +from __future__ import annotations +from typing import TYPE_CHECKING import warnings import functools +if TYPE_CHECKING: + from fontTools.annotations import UFOFormatVersionInput + from fontTools.ufoLib import UFOFormatVersion + numberTypes = (int, float) @@ -40,6 +46,19 @@ def wrapper(*args, **kwargs): return deprecated_decorator +def normalizeUFOFormatVersion(value: UFOFormatVersionInput) -> UFOFormatVersion: + # Needed for type safety of UFOFormatVersion input + if value is None: + return UFOFormatVersion.default() + if isinstance(value, UFOFormatVersion): + return value + if isinstance(value, int): + return UFOFormatVersion((value, 0)) + if isinstance(value, tuple) and len(value) == 2: + return UFOFormatVersion(value) + raise ValueError(f"Unsupported UFO format: {value!r}") + + # To be mixed with enum.Enum in UFOFormatVersion and GLIFFormatVersion class _VersionTupleEnumMixin: @property From e9844d8e2266a8fc16edfbcec88472f676c969e2 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Fri, 27 Jun 2025 20:22:17 +0200 Subject: [PATCH 05/22] Reverse inadvertent changes. --- Lib/fontTools/misc/xmlWriter.py | 6 ----- Tests/ttx/ttx_test.py | 40 --------------------------------- 2 files changed, 46 deletions(-) diff --git a/Lib/fontTools/misc/xmlWriter.py b/Lib/fontTools/misc/xmlWriter.py index 4a7c7667d6..9a8dc3e3b7 100644 --- a/Lib/fontTools/misc/xmlWriter.py +++ b/Lib/fontTools/misc/xmlWriter.py @@ -3,7 +3,6 @@ from fontTools.misc.textTools import byteord, strjoin, tobytes, tostr import sys import os -import re import string INDENT = " " @@ -168,17 +167,12 @@ def stringifyattrs(self, *args, **kwargs): return data -# XML 1.0 allows only a few control characters (0x09, 0x0A, 0x0D). -_illegal_xml_chars = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]") - - def escape(data): data = tostr(data, "utf_8") data = data.replace("&", "&") data = data.replace("<", "<") data = data.replace(">", ">") data = data.replace("\r", " ") - data = _illegal_xml_chars.sub(lambda m: f"&#x{ord(m.group(0)):02X};", data) return data diff --git a/Tests/ttx/ttx_test.py b/Tests/ttx/ttx_test.py index d04338a1db..c588a1e176 100644 --- a/Tests/ttx/ttx_test.py +++ b/Tests/ttx/ttx_test.py @@ -2,12 +2,7 @@ from fontTools.misc.timeTools import timestampSinceEpoch from fontTools.ttLib import TTFont, TTLibError from fontTools.ttLib.tables.DefaultTable import DefaultTable -from fontTools.fontBuilder import FontBuilder -from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e -from fontTools.ttLib.tables._g_l_y_f import Glyph - from fontTools import ttx - import base64 import getopt import logging @@ -74,41 +69,6 @@ def read_file(file_path): # Tests # ----- - def test_saveXML_escapes_control_characters(self): - # Set up a font with one glyph and a name record containing a control char - fb = FontBuilder(unitsPerEm=1000, isTTF=True) - fb.setupGlyphOrder([".notdef"]) - fb.setupCharacterMap({}) - fb.setupGlyf({".notdef": Glyph()}) - fb.setupHorizontalMetrics({".notdef": (600, 0)}) - fb.setupHorizontalHeader(ascent=800, descent=-200) - fb.setupOS2() - fb.setupPost() - - control_string = "Control\x01Char" - name_table = table__n_a_m_e() - name_table.setName(control_string, 1, 3, 1, 0x409) - fb.font["name"] = name_table - - self.temp_dir() - ttx_path = Path(self.tempdir) / "test.ttx" - - # Write to TTX - fb.font.saveXML(str(ttx_path)) - - # Ensure XML has the character escaped - xml_content = ttx_path.read_text(encoding="utf-8") - assert "" in xml_content - - # # Read back in from TTX - # font2 = TTFont() - # font2.importXML(str(ttx_path)) - - # # Check the name table round-tripped correctly - # recovered = font2["name"].getName(1, 3, 1, 0x409) - # assert recovered is not None - # assert recovered.toUnicode() == control_string - def test_parseOptions_no_args(self): with self.assertRaises(getopt.GetoptError) as cm: ttx.parseOptions([]) From c3bf4bc1063f8c6d61658ed60a4a929c0cc15f95 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Fri, 27 Jun 2025 20:23:39 +0200 Subject: [PATCH 06/22] annotate `ufoLib.__init__`. --- Lib/fontTools/ufoLib/__init__.py | 478 ++++++++++++++++++++----------- 1 file changed, 304 insertions(+), 174 deletions(-) diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index 2c5c51d61b..82dae7cee8 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -32,6 +32,7 @@ - :func:`.convertFontInfoValueForAttributeFromVersion3ToVersion2` """ +from __future__ import annotations import os from copy import deepcopy from os import fsdecode @@ -39,23 +40,51 @@ import zipfile import enum from collections import OrderedDict + import fs import fs.base import fs.subfs -import fs.errors import fs.copy import fs.osfs import fs.zipfs import fs.tempfs import fs.tools +import fs.errors + +from typing import TYPE_CHECKING, cast, Any, Dict, List, IO, Optional, Set, Tuple, Union +from collections.abc import Callable +from fs.base import FS +from logging import Logger +from os import PathLike + from fontTools.misc import plistlib +from fontTools.annotations import K, V, UFOFormatVersionInput from fontTools.ufoLib.validators import * from fontTools.ufoLib.filenames import userNameToFileName from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning from fontTools.ufoLib.errors import UFOLibError -from fontTools.ufoLib.utils import numberTypes, _VersionTupleEnumMixin +from fontTools.ufoLib.utils import ( + numberTypes, + normalizeUFOFormatVersion, + _VersionTupleEnumMixin, +) -__all__ = [ +if TYPE_CHECKING: + from fontTools.ufoLib.glifLib import GlyphSet + +PathStr = Union[str, PathLike[str]] +PathOrFS = Union[PathStr, FS] +KerningGroupRenameMaps = Dict[str, Dict[str, str]] +KerningPair = Tuple[str, str] +IntFloat = Union[int, float] +KerningDict = Dict[KerningPair, IntFloat] +KerningNested = Dict[str, Dict[str, IntFloat]] +LibDict = Dict[str, Any] +LayerOrderList = Optional[list[Optional[str]]] +AttributeDataDict = Dict[str, Any] +FontInfoAttributes = Dict[str, AttributeDataDict] + +__all__: list[str] = [ "makeUFOPath", "UFOLibError", "UFOReader", @@ -72,32 +101,32 @@ "convertFontInfoValueForAttributeFromVersion2ToVersion1", ] -__version__ = "3.0.0" +__version__: str = "3.0.0" -logger = logging.getLogger(__name__) +logger: Logger = logging.getLogger(__name__) # --------- # Constants # --------- -DEFAULT_GLYPHS_DIRNAME = "glyphs" -DATA_DIRNAME = "data" -IMAGES_DIRNAME = "images" -METAINFO_FILENAME = "metainfo.plist" -FONTINFO_FILENAME = "fontinfo.plist" -LIB_FILENAME = "lib.plist" -GROUPS_FILENAME = "groups.plist" -KERNING_FILENAME = "kerning.plist" -FEATURES_FILENAME = "features.fea" -LAYERCONTENTS_FILENAME = "layercontents.plist" -LAYERINFO_FILENAME = "layerinfo.plist" +DEFAULT_GLYPHS_DIRNAME: str = "glyphs" +DATA_DIRNAME: str = "data" +IMAGES_DIRNAME: str = "images" +METAINFO_FILENAME: str = "metainfo.plist" +FONTINFO_FILENAME: str = "fontinfo.plist" +LIB_FILENAME: str = "lib.plist" +GROUPS_FILENAME: str = "groups.plist" +KERNING_FILENAME: str = "kerning.plist" +FEATURES_FILENAME: str = "features.fea" +LAYERCONTENTS_FILENAME: str = "layercontents.plist" +LAYERINFO_FILENAME: str = "layerinfo.plist" -DEFAULT_LAYER_NAME = "public.default" +DEFAULT_LAYER_NAME: str = "public.default" -class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum): +class UFOFormatVersion(Tuple[int, int], _VersionTupleEnumMixin, enum.Enum): FORMAT_1_0 = (1, 0) FORMAT_2_0 = (2, 0) FORMAT_3_0 = (3, 0) @@ -106,7 +135,8 @@ class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum): # python 3.11 doesn't like when a mixin overrides a dunder method like __str__ # for some reasons it keep using Enum.__str__, see # https://github.com/fonttools/fonttools/pull/2655 -UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__ +def __str__(self): + return _VersionTupleEnumMixin.__str__(self) class UFOFileStructure(enum.Enum): @@ -120,7 +150,11 @@ class UFOFileStructure(enum.Enum): class _UFOBaseIO: - def getFileModificationTime(self, path): + if TYPE_CHECKING: + fs: FS + _havePreviousFile: bool + + def getFileModificationTime(self, path: PathLike[str]) -> Optional[float]: """ Returns the modification time for the file at the given path, as a floating point number giving the number of seconds since the epoch. @@ -132,9 +166,11 @@ def getFileModificationTime(self, path): except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound): return None else: - return dt.timestamp() + if dt is not None: + return dt.timestamp() + return None - def _getPlist(self, fileName, default=None): + def _getPlist(self, fileName: str, default: Optional[Any] = None) -> Any: """ Read a property list relative to the UFO filesystem's root. Raises UFOLibError if the file is missing and default is None, @@ -158,7 +194,7 @@ def _getPlist(self, fileName, default=None): # TODO(anthrotype): try to narrow this down a little raise UFOLibError(f"'{fileName}' could not be read on {self.fs}: {e}") - def _writePlist(self, fileName, obj): + def _writePlist(self, fileName: str, obj: Any) -> None: """ Write a property list to a file relative to the UFO filesystem's root. @@ -212,12 +248,16 @@ class UFOReader(_UFOBaseIO): ``False`` to not validate the data. """ - def __init__(self, path, validate=True): - if hasattr(path, "__fspath__"): # support os.PathLike objects + def __init__( + self, path: Union[str, PathLike[str], FS], validate: bool = True + ) -> None: + # Only call __fspath__ if path is not already a str or FS object + if not isinstance(path, (str, fs.base.FS)) and hasattr(path, "__fspath__"): path = path.__fspath__() if isinstance(path, str): structure = _sniffFileStructure(path) + parentFS: FS try: if structure is UFOFileStructure.ZIP: parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8") @@ -238,7 +278,7 @@ def __init__(self, path, validate=True): if len(rootDirs) == 1: # 'ClosingSubFS' ensures that the parent zip file is closed when # its root subdirectory is closed - self.fs = parentFS.opendir( + self.fs: FS = parentFS.opendir( rootDirs[0], factory=fs.subfs.ClosingSubFS ) else: @@ -250,10 +290,10 @@ def __init__(self, path, validate=True): self.fs = parentFS # when passed a path string, we make sure we close the newly opened fs # upon calling UFOReader.close method or context manager's __exit__ - self._shouldClose = True + self._shouldClose: bool = True self._fileStructure = structure elif isinstance(path, fs.base.FS): - filesystem = path + filesystem: FS = path try: filesystem.check() except fs.errors.FilesystemClosed: @@ -275,9 +315,9 @@ def __init__(self, path, validate=True): "Expected a path string or fs.base.FS object, found '%s'" % type(path).__name__ ) - self._path = fsdecode(path) - self._validate = validate - self._upConvertedKerningData = None + self._path: str = fsdecode(path) + self._validate: bool = validate + self._upConvertedKerningData: Optional[Dict[str, Any]] = None try: self.readMetaInfo(validate=validate) @@ -287,7 +327,7 @@ def __init__(self, path, validate=True): # properties - def _get_path(self): + def _get_path(self) -> str: import warnings warnings.warn( @@ -297,9 +337,9 @@ def _get_path(self): ) return self._path - path = property(_get_path, doc="The path of the UFO (DEPRECATED).") + path: property = property(_get_path, doc="The path of the UFO (DEPRECATED).") - def _get_formatVersion(self): + def _get_formatVersion(self) -> int: import warnings warnings.warn( @@ -315,16 +355,16 @@ def _get_formatVersion(self): ) @property - def formatVersionTuple(self): + def formatVersionTuple(self) -> Tuple[int, int]: """The (major, minor) format version of the UFO. This is determined by reading metainfo.plist during __init__. """ return self._formatVersion - def _get_fileStructure(self): + def _get_fileStructure(self) -> Any: return self._fileStructure - fileStructure = property( + fileStructure: property = property( _get_fileStructure, doc=( "The file structure of the UFO: " @@ -334,7 +374,7 @@ def _get_fileStructure(self): # up conversion - def _upConvertKerning(self, validate): + def _upConvertKerning(self, validate: bool) -> None: """ Up convert kerning and groups in UFO 1 and 2. The data will be held internally until each bit of data @@ -388,7 +428,7 @@ def _upConvertKerning(self, validate): # support methods - def readBytesFromPath(self, path): + def readBytesFromPath(self, path: PathStr) -> Optional[bytes]: """ Returns the bytes in the file at the given path. The path must be relative to the UFO's filesystem root. @@ -399,7 +439,9 @@ def readBytesFromPath(self, path): except fs.errors.ResourceNotFound: return None - def getReadFileForPath(self, path, encoding=None): + def getReadFileForPath( + self, path: PathStr, encoding: Optional[str] = None + ) -> Optional[Union[IO[bytes], IO[str]]]: """ Returns a file (or file-like) object for the file at the given path. The path must be relative to the UFO path. @@ -420,7 +462,7 @@ def getReadFileForPath(self, path, encoding=None): # metainfo.plist - def _readMetaInfo(self, validate=None): + def _readMetaInfo(self, validate: Optional[bool] = None) -> Dict[str, Any]: """ Read metainfo.plist and return raw data. Only used for internal operations. @@ -462,7 +504,7 @@ def _readMetaInfo(self, validate=None): data["formatVersionTuple"] = formatVersion return data - def readMetaInfo(self, validate=None): + def readMetaInfo(self, validate: Optional[bool] = None) -> None: """ Read metainfo.plist and set formatVersion. Only used for internal operations. @@ -474,7 +516,7 @@ def readMetaInfo(self, validate=None): # groups.plist - def _readGroups(self): + def _readGroups(self) -> Dict[str, List[str]]: groups = self._getPlist(GROUPS_FILENAME, {}) # remove any duplicate glyphs in a kerning group for groupName, glyphList in groups.items(): @@ -482,7 +524,7 @@ def _readGroups(self): groups[groupName] = list(OrderedDict.fromkeys(glyphList)) return groups - def readGroups(self, validate=None): + def readGroups(self, validate: Optional[bool] = None) -> Dict[str, List[str]]: """ Read groups.plist. Returns a dict. ``validate`` will validate the read data, by default it is set to the @@ -493,7 +535,7 @@ def readGroups(self, validate=None): # handle up conversion if self._formatVersion < UFOFormatVersion.FORMAT_3_0: self._upConvertKerning(validate) - groups = self._upConvertedKerningData["groups"] + groups = cast(dict, self._upConvertedKerningData)["groups"] # normal else: groups = self._readGroups() @@ -503,7 +545,9 @@ def readGroups(self, validate=None): raise UFOLibError(message) return groups - def getKerningGroupConversionRenameMaps(self, validate=None): + def getKerningGroupRenameMaps( + self, validate: Optional[bool] = None + ) -> KerningGroupRenameMaps: """ Get maps defining the renaming that was done during any needed kerning group conversion. This method returns a @@ -527,17 +571,17 @@ def getKerningGroupConversionRenameMaps(self, validate=None): # use the public group reader to force the load and # conversion of the data if it hasn't happened yet. self.readGroups(validate=validate) - return self._upConvertedKerningData["groupRenameMaps"] + return cast(dict, self._upConvertedKerningData)["groupRenameMaps"] # fontinfo.plist - def _readInfo(self, validate): + def _readInfo(self, validate: bool) -> Dict[str, Any]: data = self._getPlist(FONTINFO_FILENAME, {}) if validate and not isinstance(data, dict): raise UFOLibError("fontinfo.plist is not properly formatted.") return data - def readInfo(self, info, validate=None): + def readInfo(self, info: Any, validate: Optional[bool] = None) -> None: """ Read fontinfo.plist. It requires an object that allows setting attributes with names that follow the fontinfo.plist @@ -596,11 +640,12 @@ def readInfo(self, info, validate=None): # kerning.plist - def _readKerning(self): + def _readKerning(self) -> KerningNested: + data = self._getPlist(KERNING_FILENAME, {}) return data - def readKerning(self, validate=None): + def readKerning(self, validate: Optional[bool] = None) -> KerningDict: """ Read kerning.plist. Returns a dict. @@ -612,7 +657,7 @@ def readKerning(self, validate=None): # handle up conversion if self._formatVersion < UFOFormatVersion.FORMAT_3_0: self._upConvertKerning(validate) - kerningNested = self._upConvertedKerningData["kerning"] + kerningNested = cast(dict, self._upConvertedKerningData)["kerning"] # normal else: kerningNested = self._readKerning() @@ -630,7 +675,7 @@ def readKerning(self, validate=None): # lib.plist - def readLib(self, validate=None): + def readLib(self, validate: Optional[bool] = None) -> Dict[str, Any]: """ Read lib.plist. Returns a dict. @@ -648,7 +693,7 @@ def readLib(self, validate=None): # features.fea - def readFeatures(self): + def readFeatures(self) -> str: """ Read features.fea. Return a string. The returned string is empty if the file is missing. @@ -661,7 +706,7 @@ def readFeatures(self): # glyph sets & layers - def _readLayerContents(self, validate): + def _readLayerContents(self, validate: bool) -> List[Tuple[str, str]]: """ Rebuild the layer contents list by checking what glyphsets are available on disk. @@ -677,7 +722,7 @@ def _readLayerContents(self, validate): raise UFOLibError(error) return contents - def getLayerNames(self, validate=None): + def getLayerNames(self, validate: Optional[bool] = None) -> List[str]: """ Get the ordered layer names from layercontents.plist. @@ -690,7 +735,7 @@ def getLayerNames(self, validate=None): layerNames = [layerName for layerName, directoryName in layerContents] return layerNames - def getDefaultLayerName(self, validate=None): + def getDefaultLayerName(self, validate: Optional[bool] = None) -> str: """ Get the default layer name from layercontents.plist. @@ -706,7 +751,12 @@ def getDefaultLayerName(self, validate=None): # this will already have been raised during __init__ raise UFOLibError("The default layer is not defined in layercontents.plist.") - def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None): + def getGlyphSet( + self, + layerName: Optional[str] = None, + validateRead: Optional[bool] = None, + validateWrite: Optional[bool] = None, + ) -> GlyphSet: """ Return the GlyphSet associated with the glyphs directory mapped to layerName @@ -747,7 +797,9 @@ def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None): expectContentsFile=True, ) - def getCharacterMapping(self, layerName=None, validate=None): + def getCharacterMapping( + self, layerName: Optional[str] = None, validate: Optional[bool] = None + ) -> Dict[int, List[str]]: """ Return a dictionary that maps unicode values (ints) to lists of glyph names. @@ -758,7 +810,7 @@ def getCharacterMapping(self, layerName=None, validate=None): layerName, validateRead=validate, validateWrite=True ) allUnicodes = glyphSet.getUnicodes() - cmap = {} + cmap: Dict[int, List[str]] = {} for glyphName, unicodes in allUnicodes.items(): for code in unicodes: if code in cmap: @@ -769,7 +821,7 @@ def getCharacterMapping(self, layerName=None, validate=None): # /data - def getDataDirectoryListing(self): + def getDataDirectoryListing(self) -> List[str]: """ Returns a list of all files in the data directory. The returned paths will be relative to the UFO. @@ -790,7 +842,7 @@ def getDataDirectoryListing(self): except fs.errors.ResourceError: return [] - def getImageDirectoryListing(self, validate=None): + def getImageDirectoryListing(self, validate: Optional[bool] = None) -> List[str]: """ Returns a list of all image file names in the images directory. Each of the images will @@ -826,7 +878,7 @@ def getImageDirectoryListing(self, validate=None): result.append(path.name) return result - def readData(self, fileName): + def readData(self, fileName: Union[str, os.PathLike[str]]) -> bytes: """ Return bytes for the file named 'fileName' inside the 'data/' directory. """ @@ -842,7 +894,9 @@ def readData(self, fileName): raise UFOLibError(f"No data file named '{fileName}' on {self.fs}") return data - def readImage(self, fileName, validate=None): + def readImage( + self, fileName: Union[str, os.PathLike[str]], validate: Optional[bool] = None + ) -> bytes: """ Return image data for the file named fileName. @@ -871,14 +925,14 @@ def readImage(self, fileName, validate=None): raise UFOLibError(error) return data - def close(self): + def close(self) -> None: if self._shouldClose: self.fs.close() - def __enter__(self): + def __enter__(self) -> UFOReader: return self - def __exit__(self, exc_type, exc_value, exc_tb): + def __exit__(self, exc_type: Any, exc_value: Any, exc_tb: Any) -> None: self.close() @@ -913,14 +967,15 @@ class UFOWriter(UFOReader): def __init__( self, - path, - formatVersion=None, - fileCreator="com.github.fonttools.ufoLib", - structure=None, - validate=True, - ): + path: PathOrFS, + formatVersion: UFOFormatVersionInput = None, + fileCreator: str = "com.github.fonttools.ufoLib", + structure: Optional[UFOFileStructure] = None, + validate: bool = True, + ) -> None: try: - formatVersion = UFOFormatVersion(formatVersion) + if formatVersion is not None: + formatVersion = normalizeUFOFormatVersion(formatVersion) except ValueError as e: from fontTools.ufoLib.errors import UnsupportedUFOFormat @@ -966,7 +1021,7 @@ def __init__( # we can't write a zip in-place, so we have to copy its # contents to a temporary location and work from there, then # upon closing UFOWriter we create the final zip file - parentFS = fs.tempfs.TempFS() + parentFS: FS = fs.tempfs.TempFS() with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS: fs.copy.copy_fs(origFS, parentFS) # if output path is an existing zip, we require that it contains @@ -1037,7 +1092,7 @@ def __init__( self._path = fsdecode(path) self._formatVersion = formatVersion self._fileCreator = fileCreator - self._downConversionKerningData = None + self._downConversionKerningData: Optional[KerningGroupRenameMaps] = None self._validate = validate # if the file already exists, get the format version. # this will be needed for up and down conversion. @@ -1055,7 +1110,7 @@ def __init__( "that is trying to be written. This is not supported." ) # handle the layer contents - self.layerContents = {} + self.layerContents: Union[Dict[str, str], OrderedDict[str, str]] = {} if previousFormatVersion is not None and previousFormatVersion.major >= 3: # already exists self.layerContents = OrderedDict(self._readLayerContents(validate)) @@ -1067,19 +1122,30 @@ def __init__( # write the new metainfo self._writeMetaInfo() + @staticmethod + def _normalizeUFOFormatVersion(value: UFOFormatVersionInput) -> UFOFormatVersion: + if isinstance(value, UFOFormatVersion): + return value + try: + return normalizeUFOFormatVersion(value) # relies on your _missing_ logic + except ValueError: + raise ValueError(f"Unsupported UFO format: {value!r}") + # properties - def _get_fileCreator(self): + def _get_fileCreator(self) -> str: return self._fileCreator - fileCreator = property( + fileCreator: property = property( _get_fileCreator, doc="The file creator of the UFO. This is set into metainfo.plist during __init__.", ) # support methods for file system interaction - def copyFromReader(self, reader, sourcePath, destPath): + def copyFromReader( + self, reader: UFOReader, sourcePath: PathStr, destPath: PathStr + ) -> None: """ Copy the sourcePath in the provided UFOReader to destPath in this writer. The paths must be relative. This works with @@ -1102,7 +1168,7 @@ def copyFromReader(self, reader, sourcePath, destPath): else: fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath) - def writeBytesToPath(self, path, data): + def writeBytesToPath(self, path: PathStr, data: bytes) -> None: """ Write bytes to a path relative to the UFO filesystem's root. If writing to an existing UFO, check to see if data matches the data @@ -1122,7 +1188,12 @@ def writeBytesToPath(self, path, data): self.fs.makedirs(fs.path.dirname(path), recreate=True) self.fs.writebytes(path, data) - def getFileObjectForPath(self, path, mode="w", encoding=None): + def getFileObjectForPath( + self, + path: PathStr, + mode: str = "w", + encoding: Optional[str] = None, + ) -> Optional[IO[Any]]: """ Returns a file (or file-like) object for the file at the given path. The path must be relative @@ -1145,9 +1216,12 @@ def getFileObjectForPath(self, path, mode="w", encoding=None): self.fs.makedirs(fs.path.dirname(path), recreate=True) return self.fs.open(path, mode=mode, encoding=encoding) except fs.errors.ResourceError as e: - return UFOLibError(f"unable to open '{path}' on {self.fs}: {e}") + raise UFOLibError(f"unable to open '{path}' on {self.fs}: {e}") + return None - def removePath(self, path, force=False, removeEmptyParents=True): + def removePath( + self, path: PathStr, force: bool = False, removeEmptyParents: bool = True + ) -> None: """ Remove the file (or directory) at path. The path must be relative to the UFO. @@ -1174,7 +1248,7 @@ def removePath(self, path, force=False, removeEmptyParents=True): # UFO mod time - def setModificationTime(self): + def setModificationTime(self) -> None: """ Set the UFO modification time to the current time. This is never called automatically. It is up to the @@ -1190,7 +1264,7 @@ def setModificationTime(self): # metainfo.plist - def _writeMetaInfo(self): + def _writeMetaInfo(self) -> None: metaInfo = dict( creator=self._fileCreator, formatVersion=self._formatVersion.major, @@ -1201,7 +1275,7 @@ def _writeMetaInfo(self): # groups.plist - def setKerningGroupConversionRenameMaps(self, maps): + def setKerningGroupRenameMaps(self, maps: KerningGroupRenameMaps) -> None: """ Set maps defining the renaming that should be done when writing groups and kerning in UFO 1 and UFO 2. @@ -1215,7 +1289,7 @@ def setKerningGroupConversionRenameMaps(self, maps): } This is the same form returned by UFOReader's - getKerningGroupConversionRenameMaps method. + getKerningGroupRenameMaps method. """ if self._formatVersion >= UFOFormatVersion.FORMAT_3_0: return # XXX raise an error here @@ -1226,7 +1300,11 @@ def setKerningGroupConversionRenameMaps(self, maps): remap[dataName] = writeName self._downConversionKerningData = dict(groupRenameMap=remap) - def writeGroups(self, groups, validate=None): + def writeGroups( + self, + groups: Dict[str, Union[List[str], Tuple[str, ...]]], + validate: Optional[bool] = None, + ) -> None: """ Write groups.plist. This method requires a dict of glyph groups as an argument. @@ -1281,7 +1359,7 @@ def writeGroups(self, groups, validate=None): # fontinfo.plist - def writeInfo(self, info, validate=None): + def writeInfo(self, info: Any, validate: Optional[bool] = None) -> None: """ Write info.plist. This method requires an object that supports getting attributes that follow the @@ -1327,7 +1405,9 @@ def writeInfo(self, info, validate=None): # kerning.plist - def writeKerning(self, kerning, validate=None): + def writeKerning( + self, kerning: KerningDict, validate: Optional[bool] = None + ) -> None: """ Write kerning.plist. This method requires a dict of kerning pairs as an argument. @@ -1371,7 +1451,7 @@ def writeKerning(self, kerning, validate=None): remappedKerning[side1, side2] = value kerning = remappedKerning # pack and write - kerningDict = {} + kerningDict: KerningNested = {} for left, right in kerning.keys(): value = kerning[left, right] if left not in kerningDict: @@ -1384,7 +1464,7 @@ def writeKerning(self, kerning, validate=None): # lib.plist - def writeLib(self, libDict, validate=None): + def writeLib(self, libDict: LibDict, validate: Optional[bool] = None) -> None: """ Write lib.plist. This method requires a lib dict as an argument. @@ -1405,7 +1485,7 @@ def writeLib(self, libDict, validate=None): # features.fea - def writeFeatures(self, features, validate=None): + def writeFeatures(self, features: str, validate: Optional[bool] = None) -> None: """ Write features.fea. This method requires a features string as an argument. @@ -1424,7 +1504,9 @@ def writeFeatures(self, features, validate=None): # glyph sets & layers - def writeLayerContents(self, layerOrder=None, validate=None): + def writeLayerContents( + self, layerOrder: LayerOrderList = None, validate: Optional[bool] = None + ) -> None: """ Write the layercontents.plist file. This method *must* be called after all glyph sets have been written. @@ -1434,7 +1516,7 @@ def writeLayerContents(self, layerOrder=None, validate=None): if self._formatVersion < UFOFormatVersion.FORMAT_3_0: return if layerOrder is not None: - newOrder = [] + newOrder: List[Optional[str]] = [] for layerName in layerOrder: if layerName is None: layerName = DEFAULT_LAYER_NAME @@ -1447,11 +1529,13 @@ def writeLayerContents(self, layerOrder=None, validate=None): "The layer order content does not match the glyph sets that have been created." ) layerContents = [ - (layerName, self.layerContents[layerName]) for layerName in layerOrder + (layerName, self.layerContents[layerName]) + for layerName in layerOrder + if layerName is not None ] self._writePlist(LAYERCONTENTS_FILENAME, layerContents) - def _findDirectoryForLayerName(self, layerName): + def _findDirectoryForLayerName(self, layerName: Optional[str]) -> str: foundDirectory = None for existingLayerName, directoryName in list(self.layerContents.items()): if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME: @@ -1467,15 +1551,15 @@ def _findDirectoryForLayerName(self, layerName): ) return foundDirectory - def getGlyphSet( + def getGlyphSet( # type: ignore[override] self, - layerName=None, - defaultLayer=True, - glyphNameToFileNameFunc=None, - validateRead=None, - validateWrite=None, - expectContentsFile=False, - ): + layerName: Optional[str] = None, + defaultLayer: bool = True, + glyphNameToFileNameFunc: Optional[Callable[[str], str]] = None, + validateRead: Optional[bool] = None, + validateWrite: Optional[bool] = None, + expectContentsFile: bool = False, + ) -> GlyphSet: """ Return the GlyphSet object associated with the appropriate glyph directory in the .ufo. @@ -1535,11 +1619,12 @@ def getGlyphSet( def _getDefaultGlyphSet( self, - validateRead, - validateWrite, - glyphNameToFileNameFunc=None, - expectContentsFile=False, - ): + validateRead: Optional[bool], + validateWrite: Optional[bool], + glyphNameToFileNameFunc: Optional[Callable[[str], str]] = None, + expectContentsFile: bool = False, + ) -> GlyphSet: + from fontTools.ufoLib.glifLib import GlyphSet glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True) @@ -1554,13 +1639,14 @@ def _getDefaultGlyphSet( def _getGlyphSetFormatVersion3( self, - validateRead, - validateWrite, - layerName=None, - defaultLayer=True, - glyphNameToFileNameFunc=None, - expectContentsFile=False, - ): + validateRead: Optional[bool], + validateWrite: Optional[bool], + layerName: Optional[str] = None, + defaultLayer: bool = True, + glyphNameToFileNameFunc: Optional[Callable[[str], str]] = None, + expectContentsFile: bool = False, + ) -> GlyphSet: + from fontTools.ufoLib.glifLib import GlyphSet # if the default flag is on, make sure that the default in the file @@ -1578,6 +1664,11 @@ def _getGlyphSetFormatVersion3( raise UFOLibError( "The layer name is already mapped to a non-default layer." ) + + # handle layerName is None to avoid MyPy errors + if layerName is None: + raise TypeError("'leyerName' cannot be None.") + # get an existing directory name if layerName in self.layerContents: directory = self.layerContents[layerName] @@ -1606,7 +1697,12 @@ def _getGlyphSetFormatVersion3( expectContentsFile=expectContentsFile, ) - def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): + def renameGlyphSet( + self, + layerName: Optional[str], + newLayerName: Optional[str], + defaultLayer: bool = False, + ) -> None: """ Rename a glyph set. @@ -1620,7 +1716,7 @@ def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): return # the new and old names can be the same # as long as the default is being switched - if layerName == newLayerName: + if layerName is not None and layerName == newLayerName: # if the default is off and the layer is already not the default, skip if ( self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME @@ -1649,12 +1745,13 @@ def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): newLayerName, existing=existing, prefix="glyphs." ) # update the internal mapping - del self.layerContents[layerName] + if layerName is not None: + del self.layerContents[layerName] self.layerContents[newLayerName] = newDirectory # do the file system copy self.fs.movedir(oldDirectory, newDirectory, create=True) - def deleteGlyphSet(self, layerName): + def deleteGlyphSet(self, layerName: Optional[str]) -> None: """ Remove the glyph set matching layerName. """ @@ -1664,16 +1761,17 @@ def deleteGlyphSet(self, layerName): return foundDirectory = self._findDirectoryForLayerName(layerName) self.removePath(foundDirectory, removeEmptyParents=False) - del self.layerContents[layerName] + if layerName is not None: + del self.layerContents[layerName] - def writeData(self, fileName, data): + def writeData(self, fileName: Union[str, PathLike[str]], data: bytes) -> None: """ Write data to fileName in the 'data' directory. The data must be a bytes string. """ self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data) - def removeData(self, fileName): + def removeData(self, fileName: Union[str, PathLike[str]]) -> None: """ Remove the file named fileName from the data directory. """ @@ -1681,7 +1779,12 @@ def removeData(self, fileName): # /images - def writeImage(self, fileName, data, validate=None): + def writeImage( + self, + fileName: Union[str, os.PathLike[str]], + data: bytes, + validate: Optional[bool] = None, + ) -> None: """ Write data to fileName in the images directory. The data must be a valid PNG. @@ -1699,7 +1802,11 @@ def writeImage(self, fileName, data, validate=None): raise UFOLibError(error) self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data) - def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'? + def removeImage( + self, + fileName: Union[str, os.PathLike[str]], + validate: Optional[bool] = None, + ) -> None: # XXX remove unused 'validate'? """ Remove the file named fileName from the images directory. @@ -1710,7 +1817,13 @@ def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'? ) self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}") - def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None): + def copyImageFromReader( + self, + reader: UFOReader, + sourceFileName: Union[str, os.PathLike[str]], + destFileName: Union[str, os.PathLike[str]], + validate: Optional[bool] = None, + ) -> None: """ Copy the sourceFileName in the provided UFOReader to destFileName in this writer. This uses the most memory efficient method possible @@ -1726,7 +1839,7 @@ def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=Non destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}" self.copyFromReader(reader, sourcePath, destPath) - def close(self): + def close(self) -> None: if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP: # if we are updating an existing zip file, we can now compress the # contents of the temporary filesystem in the destination path @@ -1745,7 +1858,7 @@ def close(self): # ---------------- -def _sniffFileStructure(ufo_path): +def _sniffFileStructure(ufo_path: PathStr) -> UFOFileStructure: """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str) is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a directory. @@ -1764,7 +1877,7 @@ def _sniffFileStructure(ufo_path): raise UFOLibError("No such file or directory: '%s'" % ufo_path) -def makeUFOPath(path): +def makeUFOPath(path: PathStr) -> str: """ Return a .ufo pathname. @@ -1791,7 +1904,7 @@ def makeUFOPath(path): # cases of invalid values. -def validateFontInfoVersion2ValueForAttribute(attr, value): +def validateFontInfoVersion2ValueForAttribute(attr: str, value: Any) -> bool: """ This performs very basic validation of the value for attribute following the UFO 2 fontinfo.plist specification. The results @@ -1806,18 +1919,19 @@ def validateFontInfoVersion2ValueForAttribute(attr, value): validator = dataValidationDict.get("valueValidator") valueOptions = dataValidationDict.get("valueOptions") # have specific options for the validator - if valueOptions is not None: - isValidValue = validator(value, valueOptions) - # no specific options - else: - if validator == genericTypeValidator: - isValidValue = validator(value, valueType) + if validator: + if valueOptions is not None: + isValidValue = validator(value, valueOptions) + # no specific options else: - isValidValue = validator(value) + if validator == genericTypeValidator: + isValidValue = validator(value, valueType) + else: + isValidValue = validator(value) return isValidValue -def validateInfoVersion2Data(infoData): +def validateInfoVersion2Data(infoData: Dict[str, Any]) -> Dict[str, Any]: """ This performs very basic validation of the value for infoData following the UFO 2 fontinfo.plist specification. The results @@ -1837,7 +1951,7 @@ def validateInfoVersion2Data(infoData): return validInfoData -def validateFontInfoVersion3ValueForAttribute(attr, value): +def validateFontInfoVersion3ValueForAttribute(attr: str, value: Any) -> bool: """ This performs very basic validation of the value for attribute following the UFO 3 fontinfo.plist specification. The results @@ -1852,18 +1966,19 @@ def validateFontInfoVersion3ValueForAttribute(attr, value): validator = dataValidationDict.get("valueValidator") valueOptions = dataValidationDict.get("valueOptions") # have specific options for the validator - if valueOptions is not None: - isValidValue = validator(value, valueOptions) - # no specific options - else: - if validator == genericTypeValidator: - isValidValue = validator(value, valueType) + if validator: + if valueOptions is not None: + isValidValue = validator(value, valueOptions) + # no specific options else: - isValidValue = validator(value) + if validator == genericTypeValidator: + isValidValue = validator(value, valueType) + else: + isValidValue = validator(value) return isValidValue -def validateInfoVersion3Data(infoData): +def validateInfoVersion3Data(infoData: Dict[str, Any]) -> Dict[str, Any]: """ This performs very basic validation of the value for infoData following the UFO 3 fontinfo.plist specification. The results @@ -1896,7 +2011,7 @@ def validateInfoVersion3Data(infoData): # cases the possible values, that can exist is # fontinfo.plist. -fontInfoAttributesVersion1 = { +fontInfoAttributesVersion1: Set[str] = { "familyName", "styleName", "fullName", @@ -1939,7 +2054,7 @@ def validateInfoVersion3Data(infoData): "ttVersion", } -fontInfoAttributesVersion2ValueData = { +fontInfoAttributesVersion2ValueData: FontInfoAttributes = { "familyName": dict(type=str), "styleName": dict(type=str), "styleMapFamilyName": dict(type=str), @@ -2081,9 +2196,11 @@ def validateInfoVersion3Data(infoData): "macintoshFONDFamilyID": dict(type=int), "macintoshFONDName": dict(type=str), } -fontInfoAttributesVersion2 = set(fontInfoAttributesVersion2ValueData.keys()) +fontInfoAttributesVersion2: Set[str] = set(fontInfoAttributesVersion2ValueData.keys()) -fontInfoAttributesVersion3ValueData = deepcopy(fontInfoAttributesVersion2ValueData) +fontInfoAttributesVersion3ValueData: FontInfoAttributes = deepcopy( + fontInfoAttributesVersion2ValueData +) fontInfoAttributesVersion3ValueData.update( { "versionMinor": dict(type=int, valueValidator=genericNonNegativeIntValidator), @@ -2166,7 +2283,7 @@ def validateInfoVersion3Data(infoData): "guidelines": dict(type=list, valueValidator=guidelinesValidator), } ) -fontInfoAttributesVersion3 = set(fontInfoAttributesVersion3ValueData.keys()) +fontInfoAttributesVersion3: set[str] = set(fontInfoAttributesVersion3ValueData.keys()) # insert the type validator for all attrs that # have no defined validator. @@ -2183,14 +2300,14 @@ def validateInfoVersion3Data(infoData): # to version 2 or vice-versa. -def _flipDict(d): +def _flipDict(d: Dict[K, V]) -> Dict[V, K]: flipped = {} for key, value in list(d.items()): flipped[value] = key return flipped -fontInfoAttributesVersion1To2 = { +fontInfoAttributesVersion1To2: Dict[str, str] = { "menuName": "styleMapFamilyName", "designer": "openTypeNameDesigner", "designerURL": "openTypeNameDesignerURL", @@ -2222,12 +2339,17 @@ def _flipDict(d): fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2) deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys()) -_fontStyle1To2 = {64: "regular", 1: "italic", 32: "bold", 33: "bold italic"} -_fontStyle2To1 = _flipDict(_fontStyle1To2) +_fontStyle1To2: Dict[int, str] = { + 64: "regular", + 1: "italic", + 32: "bold", + 33: "bold italic", +} +_fontStyle2To1: Dict[str, int] = _flipDict(_fontStyle1To2) # Some UFO 1 files have 0 _fontStyle1To2[0] = "regular" -_widthName1To2 = { +_widthName1To2: Dict[str, int] = { "Ultra-condensed": 1, "Extra-condensed": 2, "Condensed": 3, @@ -2238,7 +2360,7 @@ def _flipDict(d): "Extra-expanded": 8, "Ultra-expanded": 9, } -_widthName2To1 = _flipDict(_widthName1To2) +_widthName2To1: Dict[int, str] = _flipDict(_widthName1To2) # FontLab's default width value is "Normal". # Many format version 1 UFOs will have this. _widthName1To2["Normal"] = 5 @@ -2250,7 +2372,7 @@ def _flipDict(d): # "Medium" appears in a lot of UFO 1 files. _widthName1To2["Medium"] = 5 -_msCharSet1To2 = { +_msCharSet1To2: Dict[int, int] = { 0: 1, 1: 2, 2: 3, @@ -2272,12 +2394,14 @@ def _flipDict(d): 238: 19, 255: 20, } -_msCharSet2To1 = _flipDict(_msCharSet1To2) +_msCharSet2To1: Dict[int, int] = _flipDict(_msCharSet1To2) # 1 <-> 2 -def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value): +def convertFontInfoValueForAttributeFromVersion1ToVersion2( + attr: str, value: Any +) -> Tuple[str, Any]: """ Convert value from version 1 to version 2 format. Returns the new attribute name and the converted value. @@ -2289,7 +2413,7 @@ def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value): value = int(value) if value is not None: if attr == "fontStyle": - v = _fontStyle1To2.get(value) + v: Optional[Union[str, int]] = _fontStyle1To2.get(value) if v is None: raise UFOLibError( f"Cannot convert value ({value!r}) for attribute {attr}." @@ -2313,7 +2437,9 @@ def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value): return attr, value -def convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value): +def convertFontInfoValueForAttributeFromVersion2ToVersion1( + attr: str, value: Any +) -> Tuple[str, Any]: """ Convert value from version 2 to version 1 format. Returns the new attribute name and the converted value. @@ -2330,7 +2456,7 @@ def convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value): return attr, value -def _convertFontInfoDataVersion1ToVersion2(data): +def _convertFontInfoDataVersion1ToVersion2(data: Dict[str, Any]) -> Dict[str, Any]: converted = {} for attr, value in list(data.items()): # FontLab gives -1 for the weightValue @@ -2354,7 +2480,7 @@ def _convertFontInfoDataVersion1ToVersion2(data): return converted -def _convertFontInfoDataVersion2ToVersion1(data): +def _convertFontInfoDataVersion2ToVersion1(data: Dict[str, Any]) -> Dict[str, Any]: converted = {} for attr, value in list(data.items()): newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1( @@ -2375,16 +2501,16 @@ def _convertFontInfoDataVersion2ToVersion1(data): # 2 <-> 3 -_ufo2To3NonNegativeInt = { +_ufo2To3NonNegativeInt: Set[str] = { "versionMinor", "openTypeHeadLowestRecPPEM", "openTypeOS2WinAscent", "openTypeOS2WinDescent", } -_ufo2To3NonNegativeIntOrFloat = { +_ufo2To3NonNegativeIntOrFloat: Set[str] = { "unitsPerEm", } -_ufo2To3FloatToInt = { +_ufo2To3FloatToInt: Set[str] = { "openTypeHeadLowestRecPPEM", "openTypeHheaAscender", "openTypeHheaDescender", @@ -2412,7 +2538,9 @@ def _convertFontInfoDataVersion2ToVersion1(data): } -def convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value): +def convertFontInfoValueForAttributeFromVersion2ToVersion3( + attr: str, value: Any +) -> Tuple[str, Any]: """ Convert value from version 2 to version 3 format. Returns the new attribute name and the converted value. @@ -2440,7 +2568,9 @@ def convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value): return attr, value -def convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value): +def convertFontInfoValueForAttributeFromVersion3ToVersion2( + attr: str, value: Any +) -> Tuple[str, Any]: """ Convert value from version 3 to version 2 format. Returns the new attribute name and the converted value. @@ -2449,7 +2579,7 @@ def convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value): return attr, value -def _convertFontInfoDataVersion3ToVersion2(data): +def _convertFontInfoDataVersion3ToVersion2(data: Dict[str, Any]) -> Dict[str, Any]: converted = {} for attr, value in list(data.items()): newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2( @@ -2461,7 +2591,7 @@ def _convertFontInfoDataVersion3ToVersion2(data): return converted -def _convertFontInfoDataVersion2ToVersion3(data): +def _convertFontInfoDataVersion2ToVersion3(data: Dict[str, Any]) -> Dict[str, Any]: converted = {} for attr, value in list(data.items()): attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3( From 8a7b2ddfd08e5bbb46623228676a1297cc5f99a6 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Fri, 27 Jun 2025 20:24:29 +0200 Subject: [PATCH 07/22] Annotate `ufoLib.utils`. --- Lib/fontTools/ufoLib/utils.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/ufoLib/utils.py b/Lib/fontTools/ufoLib/utils.py index cb00eb6966..22d639917c 100644 --- a/Lib/fontTools/ufoLib/utils.py +++ b/Lib/fontTools/ufoLib/utils.py @@ -6,7 +6,8 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar, cast +from collections.abc import Callable import warnings import functools @@ -15,10 +16,11 @@ from fontTools.ufoLib import UFOFormatVersion +F = TypeVar("F", bound=Callable[..., object]) numberTypes = (int, float) -def deprecated(msg=""): +def deprecated(msg: str = "") -> Callable[[F], F]: """Decorator factory to mark functions as deprecated with given message. >>> @deprecated("Enough!") @@ -31,7 +33,7 @@ def deprecated(msg=""): True """ - def deprecated_decorator(func): + def deprecated_decorator(func: F) -> F: @functools.wraps(func) def wrapper(*args, **kwargs): warnings.warn( @@ -41,7 +43,7 @@ def wrapper(*args, **kwargs): ) return func(*args, **kwargs) - return wrapper + return cast(F, wrapper) return deprecated_decorator From ab798326d365d23be3bf6e535e11fbeb9f63f162 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sat, 28 Jun 2025 09:13:28 +0200 Subject: [PATCH 08/22] Move type variables to `annotations` module. --- Lib/fontTools/annotations.py | 4 +++- Lib/fontTools/ufoLib/__init__.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/annotations.py b/Lib/fontTools/annotations.py index 6b8910fa00..02f31157f9 100644 --- a/Lib/fontTools/annotations.py +++ b/Lib/fontTools/annotations.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING, Dict, Optional, Tuple, TypeVar, Union if TYPE_CHECKING: from fontTools.ufoLib import UFOFormatVersion @@ -8,4 +8,6 @@ K = TypeVar("K") # Generic dict key type V = TypeVar("V") # Generic dict value type +IntFloat = Union[int, float] +KerningNested = Dict[str, Dict[str, IntFloat]] UFOFormatVersionInput = Optional[Union[int, Tuple[int, int], UFOFormatVersion]] diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index 82dae7cee8..f65416ab77 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -58,7 +58,7 @@ from os import PathLike from fontTools.misc import plistlib -from fontTools.annotations import K, V, UFOFormatVersionInput +from fontTools.annotations import K, V, IntFloat, KerningNested, UFOFormatVersionInput from fontTools.ufoLib.validators import * from fontTools.ufoLib.filenames import userNameToFileName from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning @@ -76,9 +76,7 @@ PathOrFS = Union[PathStr, FS] KerningGroupRenameMaps = Dict[str, Dict[str, str]] KerningPair = Tuple[str, str] -IntFloat = Union[int, float] KerningDict = Dict[KerningPair, IntFloat] -KerningNested = Dict[str, Dict[str, IntFloat]] LibDict = Dict[str, Any] LayerOrderList = Optional[list[Optional[str]]] AttributeDataDict = Dict[str, Any] From 9848db4154050868b2612b1d7661baf758f4a39e Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sat, 28 Jun 2025 09:14:24 +0200 Subject: [PATCH 09/22] Annotate `ufoLib.converters`. --- Lib/fontTools/ufoLib/converters.py | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/ufoLib/converters.py b/Lib/fontTools/ufoLib/converters.py index 4ee6b05e33..c280290f84 100644 --- a/Lib/fontTools/ufoLib/converters.py +++ b/Lib/fontTools/ufoLib/converters.py @@ -1,3 +1,11 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, Dict, List, Set, Tuple, Mapping, Optional, Any + +from fontTools.annotations import KerningNested + +if TYPE_CHECKING: + from fontTools.ufoLib.glifLib import GlyphSet + """ Functions for converting UFO1 or UFO2 files into UFO3 format. @@ -9,7 +17,15 @@ # adapted from the UFO spec -def convertUFO1OrUFO2KerningToUFO3Kerning(kerning, groups, glyphSet=()): +def convertUFO1OrUFO2KerningToUFO3Kerning( + kerning: KerningNested, + groups: Dict[str, List[str]], + glyphSet: Optional[GlyphSet] = None, +) -> Tuple[ + KerningNested, + Dict[str, List[str]], + Dict[str, Dict[str, str]], +]: """Convert kerning data in UFO1 or UFO2 syntax into UFO3 syntax. Args: @@ -32,15 +48,15 @@ def convertUFO1OrUFO2KerningToUFO3Kerning(kerning, groups, glyphSet=()): firstReferencedGroups, secondReferencedGroups = findKnownKerningGroups(groups) # Make lists of groups referenced in kerning pairs. for first, seconds in list(kerning.items()): - if first in groups and first not in glyphSet: + if glyphSet and first in groups and first not in glyphSet: if not first.startswith("public.kern1."): firstReferencedGroups.add(first) for second in list(seconds.keys()): - if second in groups and second not in glyphSet: + if glyphSet and second in groups and second not in glyphSet: if not second.startswith("public.kern2."): secondReferencedGroups.add(second) # Create new names for these groups. - firstRenamedGroups = {} + firstRenamedGroups: Dict[str, str] = {} for first in firstReferencedGroups: # Make a list of existing group names. existingGroupNames = list(groups.keys()) + list(firstRenamedGroups.keys()) @@ -52,7 +68,7 @@ def convertUFO1OrUFO2KerningToUFO3Kerning(kerning, groups, glyphSet=()): newName = makeUniqueGroupName(newName, existingGroupNames) # Store for use later. firstRenamedGroups[first] = newName - secondRenamedGroups = {} + secondRenamedGroups: Dict[str, str] = {} for second in secondReferencedGroups: # Make a list of existing group names. existingGroupNames = list(groups.keys()) + list(secondRenamedGroups.keys()) @@ -84,7 +100,7 @@ def convertUFO1OrUFO2KerningToUFO3Kerning(kerning, groups, glyphSet=()): return newKerning, groups, dict(side1=firstRenamedGroups, side2=secondRenamedGroups) -def findKnownKerningGroups(groups): +def findKnownKerningGroups(groups: Mapping[str, Any]) -> Tuple[Set[str], Set[str]]: """Find all kerning groups in a UFO1 or UFO2 font that use known prefixes. In some cases, not all kerning groups will be referenced @@ -150,7 +166,7 @@ def findKnownKerningGroups(groups): return firstGroups, secondGroups -def makeUniqueGroupName(name, groupNames, counter=0): +def makeUniqueGroupName(name: str, groupNames: List[str], counter: int = 0) -> str: """Make a kerning group name that will be unique within the set of group names. If the requested kerning group name already exists within the set, this From 34add16d6cda6287712800064211af54350ea2d9 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sat, 28 Jun 2025 09:17:09 +0200 Subject: [PATCH 10/22] Annotate `ufoLib.filenames`. --- Lib/fontTools/ufoLib/filenames.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/ufoLib/filenames.py b/Lib/fontTools/ufoLib/filenames.py index 83442f1c8c..917acea091 100644 --- a/Lib/fontTools/ufoLib/filenames.py +++ b/Lib/fontTools/ufoLib/filenames.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from collections.abc import Iterable +from typing import Set + """ Convert user-provided internal UFO names to spec-compliant filenames. @@ -27,7 +32,7 @@ # inclusive. # 3. Various characters that (mostly) Windows and POSIX-y filesystems don't # allow, plus "(" and ")", as per the specification. -illegalCharacters = { +illegalCharacters: Set[str] = { "\x00", "\x01", "\x02", @@ -76,7 +81,7 @@ "|", "\x7f", } -reservedFileNames = { +reservedFileNames: Set[str] = { "aux", "clock$", "com1", @@ -101,14 +106,16 @@ "nul", "prn", } -maxFileNameLength = 255 +maxFileNameLength: int = 255 class NameTranslationError(Exception): pass -def userNameToFileName(userName: str, existing=(), prefix="", suffix=""): +def userNameToFileName( + userName: str, existing: Iterable[str] = (), prefix: str = "", suffix: str = "" +) -> str: """Converts from a user name to a file name. Takes care to avoid illegal characters, reserved file names, ambiguity between @@ -212,7 +219,9 @@ def userNameToFileName(userName: str, existing=(), prefix="", suffix=""): return fullName -def handleClash1(userName, existing=[], prefix="", suffix=""): +def handleClash1( + userName: str, existing: Iterable[str] = [], prefix: str = "", suffix: str = "" +) -> str: """A helper function that resolves collisions with existing names when choosing a filename. This function attempts to append an unused integer counter to the filename. @@ -278,7 +287,9 @@ def handleClash1(userName, existing=[], prefix="", suffix=""): return finalName -def handleClash2(existing=[], prefix="", suffix=""): +def handleClash2( + existing: Iterable[str] = [], prefix: str = "", suffix: str = "" +) -> str: """A helper function that resolves collisions with existing names when choosing a filename. This function is a fallback to :func:`handleClash1`. It attempts to append an unused integer counter to the filename. From 49ce2618cc4eb07360f317562178626f04f3b971 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sun, 29 Jun 2025 16:38:56 +0200 Subject: [PATCH 11/22] Update type annotations. --- Lib/fontTools/annotations.py | 17 ++- Lib/fontTools/ufoLib/__init__.py | 159 +++++++++++++++-------------- Lib/fontTools/ufoLib/converters.py | 24 ++--- Lib/fontTools/ufoLib/utils.py | 53 +++++----- 4 files changed, 135 insertions(+), 118 deletions(-) diff --git a/Lib/fontTools/annotations.py b/Lib/fontTools/annotations.py index 02f31157f9..e2e64c8e53 100644 --- a/Lib/fontTools/annotations.py +++ b/Lib/fontTools/annotations.py @@ -1,13 +1,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, Optional, Tuple, TypeVar, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: + from typing import Optional, TypeVar, Union + from collections.abc import Callable + from fs.base import FS + from os import PathLike + from xml.etree.ElementTree import Element as ElementTreeElement + from lxml.etree import _Element as LxmlElement + from fontTools.ufoLib import UFOFormatVersion T = TypeVar("T") # Generic type K = TypeVar("K") # Generic dict key type V = TypeVar("V") # Generic dict value type +GlyphNameToFileNameFunc = Optional[Callable[[str, set[str]], str]] +ElementType = Union[ElementTreeElement, LxmlElement] IntFloat = Union[int, float] -KerningNested = Dict[str, Dict[str, IntFloat]] -UFOFormatVersionInput = Optional[Union[int, Tuple[int, int], UFOFormatVersion]] +KerningNested = dict[str, dict[str, IntFloat]] +PathStr = Union[str, PathLike[str]] +PathOrFS = Union[PathStr, FS] +UFOFormatVersionInput = Optional[Union[int, tuple[int, int], UFOFormatVersion]] diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index f65416ab77..4e6f935659 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -33,6 +33,7 @@ """ from __future__ import annotations + import os from copy import deepcopy from os import fsdecode @@ -40,6 +41,7 @@ import zipfile import enum from collections import OrderedDict +from typing import TYPE_CHECKING, cast, Any, IO, Optional, Union import fs import fs.base @@ -51,36 +53,40 @@ import fs.tools import fs.errors -from typing import TYPE_CHECKING, cast, Any, Dict, List, IO, Optional, Set, Tuple, Union -from collections.abc import Callable -from fs.base import FS -from logging import Logger -from os import PathLike - from fontTools.misc import plistlib -from fontTools.annotations import K, V, IntFloat, KerningNested, UFOFormatVersionInput from fontTools.ufoLib.validators import * from fontTools.ufoLib.filenames import userNameToFileName from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning from fontTools.ufoLib.errors import UFOLibError from fontTools.ufoLib.utils import ( numberTypes, - normalizeUFOFormatVersion, - _VersionTupleEnumMixin, + normalizeFormatVersion, + BaseFormatVersion, ) if TYPE_CHECKING: + from fs.base import FS + from logging import Logger + from os import PathLike + from fontTools.annotations import ( + K, + V, + GlyphNameToFileNameFunc, + IntFloat, + KerningNested, + UFOFormatVersionInput, + PathStr, + PathOrFS, + ) from fontTools.ufoLib.glifLib import GlyphSet -PathStr = Union[str, PathLike[str]] -PathOrFS = Union[PathStr, FS] -KerningGroupRenameMaps = Dict[str, Dict[str, str]] -KerningPair = Tuple[str, str] -KerningDict = Dict[KerningPair, IntFloat] -LibDict = Dict[str, Any] +KerningGroupRenameMaps = dict[str, dict[str, str]] +KerningPair = tuple[str, str] +KerningDict = dict[KerningPair, IntFloat] +LibDict = dict[str, Any] LayerOrderList = Optional[list[Optional[str]]] -AttributeDataDict = Dict[str, Any] -FontInfoAttributes = Dict[str, AttributeDataDict] +AttributeDataDict = dict[str, Any] +FontInfoAttributes = dict[str, AttributeDataDict] __all__: list[str] = [ "makeUFOPath", @@ -124,19 +130,12 @@ DEFAULT_LAYER_NAME: str = "public.default" -class UFOFormatVersion(Tuple[int, int], _VersionTupleEnumMixin, enum.Enum): +class UFOFormatVersion(BaseFormatVersion): FORMAT_1_0 = (1, 0) FORMAT_2_0 = (2, 0) FORMAT_3_0 = (3, 0) -# python 3.11 doesn't like when a mixin overrides a dunder method like __str__ -# for some reasons it keep using Enum.__str__, see -# https://github.com/fonttools/fonttools/pull/2655 -def __str__(self): - return _VersionTupleEnumMixin.__str__(self) - - class UFOFileStructure(enum.Enum): ZIP = "zip" PACKAGE = "package" @@ -152,7 +151,7 @@ class _UFOBaseIO: fs: FS _havePreviousFile: bool - def getFileModificationTime(self, path: PathLike[str]) -> Optional[float]: + def getFileModificationTime(self, path: PathStr) -> Optional[float]: """ Returns the modification time for the file at the given path, as a floating point number giving the number of seconds since the epoch. @@ -315,7 +314,7 @@ def __init__( ) self._path: str = fsdecode(path) self._validate: bool = validate - self._upConvertedKerningData: Optional[Dict[str, Any]] = None + self._upConvertedKerningData: Optional[dict[str, Any]] = None try: self.readMetaInfo(validate=validate) @@ -353,7 +352,7 @@ def _get_formatVersion(self) -> int: ) @property - def formatVersionTuple(self) -> Tuple[int, int]: + def formatVersionTuple(self) -> tuple[int, int]: """The (major, minor) format version of the UFO. This is determined by reading metainfo.plist during __init__. """ @@ -460,7 +459,7 @@ def getReadFileForPath( # metainfo.plist - def _readMetaInfo(self, validate: Optional[bool] = None) -> Dict[str, Any]: + def _readMetaInfo(self, validate: Optional[bool] = None) -> dict[str, Any]: """ Read metainfo.plist and return raw data. Only used for internal operations. @@ -514,7 +513,7 @@ def readMetaInfo(self, validate: Optional[bool] = None) -> None: # groups.plist - def _readGroups(self) -> Dict[str, List[str]]: + def _readGroups(self) -> dict[str, list[str]]: groups = self._getPlist(GROUPS_FILENAME, {}) # remove any duplicate glyphs in a kerning group for groupName, glyphList in groups.items(): @@ -522,7 +521,7 @@ def _readGroups(self) -> Dict[str, List[str]]: groups[groupName] = list(OrderedDict.fromkeys(glyphList)) return groups - def readGroups(self, validate: Optional[bool] = None) -> Dict[str, List[str]]: + def readGroups(self, validate: Optional[bool] = None) -> dict[str, list[str]]: """ Read groups.plist. Returns a dict. ``validate`` will validate the read data, by default it is set to the @@ -573,7 +572,7 @@ def getKerningGroupRenameMaps( # fontinfo.plist - def _readInfo(self, validate: bool) -> Dict[str, Any]: + def _readInfo(self, validate: bool) -> dict[str, Any]: data = self._getPlist(FONTINFO_FILENAME, {}) if validate and not isinstance(data, dict): raise UFOLibError("fontinfo.plist is not properly formatted.") @@ -673,7 +672,7 @@ def readKerning(self, validate: Optional[bool] = None) -> KerningDict: # lib.plist - def readLib(self, validate: Optional[bool] = None) -> Dict[str, Any]: + def readLib(self, validate: Optional[bool] = None) -> dict[str, Any]: """ Read lib.plist. Returns a dict. @@ -704,7 +703,7 @@ def readFeatures(self) -> str: # glyph sets & layers - def _readLayerContents(self, validate: bool) -> List[Tuple[str, str]]: + def _readLayerContents(self, validate: bool) -> list[tuple[str, str]]: """ Rebuild the layer contents list by checking what glyphsets are available on disk. @@ -720,7 +719,7 @@ def _readLayerContents(self, validate: bool) -> List[Tuple[str, str]]: raise UFOLibError(error) return contents - def getLayerNames(self, validate: Optional[bool] = None) -> List[str]: + def getLayerNames(self, validate: Optional[bool] = None) -> list[str]: """ Get the ordered layer names from layercontents.plist. @@ -797,7 +796,7 @@ def getGlyphSet( def getCharacterMapping( self, layerName: Optional[str] = None, validate: Optional[bool] = None - ) -> Dict[int, List[str]]: + ) -> dict[int, list[str]]: """ Return a dictionary that maps unicode values (ints) to lists of glyph names. @@ -808,7 +807,7 @@ def getCharacterMapping( layerName, validateRead=validate, validateWrite=True ) allUnicodes = glyphSet.getUnicodes() - cmap: Dict[int, List[str]] = {} + cmap: dict[int, list[str]] = {} for glyphName, unicodes in allUnicodes.items(): for code in unicodes: if code in cmap: @@ -819,7 +818,7 @@ def getCharacterMapping( # /data - def getDataDirectoryListing(self) -> List[str]: + def getDataDirectoryListing(self) -> list[str]: """ Returns a list of all files in the data directory. The returned paths will be relative to the UFO. @@ -840,7 +839,7 @@ def getDataDirectoryListing(self) -> List[str]: except fs.errors.ResourceError: return [] - def getImageDirectoryListing(self, validate: Optional[bool] = None) -> List[str]: + def getImageDirectoryListing(self, validate: Optional[bool] = None) -> list[str]: """ Returns a list of all image file names in the images directory. Each of the images will @@ -973,7 +972,7 @@ def __init__( ) -> None: try: if formatVersion is not None: - formatVersion = normalizeUFOFormatVersion(formatVersion) + formatVersion = normalizeFormatVersion(formatVersion, UFOFormatVersion) except ValueError as e: from fontTools.ufoLib.errors import UnsupportedUFOFormat @@ -1108,7 +1107,7 @@ def __init__( "that is trying to be written. This is not supported." ) # handle the layer contents - self.layerContents: Union[Dict[str, str], OrderedDict[str, str]] = {} + self.layerContents: Union[dict[str, str], OrderedDict[str, str]] = {} if previousFormatVersion is not None and previousFormatVersion.major >= 3: # already exists self.layerContents = OrderedDict(self._readLayerContents(validate)) @@ -1125,7 +1124,9 @@ def _normalizeUFOFormatVersion(value: UFOFormatVersionInput) -> UFOFormatVersion if isinstance(value, UFOFormatVersion): return value try: - return normalizeUFOFormatVersion(value) # relies on your _missing_ logic + return normalizeFormatVersion( + value, UFOFormatVersion + ) # relies on your _missing_ logic except ValueError: raise ValueError(f"Unsupported UFO format: {value!r}") @@ -1300,7 +1301,7 @@ def setKerningGroupRenameMaps(self, maps: KerningGroupRenameMaps) -> None: def writeGroups( self, - groups: Dict[str, Union[List[str], Tuple[str, ...]]], + groups: dict[str, Union[list[str], tuple[str, ...]]], validate: Optional[bool] = None, ) -> None: """ @@ -1514,7 +1515,7 @@ def writeLayerContents( if self._formatVersion < UFOFormatVersion.FORMAT_3_0: return if layerOrder is not None: - newOrder: List[Optional[str]] = [] + newOrder: list[Optional[str]] = [] for layerName in layerOrder: if layerName is None: layerName = DEFAULT_LAYER_NAME @@ -1553,7 +1554,7 @@ def getGlyphSet( # type: ignore[override] self, layerName: Optional[str] = None, defaultLayer: bool = True, - glyphNameToFileNameFunc: Optional[Callable[[str], str]] = None, + glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None, validateRead: Optional[bool] = None, validateWrite: Optional[bool] = None, expectContentsFile: bool = False, @@ -1617,9 +1618,9 @@ def getGlyphSet( # type: ignore[override] def _getDefaultGlyphSet( self, - validateRead: Optional[bool], - validateWrite: Optional[bool], - glyphNameToFileNameFunc: Optional[Callable[[str], str]] = None, + validateRead: bool, + validateWrite: bool, + glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None, expectContentsFile: bool = False, ) -> GlyphSet: @@ -1637,11 +1638,11 @@ def _getDefaultGlyphSet( def _getGlyphSetFormatVersion3( self, - validateRead: Optional[bool], - validateWrite: Optional[bool], + validateRead: bool, + validateWrite: bool, layerName: Optional[str] = None, defaultLayer: bool = True, - glyphNameToFileNameFunc: Optional[Callable[[str], str]] = None, + glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None, expectContentsFile: bool = False, ) -> GlyphSet: @@ -1929,7 +1930,7 @@ def validateFontInfoVersion2ValueForAttribute(attr: str, value: Any) -> bool: return isValidValue -def validateInfoVersion2Data(infoData: Dict[str, Any]) -> Dict[str, Any]: +def validateInfoVersion2Data(infoData: dict[str, Any]) -> dict[str, Any]: """ This performs very basic validation of the value for infoData following the UFO 2 fontinfo.plist specification. The results @@ -1976,7 +1977,7 @@ def validateFontInfoVersion3ValueForAttribute(attr: str, value: Any) -> bool: return isValidValue -def validateInfoVersion3Data(infoData: Dict[str, Any]) -> Dict[str, Any]: +def validateInfoVersion3Data(infoData: dict[str, Any]) -> dict[str, Any]: """ This performs very basic validation of the value for infoData following the UFO 3 fontinfo.plist specification. The results @@ -1998,18 +1999,18 @@ def validateInfoVersion3Data(infoData: Dict[str, Any]) -> Dict[str, Any]: # Value Options -fontInfoOpenTypeHeadFlagsOptions = list(range(0, 15)) -fontInfoOpenTypeOS2SelectionOptions = [1, 2, 3, 4, 7, 8, 9] -fontInfoOpenTypeOS2UnicodeRangesOptions = list(range(0, 128)) -fontInfoOpenTypeOS2CodePageRangesOptions = list(range(0, 64)) -fontInfoOpenTypeOS2TypeOptions = [0, 1, 2, 3, 8, 9] +fontInfoOpenTypeHeadFlagsOptions: list[int] = list(range(0, 15)) +fontInfoOpenTypeOS2SelectionOptions: list[int] = [1, 2, 3, 4, 7, 8, 9] +fontInfoOpenTypeOS2UnicodeRangesOptions: list[int] = list(range(0, 128)) +fontInfoOpenTypeOS2CodePageRangesOptions: list[int] = list(range(0, 64)) +fontInfoOpenTypeOS2TypeOptions: list[int] = [0, 1, 2, 3, 8, 9] # Version Attribute Definitions # This defines the attributes, types and, in some # cases the possible values, that can exist is # fontinfo.plist. -fontInfoAttributesVersion1: Set[str] = { +fontInfoAttributesVersion1: set[str] = { "familyName", "styleName", "fullName", @@ -2194,7 +2195,7 @@ def validateInfoVersion3Data(infoData: Dict[str, Any]) -> Dict[str, Any]: "macintoshFONDFamilyID": dict(type=int), "macintoshFONDName": dict(type=str), } -fontInfoAttributesVersion2: Set[str] = set(fontInfoAttributesVersion2ValueData.keys()) +fontInfoAttributesVersion2: set[str] = set(fontInfoAttributesVersion2ValueData.keys()) fontInfoAttributesVersion3ValueData: FontInfoAttributes = deepcopy( fontInfoAttributesVersion2ValueData @@ -2298,14 +2299,14 @@ def validateInfoVersion3Data(infoData: Dict[str, Any]) -> Dict[str, Any]: # to version 2 or vice-versa. -def _flipDict(d: Dict[K, V]) -> Dict[V, K]: +def _flipDict(d: dict[K, V]) -> dict[V, K]: flipped = {} for key, value in list(d.items()): flipped[value] = key return flipped -fontInfoAttributesVersion1To2: Dict[str, str] = { +fontInfoAttributesVersion1To2: dict[str, str] = { "menuName": "styleMapFamilyName", "designer": "openTypeNameDesigner", "designerURL": "openTypeNameDesignerURL", @@ -2337,17 +2338,17 @@ def _flipDict(d: Dict[K, V]) -> Dict[V, K]: fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2) deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys()) -_fontStyle1To2: Dict[int, str] = { +_fontStyle1To2: dict[int, str] = { 64: "regular", 1: "italic", 32: "bold", 33: "bold italic", } -_fontStyle2To1: Dict[str, int] = _flipDict(_fontStyle1To2) +_fontStyle2To1: dict[str, int] = _flipDict(_fontStyle1To2) # Some UFO 1 files have 0 _fontStyle1To2[0] = "regular" -_widthName1To2: Dict[str, int] = { +_widthName1To2: dict[str, int] = { "Ultra-condensed": 1, "Extra-condensed": 2, "Condensed": 3, @@ -2358,7 +2359,7 @@ def _flipDict(d: Dict[K, V]) -> Dict[V, K]: "Extra-expanded": 8, "Ultra-expanded": 9, } -_widthName2To1: Dict[int, str] = _flipDict(_widthName1To2) +_widthName2To1: dict[int, str] = _flipDict(_widthName1To2) # FontLab's default width value is "Normal". # Many format version 1 UFOs will have this. _widthName1To2["Normal"] = 5 @@ -2370,7 +2371,7 @@ def _flipDict(d: Dict[K, V]) -> Dict[V, K]: # "Medium" appears in a lot of UFO 1 files. _widthName1To2["Medium"] = 5 -_msCharSet1To2: Dict[int, int] = { +_msCharSet1To2: dict[int, int] = { 0: 1, 1: 2, 2: 3, @@ -2392,14 +2393,14 @@ def _flipDict(d: Dict[K, V]) -> Dict[V, K]: 238: 19, 255: 20, } -_msCharSet2To1: Dict[int, int] = _flipDict(_msCharSet1To2) +_msCharSet2To1: dict[int, int] = _flipDict(_msCharSet1To2) # 1 <-> 2 def convertFontInfoValueForAttributeFromVersion1ToVersion2( attr: str, value: Any -) -> Tuple[str, Any]: +) -> tuple[str, Any]: """ Convert value from version 1 to version 2 format. Returns the new attribute name and the converted value. @@ -2437,7 +2438,7 @@ def convertFontInfoValueForAttributeFromVersion1ToVersion2( def convertFontInfoValueForAttributeFromVersion2ToVersion1( attr: str, value: Any -) -> Tuple[str, Any]: +) -> tuple[str, Any]: """ Convert value from version 2 to version 1 format. Returns the new attribute name and the converted value. @@ -2454,7 +2455,7 @@ def convertFontInfoValueForAttributeFromVersion2ToVersion1( return attr, value -def _convertFontInfoDataVersion1ToVersion2(data: Dict[str, Any]) -> Dict[str, Any]: +def _convertFontInfoDataVersion1ToVersion2(data: dict[str, Any]) -> dict[str, Any]: converted = {} for attr, value in list(data.items()): # FontLab gives -1 for the weightValue @@ -2478,7 +2479,7 @@ def _convertFontInfoDataVersion1ToVersion2(data: Dict[str, Any]) -> Dict[str, An return converted -def _convertFontInfoDataVersion2ToVersion1(data: Dict[str, Any]) -> Dict[str, Any]: +def _convertFontInfoDataVersion2ToVersion1(data: dict[str, Any]) -> dict[str, Any]: converted = {} for attr, value in list(data.items()): newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1( @@ -2499,16 +2500,16 @@ def _convertFontInfoDataVersion2ToVersion1(data: Dict[str, Any]) -> Dict[str, An # 2 <-> 3 -_ufo2To3NonNegativeInt: Set[str] = { +_ufo2To3NonNegativeInt: set[str] = { "versionMinor", "openTypeHeadLowestRecPPEM", "openTypeOS2WinAscent", "openTypeOS2WinDescent", } -_ufo2To3NonNegativeIntOrFloat: Set[str] = { +_ufo2To3NonNegativeIntOrFloat: set[str] = { "unitsPerEm", } -_ufo2To3FloatToInt: Set[str] = { +_ufo2To3FloatToInt: set[str] = { "openTypeHeadLowestRecPPEM", "openTypeHheaAscender", "openTypeHheaDescender", @@ -2538,7 +2539,7 @@ def _convertFontInfoDataVersion2ToVersion1(data: Dict[str, Any]) -> Dict[str, An def convertFontInfoValueForAttributeFromVersion2ToVersion3( attr: str, value: Any -) -> Tuple[str, Any]: +) -> tuple[str, Any]: """ Convert value from version 2 to version 3 format. Returns the new attribute name and the converted value. @@ -2568,7 +2569,7 @@ def convertFontInfoValueForAttributeFromVersion2ToVersion3( def convertFontInfoValueForAttributeFromVersion3ToVersion2( attr: str, value: Any -) -> Tuple[str, Any]: +) -> tuple[str, Any]: """ Convert value from version 3 to version 2 format. Returns the new attribute name and the converted value. @@ -2577,7 +2578,7 @@ def convertFontInfoValueForAttributeFromVersion3ToVersion2( return attr, value -def _convertFontInfoDataVersion3ToVersion2(data: Dict[str, Any]) -> Dict[str, Any]: +def _convertFontInfoDataVersion3ToVersion2(data: dict[str, Any]) -> dict[str, Any]: converted = {} for attr, value in list(data.items()): newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2( @@ -2589,7 +2590,7 @@ def _convertFontInfoDataVersion3ToVersion2(data: Dict[str, Any]) -> Dict[str, An return converted -def _convertFontInfoDataVersion2ToVersion3(data: Dict[str, Any]) -> Dict[str, Any]: +def _convertFontInfoDataVersion2ToVersion3(data: dict[str, Any]) -> dict[str, Any]: converted = {} for attr, value in list(data.items()): attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3( diff --git a/Lib/fontTools/ufoLib/converters.py b/Lib/fontTools/ufoLib/converters.py index c280290f84..a9319669e6 100644 --- a/Lib/fontTools/ufoLib/converters.py +++ b/Lib/fontTools/ufoLib/converters.py @@ -1,11 +1,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Dict, List, Set, Tuple, Mapping, Optional, Any +from typing import Mapping, Optional, Any +from collections.abc import Container from fontTools.annotations import KerningNested -if TYPE_CHECKING: - from fontTools.ufoLib.glifLib import GlyphSet - """ Functions for converting UFO1 or UFO2 files into UFO3 format. @@ -19,12 +17,12 @@ def convertUFO1OrUFO2KerningToUFO3Kerning( kerning: KerningNested, - groups: Dict[str, List[str]], - glyphSet: Optional[GlyphSet] = None, -) -> Tuple[ + groups: dict[str, list[str]], + glyphSet: Optional[Container[str]] = None, +) -> tuple[ KerningNested, - Dict[str, List[str]], - Dict[str, Dict[str, str]], + dict[str, list[str]], + dict[str, dict[str, str]], ]: """Convert kerning data in UFO1 or UFO2 syntax into UFO3 syntax. @@ -56,7 +54,7 @@ def convertUFO1OrUFO2KerningToUFO3Kerning( if not second.startswith("public.kern2."): secondReferencedGroups.add(second) # Create new names for these groups. - firstRenamedGroups: Dict[str, str] = {} + firstRenamedGroups: dict[str, str] = {} for first in firstReferencedGroups: # Make a list of existing group names. existingGroupNames = list(groups.keys()) + list(firstRenamedGroups.keys()) @@ -68,7 +66,7 @@ def convertUFO1OrUFO2KerningToUFO3Kerning( newName = makeUniqueGroupName(newName, existingGroupNames) # Store for use later. firstRenamedGroups[first] = newName - secondRenamedGroups: Dict[str, str] = {} + secondRenamedGroups: dict[str, str] = {} for second in secondReferencedGroups: # Make a list of existing group names. existingGroupNames = list(groups.keys()) + list(secondRenamedGroups.keys()) @@ -100,7 +98,7 @@ def convertUFO1OrUFO2KerningToUFO3Kerning( return newKerning, groups, dict(side1=firstRenamedGroups, side2=secondRenamedGroups) -def findKnownKerningGroups(groups: Mapping[str, Any]) -> Tuple[Set[str], Set[str]]: +def findKnownKerningGroups(groups: Mapping[str, Any]) -> tuple[set[str], set[str]]: """Find all kerning groups in a UFO1 or UFO2 font that use known prefixes. In some cases, not all kerning groups will be referenced @@ -166,7 +164,7 @@ def findKnownKerningGroups(groups: Mapping[str, Any]) -> Tuple[Set[str], Set[str return firstGroups, secondGroups -def makeUniqueGroupName(name: str, groupNames: List[str], counter: int = 0) -> str: +def makeUniqueGroupName(name: str, groupNames: list[str], counter: int = 0) -> str: """Make a kerning group name that will be unique within the set of group names. If the requested kerning group name already exists within the set, this diff --git a/Lib/fontTools/ufoLib/utils.py b/Lib/fontTools/ufoLib/utils.py index 22d639917c..79e4fad190 100644 --- a/Lib/fontTools/ufoLib/utils.py +++ b/Lib/fontTools/ufoLib/utils.py @@ -6,17 +6,17 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING, TypeVar, cast + +from typing import Optional, Type, TypeVar, Union, cast from collections.abc import Callable -import warnings +import enum import functools - -if TYPE_CHECKING: - from fontTools.annotations import UFOFormatVersionInput - from fontTools.ufoLib import UFOFormatVersion - +import warnings F = TypeVar("F", bound=Callable[..., object]) +FormatVersion = TypeVar("FormatVersion", bound="BaseFormatVersion") +FormatVersionInput = Optional[Union[int, tuple[int, int], FormatVersion]] + numberTypes = (int, float) @@ -48,49 +48,56 @@ def wrapper(*args, **kwargs): return deprecated_decorator -def normalizeUFOFormatVersion(value: UFOFormatVersionInput) -> UFOFormatVersion: - # Needed for type safety of UFOFormatVersion input +def normalizeFormatVersion( + value: FormatVersionInput, cls: Type[FormatVersion] +) -> FormatVersion: + # Needed for type safety of UFOFormatVersion and GLIFFormatVersion input if value is None: - return UFOFormatVersion.default() - if isinstance(value, UFOFormatVersion): + return cls.default() + if isinstance(value, cls): return value if isinstance(value, int): - return UFOFormatVersion((value, 0)) + return cls((value, 0)) if isinstance(value, tuple) and len(value) == 2: - return UFOFormatVersion(value) - raise ValueError(f"Unsupported UFO format: {value!r}") + return cls(value) + raise ValueError(f"Unsupported format version: {value!r}") + + +# Base class for UFOFormatVersion and GLIFFormatVersion +class BaseFormatVersion(tuple[int, int], enum.Enum): + value: Union[tuple[int, int]] + def __new__(cls: Type[FormatVersion], value: tuple[int, int]) -> BaseFormatVersion: + return super().__new__(cls, value) -# To be mixed with enum.Enum in UFOFormatVersion and GLIFFormatVersion -class _VersionTupleEnumMixin: @property - def major(self): + def major(self) -> int: return self.value[0] @property - def minor(self): + def minor(self) -> int: return self.value[1] @classmethod - def _missing_(cls, value): + def _missing_(cls, value: object) -> BaseFormatVersion: # allow to initialize a version enum from a single (major) integer if isinstance(value, int): return cls((value, 0)) # or from None to obtain the current default version if value is None: return cls.default() - return super()._missing_(value) + raise ValueError(f"{value!r} is not a valid {cls.__name__}") - def __str__(self): + def __str__(self) -> str: return f"{self.major}.{self.minor}" @classmethod - def default(cls): + def default(cls: Type[FormatVersion]) -> FormatVersion: # get the latest defined version (i.e. the max of all versions) return max(cls.__members__.values()) @classmethod - def supported_versions(cls): + def supported_versions(cls: Type[FormatVersion]) -> frozenset[FormatVersion]: return frozenset(cls.__members__.values()) From dfd50a045c1955ca43e3c370f343b1252e6a9c9c Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sun, 29 Jun 2025 16:39:38 +0200 Subject: [PATCH 12/22] Annotate `ufoLib.glifLib`. --- Lib/fontTools/ufoLib/glifLib.py | 489 +++++++++++++++++++------------- 1 file changed, 295 insertions(+), 194 deletions(-) diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py index a5a05003ee..79d0f9d582 100755 --- a/Lib/fontTools/ufoLib/glifLib.py +++ b/Lib/fontTools/ufoLib/glifLib.py @@ -3,7 +3,7 @@ More info about the .glif format (GLyphInterchangeFormat) can be found here: - http://unifiedfontobject.org + http://unifiedfontobject.org The main class in this module is :class:`GlyphSet`. It manages a set of .glif files in a folder. It offers two ways to read glyph data, and one way to write @@ -13,14 +13,16 @@ from __future__ import annotations import logging -import enum from warnings import warn from collections import OrderedDict +from typing import TYPE_CHECKING, cast, Any, Optional, Union +from lxml import etree import fs import fs.base import fs.errors import fs.osfs import fs.path + from fontTools.misc.textTools import tobytes from fontTools.misc import plistlib from fontTools.pens.pointPen import AbstractPointPen, PointToSegmentPen @@ -37,10 +39,30 @@ ) from fontTools.misc import etree from fontTools.ufoLib import _UFOBaseIO, UFOFormatVersion -from fontTools.ufoLib.utils import numberTypes, _VersionTupleEnumMixin +from fontTools.ufoLib.utils import ( + numberTypes, + normalizeFormatVersion, + BaseFormatVersion, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Set + from fs.base import FS + from logging import Logger + from fontTools.annotations import ( + ElementType, + GlyphNameToFileNameFunc, + IntFloat, + PathOrFS, + UFOFormatVersionInput, + ) + +FormatVersion = Union[int, tuple[int, int]] +FormatVersions = Optional[Iterable[FormatVersion]] +GLIFFormatVersionInput = Optional[Union[int, tuple[int, int], "GLIFFormatVersion"]] -__all__ = [ +__all__: list[str] = [ "GlyphSet", "GlifLibError", "readGlyphFromString", @@ -48,7 +70,7 @@ "glyphNameToFileName", ] -logger = logging.getLogger(__name__) +logger: Logger = logging.getLogger(__name__) # --------- @@ -59,7 +81,7 @@ LAYERINFO_FILENAME = "layerinfo.plist" -class GLIFFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum): +class GLIFFormatVersion(BaseFormatVersion): """Class representing the versions of the .glif format supported by the UFO version in use. For a given :mod:`fontTools.ufoLib.UFOFormatVersion`, the :func:`supported_versions` method will @@ -71,13 +93,17 @@ class GLIFFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum): FORMAT_2_0 = (2, 0) @classmethod - def default(cls, ufoFormatVersion=None): + def default( + cls, ufoFormatVersion: Optional[UFOFormatVersion] = None + ) -> GLIFFormatVersion: if ufoFormatVersion is not None: return max(cls.supported_versions(ufoFormatVersion)) return super().default() @classmethod - def supported_versions(cls, ufoFormatVersion=None): + def supported_versions( + cls, ufoFormatVersion: Optional[UFOFormatVersion] = None + ) -> frozenset[GLIFFormatVersion]: if ufoFormatVersion is None: # if ufo format unspecified, return all the supported GLIF formats return super().supported_versions() @@ -88,10 +114,6 @@ def supported_versions(cls, ufoFormatVersion=None): return frozenset(versions) -# workaround for py3.11, see https://github.com/fonttools/fonttools/pull/2655 -GLIFFormatVersion.__str__ = _VersionTupleEnumMixin.__str__ - - # ------------ # Simple Glyph # ------------ @@ -103,11 +125,11 @@ class Glyph: the draw() or the drawPoints() method has been called. """ - def __init__(self, glyphName, glyphSet): - self.glyphName = glyphName - self.glyphSet = glyphSet + def __init__(self, glyphName: str, glyphSet: GlyphSet) -> None: + self.glyphName: str = glyphName + self.glyphSet: GlyphSet = glyphSet - def draw(self, pen, outputImpliedClosingLine=False): + def draw(self, pen: Any, outputImpliedClosingLine: bool = False) -> None: """ Draw this glyph onto a *FontTools* Pen. """ @@ -116,7 +138,7 @@ def draw(self, pen, outputImpliedClosingLine=False): ) self.drawPoints(pointPen) - def drawPoints(self, pointPen): + def drawPoints(self, pointPen: AbstractPointPen) -> None: """ Draw this glyph onto a PointPen. """ @@ -146,13 +168,13 @@ class GlyphSet(_UFOBaseIO): def __init__( self, - path, - glyphNameToFileNameFunc=None, - ufoFormatVersion=None, - validateRead=True, - validateWrite=True, - expectContentsFile=False, - ): + path: PathOrFS, + glyphNameToFileNameFunc: GlyphNameToFileNameFunc = None, + ufoFormatVersion: UFOFormatVersionInput = None, + validateRead: bool = True, + validateWrite: bool = True, + expectContentsFile: bool = False, + ) -> None: """ 'path' should be a path (string) to an existing local directory, or an instance of fs.base.FS class. @@ -170,7 +192,9 @@ def __init__( are reading an existing UFO and ``False`` if you create a fresh glyph set. """ try: - ufoFormatVersion = UFOFormatVersion(ufoFormatVersion) + ufoFormatVersion = normalizeFormatVersion( + ufoFormatVersion, UFOFormatVersion + ) except ValueError as e: from fontTools.ufoLib.errors import UnsupportedUFOFormat @@ -183,10 +207,10 @@ def __init__( if isinstance(path, str): try: - filesystem = fs.osfs.OSFS(path) + filesystem: FS = fs.osfs.OSFS(path) except fs.errors.CreateFailed: raise GlifLibError("No glyphs directory '%s'" % path) - self._shouldClose = True + self._shouldClose: bool = True elif isinstance(path, fs.base.FS): filesystem = path try: @@ -206,26 +230,28 @@ def __init__( # 'dirName' is kept for backward compatibility only, but it's DEPRECATED # as it's not guaranteed that it maps to an existing OSFS directory. # Client could use the FS api via the `self.fs` attribute instead. - self.dirName = fs.path.parts(path)[-1] - self.fs = filesystem + self.dirName: str = fs.path.parts(path)[-1] + self.fs: FS = filesystem # if glyphSet contains no 'contents.plist', we consider it empty - self._havePreviousFile = filesystem.exists(CONTENTS_FILENAME) + self._havePreviousFile: bool = filesystem.exists(CONTENTS_FILENAME) if expectContentsFile and not self._havePreviousFile: raise GlifLibError(f"{CONTENTS_FILENAME} is missing.") # attribute kept for backward compatibility - self.ufoFormatVersion = ufoFormatVersion.major - self.ufoFormatVersionTuple = ufoFormatVersion + self.ufoFormatVersion: int = ufoFormatVersion.major + self.ufoFormatVersionTuple: UFOFormatVersion = ufoFormatVersion if glyphNameToFileNameFunc is None: glyphNameToFileNameFunc = glyphNameToFileName - self.glyphNameToFileName = glyphNameToFileNameFunc - self._validateRead = validateRead - self._validateWrite = validateWrite + self.glyphNameToFileName: Callable[[str, set[str]], str] = ( + glyphNameToFileNameFunc + ) + self._validateRead: bool = validateRead + self._validateWrite: bool = validateWrite self._existingFileNames: set[str] | None = None - self._reverseContents = None + self._reverseContents: Optional[dict[str, str]] = None self.rebuildContents() - def rebuildContents(self, validateRead=None): + def rebuildContents(self, validateRead: bool = False) -> None: """ Rebuild the contents dict by loading contents.plist. @@ -253,11 +279,11 @@ def rebuildContents(self, validateRead=None): ) if invalidFormat: raise GlifLibError("%s is not properly formatted" % CONTENTS_FILENAME) - self.contents = contents + self.contents: dict[str, str] = contents self._existingFileNames = None self._reverseContents = None - def getReverseContents(self): + def getReverseContents(self) -> dict[str, str]: """ Return a reversed dict of self.contents, mapping file names to glyph names. This is primarily an aid for custom glyph name to file @@ -273,7 +299,7 @@ def getReverseContents(self): self._reverseContents = d return self._reverseContents - def writeContents(self): + def writeContents(self) -> None: """ Write the contents.plist file out to disk. Call this method when you're done writing glyphs. @@ -282,7 +308,7 @@ def writeContents(self): # layer info - def readLayerInfo(self, info, validateRead=None): + def readLayerInfo(self, info: Any, validateRead: bool = False) -> None: """ ``validateRead`` will validate the data, by default it is set to the class's ``validateRead`` value, can be overridden. @@ -304,7 +330,7 @@ def readLayerInfo(self, info, validateRead=None): % attr ) - def writeLayerInfo(self, info, validateWrite=None): + def writeLayerInfo(self, info: Any, validateWrite: bool = False) -> None: """ ``validateWrite`` will validate the data, by default it is set to the class's ``validateWrite`` value, can be overridden. @@ -340,7 +366,7 @@ def writeLayerInfo(self, info, validateWrite=None): # data empty, remove existing file self.fs.remove(LAYERINFO_FILENAME) - def getGLIF(self, glyphName): + def getGLIF(self, glyphName: str) -> bytes: """ Get the raw GLIF text for a given glyph name. This only works for GLIF files that are already on disk. @@ -361,7 +387,7 @@ def getGLIF(self, glyphName): "does not exist on %s" % (fileName, glyphName, self.fs) ) - def getGLIFModificationTime(self, glyphName): + def getGLIFModificationTime(self, glyphName: str) -> Optional[float]: """ Returns the modification time for the GLIF file with 'glyphName', as a floating point number giving the number of seconds since the epoch. @@ -374,7 +400,13 @@ def getGLIFModificationTime(self, glyphName): # reading/writing API - def readGlyph(self, glyphName, glyphObject=None, pointPen=None, validate=None): + def readGlyph( + self, + glyphName: str, + glyphObject: Optional[Any] = None, + pointPen: Optional[AbstractPointPen] = None, + validate: Optional[bool] = None, + ) -> None: """ Read a .glif file for 'glyphName' from the glyph set. The 'glyphObject' argument can be any kind of object (even None); @@ -451,12 +483,12 @@ def readGlyph(self, glyphName, glyphObject=None, pointPen=None, validate=None): def writeGlyph( self, - glyphName, - glyphObject=None, - drawPointsFunc=None, - formatVersion=None, - validate=None, - ): + glyphName: str, + glyphObject: Optional[Any] = None, + drawPointsFunc: Optional[Callable[[AbstractPointPen], None]] = None, + formatVersion: GLIFFormatVersionInput = None, + validate: Optional[bool] = None, + ) -> None: """ Write a .glif file for 'glyphName' to the glyph set. The 'glyphObject' argument can be any kind of object (even None); @@ -506,7 +538,7 @@ def writeGlyph( formatVersion = GLIFFormatVersion.default(self.ufoFormatVersionTuple) else: try: - formatVersion = GLIFFormatVersion(formatVersion) + formatVersion = normalizeFormatVersion(formatVersion, GLIFFormatVersion) except ValueError as e: from fontTools.ufoLib.errors import UnsupportedGLIFFormat @@ -550,7 +582,7 @@ def writeGlyph( return self.fs.writebytes(fileName, data) - def deleteGlyph(self, glyphName): + def deleteGlyph(self, glyphName: str) -> None: """Permanently delete the glyph from the glyph set on disk. Will raise KeyError if the glyph is not present in the glyph set. """ @@ -564,25 +596,27 @@ def deleteGlyph(self, glyphName): # dict-like support - def keys(self): + def keys(self) -> list[str]: return list(self.contents.keys()) - def has_key(self, glyphName): + def has_key(self, glyphName: str) -> bool: return glyphName in self.contents __contains__ = has_key - def __len__(self): + def __len__(self) -> int: return len(self.contents) - def __getitem__(self, glyphName): + def __getitem__(self, glyphName: str) -> Any: if glyphName not in self.contents: raise KeyError(glyphName) return self.glyphClass(glyphName, self) # quickly fetch unicode values - def getUnicodes(self, glyphNames=None): + def getUnicodes( + self, glyphNames: Optional[Iterable[str]] = None + ) -> dict[str, list[int]]: """ Return a dictionary that maps glyph names to lists containing the unicode value[s] for that glyph, if any. This parses the .glif @@ -597,7 +631,9 @@ def getUnicodes(self, glyphNames=None): unicodes[glyphName] = _fetchUnicodes(text) return unicodes - def getComponentReferences(self, glyphNames=None): + def getComponentReferences( + self, glyphNames: Optional[Iterable[str]] = None + ) -> dict[str, list[str]]: """ Return a dictionary that maps glyph names to lists containing the base glyph name of components in the glyph. This parses the .glif @@ -612,7 +648,9 @@ def getComponentReferences(self, glyphNames=None): components[glyphName] = _fetchComponentBases(text) return components - def getImageReferences(self, glyphNames=None): + def getImageReferences( + self, glyphNames: Optional[Iterable[str]] = None + ) -> dict[str, Optional[str]]: """ Return a dictionary that maps glyph names to the file name of the image referenced by the glyph. This parses the .glif files partially, so it is a @@ -627,14 +665,14 @@ def getImageReferences(self, glyphNames=None): images[glyphName] = _fetchImageFileName(text) return images - def close(self): + def close(self) -> None: if self._shouldClose: self.fs.close() - def __enter__(self): + def __enter__(self) -> GlyphSet: return self - def __exit__(self, exc_type, exc_value, exc_tb): + def __exit__(self, exc_type: Any, exc_value: Any, exc_tb: Any) -> None: self.close() @@ -643,7 +681,7 @@ def __exit__(self, exc_type, exc_value, exc_tb): # ----------------------- -def glyphNameToFileName(glyphName, existingFileNames): +def glyphNameToFileName(glyphName: str, existingFileNames: Optional[set[str]]) -> str: """ Wrapper around the userNameToFileName function in filenames.py @@ -661,12 +699,12 @@ def glyphNameToFileName(glyphName, existingFileNames): def readGlyphFromString( - aString, - glyphObject=None, - pointPen=None, - formatVersions=None, - validate=True, -): + aString: Union[str, bytes], + glyphObject: Optional[Any] = None, + pointPen: Optional[Any] = None, + formatVersions: FormatVersions = None, + validate: bool = True, +) -> None: """ Read .glif data from a string into a glyph object. @@ -707,7 +745,7 @@ def readGlyphFromString( The formatVersions optional argument define the GLIF format versions that are allowed to be read. - The type is Optional[Iterable[Tuple[int, int], int]]. It can contain + The type is Optional[Iterable[tuple[int, int], int]]. It can contain either integers (for the major versions to be allowed, with minor digits defaulting to 0), or tuples of integers to specify both (major, minor) versions. @@ -719,12 +757,14 @@ def readGlyphFromString( tree = _glifTreeFromString(aString) if formatVersions is None: - validFormatVersions = GLIFFormatVersion.supported_versions() + validFormatVersions: Set[GLIFFormatVersion] = ( + GLIFFormatVersion.supported_versions() + ) else: validFormatVersions, invalidFormatVersions = set(), set() for v in formatVersions: try: - formatVersion = GLIFFormatVersion(v) + formatVersion = normalizeFormatVersion(v, GLIFFormatVersion) except ValueError: invalidFormatVersions.add(v) else: @@ -745,16 +785,16 @@ def readGlyphFromString( def _writeGlyphToBytes( - glyphName, - glyphObject=None, - drawPointsFunc=None, - writer=None, - formatVersion=None, - validate=True, -): + glyphName: str, + glyphObject: Optional[Any] = None, + drawPointsFunc: Optional[Callable[[Any], None]] = None, + writer: Optional[Any] = None, + formatVersion: Optional[FormatVersion] = None, + validate: bool = True, +) -> bytes: """Return .glif data for a glyph as a UTF-8 encoded bytes string.""" try: - formatVersion = GLIFFormatVersion(formatVersion) + formatVersion = normalizeFormatVersion(formatVersion, GLIFFormatVersion) except ValueError: from fontTools.ufoLib.errors import UnsupportedGLIFFormat @@ -772,7 +812,7 @@ def _writeGlyphToBytes( if formatVersion.minor != 0: glyphAttrs["formatMinor"] = repr(formatVersion.minor) root = etree.Element("glyph", glyphAttrs) - identifiers = set() + identifiers: set[str] = set() # advance _writeAdvance(glyphObject, root, validate) # unicodes @@ -812,12 +852,12 @@ def _writeGlyphToBytes( def writeGlyphToString( - glyphName, - glyphObject=None, - drawPointsFunc=None, - formatVersion=None, - validate=True, -): + glyphName: str, + glyphObject: Optional[Any] = None, + drawPointsFunc: Optional[Callable[[Any], None]] = None, + formatVersion: Optional[FormatVersion] = None, + validate: bool = True, +) -> str: """ Return .glif data for a glyph as a string. The XML declaration's encoding is always set to "UTF-8". @@ -872,7 +912,7 @@ def writeGlyphToString( return data.decode("utf-8") -def _writeAdvance(glyphObject, element, validate): +def _writeAdvance(glyphObject: Any, element: ElementType, validate: bool) -> None: width = getattr(glyphObject, "width", None) if width is not None: if validate and not isinstance(width, numberTypes): @@ -897,8 +937,8 @@ def _writeAdvance(glyphObject, element, validate): etree.SubElement(element, "advance", dict(height=repr(height))) -def _writeUnicodes(glyphObject, element, validate): - unicodes = getattr(glyphObject, "unicodes", None) +def _writeUnicodes(glyphObject: Any, element: ElementType, validate: bool) -> None: + unicodes = getattr(glyphObject, "unicodes", []) if validate and isinstance(unicodes, int): unicodes = [unicodes] seen = set() @@ -912,17 +952,21 @@ def _writeUnicodes(glyphObject, element, validate): etree.SubElement(element, "unicode", dict(hex=hexCode)) -def _writeNote(glyphObject, element, validate): +def _writeNote(glyphObject: Any, element: ElementType, validate: bool) -> None: note = getattr(glyphObject, "note", None) if validate and not isinstance(note, str): raise GlifLibError("note attribute must be str") - note = note.strip() - note = "\n" + note + "\n" - etree.SubElement(element, "note").text = note + if isinstance(note, str): + note = note.strip() + note = "\n" + note + "\n" + etree.SubElement(element, "note").text = note -def _writeImage(glyphObject, element, validate): +def _writeImage(glyphObject: Any, element: ElementType, validate: bool) -> None: image = getattr(glyphObject, "image", None) + if image is None: + return + if validate and not imageValidator(image): raise GlifLibError( "image attribute must be a dict or dict-like object with the proper structure." @@ -938,7 +982,9 @@ def _writeImage(glyphObject, element, validate): etree.SubElement(element, "image", attrs) -def _writeGuidelines(glyphObject, element, identifiers, validate): +def _writeGuidelines( + glyphObject: Any, element: ElementType, identifiers: set[str], validate: bool +) -> None: guidelines = getattr(glyphObject, "guidelines", []) if validate and not guidelinesValidator(guidelines): raise GlifLibError("guidelines attribute does not have the proper structure.") @@ -968,7 +1014,7 @@ def _writeGuidelines(glyphObject, element, identifiers, validate): etree.SubElement(element, "guideline", attrs) -def _writeAnchorsFormat1(pen, anchors, validate): +def _writeAnchorsFormat1(pen: Any, anchors: Any, validate: bool) -> None: if validate and not anchorsValidator(anchors): raise GlifLibError("anchors attribute does not have the proper structure.") for anchor in anchors: @@ -985,7 +1031,12 @@ def _writeAnchorsFormat1(pen, anchors, validate): pen.endPath() -def _writeAnchors(glyphObject, element, identifiers, validate): +def _writeAnchors( + glyphObject: Any, + element: ElementType, + identifiers: set[str], + validate: bool, +) -> None: anchors = getattr(glyphObject, "anchors", []) if validate and not anchorsValidator(anchors): raise GlifLibError("anchors attribute does not have the proper structure.") @@ -1010,7 +1061,7 @@ def _writeAnchors(glyphObject, element, identifiers, validate): etree.SubElement(element, "anchor", attrs) -def _writeLib(glyphObject, element, validate): +def _writeLib(glyphObject: Any, element: ElementType, validate: bool) -> None: lib = getattr(glyphObject, "lib", None) if not lib: # don't write empty lib @@ -1036,7 +1087,7 @@ def _writeLib(glyphObject, element, validate): } -def validateLayerInfoVersion3ValueForAttribute(attr, value): +def validateLayerInfoVersion3ValueForAttribute(attr: str, value: Any) -> bool: """ This performs very basic validation of the value for attribute following the UFO 3 fontinfo.plist specification. The results @@ -1053,18 +1104,18 @@ def validateLayerInfoVersion3ValueForAttribute(attr, value): validator = dataValidationDict.get("valueValidator") valueOptions = dataValidationDict.get("valueOptions") # have specific options for the validator + + if not callable(validator): + return False if valueOptions is not None: - isValidValue = validator(value, valueOptions) + return validator(value, valueOptions) # no specific options - else: - if validator == genericTypeValidator: - isValidValue = validator(value, valueType) - else: - isValidValue = validator(value) - return isValidValue + if validator == genericTypeValidator: + return validator(value, valueType) + return validator(value) -def validateLayerInfoVersion3Data(infoData): +def validateLayerInfoVersion3Data(infoData: dict[str, Any]) -> dict[str, Any]: """ This performs very basic validation of the value for infoData following the UFO 3 layerinfo.plist specification. The results @@ -1088,7 +1139,7 @@ def validateLayerInfoVersion3Data(infoData): # ----------------- -def _glifTreeFromFile(aFile): +def _glifTreeFromFile(aFile: Union[str, bytes, int]) -> ElementType: if etree._have_lxml: tree = etree.parse(aFile, parser=etree.XMLParser(remove_comments=True)) else: @@ -1101,7 +1152,7 @@ def _glifTreeFromFile(aFile): return root -def _glifTreeFromString(aString): +def _glifTreeFromString(aString: Union[str, bytes]) -> ElementType: data = tobytes(aString, encoding="utf-8") try: if etree._have_lxml: @@ -1119,16 +1170,18 @@ def _glifTreeFromString(aString): def _readGlyphFromTree( - tree, - glyphObject=None, - pointPen=None, - formatVersions=GLIFFormatVersion.supported_versions(), - validate=True, -): + tree: ElementType, + glyphObject: Optional[Any] = None, + pointPen: Optional[AbstractPointPen] = None, + formatVersions: Set[GLIFFormatVersion] = GLIFFormatVersion.supported_versions(), + validate: bool = True, +) -> None: # check the format version formatVersionMajor = tree.get("format") - if validate and formatVersionMajor is None: - raise GlifLibError("Unspecified format version in GLIF.") + if formatVersionMajor is None: + if validate: + raise GlifLibError("Unspecified format version in GLIF.") + formatVersionMajor = 0 formatVersionMinor = tree.get("formatMinor", 0) try: formatVersion = GLIFFormatVersion( @@ -1170,8 +1223,12 @@ def _readGlyphFromTree( def _readGlyphFromTreeFormat1( - tree, glyphObject=None, pointPen=None, validate=None, **kwargs -): + tree: ElementType, + glyphObject: Optional[Any] = None, + pointPen: Optional[AbstractPointPen] = None, + validate: bool = False, + **kwargs: Any, +) -> None: # get the name _readName(glyphObject, tree, validate) # populate the sub elements @@ -1229,8 +1286,12 @@ def _readGlyphFromTreeFormat1( def _readGlyphFromTreeFormat2( - tree, glyphObject=None, pointPen=None, validate=None, formatMinor=0 -): + tree: ElementType, + glyphObject: Optional[Any] = None, + pointPen: Optional[AbstractPointPen] = None, + validate: bool = False, + formatMinor: int = 0, +) -> None: # get the name _readName(glyphObject, tree, validate) # populate the sub elements @@ -1240,7 +1301,7 @@ def _readGlyphFromTreeFormat2( haveSeenAdvance = haveSeenImage = haveSeenOutline = haveSeenLib = haveSeenNote = ( False ) - identifiers = set() + identifiers: set[str] = set() for element in tree: if element.tag == "outline": if validate: @@ -1329,13 +1390,13 @@ def _readGlyphFromTreeFormat2( _relaxedSetattr(glyphObject, "anchors", anchors) -_READ_GLYPH_FROM_TREE_FUNCS = { +_READ_GLYPH_FROM_TREE_FUNCS: dict[GLIFFormatVersion, Callable[..., Any]] = { GLIFFormatVersion.FORMAT_1_0: _readGlyphFromTreeFormat1, GLIFFormatVersion.FORMAT_2_0: _readGlyphFromTreeFormat2, } -def _readName(glyphObject, root, validate): +def _readName(glyphObject: Optional[Any], root: ElementType, validate: bool) -> None: glyphName = root.get("name") if validate and not glyphName: raise GlifLibError("Empty glyph name in GLIF.") @@ -1343,20 +1404,22 @@ def _readName(glyphObject, root, validate): _relaxedSetattr(glyphObject, "name", glyphName) -def _readAdvance(glyphObject, advance): +def _readAdvance(glyphObject: Optional[Any], advance: ElementType) -> None: width = _number(advance.get("width", 0)) _relaxedSetattr(glyphObject, "width", width) height = _number(advance.get("height", 0)) _relaxedSetattr(glyphObject, "height", height) -def _readNote(glyphObject, note): +def _readNote(glyphObject: Optional[Any], note: ElementType) -> None: + if note.text is None: + return lines = note.text.split("\n") note = "\n".join(line.strip() for line in lines if line.strip()) _relaxedSetattr(glyphObject, "note", note) -def _readLib(glyphObject, lib, validate): +def _readLib(glyphObject: Optional[Any], lib: ElementType, validate: bool) -> None: assert len(lib) == 1 child = lib[0] plist = plistlib.fromtree(child) @@ -1367,7 +1430,7 @@ def _readLib(glyphObject, lib, validate): _relaxedSetattr(glyphObject, "lib", plist) -def _readImage(glyphObject, image, validate): +def _readImage(glyphObject: Optional[Any], image: ElementType, validate: bool) -> None: imageData = dict(image.attrib) for attr, default in _transformationInfo: value = imageData.get(attr, default) @@ -1381,8 +1444,8 @@ def _readImage(glyphObject, image, validate): # GLIF to PointPen # ---------------- -contourAttributesFormat2 = {"identifier"} -componentAttributesFormat1 = { +contourAttributesFormat2: set[str] = {"identifier"} +componentAttributesFormat1: set[str] = { "base", "xScale", "xyScale", @@ -1391,16 +1454,21 @@ def _readImage(glyphObject, image, validate): "xOffset", "yOffset", } -componentAttributesFormat2 = componentAttributesFormat1 | {"identifier"} -pointAttributesFormat1 = {"x", "y", "type", "smooth", "name"} -pointAttributesFormat2 = pointAttributesFormat1 | {"identifier"} -pointSmoothOptions = {"no", "yes"} -pointTypeOptions = {"move", "line", "offcurve", "curve", "qcurve"} +componentAttributesFormat2: set[str] = componentAttributesFormat1 | {"identifier"} +pointAttributesFormat1: set[str] = {"x", "y", "type", "smooth", "name"} +pointAttributesFormat2: set[str] = pointAttributesFormat1 | {"identifier"} +pointSmoothOptions: set[str] = {"no", "yes"} +pointTypeOptions: set[str] = {"move", "line", "offcurve", "curve", "qcurve"} # format 1 -def buildOutlineFormat1(glyphObject, pen, outline, validate): +def buildOutlineFormat1( + glyphObject: Any, + pen: Optional[AbstractPointPen], + outline: Iterable[ElementType], + validate: bool, +) -> None: anchors = [] for element in outline: if element.tag == "contour": @@ -1424,7 +1492,7 @@ def buildOutlineFormat1(glyphObject, pen, outline, validate): _relaxedSetattr(glyphObject, "anchors", anchors) -def _buildAnchorFormat1(point, validate): +def _buildAnchorFormat1(point: ElementType, validate: bool) -> Optional[dict[str, Any]]: if point.get("type") != "move": return None name = point.get("name") @@ -1434,15 +1502,19 @@ def _buildAnchorFormat1(point, validate): y = point.get("y") if validate and x is None: raise GlifLibError("Required x attribute is missing in point element.") + assert x is not None if validate and y is None: raise GlifLibError("Required y attribute is missing in point element.") + assert y is not None x = _number(x) y = _number(y) anchor = dict(x=x, y=y, name=name) return anchor -def _buildOutlineContourFormat1(pen, contour, validate): +def _buildOutlineContourFormat1( + pen: AbstractPointPen, contour: ElementType, validate: bool +) -> None: if validate and contour.attrib: raise GlifLibError("Unknown attributes in contour element.") pen.beginPath() @@ -1457,7 +1529,9 @@ def _buildOutlineContourFormat1(pen, contour, validate): pen.endPath() -def _buildOutlinePointsFormat1(pen, contour): +def _buildOutlinePointsFormat1( + pen: AbstractPointPen, contour: list[dict[str, Any]] +) -> None: for point in contour: x = point["x"] y = point["y"] @@ -1467,7 +1541,9 @@ def _buildOutlinePointsFormat1(pen, contour): pen.addPoint((x, y), segmentType=segmentType, smooth=smooth, name=name) -def _buildOutlineComponentFormat1(pen, component, validate): +def _buildOutlineComponentFormat1( + pen: AbstractPointPen, component: ElementType, validate: bool +) -> None: if validate: if len(component): raise GlifLibError("Unknown child elements of component element.") @@ -1477,21 +1553,27 @@ def _buildOutlineComponentFormat1(pen, component, validate): baseGlyphName = component.get("base") if validate and baseGlyphName is None: raise GlifLibError("The base attribute is not defined in the component.") - transformation = [] - for attr, default in _transformationInfo: - value = component.get(attr) - if value is None: - value = default - else: - value = _number(value) - transformation.append(value) - pen.addComponent(baseGlyphName, tuple(transformation)) + assert baseGlyphName is not None + transformation = cast( + tuple[float, float, float, float, float, float], + tuple( + float(_number(component.get(attr) or default)) + for attr, default in _transformationInfo + ), + ) + pen.addComponent(baseGlyphName, transformation) # format 2 -def buildOutlineFormat2(glyphObject, pen, outline, identifiers, validate): +def buildOutlineFormat2( + glyphObject: Any, + pen: AbstractPointPen, + outline: Iterable[ElementType], + identifiers: set[str], + validate: bool, +) -> None: for element in outline: if element.tag == "contour": _buildOutlineContourFormat2(pen, element, identifiers, validate) @@ -1501,7 +1583,9 @@ def buildOutlineFormat2(glyphObject, pen, outline, identifiers, validate): raise GlifLibError("Unknown element in outline element: %s" % element.tag) -def _buildOutlineContourFormat2(pen, contour, identifiers, validate): +def _buildOutlineContourFormat2( + pen: AbstractPointPen, contour: ElementType, identifiers: set[str], validate: bool +) -> None: if validate: for attr in contour.attrib.keys(): if attr not in contourAttributesFormat2: @@ -1534,7 +1618,12 @@ def _buildOutlineContourFormat2(pen, contour, identifiers, validate): pen.endPath() -def _buildOutlinePointsFormat2(pen, contour, identifiers, validate): +def _buildOutlinePointsFormat2( + pen: AbstractPointPen, + contour: list[dict[str, Any]], + identifiers: set[str], + validate: bool, +) -> None: for point in contour: x = point["x"] y = point["y"] @@ -1567,7 +1656,12 @@ def _buildOutlinePointsFormat2(pen, contour, identifiers, validate): ) -def _buildOutlineComponentFormat2(pen, component, identifiers, validate): +def _buildOutlineComponentFormat2( + pen: AbstractPointPen, + component: ElementType, + identifiers: set[str], + validate: bool, +) -> None: if validate: if len(component): raise GlifLibError("Unknown child elements of component element.") @@ -1577,14 +1671,14 @@ def _buildOutlineComponentFormat2(pen, component, identifiers, validate): baseGlyphName = component.get("base") if validate and baseGlyphName is None: raise GlifLibError("The base attribute is not defined in the component.") - transformation = [] - for attr, default in _transformationInfo: - value = component.get(attr) - if value is None: - value = default - else: - value = _number(value) - transformation.append(value) + assert baseGlyphName is not None + transformation = cast( + tuple[float, float, float, float, float, float], + tuple( + float(_number(component.get(attr) or default)) + for attr, default in _transformationInfo + ), + ) identifier = component.get("identifier") if identifier is not None: if validate: @@ -1596,9 +1690,9 @@ def _buildOutlineComponentFormat2(pen, component, identifiers, validate): raise GlifLibError("The identifier %s is not valid." % identifier) identifiers.add(identifier) try: - pen.addComponent(baseGlyphName, tuple(transformation), identifier=identifier) + pen.addComponent(baseGlyphName, transformation, identifier=identifier) except TypeError: - pen.addComponent(baseGlyphName, tuple(transformation)) + pen.addComponent(baseGlyphName, transformation) warn( "The addComponent method needs an identifier kwarg. The component's identifier value has been discarded.", DeprecationWarning, @@ -1717,14 +1811,15 @@ def _validateAndMassagePointStructures( # --------------------- -def _relaxedSetattr(object, attr, value): +def _relaxedSetattr(object: Any, attr: str, value: Any) -> None: + try: setattr(object, attr, value) except AttributeError: pass -def _number(s): +def _number(s: Union[str, int, float]) -> IntFloat: """ Given a numeric string, return an integer or a float, whichever the string indicates. _number("1") will return the integer 1, @@ -1740,7 +1835,7 @@ def _number(s): GlifLibError: Could not convert a to an int or float. """ try: - n = int(s) + n: IntFloat = int(s) return n except ValueError: pass @@ -1763,21 +1858,21 @@ class _DoneParsing(Exception): class _BaseParser: - def __init__(self): - self._elementStack = [] + def __init__(self) -> None: + self._elementStack: list[str] = [] - def parse(self, text): + def parse(self, text: str): from xml.parsers.expat import ParserCreate parser = ParserCreate() parser.StartElementHandler = self.startElementHandler parser.EndElementHandler = self.endElementHandler - parser.Parse(text, 1) + parser.Parse(text, True) - def startElementHandler(self, name, attrs): + def startElementHandler(self, name: str, attrs: Any) -> None: self._elementStack.append(name) - def endElementHandler(self, name): + def endElementHandler(self, name: str) -> None: other = self._elementStack.pop(-1) assert other == name @@ -1785,7 +1880,7 @@ def endElementHandler(self, name): # unicodes -def _fetchUnicodes(glif): +def _fetchUnicodes(glif: str) -> list[int]: """ Get a list of unicodes listed in glif. """ @@ -1795,11 +1890,11 @@ def _fetchUnicodes(glif): class _FetchUnicodesParser(_BaseParser): - def __init__(self): - self.unicodes = [] + def __init__(self) -> None: + self.unicodes: list[int] = [] super().__init__() - def startElementHandler(self, name, attrs): + def startElementHandler(self, name: str, attrs: dict[str, str]) -> None: if ( name == "unicode" and self._elementStack @@ -1808,9 +1903,9 @@ def startElementHandler(self, name, attrs): value = attrs.get("hex") if value is not None: try: - value = int(value, 16) - if value not in self.unicodes: - self.unicodes.append(value) + intValue = int(value, 16) + if intValue not in self.unicodes: + self.unicodes.append(intValue) except ValueError: pass super().startElementHandler(name, attrs) @@ -1819,7 +1914,7 @@ def startElementHandler(self, name, attrs): # image -def _fetchImageFileName(glif): +def _fetchImageFileName(glif: str) -> Optional[str]: """ The image file name (if any) from glif. """ @@ -1832,11 +1927,11 @@ def _fetchImageFileName(glif): class _FetchImageFileNameParser(_BaseParser): - def __init__(self): - self.fileName = None + def __init__(self) -> None: + self.fileName: Optional[str] = None super().__init__() - def startElementHandler(self, name, attrs): + def startElementHandler(self, name: str, attrs: dict[str, str]) -> None: if name == "image" and self._elementStack and self._elementStack[-1] == "glyph": self.fileName = attrs.get("fileName") raise _DoneParsing @@ -1846,7 +1941,7 @@ def startElementHandler(self, name, attrs): # component references -def _fetchComponentBases(glif): +def _fetchComponentBases(glif: str) -> list[str]: """ Get a list of component base glyphs listed in glif. """ @@ -1859,11 +1954,11 @@ def _fetchComponentBases(glif): class _FetchComponentBasesParser(_BaseParser): - def __init__(self): - self.bases = [] + def __init__(self) -> None: + self.bases: list[str] = [] super().__init__() - def startElementHandler(self, name, attrs): + def startElementHandler(self, name: str, attrs: dict[str, str]) -> None: if ( name == "component" and self._elementStack @@ -1874,7 +1969,7 @@ def startElementHandler(self, name, attrs): self.bases.append(base) super().startElementHandler(name, attrs) - def endElementHandler(self, name): + def endElementHandler(self, name: str) -> None: if name == "outline": raise _DoneParsing super().endElementHandler(name) @@ -1884,7 +1979,7 @@ def endElementHandler(self, name): # GLIF Point Pen # -------------- -_transformationInfo = [ +_transformationInfo: list[tuple[str, int]] = [ # field name, default value ("xScale", 1), ("xyScale", 0), @@ -1901,15 +1996,21 @@ class GLIFPointPen(AbstractPointPen): part of .glif files. """ - def __init__(self, element, formatVersion=None, identifiers=None, validate=True): + def __init__( + self, + element: ElementType, + formatVersion: Optional[FormatVersion] = None, + identifiers: Optional[set[str]] = None, + validate: bool = True, + ) -> None: if identifiers is None: identifiers = set() - self.formatVersion = GLIFFormatVersion(formatVersion) + self.formatVersion = normalizeFormatVersion(formatVersion, GLIFFormatVersion) self.identifiers = identifiers self.outline = element self.contour = None self.prevOffCurveCount = 0 - self.prevPointTypes = [] + self.prevPointTypes: list[str] = [] self.validate = validate def beginPath(self, identifier=None, **kwargs): From 4184e32043e6e5ac3ceadf0c95625bd1370edfe4 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Sun, 29 Jun 2025 16:44:25 +0200 Subject: [PATCH 13/22] Replace `typing.Set` with `set`. --- Lib/fontTools/ufoLib/filenames.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/ufoLib/filenames.py b/Lib/fontTools/ufoLib/filenames.py index 917acea091..6a4090a755 100644 --- a/Lib/fontTools/ufoLib/filenames.py +++ b/Lib/fontTools/ufoLib/filenames.py @@ -1,7 +1,6 @@ from __future__ import annotations from collections.abc import Iterable -from typing import Set """ Convert user-provided internal UFO names to spec-compliant filenames. @@ -32,7 +31,7 @@ # inclusive. # 3. Various characters that (mostly) Windows and POSIX-y filesystems don't # allow, plus "(" and ")", as per the specification. -illegalCharacters: Set[str] = { +illegalCharacters: set[str] = { "\x00", "\x01", "\x02", @@ -81,7 +80,7 @@ "|", "\x7f", } -reservedFileNames: Set[str] = { +reservedFileNames: set[str] = { "aux", "clock$", "com1", From 8e3b4ed43a806b12d5574de164b931f6397e645a Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Mon, 30 Jun 2025 10:27:42 +0200 Subject: [PATCH 14/22] Centralize type variables and fix mypy errors. --- Lib/fontTools/annotations.py | 10 +++-- Lib/fontTools/ufoLib/__init__.py | 9 ++--- Lib/fontTools/ufoLib/glifLib.py | 67 +++++++++++++++++--------------- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/Lib/fontTools/annotations.py b/Lib/fontTools/annotations.py index e2e64c8e53..6cf6a5ade3 100644 --- a/Lib/fontTools/annotations.py +++ b/Lib/fontTools/annotations.py @@ -1,16 +1,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeVar, Union if TYPE_CHECKING: - from typing import Optional, TypeVar, Union - from collections.abc import Callable + from collections.abc import Callable, Sequence from fs.base import FS from os import PathLike from xml.etree.ElementTree import Element as ElementTreeElement from lxml.etree import _Element as LxmlElement + from fontTools.pens.pointPen import AbstractPointPen from fontTools.ufoLib import UFOFormatVersion + T = TypeVar("T") # Generic type K = TypeVar("K") # Generic dict key type V = TypeVar("V") # Generic dict value type @@ -18,6 +19,9 @@ GlyphNameToFileNameFunc = Optional[Callable[[str, set[str]], str]] ElementType = Union[ElementTreeElement, LxmlElement] IntFloat = Union[int, float] +KerningPair = tuple[str, str] +KerningDict = dict[KerningPair, IntFloat] +KerningGroups = dict[str, Sequence[str]] KerningNested = dict[str, dict[str, IntFloat]] PathStr = Union[str, PathLike[str]] PathOrFS = Union[PathStr, FS] diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index 4e6f935659..9dc1487849 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -72,7 +72,8 @@ K, V, GlyphNameToFileNameFunc, - IntFloat, + KerningDict, + KerningGroups, KerningNested, UFOFormatVersionInput, PathStr, @@ -81,8 +82,6 @@ from fontTools.ufoLib.glifLib import GlyphSet KerningGroupRenameMaps = dict[str, dict[str, str]] -KerningPair = tuple[str, str] -KerningDict = dict[KerningPair, IntFloat] LibDict = dict[str, Any] LayerOrderList = Optional[list[Optional[str]]] AttributeDataDict = dict[str, Any] @@ -1300,9 +1299,7 @@ def setKerningGroupRenameMaps(self, maps: KerningGroupRenameMaps) -> None: self._downConversionKerningData = dict(groupRenameMap=remap) def writeGroups( - self, - groups: dict[str, Union[list[str], tuple[str, ...]]], - validate: Optional[bool] = None, + self, groups: KerningGroups, validate: Optional[bool] = None ) -> None: """ Write groups.plist. This method requires a diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py index 79d0f9d582..70890052bb 100755 --- a/Lib/fontTools/ufoLib/glifLib.py +++ b/Lib/fontTools/ufoLib/glifLib.py @@ -627,8 +627,9 @@ def getUnicodes( if glyphNames is None: glyphNames = self.contents.keys() for glyphName in glyphNames: - text = self.getGLIF(glyphName) - unicodes[glyphName] = _fetchUnicodes(text) + bytesText = self.getGLIF(glyphName) + strText = bytesText.decode(encoding="utf-8", errors="strict") + unicodes[glyphName] = _fetchUnicodes(strText) return unicodes def getComponentReferences( @@ -644,8 +645,9 @@ def getComponentReferences( if glyphNames is None: glyphNames = self.contents.keys() for glyphName in glyphNames: - text = self.getGLIF(glyphName) - components[glyphName] = _fetchComponentBases(text) + bytesText = self.getGLIF(glyphName) + strText = bytesText.decode(encoding="utf-8", errors="strict") + components[glyphName] = _fetchComponentBases(strText) return components def getImageReferences( @@ -661,8 +663,9 @@ def getImageReferences( if glyphNames is None: glyphNames = self.contents.keys() for glyphName in glyphNames: - text = self.getGLIF(glyphName) - images[glyphName] = _fetchImageFileName(text) + bytesText = self.getGLIF(glyphName) + strText = bytesText.decode(encoding="utf-8", errors="strict") + images[glyphName] = _fetchImageFileName(strText) return images def close(self) -> None: @@ -813,24 +816,28 @@ def _writeGlyphToBytes( glyphAttrs["formatMinor"] = repr(formatVersion.minor) root = etree.Element("glyph", glyphAttrs) identifiers: set[str] = set() - # advance - _writeAdvance(glyphObject, root, validate) - # unicodes - if getattr(glyphObject, "unicodes", None): - _writeUnicodes(glyphObject, root, validate) - # note - if getattr(glyphObject, "note", None): - _writeNote(glyphObject, root, validate) - # image - if formatVersion.major >= 2 and getattr(glyphObject, "image", None): - _writeImage(glyphObject, root, validate) - # guidelines - if formatVersion.major >= 2 and getattr(glyphObject, "guidelines", None): - _writeGuidelines(glyphObject, root, identifiers, validate) - # anchors - anchors = getattr(glyphObject, "anchors", None) - if formatVersion.major >= 2 and anchors: - _writeAnchors(glyphObject, root, identifiers, validate) + if glyphObject is not None: + # advance + _writeAdvance(glyphObject, root, validate) + # unicodes + if getattr(glyphObject, "unicodes", None): + _writeUnicodes(glyphObject, root, validate) + # note + if getattr(glyphObject, "note", None): + _writeNote(glyphObject, root, validate) + # image + if formatVersion.major >= 2 and getattr(glyphObject, "image", None): + _writeImage(glyphObject, root, validate) + # guidelines + if formatVersion.major >= 2 and getattr(glyphObject, "guidelines", None): + _writeGuidelines(glyphObject, root, identifiers, validate) + # anchors + anchors = getattr(glyphObject, "anchors", None) + if formatVersion.major >= 2 and anchors: + _writeAnchors(glyphObject, root, identifiers, validate) + # lib + if getattr(glyphObject, "lib", None): + _writeLib(glyphObject, root, validate) # outline if drawPointsFunc is not None: outline = etree.SubElement(root, "outline") @@ -841,9 +848,6 @@ def _writeGlyphToBytes( # prevent lxml from writing self-closing tags if not len(outline): outline.text = "\n " - # lib - if getattr(glyphObject, "lib", None): - _writeLib(glyphObject, root, validate) # return the text data = etree.tostring( root, encoding="UTF-8", xml_declaration=True, pretty_print=True @@ -1235,6 +1239,9 @@ def _readGlyphFromTreeFormat1( unicodes = [] haveSeenAdvance = haveSeenOutline = haveSeenLib = haveSeenNote = False for element in tree: + if glyphObject is None: + continue + if element.tag == "outline": if validate: if haveSeenOutline: @@ -1247,8 +1254,6 @@ def _readGlyphFromTreeFormat1( raise GlifLibError("Invalid outline structure.") haveSeenOutline = True buildOutlineFormat1(glyphObject, pointPen, element, validate) - elif glyphObject is None: - continue elif element.tag == "advance": if validate and haveSeenAdvance: raise GlifLibError("The advance element occurs more than once.") @@ -1303,6 +1308,8 @@ def _readGlyphFromTreeFormat2( ) identifiers: set[str] = set() for element in tree: + if glyphObject is None: + continue if element.tag == "outline": if validate: if haveSeenOutline: @@ -1318,8 +1325,6 @@ def _readGlyphFromTreeFormat2( buildOutlineFormat2( glyphObject, pointPen, element, identifiers, validate ) - elif glyphObject is None: - continue elif element.tag == "advance": if validate and haveSeenAdvance: raise GlifLibError("The advance element occurs more than once.") From 8dd4273200fe47ff62ff9adbc59ccdec76d5031c Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Mon, 30 Jun 2025 10:28:40 +0200 Subject: [PATCH 15/22] Annotate `ufoLib.kerning`. --- Lib/fontTools/ufoLib/kerning.py | 45 ++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/Lib/fontTools/ufoLib/kerning.py b/Lib/fontTools/ufoLib/kerning.py index 5c84dd720a..6a5337695b 100644 --- a/Lib/fontTools/ufoLib/kerning.py +++ b/Lib/fontTools/ufoLib/kerning.py @@ -1,6 +1,21 @@ +from __future__ import annotations + +from typing import Optional, Union +from collections.abc import Container + +from fontTools.annotations import KerningPair, KerningDict, KerningGroups, IntFloat + +StrDict = dict[str, str] + + def lookupKerningValue( - pair, kerning, groups, fallback=0, glyphToFirstGroup=None, glyphToSecondGroup=None -): + pair: KerningPair, + kerning: KerningDict, + groups: KerningGroups, + fallback: IntFloat = 0, + glyphToFirstGroup: Optional[StrDict] = None, + glyphToSecondGroup: Optional[StrDict] = None, +) -> IntFloat: """Retrieve the kerning value (if any) between a pair of elements. The elments can be either individual glyphs (by name) or kerning @@ -72,11 +87,12 @@ def lookupKerningValue( # quickly check to see if the pair is in the kerning dictionary if pair in kerning: return kerning[pair] + # ensure both or no glyph-to-group mappings are provided + if (glyphToFirstGroup is None) != (glyphToSecondGroup is None): + raise ValueError( + "Must provide both 'glyphToFirstGroup' and 'glyphToSecondGroup', or neither." + ) # create glyph to group mapping - if glyphToFirstGroup is not None: - assert glyphToSecondGroup is not None - if glyphToSecondGroup is not None: - assert glyphToFirstGroup is not None if glyphToFirstGroup is None: glyphToFirstGroup = {} glyphToSecondGroup = {} @@ -87,25 +103,30 @@ def lookupKerningValue( elif group.startswith("public.kern2."): for glyph in groupMembers: glyphToSecondGroup[glyph] = group + # ensure type safety for mappings + assert glyphToFirstGroup is not None + assert glyphToSecondGroup is not None # get group names and make sure first and second are glyph names first, second = pair firstGroup = secondGroup = None if first.startswith("public.kern1."): firstGroup = first - first = None + firstGlyph = None else: firstGroup = glyphToFirstGroup.get(first) + firstGlyph = first if second.startswith("public.kern2."): secondGroup = second - second = None + secondGlyph = None else: secondGroup = glyphToSecondGroup.get(second) + secondGlyph = second # make an ordered list of pairs to look up pairs = [ - (first, second), - (first, secondGroup), - (firstGroup, second), - (firstGroup, secondGroup), + (a, b) + for a in (firstGlyph, firstGroup) + for b in (secondGlyph, secondGroup) + if a is not None and b is not None ] # look up the pairs and return any matches for pair in pairs: From 88676a77d730804c57780a18a6ffc17054e532a4 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Mon, 30 Jun 2025 19:19:22 +0200 Subject: [PATCH 16/22] Annotate `ufoLib.validators`. --- Lib/fontTools/ufoLib/validators.py | 217 ++++++++++++++++------------- 1 file changed, 120 insertions(+), 97 deletions(-) diff --git a/Lib/fontTools/ufoLib/validators.py b/Lib/fontTools/ufoLib/validators.py index 01e3124fd3..17b26fc065 100644 --- a/Lib/fontTools/ufoLib/validators.py +++ b/Lib/fontTools/ufoLib/validators.py @@ -1,20 +1,26 @@ """Various low level data validators.""" +from __future__ import annotations + import calendar from io import open import fs.base import fs.osfs -from collections.abc import Mapping +from collections.abc import Mapping, Sequence, Set +from typing import Any, Type, Optional, Union + +from fontTools.annotations import IntFloat from fontTools.ufoLib.utils import numberTypes +GenericDict = dict[str, tuple[Union[type, tuple[Type[Any], ...]], bool]] # ------- # Generic # ------- -def isDictEnough(value): +def isDictEnough(value: Any) -> bool: """ Some objects will likely come in that aren't dicts but are dict-ish enough. @@ -27,14 +33,14 @@ def isDictEnough(value): return True -def genericTypeValidator(value, typ): +def genericTypeValidator(value: Any, typ: Type[Any]) -> bool: """ Generic. (Added at version 2.) """ return isinstance(value, typ) -def genericIntListValidator(values, validValues): +def genericIntListValidator(values: Any, validValues: Sequence[int]) -> bool: """ Generic. (Added at version 2.) """ @@ -50,7 +56,7 @@ def genericIntListValidator(values, validValues): return True -def genericNonNegativeIntValidator(value): +def genericNonNegativeIntValidator(value: Any) -> bool: """ Generic. (Added at version 3.) """ @@ -61,7 +67,7 @@ def genericNonNegativeIntValidator(value): return True -def genericNonNegativeNumberValidator(value): +def genericNonNegativeNumberValidator(value: Any) -> bool: """ Generic. (Added at version 3.) """ @@ -72,7 +78,7 @@ def genericNonNegativeNumberValidator(value): return True -def genericDictValidator(value, prototype): +def genericDictValidator(value: Any, prototype: GenericDict) -> bool: """ Generic. (Added at version 3.) """ @@ -106,7 +112,7 @@ def genericDictValidator(value, prototype): # Data Validators -def fontInfoStyleMapStyleNameValidator(value): +def fontInfoStyleMapStyleNameValidator(value: Any) -> bool: """ Version 2+. """ @@ -114,7 +120,7 @@ def fontInfoStyleMapStyleNameValidator(value): return value in options -def fontInfoOpenTypeGaspRangeRecordsValidator(value): +def fontInfoOpenTypeGaspRangeRecordsValidator(value: Any) -> bool: """ Version 3+. """ @@ -123,7 +129,9 @@ def fontInfoOpenTypeGaspRangeRecordsValidator(value): if len(value) == 0: return True validBehaviors = [0, 1, 2, 3] - dictPrototype = dict(rangeMaxPPEM=(int, True), rangeGaspBehavior=(list, True)) + dictPrototype: GenericDict = dict( + rangeMaxPPEM=(int, True), rangeGaspBehavior=(list, True) + ) ppemOrder = [] for rangeRecord in value: if not genericDictValidator(rangeRecord, dictPrototype): @@ -142,7 +150,7 @@ def fontInfoOpenTypeGaspRangeRecordsValidator(value): return True -def fontInfoOpenTypeHeadCreatedValidator(value): +def fontInfoOpenTypeHeadCreatedValidator(value: Any) -> bool: """ Version 2+. """ @@ -154,61 +162,61 @@ def fontInfoOpenTypeHeadCreatedValidator(value): return False if value.count(" ") != 1: return False - date, time = value.split(" ") - if date.count("/") != 2: + strDate, strTime = value.split(" ") + if strDate.count("/") != 2: return False - if time.count(":") != 2: + if strTime.count(":") != 2: return False # date - year, month, day = date.split("/") - if len(year) != 4: + strYear, strMonth, strDay = strDate.split("/") + if len(strYear) != 4: return False - if len(month) != 2: + if len(strMonth) != 2: return False - if len(day) != 2: + if len(strDay) != 2: return False try: - year = int(year) - month = int(month) - day = int(day) + intYear = int(strYear) + intMonth = int(strMonth) + intDay = int(strDay) except ValueError: return False - if month < 1 or month > 12: + if intMonth < 1 or intMonth > 12: return False - monthMaxDay = calendar.monthrange(year, month)[1] - if day < 1 or day > monthMaxDay: + monthMaxDay = calendar.monthrange(intYear, intMonth)[1] + if intDay < 1 or intDay > monthMaxDay: return False # time - hour, minute, second = time.split(":") - if len(hour) != 2: + strHour, strMinute, strSecond = strTime.split(":") + if len(strHour) != 2: return False - if len(minute) != 2: + if len(strMinute) != 2: return False - if len(second) != 2: + if len(strSecond) != 2: return False try: - hour = int(hour) - minute = int(minute) - second = int(second) + intHour = int(strHour) + intMinute = int(strMinute) + intSecond = int(strSecond) except ValueError: return False - if hour < 0 or hour > 23: + if intHour < 0 or intHour > 23: return False - if minute < 0 or minute > 59: + if intMinute < 0 or intMinute > 59: return False - if second < 0 or second > 59: + if intSecond < 0 or intSecond > 59: return False # fallback return True -def fontInfoOpenTypeNameRecordsValidator(value): +def fontInfoOpenTypeNameRecordsValidator(value: Any) -> bool: """ Version 3+. """ if not isinstance(value, list): return False - dictPrototype = dict( + dictPrototype: GenericDict = dict( nameID=(int, True), platformID=(int, True), encodingID=(int, True), @@ -221,7 +229,7 @@ def fontInfoOpenTypeNameRecordsValidator(value): return True -def fontInfoOpenTypeOS2WeightClassValidator(value): +def fontInfoOpenTypeOS2WeightClassValidator(value: Any) -> bool: """ Version 2+. """ @@ -232,7 +240,7 @@ def fontInfoOpenTypeOS2WeightClassValidator(value): return True -def fontInfoOpenTypeOS2WidthClassValidator(value): +def fontInfoOpenTypeOS2WidthClassValidator(value: Any) -> bool: """ Version 2+. """ @@ -245,7 +253,7 @@ def fontInfoOpenTypeOS2WidthClassValidator(value): return True -def fontInfoVersion2OpenTypeOS2PanoseValidator(values): +def fontInfoVersion2OpenTypeOS2PanoseValidator(values: Any) -> bool: """ Version 2. """ @@ -260,7 +268,7 @@ def fontInfoVersion2OpenTypeOS2PanoseValidator(values): return True -def fontInfoVersion3OpenTypeOS2PanoseValidator(values): +def fontInfoVersion3OpenTypeOS2PanoseValidator(values: Any) -> bool: """ Version 3+. """ @@ -277,7 +285,7 @@ def fontInfoVersion3OpenTypeOS2PanoseValidator(values): return True -def fontInfoOpenTypeOS2FamilyClassValidator(values): +def fontInfoOpenTypeOS2FamilyClassValidator(values: Any) -> bool: """ Version 2+. """ @@ -296,7 +304,7 @@ def fontInfoOpenTypeOS2FamilyClassValidator(values): return True -def fontInfoPostscriptBluesValidator(values): +def fontInfoPostscriptBluesValidator(values: Any) -> bool: """ Version 2+. """ @@ -312,7 +320,7 @@ def fontInfoPostscriptBluesValidator(values): return True -def fontInfoPostscriptOtherBluesValidator(values): +def fontInfoPostscriptOtherBluesValidator(values: Any) -> bool: """ Version 2+. """ @@ -328,7 +336,7 @@ def fontInfoPostscriptOtherBluesValidator(values): return True -def fontInfoPostscriptStemsValidator(values): +def fontInfoPostscriptStemsValidator(values: Any) -> bool: """ Version 2+. """ @@ -342,7 +350,7 @@ def fontInfoPostscriptStemsValidator(values): return True -def fontInfoPostscriptWindowsCharacterSetValidator(value): +def fontInfoPostscriptWindowsCharacterSetValidator(value: Any) -> bool: """ Version 2+. """ @@ -352,21 +360,21 @@ def fontInfoPostscriptWindowsCharacterSetValidator(value): return True -def fontInfoWOFFMetadataUniqueIDValidator(value): +def fontInfoWOFFMetadataUniqueIDValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(id=(str, True)) + dictPrototype: GenericDict = dict(id=(str, True)) if not genericDictValidator(value, dictPrototype): return False return True -def fontInfoWOFFMetadataVendorValidator(value): +def fontInfoWOFFMetadataVendorValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = { + dictPrototype: GenericDict = { "name": (str, True), "url": (str, False), "dir": (str, False), @@ -379,11 +387,11 @@ def fontInfoWOFFMetadataVendorValidator(value): return True -def fontInfoWOFFMetadataCreditsValidator(value): +def fontInfoWOFFMetadataCreditsValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(credits=(list, True)) + dictPrototype: GenericDict = dict(credits=(list, True)) if not genericDictValidator(value, dictPrototype): return False if not len(value["credits"]): @@ -403,11 +411,11 @@ def fontInfoWOFFMetadataCreditsValidator(value): return True -def fontInfoWOFFMetadataDescriptionValidator(value): +def fontInfoWOFFMetadataDescriptionValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(url=(str, False), text=(list, True)) + dictPrototype: GenericDict = dict(url=(str, False), text=(list, True)) if not genericDictValidator(value, dictPrototype): return False for text in value["text"]: @@ -416,11 +424,13 @@ def fontInfoWOFFMetadataDescriptionValidator(value): return True -def fontInfoWOFFMetadataLicenseValidator(value): +def fontInfoWOFFMetadataLicenseValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(url=(str, False), text=(list, False), id=(str, False)) + dictPrototype: GenericDict = dict( + url=(str, False), text=(list, False), id=(str, False) + ) if not genericDictValidator(value, dictPrototype): return False if "text" in value: @@ -430,11 +440,11 @@ def fontInfoWOFFMetadataLicenseValidator(value): return True -def fontInfoWOFFMetadataTrademarkValidator(value): +def fontInfoWOFFMetadataTrademarkValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(text=(list, True)) + dictPrototype: GenericDict = dict(text=(list, True)) if not genericDictValidator(value, dictPrototype): return False for text in value["text"]: @@ -443,11 +453,11 @@ def fontInfoWOFFMetadataTrademarkValidator(value): return True -def fontInfoWOFFMetadataCopyrightValidator(value): +def fontInfoWOFFMetadataCopyrightValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(text=(list, True)) + dictPrototype: GenericDict = dict(text=(list, True)) if not genericDictValidator(value, dictPrototype): return False for text in value["text"]: @@ -456,11 +466,15 @@ def fontInfoWOFFMetadataCopyrightValidator(value): return True -def fontInfoWOFFMetadataLicenseeValidator(value): +def fontInfoWOFFMetadataLicenseeValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = {"name": (str, True), "dir": (str, False), "class": (str, False)} + dictPrototype: GenericDict = { + "name": (str, True), + "dir": (str, False), + "class": (str, False), + } if not genericDictValidator(value, dictPrototype): return False if "dir" in value and value.get("dir") not in ("ltr", "rtl"): @@ -468,11 +482,11 @@ def fontInfoWOFFMetadataLicenseeValidator(value): return True -def fontInfoWOFFMetadataTextValue(value): +def fontInfoWOFFMetadataTextValue(value: Any) -> bool: """ Version 3+. """ - dictPrototype = { + dictPrototype: GenericDict = { "text": (str, True), "language": (str, False), "dir": (str, False), @@ -485,7 +499,7 @@ def fontInfoWOFFMetadataTextValue(value): return True -def fontInfoWOFFMetadataExtensionsValidator(value): +def fontInfoWOFFMetadataExtensionsValidator(value: Any) -> bool: """ Version 3+. """ @@ -499,11 +513,13 @@ def fontInfoWOFFMetadataExtensionsValidator(value): return True -def fontInfoWOFFMetadataExtensionValidator(value): +def fontInfoWOFFMetadataExtensionValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(names=(list, False), items=(list, True), id=(str, False)) + dictPrototype: GenericDict = dict( + names=(list, False), items=(list, True), id=(str, False) + ) if not genericDictValidator(value, dictPrototype): return False if "names" in value: @@ -516,11 +532,13 @@ def fontInfoWOFFMetadataExtensionValidator(value): return True -def fontInfoWOFFMetadataExtensionItemValidator(value): +def fontInfoWOFFMetadataExtensionItemValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = dict(id=(str, False), names=(list, True), values=(list, True)) + dictPrototype: GenericDict = dict( + id=(str, False), names=(list, True), values=(list, True) + ) if not genericDictValidator(value, dictPrototype): return False for name in value["names"]: @@ -532,11 +550,11 @@ def fontInfoWOFFMetadataExtensionItemValidator(value): return True -def fontInfoWOFFMetadataExtensionNameValidator(value): +def fontInfoWOFFMetadataExtensionNameValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = { + dictPrototype: GenericDict = { "text": (str, True), "language": (str, False), "dir": (str, False), @@ -549,11 +567,11 @@ def fontInfoWOFFMetadataExtensionNameValidator(value): return True -def fontInfoWOFFMetadataExtensionValueValidator(value): +def fontInfoWOFFMetadataExtensionValueValidator(value: Any) -> bool: """ Version 3+. """ - dictPrototype = { + dictPrototype: GenericDict = { "text": (str, True), "language": (str, False), "dir": (str, False), @@ -571,7 +589,7 @@ def fontInfoWOFFMetadataExtensionValueValidator(value): # ---------- -def guidelinesValidator(value, identifiers=None): +def guidelinesValidator(value: Any, identifiers: Optional[set[str]] = None) -> bool: """ Version 3+. """ @@ -590,7 +608,7 @@ def guidelinesValidator(value, identifiers=None): return True -_guidelineDictPrototype = dict( +_guidelineDictPrototype: GenericDict = dict( x=((int, float), False), y=((int, float), False), angle=((int, float), False), @@ -600,7 +618,7 @@ def guidelinesValidator(value, identifiers=None): ) -def guidelineValidator(value): +def guidelineValidator(value: Any) -> bool: """ Version 3+. """ @@ -641,7 +659,7 @@ def guidelineValidator(value): # ------- -def anchorsValidator(value, identifiers=None): +def anchorsValidator(value: Any, identifiers: Optional[set[str]] = None) -> bool: """ Version 3+. """ @@ -660,7 +678,7 @@ def anchorsValidator(value, identifiers=None): return True -_anchorDictPrototype = dict( +_anchorDictPrototype: GenericDict = dict( x=((int, float), False), y=((int, float), False), name=(str, False), @@ -669,7 +687,7 @@ def anchorsValidator(value, identifiers=None): ) -def anchorValidator(value): +def anchorValidator(value: Any) -> bool: """ Version 3+. """ @@ -696,7 +714,7 @@ def anchorValidator(value): # ---------- -def identifierValidator(value): +def identifierValidator(value: Any) -> bool: """ Version 3+. @@ -716,8 +734,8 @@ def identifierValidator(value): if len(value) > 100: return False for c in value: - c = ord(c) - if c < validCharactersMin or c > validCharactersMax: + i = ord(c) + if i < validCharactersMin or i > validCharactersMax: return False return True @@ -727,7 +745,7 @@ def identifierValidator(value): # ----- -def colorValidator(value): +def colorValidator(value: Any) -> bool: """ Version 3+. @@ -778,22 +796,21 @@ def colorValidator(value): for part in parts: part = part.strip() converted = False + number: IntFloat try: - part = int(part) + number = int(part) converted = True except ValueError: pass if not converted: try: - part = float(part) + number = float(part) converted = True except ValueError: pass if not converted: return False - if part < 0: - return False - if part > 1: + if not 0 <= number <= 1: return False return True @@ -802,9 +819,9 @@ def colorValidator(value): # image # ----- -pngSignature = b"\x89PNG\r\n\x1a\n" +pngSignature: bytes = b"\x89PNG\r\n\x1a\n" -_imageDictPrototype = dict( +_imageDictPrototype: GenericDict = dict( fileName=(str, True), xScale=((int, float), False), xyScale=((int, float), False), @@ -832,7 +849,11 @@ def imageValidator(value): return True -def pngValidator(path=None, data=None, fileObj=None): +def pngValidator( + path: Optional[str] = None, + data: Optional[bytes] = None, + fileObj: Optional[Any] = None, +) -> tuple[bool, Any]: """ Version 3+. @@ -858,7 +879,9 @@ def pngValidator(path=None, data=None, fileObj=None): # ------------------- -def layerContentsValidator(value, ufoPathOrFileSystem): +def layerContentsValidator( + value: Any, ufoPathOrFileSystem: Union[str, fs.base.FS] +) -> tuple[bool, Optional[str]]: """ Check the validity of layercontents.plist. Version 3+. @@ -932,7 +955,7 @@ def layerContentsValidator(value, ufoPathOrFileSystem): # ------------ -def groupsValidator(value): +def groupsValidator(value: Any) -> tuple[bool, Optional[str]]: """ Check the validity of the groups. Version 3+ (though it's backwards compatible with UFO 1 and UFO 2). @@ -979,8 +1002,8 @@ def groupsValidator(value): bogusFormatMessage = "The group data is not in the correct format." if not isDictEnough(value): return False, bogusFormatMessage - firstSideMapping = {} - secondSideMapping = {} + firstSideMapping: dict[str, str] = {} + secondSideMapping: dict[str, str] = {} for groupName, glyphList in value.items(): if not isinstance(groupName, (str)): return False, bogusFormatMessage @@ -1024,7 +1047,7 @@ def groupsValidator(value): # ------------- -def kerningValidator(data): +def kerningValidator(data: Any) -> tuple[bool, Optional[str]]: """ Check the validity of the kerning data structure. Version 3+ (though it's backwards compatible with UFO 1 and UFO 2). @@ -1070,7 +1093,7 @@ def kerningValidator(data): _bogusLibFormatMessage = "The lib data is not in the correct format: %s" -def fontLibValidator(value): +def fontLibValidator(value: Any) -> tuple[bool, Optional[str]]: """ Check the validity of the lib. Version 3+ (though it's backwards compatible with UFO 1 and UFO 2). @@ -1142,7 +1165,7 @@ def fontLibValidator(value): # -------- -def glyphLibValidator(value): +def glyphLibValidator(value: Any) -> tuple[bool, Optional[str]]: """ Check the validity of the lib. Version 3+ (though it's backwards compatible with UFO 1 and UFO 2). From c0af06c9ce07482d7e6d1daec0b451f8d87d9258 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Tue, 1 Jul 2025 14:33:28 +0200 Subject: [PATCH 17/22] Remove unused imports. --- Lib/fontTools/ufoLib/kerning.py | 3 +-- Lib/fontTools/ufoLib/validators.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/ufoLib/kerning.py b/Lib/fontTools/ufoLib/kerning.py index 6a5337695b..01ae55c062 100644 --- a/Lib/fontTools/ufoLib/kerning.py +++ b/Lib/fontTools/ufoLib/kerning.py @@ -1,7 +1,6 @@ from __future__ import annotations -from typing import Optional, Union -from collections.abc import Container +from typing import Optional from fontTools.annotations import KerningPair, KerningDict, KerningGroups, IntFloat diff --git a/Lib/fontTools/ufoLib/validators.py b/Lib/fontTools/ufoLib/validators.py index 17b26fc065..74fc8b4ef7 100644 --- a/Lib/fontTools/ufoLib/validators.py +++ b/Lib/fontTools/ufoLib/validators.py @@ -7,7 +7,7 @@ import fs.base import fs.osfs -from collections.abc import Mapping, Sequence, Set +from collections.abc import Mapping, Sequence from typing import Any, Type, Optional, Union from fontTools.annotations import IntFloat From 4afc71d373c36b689e339df23911ef5edc221314 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Tue, 1 Jul 2025 14:38:31 +0200 Subject: [PATCH 18/22] Correct annotation and typo, remove unused normalizer. --- Lib/fontTools/ufoLib/__init__.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index 9dc1487849..2a4c598411 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -970,8 +970,7 @@ def __init__( validate: bool = True, ) -> None: try: - if formatVersion is not None: - formatVersion = normalizeFormatVersion(formatVersion, UFOFormatVersion) + formatVersion = normalizeFormatVersion(formatVersion, UFOFormatVersion) except ValueError as e: from fontTools.ufoLib.errors import UnsupportedUFOFormat @@ -1118,17 +1117,6 @@ def __init__( # write the new metainfo self._writeMetaInfo() - @staticmethod - def _normalizeUFOFormatVersion(value: UFOFormatVersionInput) -> UFOFormatVersion: - if isinstance(value, UFOFormatVersion): - return value - try: - return normalizeFormatVersion( - value, UFOFormatVersion - ) # relies on your _missing_ logic - except ValueError: - raise ValueError(f"Unsupported UFO format: {value!r}") - # properties def _get_fileCreator(self) -> str: @@ -1273,7 +1261,7 @@ def _writeMetaInfo(self) -> None: # groups.plist - def setKerningGroupRenameMaps(self, maps: KerningGroupRenameMaps) -> None: + def setKerningGroupConversionRenameMaps(self, maps: KerningGroupRenameMaps) -> None: """ Set maps defining the renaming that should be done when writing groups and kerning in UFO 1 and UFO 2. From 6ffb9cf6e93e14a5690690c58b6389b670b84a50 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Tue, 1 Jul 2025 14:42:16 +0200 Subject: [PATCH 19/22] Redistribute and centralize imports. --- Lib/fontTools/annotations.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Lib/fontTools/annotations.py b/Lib/fontTools/annotations.py index 6cf6a5ade3..ebaae16475 100644 --- a/Lib/fontTools/annotations.py +++ b/Lib/fontTools/annotations.py @@ -1,15 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeVar, Union +from typing import TYPE_CHECKING, Iterable, Optional, TypeVar, Union +from collections.abc import Callable, Sequence +from fs.base import FS +from os import PathLike +from xml.etree.ElementTree import Element as ElementTreeElement +from lxml.etree import _Element as LxmlElement if TYPE_CHECKING: - from collections.abc import Callable, Sequence - from fs.base import FS - from os import PathLike - from xml.etree.ElementTree import Element as ElementTreeElement - from lxml.etree import _Element as LxmlElement - - from fontTools.pens.pointPen import AbstractPointPen from fontTools.ufoLib import UFOFormatVersion + from fontTools.ufoLib.glifLib import GLIFFormatVersion T = TypeVar("T") # Generic type @@ -18,6 +17,10 @@ GlyphNameToFileNameFunc = Optional[Callable[[str, set[str]], str]] ElementType = Union[ElementTreeElement, LxmlElement] +FormatVersion = Union[int, tuple[int, int]] +FormatVersions = Optional[Iterable[FormatVersion]] +GLIFFormatVersionInput = Optional[Union[int, tuple[int, int], "GLIFFormatVersion"]] +UFOFormatVersionInput = Optional[Union[int, tuple[int, int], "UFOFormatVersion"]] IntFloat = Union[int, float] KerningPair = tuple[str, str] KerningDict = dict[KerningPair, IntFloat] @@ -25,4 +28,3 @@ KerningNested = dict[str, dict[str, IntFloat]] PathStr = Union[str, PathLike[str]] PathOrFS = Union[PathStr, FS] -UFOFormatVersionInput = Optional[Union[int, tuple[int, int], UFOFormatVersion]] From cad5762efc0c4676f8ba70512f882ba5f40284f1 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Tue, 1 Jul 2025 14:43:57 +0200 Subject: [PATCH 20/22] Revert default `kerning` type. --- Lib/fontTools/ufoLib/converters.py | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/ufoLib/converters.py b/Lib/fontTools/ufoLib/converters.py index a9319669e6..94a229f48d 100644 --- a/Lib/fontTools/ufoLib/converters.py +++ b/Lib/fontTools/ufoLib/converters.py @@ -1,5 +1,6 @@ from __future__ import annotations -from typing import Mapping, Optional, Any + +from typing import Mapping, Any from collections.abc import Container from fontTools.annotations import KerningNested @@ -16,14 +17,8 @@ def convertUFO1OrUFO2KerningToUFO3Kerning( - kerning: KerningNested, - groups: dict[str, list[str]], - glyphSet: Optional[Container[str]] = None, -) -> tuple[ - KerningNested, - dict[str, list[str]], - dict[str, dict[str, str]], -]: + kerning: KerningNested, groups: dict[str, list[str]], glyphSet: Container[str] = () +) -> tuple[KerningNested, dict[str, list[str]], dict[str, dict[str, str]]]: """Convert kerning data in UFO1 or UFO2 syntax into UFO3 syntax. Args: @@ -46,11 +41,11 @@ def convertUFO1OrUFO2KerningToUFO3Kerning( firstReferencedGroups, secondReferencedGroups = findKnownKerningGroups(groups) # Make lists of groups referenced in kerning pairs. for first, seconds in list(kerning.items()): - if glyphSet and first in groups and first not in glyphSet: + if first in groups and first not in glyphSet: if not first.startswith("public.kern1."): firstReferencedGroups.add(first) for second in list(seconds.keys()): - if glyphSet and second in groups and second not in glyphSet: + if second in groups and second not in glyphSet: if not second.startswith("public.kern2."): secondReferencedGroups.add(second) # Create new names for these groups. From e525812c5b2a76e21fb8164da10559d24f35a1f5 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Tue, 1 Jul 2025 14:45:46 +0200 Subject: [PATCH 21/22] Correct errors and centralize type variables. --- Lib/fontTools/ufoLib/glifLib.py | 92 +++++++++++++++------------------ 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py index 70890052bb..6725883c7f 100755 --- a/Lib/fontTools/ufoLib/glifLib.py +++ b/Lib/fontTools/ufoLib/glifLib.py @@ -51,16 +51,15 @@ from logging import Logger from fontTools.annotations import ( ElementType, + FormatVersion, + FormatVersions, + GLIFFormatVersionInput, GlyphNameToFileNameFunc, IntFloat, PathOrFS, UFOFormatVersionInput, ) -FormatVersion = Union[int, tuple[int, int]] -FormatVersions = Optional[Iterable[FormatVersion]] -GLIFFormatVersionInput = Optional[Union[int, tuple[int, int], "GLIFFormatVersion"]] - __all__: list[str] = [ "GlyphSet", @@ -308,7 +307,7 @@ def writeContents(self) -> None: # layer info - def readLayerInfo(self, info: Any, validateRead: bool = False) -> None: + def readLayerInfo(self, info: Any, validateRead: Optional[bool] = None) -> None: """ ``validateRead`` will validate the data, by default it is set to the class's ``validateRead`` value, can be overridden. @@ -330,7 +329,7 @@ def readLayerInfo(self, info: Any, validateRead: bool = False) -> None: % attr ) - def writeLayerInfo(self, info: Any, validateWrite: bool = False) -> None: + def writeLayerInfo(self, info: Any, validateWrite: Optional[bool] = None) -> None: """ ``validateWrite`` will validate the data, by default it is set to the class's ``validateWrite`` value, can be overridden. @@ -816,28 +815,24 @@ def _writeGlyphToBytes( glyphAttrs["formatMinor"] = repr(formatVersion.minor) root = etree.Element("glyph", glyphAttrs) identifiers: set[str] = set() - if glyphObject is not None: - # advance - _writeAdvance(glyphObject, root, validate) - # unicodes - if getattr(glyphObject, "unicodes", None): - _writeUnicodes(glyphObject, root, validate) - # note - if getattr(glyphObject, "note", None): - _writeNote(glyphObject, root, validate) - # image - if formatVersion.major >= 2 and getattr(glyphObject, "image", None): - _writeImage(glyphObject, root, validate) - # guidelines - if formatVersion.major >= 2 and getattr(glyphObject, "guidelines", None): - _writeGuidelines(glyphObject, root, identifiers, validate) - # anchors - anchors = getattr(glyphObject, "anchors", None) - if formatVersion.major >= 2 and anchors: - _writeAnchors(glyphObject, root, identifiers, validate) - # lib - if getattr(glyphObject, "lib", None): - _writeLib(glyphObject, root, validate) + # advance + _writeAdvance(glyphObject, root, validate) + # unicodes + if getattr(glyphObject, "unicodes", None): + _writeUnicodes(glyphObject, root, validate) + # note + if getattr(glyphObject, "note", None): + _writeNote(glyphObject, root, validate) + # image + if formatVersion.major >= 2 and getattr(glyphObject, "image", None): + _writeImage(glyphObject, root, validate) + # guidelines + if formatVersion.major >= 2 and getattr(glyphObject, "guidelines", None): + _writeGuidelines(glyphObject, root, identifiers, validate) + # anchors + anchors = getattr(glyphObject, "anchors", None) + if formatVersion.major >= 2 and anchors: + _writeAnchors(glyphObject, root, identifiers, validate) # outline if drawPointsFunc is not None: outline = etree.SubElement(root, "outline") @@ -848,6 +843,9 @@ def _writeGlyphToBytes( # prevent lxml from writing self-closing tags if not len(outline): outline.text = "\n " + # lib + if getattr(glyphObject, "lib", None): + _writeLib(glyphObject, root, validate) # return the text data = etree.tostring( root, encoding="UTF-8", xml_declaration=True, pretty_print=True @@ -1108,15 +1106,16 @@ def validateLayerInfoVersion3ValueForAttribute(attr: str, value: Any) -> bool: validator = dataValidationDict.get("valueValidator") valueOptions = dataValidationDict.get("valueOptions") # have specific options for the validator - - if not callable(validator): - return False + assert callable(validator) if valueOptions is not None: - return validator(value, valueOptions) + isValidValue = validator(value, valueOptions) # no specific options - if validator == genericTypeValidator: - return validator(value, valueType) - return validator(value) + else: + if validator == genericTypeValidator: + isValidValue = validator(value, valueType) + else: + isValidValue = validator(value) + return isValidValue def validateLayerInfoVersion3Data(infoData: dict[str, Any]) -> dict[str, Any]: @@ -1559,12 +1558,11 @@ def _buildOutlineComponentFormat1( if validate and baseGlyphName is None: raise GlifLibError("The base attribute is not defined in the component.") assert baseGlyphName is not None + transformation = tuple( + _number(component.get(attr) or default) for attr, default in _transformationInfo + ) transformation = cast( - tuple[float, float, float, float, float, float], - tuple( - float(_number(component.get(attr) or default)) - for attr, default in _transformationInfo - ), + tuple[float, float, float, float, float, float], transformation ) pen.addComponent(baseGlyphName, transformation) @@ -1662,10 +1660,7 @@ def _buildOutlinePointsFormat2( def _buildOutlineComponentFormat2( - pen: AbstractPointPen, - component: ElementType, - identifiers: set[str], - validate: bool, + pen: AbstractPointPen, component: ElementType, identifiers: set[str], validate: bool ) -> None: if validate: if len(component): @@ -1677,12 +1672,11 @@ def _buildOutlineComponentFormat2( if validate and baseGlyphName is None: raise GlifLibError("The base attribute is not defined in the component.") assert baseGlyphName is not None + transformation = tuple( + _number(component.get(attr) or default) for attr, default in _transformationInfo + ) transformation = cast( - tuple[float, float, float, float, float, float], - tuple( - float(_number(component.get(attr) or default)) - for attr, default in _transformationInfo - ), + tuple[float, float, float, float, float, float], transformation ) identifier = component.get("identifier") if identifier is not None: From a0bb9bbf6f0137907663feb30b652cb50869c724 Mon Sep 17 00:00:00 2001 From: knutnergaard Date: Tue, 1 Jul 2025 16:23:02 +0200 Subject: [PATCH 22/22] Correct input to `xml...Parse`. --- Lib/fontTools/ufoLib/glifLib.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/Lib/fontTools/ufoLib/glifLib.py b/Lib/fontTools/ufoLib/glifLib.py index 6725883c7f..02c28690d9 100755 --- a/Lib/fontTools/ufoLib/glifLib.py +++ b/Lib/fontTools/ufoLib/glifLib.py @@ -626,9 +626,8 @@ def getUnicodes( if glyphNames is None: glyphNames = self.contents.keys() for glyphName in glyphNames: - bytesText = self.getGLIF(glyphName) - strText = bytesText.decode(encoding="utf-8", errors="strict") - unicodes[glyphName] = _fetchUnicodes(strText) + text = self.getGLIF(glyphName) + unicodes[glyphName] = _fetchUnicodes(text) return unicodes def getComponentReferences( @@ -644,9 +643,8 @@ def getComponentReferences( if glyphNames is None: glyphNames = self.contents.keys() for glyphName in glyphNames: - bytesText = self.getGLIF(glyphName) - strText = bytesText.decode(encoding="utf-8", errors="strict") - components[glyphName] = _fetchComponentBases(strText) + text = self.getGLIF(glyphName) + components[glyphName] = _fetchComponentBases(text) return components def getImageReferences( @@ -662,9 +660,8 @@ def getImageReferences( if glyphNames is None: glyphNames = self.contents.keys() for glyphName in glyphNames: - bytesText = self.getGLIF(glyphName) - strText = bytesText.decode(encoding="utf-8", errors="strict") - images[glyphName] = _fetchImageFileName(strText) + text = self.getGLIF(glyphName) + images[glyphName] = _fetchImageFileName(text) return images def close(self) -> None: @@ -1860,7 +1857,7 @@ class _BaseParser: def __init__(self) -> None: self._elementStack: list[str] = [] - def parse(self, text: str): + def parse(self, text: bytes): from xml.parsers.expat import ParserCreate parser = ParserCreate() @@ -1879,7 +1876,7 @@ def endElementHandler(self, name: str) -> None: # unicodes -def _fetchUnicodes(glif: str) -> list[int]: +def _fetchUnicodes(glif: bytes) -> list[int]: """ Get a list of unicodes listed in glif. """ @@ -1913,7 +1910,7 @@ def startElementHandler(self, name: str, attrs: dict[str, str]) -> None: # image -def _fetchImageFileName(glif: str) -> Optional[str]: +def _fetchImageFileName(glif: bytes) -> Optional[str]: """ The image file name (if any) from glif. """ @@ -1940,7 +1937,7 @@ def startElementHandler(self, name: str, attrs: dict[str, str]) -> None: # component references -def _fetchComponentBases(glif: str) -> list[str]: +def _fetchComponentBases(glif: bytes) -> list[str]: """ Get a list of component base glyphs listed in glif. """