diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index dcc187d45..4b87bf598 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -118,7 +118,7 @@ jobs: # so that all artifacts are downloaded in the same directory specified by 'path' merge-multiple: true path: dist - - uses: pypa/gh-action-pypi-publish@v1.9.0 + - uses: pypa/gh-action-pypi-publish@v1.10.1 with: user: __token__ password: ${{ secrets.PYPI_PASSWORD }} diff --git a/Doc/source/colorLib/index.rst b/Doc/source/colorLib/index.rst index 5a9bf8a16..f9447f622 100644 --- a/Doc/source/colorLib/index.rst +++ b/Doc/source/colorLib/index.rst @@ -3,3 +3,4 @@ colorLib.builder: Build COLR/CPAL tables from scratch ##################################################### .. automodule:: fontTools.colorLib.builder + :no-inherited-members: diff --git a/Doc/source/conf.py b/Doc/source/conf.py index 982af8032..4976bd813 100644 --- a/Doc/source/conf.py +++ b/Doc/source/conf.py @@ -31,16 +31,20 @@ needs_sphinx = "1.3" # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ + "sphinx.ext.napoleon", "sphinx.ext.autodoc", "sphinx.ext.viewcode", - "sphinx.ext.napoleon", "sphinx.ext.coverage", "sphinx.ext.autosectionlabel", ] autodoc_mock_imports = ["gtk", "reportlab"] -autodoc_default_options = {"members": True, "inherited-members": True} +autodoc_default_options = { + "members": True, + "inherited-members": True, + "show-inheritance": True, +} # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -78,7 +82,7 @@ release = "4.0" # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. diff --git a/Doc/source/designspaceLib/xml.rst b/Doc/source/designspaceLib/xml.rst index 7b59dbb17..d8c76be99 100644 --- a/Doc/source/designspaceLib/xml.rst +++ b/Doc/source/designspaceLib/xml.rst @@ -297,7 +297,7 @@ Example of all axis elements together ```` element -................... +.................... - Defines the output location of an axis mapping. - Child element of ```` diff --git a/Doc/source/developer.rst b/Doc/source/developer.rst index e480706af..abaea79af 100644 --- a/Doc/source/developer.rst +++ b/Doc/source/developer.rst @@ -1,4 +1,5 @@ :orphan: + .. _developerinfo: .. image:: ../../Icons/FontToolsIconGreenCircle.png :width: 200px diff --git a/Doc/source/index.rst b/Doc/source/index.rst index 51dfc3c11..e74abf219 100644 --- a/Doc/source/index.rst +++ b/Doc/source/index.rst @@ -6,7 +6,7 @@ ---fontTools Documentation--- -======= +============================= About ----- diff --git a/Doc/source/ttLib/ttFont.rst b/Doc/source/ttLib/ttFont.rst index a3b4c9d0e..d094b29dc 100644 --- a/Doc/source/ttLib/ttFont.rst +++ b/Doc/source/ttLib/ttFont.rst @@ -14,4 +14,4 @@ ttFont: Read/write OpenType and TrueType fonts .. automodule:: fontTools.ttLib.ttFont :members: getTableModule, registerCustomTableClass, unregisterCustomTableClass, getCustomTableClass, getClassTag, newTable, tagToIdentifier, identifierToTag, tagToXML, xmlToTag, sortedTagList, reorderFontTables - + :exclude-members: TTFont, GlyphOrder diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py index 342f1decd..0a1e782f5 100644 --- a/Lib/fontTools/designspaceLib/__init__.py +++ b/Lib/fontTools/designspaceLib/__init__.py @@ -1,3 +1,9 @@ +""" + designSpaceDocument + + - Read and write designspace files +""" + from __future__ import annotations import collections @@ -15,11 +21,6 @@ from fontTools.misc import plistlib from fontTools.misc.loggingTools import LogMixin from fontTools.misc.textTools import tobytes, tostr -""" - designSpaceDocument - - - read and write designspace files -""" __all__ = [ "AxisDescriptor", diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py index 4aa60ad84..5a1af9a6c 100644 --- a/Lib/fontTools/subset/__init__.py +++ b/Lib/fontTools/subset/__init__.py @@ -122,15 +122,16 @@ Other options ^^^^^^^^^^^^^ For the other options listed below, to see the current value of the option, -pass a value of '?' to it, with or without a '='. +pass a value of '?' to it, with or without a '='. In some environments, +you might need to escape the question mark, like this: '--glyph-names\?'. Examples:: $ pyftsubset --glyph-names? Current setting for 'glyph-names' is: False - $ ./pyftsubset --name-IDs=? + $ pyftsubset --name-IDs=? Current setting for 'name-IDs' is: [0, 1, 2, 3, 4, 5, 6] - $ ./pyftsubset --hinting? --no-hinting --hinting? + $ pyftsubset --hinting? --no-hinting --hinting? Current setting for 'hinting' is: True Current setting for 'hinting' is: False diff --git a/Lib/fontTools/ttLib/tables/_n_a_m_e.py b/Lib/fontTools/ttLib/tables/_n_a_m_e.py index bbb4f5364..e30086adb 100644 --- a/Lib/fontTools/ttLib/tables/_n_a_m_e.py +++ b/Lib/fontTools/ttLib/tables/_n_a_m_e.py @@ -1175,17 +1175,8 @@ class NameRecordVisitor(TTVisitor): @NameRecordVisitor.register_attrs( ( - (otTables.FeatureParamsSize, ("SubfamilyID", "SubfamilyNameID")), + (otTables.FeatureParamsSize, ("SubfamilyNameID",)), (otTables.FeatureParamsStylisticSet, ("UINameID",)), - ( - otTables.FeatureParamsCharacterVariants, - ( - "FeatUILabelNameID", - "FeatUITooltipTextNameID", - "SampleTextNameID", - "FirstParamUILabelNameID", - ), - ), (otTables.STAT, ("ElidedFallbackNameID",)), (otTables.AxisRecord, ("AxisNameID",)), (otTables.AxisValue, ("ValueNameID",)), @@ -1197,6 +1188,22 @@ def visit(visitor, obj, attr, value): visitor.seen.add(value) +@NameRecordVisitor.register(otTables.FeatureParamsCharacterVariants) +def visit(visitor, obj): + for attr in ("FeatUILabelNameID", "FeatUITooltipTextNameID", "SampleTextNameID"): + value = getattr(obj, attr) + visitor.seen.add(value) + # also include the sequence of UI strings for individual variants, if any + if obj.FirstParamUILabelNameID == 0 or obj.NumNamedParameters == 0: + return + visitor.seen.update( + range( + obj.FirstParamUILabelNameID, + obj.FirstParamUILabelNameID + obj.NumNamedParameters, + ) + ) + + @NameRecordVisitor.register(ttLib.getTableClass("fvar")) def visit(visitor, obj): for inst in obj.instances: diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index c2d2b0b26..a014c9317 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -1,26 +1,3 @@ -import os -from copy import deepcopy -from os import fsdecode -import logging -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 -from fontTools.misc import plistlib -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 - """ A library for importing .ufo files and their descendants. Refer to http://unifiedfontobject.com for the UFO specification. @@ -51,6 +28,29 @@ fontinfo.plist values between the possible format versions. convertFontInfoValueForAttributeFromVersion3ToVersion2 """ +import os +from copy import deepcopy +from os import fsdecode +import logging +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 +from fontTools.misc import plistlib +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 + __all__ = [ "makeUFOPath", "UFOLibError", diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 6d0e00ee1..5c96fd01c 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -869,7 +869,7 @@ def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True): colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) -def load_designspace(designspace, log_enabled=True): +def load_designspace(designspace, log_enabled=True, *, require_sources=True): # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, # never a file path, as that's already handled by caller if hasattr(designspace, "sources"): # Assume a DesignspaceDocument @@ -878,7 +878,7 @@ def load_designspace(designspace, log_enabled=True): ds = DesignSpaceDocument.fromfile(designspace) masters = ds.sources - if not masters: + if require_sources and not masters: raise VarLibValidationError("Designspace must have at least one source.") instances = ds.instances @@ -978,7 +978,7 @@ def load_designspace(designspace, log_enabled=True): "More than one base master found in Designspace." ) base_idx = i - if base_idx is None: + if require_sources and base_idx is None: raise VarLibValidationError( "Base master not found; no master at default location?" ) diff --git a/Lib/fontTools/varLib/avar.py b/Lib/fontTools/varLib/avar.py index 60f0d7e70..164a4a805 100644 --- a/Lib/fontTools/varLib/avar.py +++ b/Lib/fontTools/varLib/avar.py @@ -1,10 +1,187 @@ from fontTools.varLib import _add_avar, load_designspace +from fontTools.varLib.models import VariationModel +from fontTools.varLib.varStore import VarStoreInstancer +from fontTools.misc.fixedTools import fixedToFloat as fi2fl from fontTools.misc.cliTools import makeOutputFileName +from itertools import product import logging log = logging.getLogger("fontTools.varLib.avar") +def _denormalize(v, axis): + if v >= 0: + return axis.defaultValue + v * (axis.maxValue - axis.defaultValue) + else: + return axis.defaultValue + v * (axis.defaultValue - axis.minValue) + + +def _pruneLocations(locations, poles, axisTags): + # Now we have all the input locations, find which ones are + # not needed and remove them. + + # Note: This algorithm is heavily tied to how VariationModel + # is implemented. It assumes that input was extracted from + # VariationModel-generated object, like an ItemVariationStore + # created by fontmake using varLib.models.VariationModel. + # Some CoPilot blabbering: + # I *think* I can prove that this algorithm is correct, but + # I'm not 100% sure. It's possible that there are edge cases + # where this algorithm will fail. I'm not sure how to prove + # that it's correct, but I'm also not sure how to prove that + # it's incorrect. I'm not sure how to write a test case that + # would prove that it's incorrect. I'm not sure how to write + # a test case that would prove that it's correct. + + model = VariationModel(locations, axisTags) + modelMapping = model.mapping + modelSupports = model.supports + pins = {tuple(k.items()): None for k in poles} + for location in poles: + i = locations.index(location) + i = modelMapping[i] + support = modelSupports[i] + supportAxes = set(support.keys()) + for axisTag, (minV, _, maxV) in support.items(): + for v in (minV, maxV): + if v in (-1, 0, 1): + continue + for pin in pins.keys(): + pinLocation = dict(pin) + pinAxes = set(pinLocation.keys()) + if pinAxes != supportAxes: + continue + if axisTag not in pinAxes: + continue + if pinLocation[axisTag] == v: + break + else: + # No pin found. Go through the previous masters + # and find a suitable pin. Going backwards is + # better because it can find a pin that is close + # to the pole in more dimensions, and reducing + # the total number of pins needed. + for candidateIdx in range(i - 1, -1, -1): + candidate = modelSupports[candidateIdx] + candidateAxes = set(candidate.keys()) + if candidateAxes != supportAxes: + continue + if axisTag not in candidateAxes: + continue + candidate = { + k: defaultV for k, (_, defaultV, _) in candidate.items() + } + if candidate[axisTag] == v: + pins[tuple(candidate.items())] = None + break + else: + assert False, "No pin found" + return [dict(t) for t in pins.keys()] + + +def mappings_from_avar(font, denormalize=True): + fvarAxes = font["fvar"].axes + axisMap = {a.axisTag: a for a in fvarAxes} + axisTags = [a.axisTag for a in fvarAxes] + axisIndexes = {a.axisTag: i for i, a in enumerate(fvarAxes)} + if "avar" not in font: + return {}, {} + avar = font["avar"] + axisMaps = { + tag: seg + for tag, seg in avar.segments.items() + if seg and seg != {-1: -1, 0: 0, 1: 1} + } + mappings = [] + + if getattr(avar, "majorVersion", 1) == 2: + varStore = avar.table.VarStore + regions = varStore.VarRegionList.Region + + # Find all the input locations; this finds "poles", that are + # locations of the peaks, and "corners", that are locations + # of the corners of the regions. These two sets of locations + # together constitute inputLocations to consider. + + poles = {(): None} # Just using it as an ordered set + inputLocations = set({()}) + for varData in varStore.VarData: + regionIndices = varData.VarRegionIndex + for regionIndex in regionIndices: + peakLocation = [] + corners = [] + region = regions[regionIndex] + for axisIndex, axis in enumerate(region.VarRegionAxis): + if axis.PeakCoord == 0: + continue + axisTag = axisTags[axisIndex] + peakLocation.append((axisTag, axis.PeakCoord)) + corner = [] + if axis.StartCoord != 0: + corner.append((axisTag, axis.StartCoord)) + if axis.EndCoord != 0: + corner.append((axisTag, axis.EndCoord)) + corners.append(corner) + corners = set(product(*corners)) + peakLocation = tuple(peakLocation) + poles[peakLocation] = None + inputLocations.add(peakLocation) + inputLocations.update(corners) + + # Sort them by number of axes, then by axis order + inputLocations = [ + dict(t) + for t in sorted( + inputLocations, + key=lambda t: (len(t), tuple(axisIndexes[tag] for tag, _ in t)), + ) + ] + poles = [dict(t) for t in poles.keys()] + inputLocations = _pruneLocations(inputLocations, list(poles), axisTags) + + # Find the output locations, at input locations + varIdxMap = avar.table.VarIdxMap + instancer = VarStoreInstancer(varStore, fvarAxes) + for location in inputLocations: + instancer.setLocation(location) + outputLocation = {} + for axisIndex, axisTag in enumerate(axisTags): + varIdx = axisIndex + if varIdxMap is not None: + varIdx = varIdxMap[varIdx] + delta = instancer[varIdx] + if delta != 0: + v = location.get(axisTag, 0) + v = v + fi2fl(delta, 14) + # See https://github.com/fonttools/fonttools/pull/3598#issuecomment-2266082009 + # v = max(-1, min(1, v)) + outputLocation[axisTag] = v + mappings.append((location, outputLocation)) + + # Remove base master we added, if it maps to the default location + assert mappings[0][0] == {} + if mappings[0][1] == {}: + mappings.pop(0) + + if denormalize: + for tag, seg in axisMaps.items(): + if tag not in axisMap: + raise ValueError(f"Unknown axis tag {tag}") + denorm = lambda v: _denormalize(v, axisMap[tag]) + axisMaps[tag] = {denorm(k): denorm(v) for k, v in seg.items()} + + for i, (inputLoc, outputLoc) in enumerate(mappings): + inputLoc = { + tag: _denormalize(val, axisMap[tag]) for tag, val in inputLoc.items() + } + outputLoc = { + tag: _denormalize(val, axisMap[tag]) for tag, val in outputLoc.items() + } + mappings[i] = (inputLoc, outputLoc) + + return axisMaps, mappings + + def main(args=None): """Add `avar` table from designspace file to variable font.""" @@ -24,7 +201,11 @@ def main(args=None): ) parser.add_argument("font", metavar="varfont.ttf", help="Variable-font file.") parser.add_argument( - "designspace", metavar="family.designspace", help="Designspace file." + "designspace", + metavar="family.designspace", + help="Designspace file.", + nargs="?", + default=None, ) parser.add_argument( "-o", @@ -45,9 +226,18 @@ def main(args=None): log.error("Not a variable font.") return 1 + if options.designspace is None: + from pprint import pprint + + segments, mappings = mappings_from_avar(font) + pprint(segments) + pprint(mappings) + print(len(mappings), "mappings") + return + axisTags = [a.axisTag for a in font["fvar"].axes] - ds = load_designspace(options.designspace) + ds = load_designspace(options.designspace, require_sources=False) if "avar" in font: log.warning("avar table already present, overwriting.") diff --git a/Lib/fontTools/varLib/interpolatable.py b/Lib/fontTools/varLib/interpolatable.py index e16a95b49..60dbfeb7f 100644 --- a/Lib/fontTools/varLib/interpolatable.py +++ b/Lib/fontTools/varLib/interpolatable.py @@ -135,6 +135,7 @@ def test_gen( kinkiness=DEFAULT_KINKINESS, upem=DEFAULT_UPEM, show_all=False, + discrete_axes=[], ): if tolerance >= 10: tolerance *= 0.01 @@ -150,7 +151,9 @@ def test_gen( # ... risks the sparse master being the first one, and only processing a subset of the glyphs glyphs = {g for glyphset in glyphsets for g in glyphset.keys()} - parents, order = find_parents_and_order(glyphsets, locations) + parents, order = find_parents_and_order( + glyphsets, locations, discrete_axes=discrete_axes + ) def grand_parent(i, glyphname): if i is None: @@ -701,6 +704,7 @@ def main(args=None): fonts = [] names = [] locations = [] + discrete_axes = set() upem = DEFAULT_UPEM original_args_inputs = tuple(args.inputs) @@ -713,8 +717,13 @@ def main(args=None): designspace = DesignSpaceDocument.fromfile(args.inputs[0]) args.inputs = [master.path for master in designspace.sources] locations = [master.location for master in designspace.sources] + discrete_axes = { + a.name for a in designspace.axes if not hasattr(a, "minimum") + } axis_triples = { - a.name: (a.minimum, a.default, a.maximum) for a in designspace.axes + a.name: (a.minimum, a.default, a.maximum) + for a in designspace.axes + if a.name not in discrete_axes } axis_mappings = {a.name: a.map for a in designspace.axes} axis_triples = { @@ -879,7 +888,13 @@ def main(args=None): glyphset[gn] = None # Normalize locations - locations = [normalizeLocation(loc, axis_triples) for loc in locations] + locations = [ + { + **normalizeLocation(loc, axis_triples), + **{k: v for k, v in loc.items() if k in discrete_axes}, + } + for loc in locations + ] tolerance = args.tolerance or DEFAULT_TOLERANCE kinkiness = args.kinkiness if args.kinkiness is not None else DEFAULT_KINKINESS @@ -896,6 +911,7 @@ def main(args=None): tolerance=tolerance, kinkiness=kinkiness, show_all=args.show_all, + discrete_axes=discrete_axes, ) problems = defaultdict(list) diff --git a/Lib/fontTools/varLib/interpolatableHelpers.py b/Lib/fontTools/varLib/interpolatableHelpers.py index f71b32afd..5cf22cf87 100644 --- a/Lib/fontTools/varLib/interpolatableHelpers.py +++ b/Lib/fontTools/varLib/interpolatableHelpers.py @@ -293,17 +293,19 @@ def add_isomorphisms(points, isomorphisms, reverse): ) -def find_parents_and_order(glyphsets, locations): +def find_parents_and_order(glyphsets, locations, *, discrete_axes=set()): parents = [None] + list(range(len(glyphsets) - 1)) order = list(range(len(glyphsets))) if locations: # Order base master first - bases = (i for i, l in enumerate(locations) if all(v == 0 for v in l.values())) + bases = [ + i + for i, l in enumerate(locations) + if all(v == 0 for k, v in l.items() if k not in discrete_axes) + ] if bases: - base = next(bases) - logging.info("Base master index %s, location %s", base, locations[base]) + logging.info("Found %s base masters: %s", len(bases), bases) else: - base = 0 logging.warning("No base master location found") # Form a minimum spanning tree of the locations @@ -317,9 +319,17 @@ def find_parents_and_order(glyphsets, locations): axes = sorted(axes) vectors = [tuple(l.get(k, 0) for k in axes) for l in locations] for i, j in itertools.combinations(range(len(locations)), 2): + i_discrete_location = { + k: v for k, v in zip(axes, vectors[i]) if k in discrete_axes + } + j_discrete_location = { + k: v for k, v in zip(axes, vectors[j]) if k in discrete_axes + } + if i_discrete_location != j_discrete_location: + continue graph[i][j] = vdiff_hypot2(vectors[i], vectors[j]) - tree = minimum_spanning_tree(graph) + tree = minimum_spanning_tree(graph, overwrite=True) rows, cols = tree.nonzero() graph = defaultdict(set) for row, col in zip(rows, cols): @@ -330,7 +340,7 @@ def find_parents_and_order(glyphsets, locations): parents = [None] * len(locations) order = [] visited = set() - queue = deque([base]) + queue = deque(bases) while queue: i = queue.popleft() visited.add(i) @@ -339,6 +349,9 @@ def find_parents_and_order(glyphsets, locations): if j not in visited: parents[j] = i queue.append(j) + assert len(order) == len( + parents + ), "Not all masters are reachable; report an issue" except ImportError: pass diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 819596991..52433a66a 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -209,10 +209,14 @@ def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None class VariationModel(object): """Locations must have the base master at the origin (ie. 0). + If axis-ranges are not provided, values are assumed to be normalized to + the range [-1, 1]. + If the extrapolate argument is set to True, then values are extrapolated outside the axis range. >>> from pprint import pprint + >>> axisRanges = {'wght': (-180, +180), 'wdth': (-1, +1)} >>> locations = [ \ {'wght':100}, \ {'wght':-100}, \ @@ -224,7 +228,7 @@ class VariationModel(object): {'wght':+180,'wdth':.3}, \ {'wght':+180}, \ ] - >>> model = VariationModel(locations, axisOrder=['wght']) + >>> model = VariationModel(locations, axisOrder=['wght'], axisRanges=axisRanges) >>> pprint(model.locations) [{}, {'wght': -100}, @@ -252,14 +256,22 @@ class VariationModel(object): 7: 0.6666666666666667}] """ - def __init__(self, locations, axisOrder=None, extrapolate=False): + def __init__( + self, locations, axisOrder=None, extrapolate=False, *, axisRanges=None + ): if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations): raise VariationModelError("Locations must be unique.") self.origLocations = locations self.axisOrder = axisOrder if axisOrder is not None else [] self.extrapolate = extrapolate - self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None + if axisRanges is None: + if extrapolate: + axisRanges = self.computeAxisRanges(locations) + else: + allAxes = {axis for loc in locations for axis in loc.keys()} + axisRanges = {axis: (-1, 1) for axis in allAxes} + self.axisRanges = axisRanges locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations] keyFunc = self.getMasterLocationsSortKeyFunc( @@ -374,7 +386,7 @@ class VariationModel(object): locAxes = set(region.keys()) # Walk over previous masters now for prev_region in regions[:i]: - # Master with extra axes do not participte + # Master with different axes do not participte if set(prev_region.keys()) != locAxes: continue # If it's NOT in the current box, it does not participate @@ -425,23 +437,16 @@ class VariationModel(object): def _locationsToRegions(self): locations = self.locations - # Compute min/max across each axis, use it as total range. - # TODO Take this as input from outside? - minV = {} - maxV = {} - for l in locations: - for k, v in l.items(): - minV[k] = min(v, minV.get(k, v)) - maxV[k] = max(v, maxV.get(k, v)) + axisRanges = self.axisRanges regions = [] for loc in locations: region = {} for axis, locV in loc.items(): if locV > 0: - region[axis] = (0, locV, maxV[axis]) + region[axis] = (0, locV, axisRanges[axis][1]) else: - region[axis] = (minV[axis], locV, 0) + region[axis] = (axisRanges[axis][0], locV, 0) regions.append(region) return regions diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index e2f6a1d4d..6c1fdab24 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -1112,12 +1112,12 @@ class BuilderTest(unittest.TestCase): var_region_list = font.tables["GDEF"].table.VarStore.VarRegionList var_region_axis_wght = var_region_list.Region[0].VarRegionAxis[0] var_region_axis_wdth = var_region_list.Region[0].VarRegionAxis[1] - assert self.get_region(var_region_axis_wght) == (0.0, 0.875, 0.875) + assert self.get_region(var_region_axis_wght) == (0.0, 0.875, 1.0) assert self.get_region(var_region_axis_wdth) == (0.0, 0.0, 0.0) var_region_axis_wght = var_region_list.Region[1].VarRegionAxis[0] var_region_axis_wdth = var_region_list.Region[1].VarRegionAxis[1] - assert self.get_region(var_region_axis_wght) == (0.0, 0.875, 0.875) - assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 0.5) + assert self.get_region(var_region_axis_wght) == (0.0, 0.875, 1.0) + assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 1.0) # With `avar`, shifting the wght axis' positive midpoint 0.5 a bit to # the right, but leaving the wdth axis alone: @@ -1129,12 +1129,12 @@ class BuilderTest(unittest.TestCase): var_region_list = font.tables["GDEF"].table.VarStore.VarRegionList var_region_axis_wght = var_region_list.Region[0].VarRegionAxis[0] var_region_axis_wdth = var_region_list.Region[0].VarRegionAxis[1] - assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 0.90625) + assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 1.0) assert self.get_region(var_region_axis_wdth) == (0.0, 0.0, 0.0) var_region_axis_wght = var_region_list.Region[1].VarRegionAxis[0] var_region_axis_wdth = var_region_list.Region[1].VarRegionAxis[1] - assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 0.90625) - assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 0.5) + assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 1.0) + assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 1.0) def test_ligatureCaretByPos_variable_scalar(self): """Test that the `avar` table is consulted when normalizing user-space @@ -1158,7 +1158,7 @@ class BuilderTest(unittest.TestCase): var_region_list = table.VarStore.VarRegionList var_region_axis = var_region_list.Region[0].VarRegionAxis[0] - assert self.get_region(var_region_axis) == (0.0, 0.875, 0.875) + assert self.get_region(var_region_axis) == (0.0, 0.875, 1.0) def generate_feature_file_test(name): diff --git a/Tests/feaLib/data/variable_mark_anchor.ttx b/Tests/feaLib/data/variable_mark_anchor.ttx index 962cff741..d29fc43a1 100644 --- a/Tests/feaLib/data/variable_mark_anchor.ttx +++ b/Tests/feaLib/data/variable_mark_anchor.ttx @@ -16,7 +16,7 @@ - + diff --git a/Tests/feaLib/data/variable_scalar_anchor.ttx b/Tests/feaLib/data/variable_scalar_anchor.ttx index 6bb55691f..92a456d3b 100644 --- a/Tests/feaLib/data/variable_scalar_anchor.ttx +++ b/Tests/feaLib/data/variable_scalar_anchor.ttx @@ -12,7 +12,7 @@ - + @@ -24,12 +24,12 @@ - + - + diff --git a/Tests/feaLib/data/variable_scalar_valuerecord.ttx b/Tests/feaLib/data/variable_scalar_valuerecord.ttx index e3251f691..94bd3867d 100644 --- a/Tests/feaLib/data/variable_scalar_valuerecord.ttx +++ b/Tests/feaLib/data/variable_scalar_valuerecord.ttx @@ -12,7 +12,7 @@ - + @@ -24,12 +24,12 @@ - + - + diff --git a/Tests/otlLib/optimize_test.py b/Tests/otlLib/optimize_test.py index a2e433225..a1ab88da7 100644 --- a/Tests/otlLib/optimize_test.py +++ b/Tests/otlLib/optimize_test.py @@ -2,7 +2,6 @@ import contextlib import logging import os from pathlib import Path -from subprocess import run from typing import List, Optional, Tuple import pytest @@ -28,18 +27,17 @@ def test_main(tmpdir: Path): input = tmpdir / "in.ttf" fb.save(str(input)) output = tmpdir / "out.ttf" - run( - [ - "fonttools", - "otlLib.optimize", - "--gpos-compression-level", - "5", - str(input), - "-o", - str(output), - ], - check=True, - ) + args = [ + "--gpos-compression-level", + "5", + str(input), + "-o", + str(output), + ] + from fontTools.otlLib.optimize import main + + ret = main(args) + assert ret in (0, None) assert output.exists() diff --git a/Tests/subset/data/Andika-Regular.subset.ttx b/Tests/subset/data/Andika-Regular.subset.ttx new file mode 100644 index 000000000..8fd28a7a9 --- /dev/null +++ b/Tests/subset/data/Andika-Regular.subset.ttx @@ -0,0 +1,733 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Copyright (c) 2004-2022 SIL International + + + Andika + + + Regular + + + SIL International: Andika Regular: 2022 + + + Andika + + + Version 6.101 + + + Andika + + + Capital Eng + + + Alternate forms of capital Eng + + + Ŋ + + + Lowercase no descender + + + Capital form + + + Lowercase short stem + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/subset/subset_test.py b/Tests/subset/subset_test.py index abb82687e..8fdee83b4 100644 --- a/Tests/subset/subset_test.py +++ b/Tests/subset/subset_test.py @@ -2071,5 +2071,28 @@ def test_prune_unused_user_name_IDs_with_keep_all(ttf_path): assert nameIDs == keepNameIDs +def test_cvXX_feature_params_nameIDs_are_retained(): + # https://github.com/fonttools/fonttools/issues/3616 + font = TTFont() + ttx = pathlib.Path(__file__).parent / "data" / "Andika-Regular.subset.ttx" + font.importXML(ttx) + + keepNameIDs = {n.nameID for n in font["name"].names} + + options = subset.Options() + options.glyph_names = True + # that's where the FeatureParamsCharacteVariants are stored + options.layout_features.append("cv43") + + subsetter = subset.Subsetter(options) + subsetter.populate(glyphs=font.getGlyphOrder()) + subsetter.subset(font) + + # we expect that all nameIDs are retained, including all the nameIDs + # used by the FeatureParamsCharacterVariants + nameIDs = {n.nameID for n in font["name"].names} + assert nameIDs == keepNameIDs + + if __name__ == "__main__": sys.exit(unittest.main()) diff --git a/Tests/varLib/avar_test.py b/Tests/varLib/avar_test.py new file mode 100644 index 000000000..92f7b3a7d --- /dev/null +++ b/Tests/varLib/avar_test.py @@ -0,0 +1,94 @@ +from fontTools.ttLib import TTFont +from fontTools.varLib.models import VariationModel +from fontTools.varLib.avar import _pruneLocations, mappings_from_avar +import os +import unittest +import pytest + +TESTS = [ + ( + [ + {"wght": 1}, + {"wght": 0.5}, + ], + [ + {"wght": 0.5}, + ], + [ + {"wght": 0.5}, + ], + ), + ( + [ + {"wght": 1, "wdth": 1}, + {"wght": 0.5, "wdth": 1}, + ], + [ + {"wght": 1, "wdth": 1}, + ], + [ + {"wght": 1, "wdth": 1}, + {"wght": 0.5, "wdth": 1}, + ], + ), + ( + [ + {"wght": 1}, + {"wdth": 1}, + {"wght": 0.5, "wdth": 0.5}, + ], + [ + {"wght": 0.5, "wdth": 0.5}, + ], + [ + {"wght": 0.5, "wdth": 0.5}, + ], + ), +] + + +@pytest.mark.parametrize("locations, poles, expected", TESTS) +def test_pruneLocations(locations, poles, expected): + axisTags = set() + for location in locations: + axisTags.update(location.keys()) + axisTags = sorted(axisTags) + + locations = [{}] + locations + + pruned = _pruneLocations(locations, poles, axisTags) + + assert pruned == expected, (pruned, expected) + + +@pytest.mark.parametrize("locations, poles, expected", TESTS) +def test_roundtrip(locations, poles, expected): + axisTags = set() + for location in locations: + axisTags.update(location.keys()) + axisTags = sorted(axisTags) + + locations = [{}] + locations + expected = [{}] + expected + + model1 = VariationModel(locations, axisTags) + model2 = VariationModel(expected, axisTags) + + for location in poles: + i = model1.locations.index(location) + support1 = model1.supports[i] + + i = model2.locations.index(location) + support2 = model2.supports[i] + + assert support1 == support2, (support1, support2) + + +def test_mappings_from_avar(): + CWD = os.path.abspath(os.path.dirname(__file__)) + DATADIR = os.path.join(CWD, "..", "ttLib", "tables", "data") + varfont_path = os.path.join(DATADIR, "Amstelvar-avar2.subset.ttf") + font = TTFont(varfont_path) + mappings = mappings_from_avar(font) + + assert len(mappings) == 2, mappings diff --git a/Tests/varLib/data/test_results/BuildAvar2.ttx b/Tests/varLib/data/test_results/BuildAvar2.ttx index 27a41bfbb..493d9bdbb 100644 --- a/Tests/varLib/data/test_results/BuildAvar2.ttx +++ b/Tests/varLib/data/test_results/BuildAvar2.ttx @@ -23,7 +23,7 @@ - + diff --git a/dev-requirements.txt b/dev-requirements.txt index a2eea1b01..13bebe81f 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -6,4 +6,4 @@ mypy>=0.782 readme_renderer[md]>=43.0 # Pin black as each version could change formatting, breaking CI randomly. -black==24.4.2 +black==24.8.0 diff --git a/requirements.txt b/requirements.txt index d9632cad0..2fde2798d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,10 +11,10 @@ fs==2.4.16 skia-pathops==0.8.0.post1; platform_python_implementation != "PyPy" # this is only required to run Tests/cu2qu/{ufo,cli}_test.py ufoLib2==0.16.0 -ufo2ft==3.2.5 +ufo2ft==3.2.7 pyobjc==10.3.1; sys_platform == "darwin" freetype-py==2.4.0 uharfbuzz==0.39.3 -glyphsLib==6.7.1 # this is only required to run Tests/varLib/interpolatable_test.py -lxml==5.2.2 -sympy==1.13.0 +glyphsLib==6.8.0 # this is only required to run Tests/varLib/interpolatable_test.py +lxml==5.3.0 +sympy==1.13.2