This commit is contained in:
Christian Anetzberger
2026-01-22 20:23:51 +01:00
commit a197de9456
4327 changed files with 1235205 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,407 @@
from __future__ import annotations
from typing import Mapping, Any
from collections.abc import Container
from fontTools.annotations import KerningNested
"""
Functions for converting UFO1 or UFO2 files into UFO3 format.
Currently provides functionality for converting kerning rules
and kerning groups. Conversion is only supported _from_ UFO1
or UFO2, and _to_ UFO3.
"""
# adapted from the UFO spec
def convertUFO1OrUFO2KerningToUFO3Kerning(
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:
kerning:
A dictionary containing the kerning rules defined in
the UFO font, as used in :class:`.UFOReader` objects.
groups:
A dictionary containing the groups defined in the UFO
font, as used in :class:`.UFOReader` objects.
glyphSet:
Optional; a set of glyph objects to skip (default: None).
Returns:
1. A dictionary representing the converted kerning data.
2. A copy of the groups dictionary, with all groups renamed to UFO3 syntax.
3. A dictionary containing the mapping of old group names to new group names.
"""
# gather known kerning groups based on the prefixes
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 not first.startswith("public.kern1."):
firstReferencedGroups.add(first)
for second in list(seconds.keys()):
if second in groups and second not in glyphSet:
if not second.startswith("public.kern2."):
secondReferencedGroups.add(second)
# Create new names for these groups.
firstRenamedGroups: dict[str, str] = {}
for first in firstReferencedGroups:
# Make a list of existing group names.
existingGroupNames = list(groups.keys()) + list(firstRenamedGroups.keys())
# Remove the old prefix from the name
newName = first.replace("@MMK_L_", "")
# Add the new prefix to the name.
newName = "public.kern1." + newName
# Make a unique group name.
newName = makeUniqueGroupName(newName, existingGroupNames)
# Store for use later.
firstRenamedGroups[first] = newName
secondRenamedGroups: dict[str, str] = {}
for second in secondReferencedGroups:
# Make a list of existing group names.
existingGroupNames = list(groups.keys()) + list(secondRenamedGroups.keys())
# Remove the old prefix from the name
newName = second.replace("@MMK_R_", "")
# Add the new prefix to the name.
newName = "public.kern2." + newName
# Make a unique group name.
newName = makeUniqueGroupName(newName, existingGroupNames)
# Store for use later.
secondRenamedGroups[second] = newName
# Populate the new group names into the kerning dictionary as needed.
newKerning = {}
for first, seconds in list(kerning.items()):
first = firstRenamedGroups.get(first, first)
newSeconds = {}
for second, value in list(seconds.items()):
second = secondRenamedGroups.get(second, second)
newSeconds[second] = value
newKerning[first] = newSeconds
# Make copies of the referenced groups and store them
# under the new names in the overall groups dictionary.
allRenamedGroups = list(firstRenamedGroups.items())
allRenamedGroups += list(secondRenamedGroups.items())
for oldName, newName in allRenamedGroups:
group = list(groups[oldName])
groups[newName] = group
# Return the kerning and the groups.
return newKerning, groups, dict(side1=firstRenamedGroups, side2=secondRenamedGroups)
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
by the kerning pairs in a UFO. The algorithm for locating
groups in :func:`convertUFO1OrUFO2KerningToUFO3Kerning` will
miss these unreferenced groups. By scanning for known prefixes,
this function will catch all of the prefixed groups.
The prefixes and sides by this function are:
@MMK_L_ - side 1
@MMK_R_ - side 2
as defined in the UFO1 specification.
Args:
groups:
A dictionary containing the groups defined in the UFO
font, as read by :class:`.UFOReader`.
Returns:
Two sets; the first containing the names of all
first-side kerning groups identified in the ``groups``
dictionary, and the second containing the names of all
second-side kerning groups identified.
"First-side" and "second-side" are with respect to the
writing direction of the script.
Example::
>>> testGroups = {
... "@MMK_L_1" : None,
... "@MMK_L_2" : None,
... "@MMK_L_3" : None,
... "@MMK_R_1" : None,
... "@MMK_R_2" : None,
... "@MMK_R_3" : None,
... "@MMK_l_1" : None,
... "@MMK_r_1" : None,
... "@MMK_X_1" : None,
... "foo" : None,
... }
>>> first, second = findKnownKerningGroups(testGroups)
>>> sorted(first) == ['@MMK_L_1', '@MMK_L_2', '@MMK_L_3']
True
>>> sorted(second) == ['@MMK_R_1', '@MMK_R_2', '@MMK_R_3']
True
"""
knownFirstGroupPrefixes = ["@MMK_L_"]
knownSecondGroupPrefixes = ["@MMK_R_"]
firstGroups = set()
secondGroups = set()
for groupName in list(groups.keys()):
for firstPrefix in knownFirstGroupPrefixes:
if groupName.startswith(firstPrefix):
firstGroups.add(groupName)
break
for secondPrefix in knownSecondGroupPrefixes:
if groupName.startswith(secondPrefix):
secondGroups.add(groupName)
break
return firstGroups, secondGroups
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
will return a new name by adding an incremented counter to the end
of the requested name.
Args:
name:
The requested kerning group name.
groupNames:
A list of the existing kerning group names.
counter:
Optional; a counter of group names already seen (default: 0). If
:attr:`.counter` is not provided, the function will recurse,
incrementing the value of :attr:`.counter` until it finds the
first unused ``name+counter`` combination, and return that result.
Returns:
A unique kerning group name composed of the requested name suffixed
by the smallest available integer counter.
"""
# Add a number to the name if the counter is higher than zero.
newName = name
if counter > 0:
newName = "%s%d" % (newName, counter)
# If the new name is in the existing group names, recurse.
if newName in groupNames:
return makeUniqueGroupName(name, groupNames, counter + 1)
# Otherwise send back the new name.
return newName
def test():
"""
Tests for :func:`.convertUFO1OrUFO2KerningToUFO3Kerning`.
No known prefixes.
>>> testKerning = {
... "A" : {
... "A" : 1,
... "B" : 2,
... "CGroup" : 3,
... "DGroup" : 4
... },
... "BGroup" : {
... "A" : 5,
... "B" : 6,
... "CGroup" : 7,
... "DGroup" : 8
... },
... "CGroup" : {
... "A" : 9,
... "B" : 10,
... "CGroup" : 11,
... "DGroup" : 12
... },
... }
>>> testGroups = {
... "BGroup" : ["B"],
... "CGroup" : ["C"],
... "DGroup" : ["D"],
... }
>>> kerning, groups, maps = convertUFO1OrUFO2KerningToUFO3Kerning(
... testKerning, testGroups, [])
>>> expected = {
... "A" : {
... "A": 1,
... "B": 2,
... "public.kern2.CGroup": 3,
... "public.kern2.DGroup": 4
... },
... "public.kern1.BGroup": {
... "A": 5,
... "B": 6,
... "public.kern2.CGroup": 7,
... "public.kern2.DGroup": 8
... },
... "public.kern1.CGroup": {
... "A": 9,
... "B": 10,
... "public.kern2.CGroup": 11,
... "public.kern2.DGroup": 12
... }
... }
>>> kerning == expected
True
>>> expected = {
... "BGroup": ["B"],
... "CGroup": ["C"],
... "DGroup": ["D"],
... "public.kern1.BGroup": ["B"],
... "public.kern1.CGroup": ["C"],
... "public.kern2.CGroup": ["C"],
... "public.kern2.DGroup": ["D"],
... }
>>> groups == expected
True
Known prefixes.
>>> testKerning = {
... "A" : {
... "A" : 1,
... "B" : 2,
... "@MMK_R_CGroup" : 3,
... "@MMK_R_DGroup" : 4
... },
... "@MMK_L_BGroup" : {
... "A" : 5,
... "B" : 6,
... "@MMK_R_CGroup" : 7,
... "@MMK_R_DGroup" : 8
... },
... "@MMK_L_CGroup" : {
... "A" : 9,
... "B" : 10,
... "@MMK_R_CGroup" : 11,
... "@MMK_R_DGroup" : 12
... },
... }
>>> testGroups = {
... "@MMK_L_BGroup" : ["B"],
... "@MMK_L_CGroup" : ["C"],
... "@MMK_L_XGroup" : ["X"],
... "@MMK_R_CGroup" : ["C"],
... "@MMK_R_DGroup" : ["D"],
... "@MMK_R_XGroup" : ["X"],
... }
>>> kerning, groups, maps = convertUFO1OrUFO2KerningToUFO3Kerning(
... testKerning, testGroups, [])
>>> expected = {
... "A" : {
... "A": 1,
... "B": 2,
... "public.kern2.CGroup": 3,
... "public.kern2.DGroup": 4
... },
... "public.kern1.BGroup": {
... "A": 5,
... "B": 6,
... "public.kern2.CGroup": 7,
... "public.kern2.DGroup": 8
... },
... "public.kern1.CGroup": {
... "A": 9,
... "B": 10,
... "public.kern2.CGroup": 11,
... "public.kern2.DGroup": 12
... }
... }
>>> kerning == expected
True
>>> expected = {
... "@MMK_L_BGroup": ["B"],
... "@MMK_L_CGroup": ["C"],
... "@MMK_L_XGroup": ["X"],
... "@MMK_R_CGroup": ["C"],
... "@MMK_R_DGroup": ["D"],
... "@MMK_R_XGroup": ["X"],
... "public.kern1.BGroup": ["B"],
... "public.kern1.CGroup": ["C"],
... "public.kern1.XGroup": ["X"],
... "public.kern2.CGroup": ["C"],
... "public.kern2.DGroup": ["D"],
... "public.kern2.XGroup": ["X"],
... }
>>> groups == expected
True
>>> from .validators import kerningValidator
>>> kerningValidator(kerning)
(True, None)
Mixture of known prefixes and groups without prefixes.
>>> testKerning = {
... "A" : {
... "A" : 1,
... "B" : 2,
... "@MMK_R_CGroup" : 3,
... "DGroup" : 4
... },
... "BGroup" : {
... "A" : 5,
... "B" : 6,
... "@MMK_R_CGroup" : 7,
... "DGroup" : 8
... },
... "@MMK_L_CGroup" : {
... "A" : 9,
... "B" : 10,
... "@MMK_R_CGroup" : 11,
... "DGroup" : 12
... },
... }
>>> testGroups = {
... "BGroup" : ["B"],
... "@MMK_L_CGroup" : ["C"],
... "@MMK_R_CGroup" : ["C"],
... "DGroup" : ["D"],
... }
>>> kerning, groups, maps = convertUFO1OrUFO2KerningToUFO3Kerning(
... testKerning, testGroups, [])
>>> expected = {
... "A" : {
... "A": 1,
... "B": 2,
... "public.kern2.CGroup": 3,
... "public.kern2.DGroup": 4
... },
... "public.kern1.BGroup": {
... "A": 5,
... "B": 6,
... "public.kern2.CGroup": 7,
... "public.kern2.DGroup": 8
... },
... "public.kern1.CGroup": {
... "A": 9,
... "B": 10,
... "public.kern2.CGroup": 11,
... "public.kern2.DGroup": 12
... }
... }
>>> kerning == expected
True
>>> expected = {
... "BGroup": ["B"],
... "@MMK_L_CGroup": ["C"],
... "@MMK_R_CGroup": ["C"],
... "DGroup": ["D"],
... "public.kern1.BGroup": ["B"],
... "public.kern1.CGroup": ["C"],
... "public.kern2.CGroup": ["C"],
... "public.kern2.DGroup": ["D"],
... }
>>> groups == expected
True
"""
if __name__ == "__main__":
import doctest
doctest.testmod()

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
class UFOLibError(Exception):
pass
class UnsupportedUFOFormat(UFOLibError):
pass
class GlifLibError(UFOLibError):
"""An error raised by glifLib.
This class is a loose backport of PEP 678, adding a :attr:`.note`
attribute that can hold additional context for errors encountered.
It will be maintained until only Python 3.11-and-later are supported.
"""
def _add_note(self, note: str) -> None:
# Loose backport of PEP 678 until we only support Python 3.11+, used for
# adding additional context to errors.
# TODO: Replace with https://docs.python.org/3.11/library/exceptions.html#BaseException.add_note
(message, *rest) = self.args
self.args = ((message + "\n" + note), *rest)
class UnsupportedGLIFFormat(GlifLibError):
pass

View File

@@ -0,0 +1,6 @@
"""DEPRECATED - This module is kept here only as a backward compatibility shim
for the old ufoLib.etree module, which was moved to :mod:`fontTools.misc.etree`.
Please use the latter instead.
"""
from fontTools.misc.etree import *

View File

@@ -0,0 +1,356 @@
from __future__ import annotations
from collections.abc import Iterable
"""
Convert user-provided internal UFO names to spec-compliant filenames.
This module implements the algorithm for converting between a "user name" -
something that a user can choose arbitrarily inside a font editor - and a file
name suitable for use in a wide range of operating systems and filesystems.
The `UFO 3 specification <http://unifiedfontobject.org/versions/ufo3/conventions/>`_
provides an example of an algorithm for such conversion, which avoids illegal
characters, reserved file names, ambiguity between upper- and lower-case
characters, and clashes with existing files.
This code was originally copied from
`ufoLib <https://github.com/unified-font-object/ufoLib/blob/8747da7/Lib/ufoLib/filenames.py>`_
by Tal Leming and is copyright (c) 2005-2016, The RoboFab Developers:
- Erik van Blokland
- Tal Leming
- Just van Rossum
"""
# Restrictions are taken mostly from
# https://docs.microsoft.com/en-gb/windows/win32/fileio/naming-a-file#naming-conventions.
#
# 1. Integer value zero, sometimes referred to as the ASCII NUL character.
# 2. Characters whose integer representations are in the range 1 to 31,
# inclusive.
# 3. Various characters that (mostly) Windows and POSIX-y filesystems don't
# allow, plus "(" and ")", as per the specification.
illegalCharacters: set[str] = {
"\x00",
"\x01",
"\x02",
"\x03",
"\x04",
"\x05",
"\x06",
"\x07",
"\x08",
"\t",
"\n",
"\x0b",
"\x0c",
"\r",
"\x0e",
"\x0f",
"\x10",
"\x11",
"\x12",
"\x13",
"\x14",
"\x15",
"\x16",
"\x17",
"\x18",
"\x19",
"\x1a",
"\x1b",
"\x1c",
"\x1d",
"\x1e",
"\x1f",
'"',
"*",
"+",
"/",
":",
"<",
">",
"?",
"[",
"\\",
"]",
"(",
")",
"|",
"\x7f",
}
reservedFileNames: set[str] = {
"aux",
"clock$",
"com1",
"com2",
"com3",
"com4",
"com5",
"com6",
"com7",
"com8",
"com9",
"con",
"lpt1",
"lpt2",
"lpt3",
"lpt4",
"lpt5",
"lpt6",
"lpt7",
"lpt8",
"lpt9",
"nul",
"prn",
}
maxFileNameLength: int = 255
class NameTranslationError(Exception):
pass
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
upper- and lower-case characters, and clashes with existing files.
Args:
userName (str): The input file name.
existing: A case-insensitive list of all existing file names.
prefix: Prefix to be prepended to the file name.
suffix: Suffix to be appended to the file name.
Returns:
A suitable filename.
Raises:
NameTranslationError: If no suitable name could be generated.
Examples::
>>> userNameToFileName("a") == "a"
True
>>> userNameToFileName("A") == "A_"
True
>>> userNameToFileName("AE") == "A_E_"
True
>>> userNameToFileName("Ae") == "A_e"
True
>>> userNameToFileName("ae") == "ae"
True
>>> userNameToFileName("aE") == "aE_"
True
>>> userNameToFileName("a.alt") == "a.alt"
True
>>> userNameToFileName("A.alt") == "A_.alt"
True
>>> userNameToFileName("A.Alt") == "A_.A_lt"
True
>>> userNameToFileName("A.aLt") == "A_.aL_t"
True
>>> userNameToFileName(u"A.alT") == "A_.alT_"
True
>>> userNameToFileName("T_H") == "T__H_"
True
>>> userNameToFileName("T_h") == "T__h"
True
>>> userNameToFileName("t_h") == "t_h"
True
>>> userNameToFileName("F_F_I") == "F__F__I_"
True
>>> userNameToFileName("f_f_i") == "f_f_i"
True
>>> userNameToFileName("Aacute_V.swash") == "A_acute_V_.swash"
True
>>> userNameToFileName(".notdef") == "_notdef"
True
>>> userNameToFileName("con") == "_con"
True
>>> userNameToFileName("CON") == "C_O_N_"
True
>>> userNameToFileName("con.alt") == "_con.alt"
True
>>> userNameToFileName("alt.con") == "alt._con"
True
"""
# the incoming name must be a string
if not isinstance(userName, str):
raise ValueError("The value for userName must be a string.")
# establish the prefix and suffix lengths
prefixLength = len(prefix)
suffixLength = len(suffix)
# replace an initial period with an _
# if no prefix is to be added
if not prefix and userName[0] == ".":
userName = "_" + userName[1:]
# filter the user name
filteredUserName = []
for character in userName:
# replace illegal characters with _
if character in illegalCharacters:
character = "_"
# add _ to all non-lower characters
elif character != character.lower():
character += "_"
filteredUserName.append(character)
userName = "".join(filteredUserName)
# clip to 255
sliceLength = maxFileNameLength - prefixLength - suffixLength
userName = userName[:sliceLength]
# test for illegal files names
parts = []
for part in userName.split("."):
if part.lower() in reservedFileNames:
part = "_" + part
parts.append(part)
userName = ".".join(parts)
# test for clash
fullName = prefix + userName + suffix
if fullName.lower() in existing:
fullName = handleClash1(userName, existing, prefix, suffix)
# finished
return fullName
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.
Args:
userName (str): The input file name.
existing: A case-insensitive list of all existing file names.
prefix: Prefix to be prepended to the file name.
suffix: Suffix to be appended to the file name.
Returns:
A suitable filename.
>>> prefix = ("0" * 5) + "."
>>> suffix = "." + ("0" * 10)
>>> existing = ["a" * 5]
>>> e = list(existing)
>>> handleClash1(userName="A" * 5, existing=e,
... prefix=prefix, suffix=suffix) == (
... '00000.AAAAA000000000000001.0000000000')
True
>>> e = list(existing)
>>> e.append(prefix + "aaaaa" + "1".zfill(15) + suffix)
>>> handleClash1(userName="A" * 5, existing=e,
... prefix=prefix, suffix=suffix) == (
... '00000.AAAAA000000000000002.0000000000')
True
>>> e = list(existing)
>>> e.append(prefix + "AAAAA" + "2".zfill(15) + suffix)
>>> handleClash1(userName="A" * 5, existing=e,
... prefix=prefix, suffix=suffix) == (
... '00000.AAAAA000000000000001.0000000000')
True
"""
# if the prefix length + user name length + suffix length + 15 is at
# or past the maximum length, silce 15 characters off of the user name
prefixLength = len(prefix)
suffixLength = len(suffix)
if prefixLength + len(userName) + suffixLength + 15 > maxFileNameLength:
l = prefixLength + len(userName) + suffixLength + 15
sliceLength = maxFileNameLength - l
userName = userName[:sliceLength]
finalName = None
# try to add numbers to create a unique name
counter = 1
while finalName is None:
name = userName + str(counter).zfill(15)
fullName = prefix + name + suffix
if fullName.lower() not in existing:
finalName = fullName
break
else:
counter += 1
if counter >= 999999999999999:
break
# if there is a clash, go to the next fallback
if finalName is None:
finalName = handleClash2(existing, prefix, suffix)
# finished
return finalName
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.
Args:
userName (str): The input file name.
existing: A case-insensitive list of all existing file names.
prefix: Prefix to be prepended to the file name.
suffix: Suffix to be appended to the file name.
Returns:
A suitable filename.
Raises:
NameTranslationError: If no suitable name could be generated.
Examples::
>>> prefix = ("0" * 5) + "."
>>> suffix = "." + ("0" * 10)
>>> existing = [prefix + str(i) + suffix for i in range(100)]
>>> e = list(existing)
>>> handleClash2(existing=e, prefix=prefix, suffix=suffix) == (
... '00000.100.0000000000')
True
>>> e = list(existing)
>>> e.remove(prefix + "1" + suffix)
>>> handleClash2(existing=e, prefix=prefix, suffix=suffix) == (
... '00000.1.0000000000')
True
>>> e = list(existing)
>>> e.remove(prefix + "2" + suffix)
>>> handleClash2(existing=e, prefix=prefix, suffix=suffix) == (
... '00000.2.0000000000')
True
"""
# calculate the longest possible string
maxLength = maxFileNameLength - len(prefix) - len(suffix)
maxValue = int("9" * maxLength)
# try to find a number
finalName = None
counter = 1
while finalName is None:
fullName = prefix + str(counter) + suffix
if fullName.lower() not in existing:
finalName = fullName
break
else:
counter += 1
if counter >= maxValue:
break
# raise an error if nothing has been found
if finalName is None:
raise NameTranslationError("No unique name could be found.")
# finished
return finalName
if __name__ == "__main__":
import doctest
doctest.testmod()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
from __future__ import annotations
from typing import Optional
from fontTools.annotations import KerningPair, KerningDict, KerningGroups, IntFloat
StrDict = dict[str, str]
def lookupKerningValue(
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
groups (by name), or any combination of the two.
Args:
pair:
A tuple, in logical order (first, second) with respect
to the reading direction, to query the font for kerning
information on. Each element in the tuple can be either
a glyph name or a kerning group name.
kerning:
A dictionary of kerning pairs.
groups:
A set of kerning groups.
fallback:
The fallback value to return if no kern is found between
the elements in ``pair``. Defaults to 0.
glyphToFirstGroup:
A dictionary mapping glyph names to the first-glyph kerning
groups to which they belong. Defaults to ``None``.
glyphToSecondGroup:
A dictionary mapping glyph names to the second-glyph kerning
groups to which they belong. Defaults to ``None``.
Returns:
The kerning value between the element pair. If no kerning for
the pair is found, the fallback value is returned.
Note: This function expects the ``kerning`` argument to be a flat
dictionary of kerning pairs, not the nested structure used in a
kerning.plist file.
Examples::
>>> groups = {
... "public.kern1.O" : ["O", "D", "Q"],
... "public.kern2.E" : ["E", "F"]
... }
>>> kerning = {
... ("public.kern1.O", "public.kern2.E") : -100,
... ("public.kern1.O", "F") : -200,
... ("D", "F") : -300
... }
>>> lookupKerningValue(("D", "F"), kerning, groups)
-300
>>> lookupKerningValue(("O", "F"), kerning, groups)
-200
>>> lookupKerningValue(("O", "E"), kerning, groups)
-100
>>> lookupKerningValue(("O", "O"), kerning, groups)
0
>>> lookupKerningValue(("E", "E"), kerning, groups)
0
>>> lookupKerningValue(("E", "O"), kerning, groups)
0
>>> lookupKerningValue(("X", "X"), kerning, groups)
0
>>> lookupKerningValue(("public.kern1.O", "public.kern2.E"),
... kerning, groups)
-100
>>> lookupKerningValue(("public.kern1.O", "F"), kerning, groups)
-200
>>> lookupKerningValue(("O", "public.kern2.E"), kerning, groups)
-100
>>> lookupKerningValue(("public.kern1.X", "public.kern2.X"), kerning, groups)
0
"""
# 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 None:
glyphToFirstGroup = {}
glyphToSecondGroup = {}
for group, groupMembers in groups.items():
if group.startswith("public.kern1."):
for glyph in groupMembers:
glyphToFirstGroup[glyph] = group
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
firstGlyph = None
else:
firstGroup = glyphToFirstGroup.get(first)
firstGlyph = first
if second.startswith("public.kern2."):
secondGroup = second
secondGlyph = None
else:
secondGroup = glyphToSecondGroup.get(second)
secondGlyph = second
# make an ordered list of pairs to look up
pairs = [
(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:
if pair in kerning:
return kerning[pair]
# use the fallback value
return fallback
if __name__ == "__main__":
import doctest
doctest.testmod()

View File

@@ -0,0 +1,47 @@
"""DEPRECATED - This module is kept here only as a backward compatibility shim
for the old `ufoLib.plistlib` module, which was moved to :class:`fontTools.misc.plistlib`.
Please use the latter instead.
"""
from fontTools.misc.plistlib import dump, dumps, load, loads
from fontTools.misc.textTools import tobytes
# The following functions were part of the old py2-like ufoLib.plistlib API.
# They are kept only for backward compatiblity.
from fontTools.ufoLib.utils import deprecated
@deprecated("Use 'fontTools.misc.plistlib.load' instead")
def readPlist(path_or_file):
did_open = False
if isinstance(path_or_file, str):
path_or_file = open(path_or_file, "rb")
did_open = True
try:
return load(path_or_file, use_builtin_types=False)
finally:
if did_open:
path_or_file.close()
@deprecated("Use 'fontTools.misc.plistlib.dump' instead")
def writePlist(value, path_or_file):
did_open = False
if isinstance(path_or_file, str):
path_or_file = open(path_or_file, "wb")
did_open = True
try:
dump(value, path_or_file, use_builtin_types=False)
finally:
if did_open:
path_or_file.close()
@deprecated("Use 'fontTools.misc.plistlib.loads' instead")
def readPlistFromString(data):
return loads(tobytes(data, encoding="utf-8"), use_builtin_types=False)
@deprecated("Use 'fontTools.misc.plistlib.dumps' instead")
def writePlistToString(value):
return dumps(value, use_builtin_types=False)

View File

@@ -0,0 +1,6 @@
"""DEPRECATED - This module is kept here only as a backward compatibility shim
for the old `ufoLib.pointPen` module, which was moved to :class:`fontTools.pens.pointPen`.
Please use the latter instead.
"""
from fontTools.pens.pointPen import *

View File

@@ -0,0 +1,107 @@
"""This module contains miscellaneous helpers.
It is not considered part of the public ufoLib API. It does, however,
define the :py:obj:`.deprecated` decorator that is used elsewhere in
the module.
"""
from __future__ import annotations
from typing import Optional, Type, TypeVar, Union, cast
from collections.abc import Callable
import enum
import functools
import warnings
F = TypeVar("F", bound=Callable[..., object])
FormatVersion = TypeVar("FormatVersion", bound="BaseFormatVersion")
FormatVersionInput = Optional[Union[int, tuple[int, int], FormatVersion]]
numberTypes = (int, float)
def deprecated(msg: str = "") -> Callable[[F], F]:
"""Decorator factory to mark functions as deprecated with given message.
>>> @deprecated("Enough!")
... def some_function():
... "I just print 'hello world'."
... print("hello world")
>>> some_function()
hello world
>>> some_function.__doc__ == "I just print 'hello world'."
True
"""
def deprecated_decorator(func: F) -> F:
@functools.wraps(func)
def wrapper(*args, **kwargs):
warnings.warn(
f"{func.__name__} function is a deprecated. {msg}",
category=DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)
return cast(F, wrapper)
return deprecated_decorator
def normalizeFormatVersion(
value: FormatVersionInput, cls: Type[FormatVersion]
) -> FormatVersion:
# Needed for type safety of UFOFormatVersion and GLIFFormatVersion input
if value is None:
return cls.default()
if isinstance(value, cls):
return value
if isinstance(value, int):
return cls((value, 0))
if isinstance(value, tuple) and len(value) == 2:
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: tuple[int, int]
def __new__(cls: Type[FormatVersion], value: tuple[int, int]) -> BaseFormatVersion:
return super().__new__(cls, value)
@property
def major(self) -> int:
return self.value[0]
@property
def minor(self) -> int:
return self.value[1]
@classmethod
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()
raise ValueError(f"{value!r} is not a valid {cls.__name__}")
def __str__(self) -> str:
return f"{self.major}.{self.minor}"
@classmethod
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: Type[FormatVersion]) -> frozenset[FormatVersion]:
return frozenset(cls.__members__.values())
if __name__ == "__main__":
import doctest
doctest.testmod()

File diff suppressed because it is too large Load Diff