diff --git a/.gitignore b/.gitignore
index 448317353..a0c366621 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,5 +31,11 @@ htmlcov/
# autosaved emacs files
*~
+# virtual environments dirs
+venv/
+.venv/
+venv3*/
+.venv3*/
+
# autogenerated by setuptools-scm
Lib/ufo2ft/_version.py
diff --git a/Lib/ufo2ft/_compilers/baseCompiler.py b/Lib/ufo2ft/_compilers/baseCompiler.py
index c581452eb..d1b6c1fd0 100644
--- a/Lib/ufo2ft/_compilers/baseCompiler.py
+++ b/Lib/ufo2ft/_compilers/baseCompiler.py
@@ -16,8 +16,10 @@
VariableFeatureCompiler,
_featuresCompatible,
)
+from ufo2ft.instantiator import Instantiator
from ufo2ft.postProcessor import PostProcessor
from ufo2ft.util import (
+ _LazyFontName,
_notdefGlyphFallback,
colrClipBoxQuantization,
ensure_all_sources_have_names,
@@ -48,7 +50,6 @@ class BaseCompiler:
feaIncludeDir: Optional[str] = None
skipFeatureCompilation: bool = False
ftConfig: dict = field(default_factory=dict)
- _tables: Optional[list] = None
def __post_init__(self):
self.logger = logging.getLogger("ufo2ft")
@@ -93,7 +94,6 @@ def preprocess(self, ufo_or_ufos):
def compileOutlines(self, ufo, glyphSet):
kwargs = prune_unknown_kwargs(self.__dict__, self.outlineCompilerClass)
- kwargs["tables"] = self._tables
outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
return outlineCompiler.compile()
@@ -153,7 +153,6 @@ def compileFeatures(
@dataclass
class BaseInterpolatableCompiler(BaseCompiler):
- variableFontNames: Optional[list] = None
"""Create FontTools TrueType fonts from the DesignSpaceDocument UFO sources
with interpolatable outlines. Cubic curves are converted compatibly to
quadratic curves using the Cu2Qu conversion algorithm.
@@ -179,6 +178,62 @@ class BaseInterpolatableCompiler(BaseCompiler):
"maxp", "post" and "vmtx"), and no OpenType layout tables.
"""
+ extraSubstitutions: Optional[dict] = None
+ variableFontNames: Optional[list] = None
+
+ # used to generate glyph instances on-the-fly (e.g. decomposing sparse composites)
+ instantiator: Optional[Instantiator] = field(init=False, default=None)
+ # We may need to compile things differently based on whether the source is default
+ # or not: e.g. handling of composite glyphs pointing to missing components.
+ compilingVFDefaultSource: bool = field(init=False, default=True)
+
+ def compile(self, ufos):
+ if self.layerNames is None:
+ self.layerNames = [None] * len(ufos)
+ assert len(ufos) == len(self.layerNames)
+ self.glyphSets = self.preprocess(ufos)
+
+ default_idx = (
+ self.instantiator.default_source_idx if self.instantiator else None
+ )
+ for i, (ufo, glyphSet, layerName) in enumerate(
+ zip(ufos, self.glyphSets, self.layerNames)
+ ):
+ if default_idx is not None:
+ self.compilingVFDefaultSource = i == default_idx
+ yield self.compile_one(ufo, glyphSet, layerName)
+
+ def compile_one(self, ufo, glyphSet, layerName):
+ fontName = _LazyFontName(ufo)
+ if layerName is not None:
+ self.logger.info("Building OpenType tables for %s-%s", fontName, layerName)
+ else:
+ self.logger.info("Building OpenType tables for %s", fontName)
+
+ ttf = self.compileOutlines(ufo, glyphSet, layerName)
+
+ # Only the default layer is likely to have all glyphs used in feature
+ # code.
+ if layerName is None and not self.skipFeatureCompilation:
+ if self.debugFeatureFile:
+ self.debugFeatureFile.write("\n### %s ###\n" % fontName)
+ self.compileFeatures(ufo, ttf, glyphSet=glyphSet)
+
+ ttf = self.postprocess(ttf, ufo, glyphSet)
+
+ if layerName is not None and "post" in ttf:
+ # for sparse masters (i.e. containing only a subset of the glyphs), we
+ # need to include the post table in order to store glyph names, so that
+ # fontTools.varLib can interpolate glyphs with same name across masters.
+ # However we want to prevent the underlinePosition/underlineThickness
+ # fields in such sparse masters to be included when computing the deltas
+ # for the MVAR table. Thus, we set them to this unlikely, limit value
+ # (-36768) which is a signal varLib should ignore them when building MVAR.
+ ttf["post"].underlinePosition = -0x8000
+ ttf["post"].underlineThickness = -0x8000
+
+ return ttf
+
def compile_designspace(self, designSpaceDoc):
ufos = self._pre_compile_designspace(designSpaceDoc)
ttfs = self.compile(ufos)
@@ -206,6 +261,11 @@ def _pre_compile_designspace(self, designSpaceDoc):
for left, right in rule.subs:
self.extraSubstitutions[left].add(right)
+ # used to interpolate glyphs on-the-fly in filters (e.g. DecomposeComponents)
+ self.instantiator = Instantiator.from_designspace(
+ designSpaceDoc, round_geometry=False, do_info=False, do_kerning=False
+ )
+
return ufos
def _post_compile_designspace(self, designSpaceDoc, fonts):
diff --git a/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py b/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
index 9ebdde9d3..1d70e0939 100644
--- a/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
+++ b/Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
@@ -1,4 +1,3 @@
-import dataclasses
from dataclasses import dataclass
from typing import Optional, Type
@@ -6,57 +5,28 @@
from ufo2ft.constants import SPARSE_OTF_MASTER_TABLES, CFFOptimization
from ufo2ft.outlineCompiler import OutlineOTFCompiler
-from ufo2ft.preProcessor import OTFPreProcessor
+from ufo2ft.preProcessor import OTFInterpolatablePreProcessor
from ufo2ft.util import prune_unknown_kwargs
from .baseCompiler import BaseInterpolatableCompiler
-from .otfCompiler import OTFCompiler
-# We want the designspace handling of BaseInterpolatableCompiler but
-# we also need to pick up the OTF-specific compileOutlines/postprocess
-# methods from OTFCompiler.
@dataclass
-class InterpolatableOTFCompiler(OTFCompiler, BaseInterpolatableCompiler):
- preProcessorClass: Type = OTFPreProcessor
+class InterpolatableOTFCompiler(BaseInterpolatableCompiler):
+ preProcessorClass: Type = OTFInterpolatablePreProcessor
outlineCompilerClass: Type = OutlineOTFCompiler
- featureCompilerClass: Optional[Type] = None
roundTolerance: Optional[float] = None
optimizeCFF: CFFOptimization = CFFOptimization.NONE
colrLayerReuse: bool = False
colrAutoClipBoxes: bool = False
- extraSubstitutions: Optional[dict] = None
skipFeatureCompilation: bool = False
- excludeVariationTables: tuple = ()
- # We can't use the same compile method as interpolatableTTFCompiler
- # because that has a TTFInterpolatablePreProcessor which preprocesses
- # all UFOs together, whereas we need to do the preprocessing one at
- # at a time.
- def compile(self, ufos):
- otfs = []
- for ufo, layerName in zip(ufos, self.layerNames):
- # There's a Python bug where dataclasses.asdict() doesn't work with
- # dataclasses that contain a defaultdict.
- save_extraSubstitutions = self.extraSubstitutions
- self.extraSubstitutions = None
- args = {
- **dataclasses.asdict(self),
- **dict(
- layerName=layerName,
- removeOverlaps=False,
- overlapsBackend=None,
- optimizeCFF=CFFOptimization.NONE,
- _tables=SPARSE_OTF_MASTER_TABLES if layerName else None,
- ),
- }
- # Remove interpolatable-specific args
- args = prune_unknown_kwargs(args, OTFCompiler)
- compiler = OTFCompiler(**args)
- self.extraSubstitutions = save_extraSubstitutions
- otfs.append(compiler.compile(ufo))
- self.glyphSets.append(compiler._glyphSet)
- return otfs
+ def compileOutlines(self, ufo, glyphSet, layerName=None):
+ kwargs = prune_unknown_kwargs(self.__dict__, self.outlineCompilerClass)
+ kwargs["tables"] = SPARSE_OTF_MASTER_TABLES if layerName is not None else None
+ kwargs["optimizeCFF"] = CFFOptimization.NONE
+ outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
+ return outlineCompiler.compile()
def _merge(self, designSpaceDoc, excludeVariationTables):
return varLib.build_many(
diff --git a/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py b/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
index 4244a067d..f797b8485 100644
--- a/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
+++ b/Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
@@ -6,7 +6,7 @@
from ufo2ft.constants import SPARSE_TTF_MASTER_TABLES
from ufo2ft.outlineCompiler import OutlineTTFCompiler
from ufo2ft.preProcessor import TTFInterpolatablePreProcessor
-from ufo2ft.util import _LazyFontName, prune_unknown_kwargs
+from ufo2ft.util import prune_unknown_kwargs
from .baseCompiler import BaseInterpolatableCompiler
@@ -22,51 +22,10 @@ class InterpolatableTTFCompiler(BaseInterpolatableCompiler):
layerNames: Optional[str] = None
colrLayerReuse: bool = False
colrAutoClipBoxes: bool = False
- extraSubstitutions: Optional[bool] = None
autoUseMyMetrics: bool = True
allQuadratic: bool = True
skipFeatureCompilation: bool = False
- def compile(self, ufos):
- if self.layerNames is None:
- self.layerNames = [None] * len(ufos)
- assert len(ufos) == len(self.layerNames)
- self.glyphSets = self.preprocess(ufos)
-
- for ufo, glyphSet, layerName in zip(ufos, self.glyphSets, self.layerNames):
- yield self.compile_one(ufo, glyphSet, layerName)
-
- def compile_one(self, ufo, glyphSet, layerName):
- fontName = _LazyFontName(ufo)
- if layerName is not None:
- self.logger.info("Building OpenType tables for %s-%s", fontName, layerName)
- else:
- self.logger.info("Building OpenType tables for %s", fontName)
-
- ttf = self.compileOutlines(ufo, glyphSet, layerName)
-
- # Only the default layer is likely to have all glyphs used in feature
- # code.
- if layerName is None and not self.skipFeatureCompilation:
- if self.debugFeatureFile:
- self.debugFeatureFile.write("\n### %s ###\n" % fontName)
- self.compileFeatures(ufo, ttf, glyphSet=glyphSet)
-
- ttf = self.postprocess(ttf, ufo, glyphSet)
-
- if layerName is not None:
- # for sparse masters (i.e. containing only a subset of the glyphs), we
- # need to include the post table in order to store glyph names, so that
- # fontTools.varLib can interpolate glyphs with same name across masters.
- # However we want to prevent the underlinePosition/underlineThickness
- # fields in such sparse masters to be included when computing the deltas
- # for the MVAR table. Thus, we set them to this unlikely, limit value
- # (-36768) which is a signal varLib should ignore them when building MVAR.
- ttf["post"].underlinePosition = -0x8000
- ttf["post"].underlineThickness = -0x8000
-
- return ttf
-
def compileOutlines(self, ufo, glyphSet, layerName=None):
kwargs = prune_unknown_kwargs(self.__dict__, self.outlineCompilerClass)
kwargs["glyphDataFormat"] = 0 if self.allQuadratic else 1
diff --git a/Lib/ufo2ft/_compilers/otfCompiler.py b/Lib/ufo2ft/_compilers/otfCompiler.py
index 48bbe3ae4..964add068 100644
--- a/Lib/ufo2ft/_compilers/otfCompiler.py
+++ b/Lib/ufo2ft/_compilers/otfCompiler.py
@@ -4,7 +4,6 @@
from ufo2ft.constants import CFFOptimization
from ufo2ft.outlineCompiler import OutlineOTFCompiler
from ufo2ft.preProcessor import OTFPreProcessor
-from ufo2ft.util import prune_unknown_kwargs
from .baseCompiler import BaseCompiler
@@ -17,23 +16,3 @@ class OTFCompiler(BaseCompiler):
roundTolerance: Optional[float] = None
cffVersion: int = 1
subroutinizer: Optional[str] = None
- _tables: Optional[list] = None
- extraSubstitutions: Optional[dict] = None
-
- def compileOutlines(self, ufo, glyphSet):
- kwargs = prune_unknown_kwargs(self.__dict__, self.outlineCompilerClass)
- kwargs["optimizeCFF"] = self.optimizeCFF >= CFFOptimization.SPECIALIZE
- kwargs["tables"] = self._tables
- outlineCompiler = self.outlineCompilerClass(ufo, glyphSet=glyphSet, **kwargs)
- return outlineCompiler.compile()
-
- def postprocess(self, font, ufo, glyphSet, info=None):
- if self.postProcessorClass is not None:
- postProcessor = self.postProcessorClass(
- font, ufo, glyphSet=glyphSet, info=info
- )
- kwargs = prune_unknown_kwargs(self.__dict__, postProcessor.process)
- kwargs["optimizeCFF"] = self.optimizeCFF >= CFFOptimization.SUBROUTINIZE
- font = postProcessor.process(**kwargs)
- self._glyphSet = glyphSet
- return font
diff --git a/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py b/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
index b79cb543c..c31d383d7 100644
--- a/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
+++ b/Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
@@ -3,14 +3,14 @@
from ufo2ft.constants import CFFOptimization
from ufo2ft.outlineCompiler import OutlineOTFCompiler
-from ufo2ft.preProcessor import OTFPreProcessor
+from ufo2ft.preProcessor import OTFInterpolatablePreProcessor
from .interpolatableOTFCompiler import InterpolatableOTFCompiler
@dataclass
class VariableCFF2sCompiler(InterpolatableOTFCompiler):
- preProcessorClass: Type = OTFPreProcessor
+ preProcessorClass: Type = OTFInterpolatablePreProcessor
outlineCompilerClass: Type = OutlineOTFCompiler
roundTolerance: Optional[float] = None
colrAutoClipBoxes: bool = False
diff --git a/Lib/ufo2ft/filters/__init__.py b/Lib/ufo2ft/filters/__init__.py
index 841c3cc53..e69213222 100644
--- a/Lib/ufo2ft/filters/__init__.py
+++ b/Lib/ufo2ft/filters/__init__.py
@@ -5,30 +5,41 @@
from ufo2ft.constants import FILTERS_KEY
from ufo2ft.util import _loadPluginFromString
-from .base import BaseFilter
+from .base import BaseFilter, BaseIFilter
from .cubicToQuadratic import CubicToQuadraticFilter
-from .decomposeComponents import DecomposeComponentsFilter
-from .decomposeTransformedComponents import DecomposeTransformedComponentsFilter
+from .decomposeComponents import DecomposeComponentsFilter, DecomposeComponentsIFilter
+from .decomposeTransformedComponents import (
+ DecomposeTransformedComponentsFilter,
+ DecomposeTransformedComponentsIFilter,
+)
from .dottedCircle import DottedCircleFilter
from .explodeColorLayerGlyphs import ExplodeColorLayerGlyphsFilter
-from .flattenComponents import FlattenComponentsFilter
-from .propagateAnchors import PropagateAnchorsFilter
+from .flattenComponents import FlattenComponentsFilter, FlattenComponentsIFilter
+from .propagateAnchors import PropagateAnchorsFilter, PropagateAnchorsIFilter
from .removeOverlaps import RemoveOverlapsFilter
from .reverseContourDirection import ReverseContourDirectionFilter
+from .skipExportGlyphs import SkipExportGlyphsFilter, SkipExportGlyphsIFilter
from .sortContours import SortContoursFilter
from .transformations import TransformationsFilter
__all__ = [
"BaseFilter",
+ "BaseIFilter",
"CubicToQuadraticFilter",
"DecomposeComponentsFilter",
+ "DecomposeComponentsIFilter",
"DecomposeTransformedComponentsFilter",
+ "DecomposeTransformedComponentsIFilter",
"DottedCircleFilter",
"ExplodeColorLayerGlyphsFilter",
"FlattenComponentsFilter",
+ "FlattenComponentsIFilter",
"PropagateAnchorsFilter",
+ "PropagateAnchorsIFilter",
"RemoveOverlapsFilter",
"ReverseContourDirectionFilter",
+ "SkipExportGlyphsFilter",
+ "SkipExportGlyphsIFilter",
"SortContoursFilter",
"TransformationsFilter",
"loadFilters",
@@ -85,13 +96,15 @@ def loadFilters(ufo):
return preFilters, postFilters
-def isValidFilter(klass):
+def isValidFilter(klass, *bases):
"""Return True if 'klass' is a valid filter class.
A valid filter class is a class (of type 'type'), that has
a '__call__' (bound method), with the signature matching the same method
- from the BaseFilter class:
+ from the BaseFilter or BaseIFilter classes, respectively:
def __call__(self, font, glyphSet=None)
+
+ def __call__(self, fonts, glyphSets=None, instantiator=None, **kwargs)
"""
if not isclass(klass):
logger.error(f"{klass!r} is not a class")
@@ -99,10 +112,14 @@ def __call__(self, font, glyphSet=None)
if not callable(klass):
logger.error(f"{klass!r} is not callable")
return False
- if getfullargspec(klass.__call__).args != getfullargspec(BaseFilter.__call__).args:
- logger.error(f"{klass!r} '__call__' method has incorrect signature")
- return False
- return True
+ for baseClass in bases or (BaseFilter, BaseIFilter):
+ if (
+ getfullargspec(klass.__call__).args
+ == getfullargspec(baseClass.__call__).args
+ ):
+ return True
+ logger.error(f"{klass!r} '__call__' method has incorrect signature")
+ return False
def loadFilterFromString(spec):
diff --git a/Lib/ufo2ft/filters/base.py b/Lib/ufo2ft/filters/base.py
index 0c90e045e..e286b6ced 100644
--- a/Lib/ufo2ft/filters/base.py
+++ b/Lib/ufo2ft/filters/base.py
@@ -1,9 +1,26 @@
+from __future__ import annotations
+
import logging
+import sys
from types import SimpleNamespace
+from typing import TYPE_CHECKING, FrozenSet, Tuple
from fontTools.misc.loggingTools import Timer
-from ufo2ft.util import _GlyphSet, _LazyFontName, getMaxComponentDepth
+from ufo2ft.util import (
+ _getNewGlyphFactory,
+ _GlyphSet,
+ _LazyFontName,
+ getMaxComponentDepth,
+ zip_strict,
+)
+
+if TYPE_CHECKING:
+ from typing import Any, TypeAlias
+
+ from ufoLib2.objects import Font, Glyph
+
+ from ufo2ft.instantiator import Instantiator, InterpolatedLayer
# reuse the "ufo2ft.filters" logger
logger = logging.getLogger("ufo2ft.filters")
@@ -153,6 +170,8 @@ def set_context(self, font, glyphSet):
"""
self.context = SimpleNamespace(font=font, glyphSet=glyphSet)
self.context.modified = set()
+ proto = font.layers.defaultLayer.instantiateGlyphObject()
+ self.context.glyphFactory = _getNewGlyphFactory(proto)
return self.context
def filter(self, glyph):
@@ -216,3 +235,231 @@ def __call__(self, font, glyphSet=None):
"" if num == 1 else "s",
)
return modified
+
+ @classmethod
+ def getInterpolatableFilterClass(cls) -> BaseIFilter | None:
+ """Return interpolatable filter class if one is found in the same module.
+
+ We search for a class with the same name and the 'IFilter' suffix
+ (where the 'I' stands for "interpolatable").
+
+ Subclasses can override this if they wish to use a different class name
+ or module.
+ """
+ module = sys.modules[cls.__module__]
+ filter_name = cls.__name__
+ if filter_name.endswith("Filter"):
+ filter_name = filter_name[:-6]
+ ifilter_name = f"{filter_name}IFilter"
+ return getattr(module, ifilter_name, None)
+
+
+HashableLocation: TypeAlias = FrozenSet[Tuple[str, float]]
+
+
+class BaseIFilter(BaseFilter):
+ """Interpolatable variant that zips through mutliple glyphs at a time."""
+
+ def set_context(
+ self,
+ fonts: list[Font],
+ glyphSets: list[dict[str, Glyph]],
+ instantiator: Instantiator | None = None,
+ **kwargs: dict[str, Any],
+ ) -> SimpleNamespace:
+ """Populate a `self.context` namespace, which is reset before each
+ new filter call.
+
+ Subclasses can override this to provide contextual information
+ which depends on other data in the fonts that is not available in
+ the glyphs objects currently being filtered, or set any other
+ temporary attributes.
+
+ The default implementation simply sets the current fonts, glyphSets,
+ and optional instantiator and initializes an empty set that keeps track
+ of the names of the glyphs that were modified.
+
+ Any extra keyword arguments are passed to the context namespace.
+
+ Returns the namespace instance.
+ """
+ assert len(fonts) == len(glyphSets)
+ self.context = SimpleNamespace(
+ fonts=fonts,
+ glyphSets=glyphSets,
+ instantiator=instantiator,
+ **kwargs,
+ )
+ self.context.modified = set()
+ proto = fonts[0].layers.defaultLayer.instantiateGlyphObject()
+ self.context.glyphFactory = _getNewGlyphFactory(proto)
+ return self.context
+
+ def filter(self, glyphName: str, glyphs: list) -> bool:
+ """This is where the filter is applied to a set of interpolatable glyphs.
+
+ Subclasses must override this method, and return True
+ when the glyph was modified.
+ """
+ raise NotImplementedError
+
+ def __call__(
+ self,
+ fonts: list[Font],
+ glyphSets: list[dict[str, Glyph]] | None = None,
+ instantiator: Instantiator | None = None,
+ **kwargs: dict[str, Any],
+ ) -> set[str]:
+ """Run this filter on all the included glyphs from the given glyphSets.
+ Return the set of glyph names that were modified, if any.
+
+ If `glyphSets` (list[dict]) argument is provided, run the filter on
+ the glyphs contained therein (which may be copies).
+ Otherwise, run the filter in-place on the fonts' default
+ glyph sets.
+
+ The `instantiator` optional argument allows interpolatable filters to
+ generate glyph instances on demand at any location in the designspace.
+
+ Any extra keyword arguments are passed on to the `set_context` method.
+ """
+ logger.info("Running interpolatable %s", self.name)
+
+ if glyphSets is None:
+ glyphSets = [_GlyphSet.from_layer(font) for font in fonts]
+
+ context = self.set_context(fonts, glyphSets, instantiator, **kwargs)
+
+ filter_ = self.filter
+ include = self.include
+ modified = context.modified
+
+ # process composite glyphs in decreasing component depth order (i.e. composites
+ # with more deeply nested components before shallower ones) to avoid
+ # order-dependent interferences while filtering glyphs with nested components
+ # https://github.com/googlefonts/ufo2ft/issues/621
+ allGlyphNames = set.union(*(set(glyphSet.keys()) for glyphSet in glyphSets))
+
+ def comp_depth(g):
+ for glyphSet in glyphSets:
+ if g in glyphSet:
+ return -getMaxComponentDepth(glyphSet[g], glyphSet)
+ raise AssertionError
+
+ orderedGlyphs = sorted(allGlyphNames, key=comp_depth)
+
+ with Timer() as t:
+ for glyphName in orderedGlyphs:
+ if glyphName in modified:
+ continue
+ glyphs = [
+ glyphSet[glyphName]
+ for glyphSet in glyphSets
+ if glyphName in glyphSet
+ ]
+ if any(include(g) for g in glyphs) and filter_(glyphName, glyphs):
+ modified.add(glyphName)
+
+ num = len(modified)
+ if num > 0:
+ timing_logger.debug(
+ "Took %.3fs to run %s on %d glyph%s",
+ t,
+ self.name,
+ len(modified),
+ "" if num == 1 else "s",
+ )
+ return modified
+
+ @classmethod
+ def getInterpolatableFilterClass(cls) -> "BaseIFilter" | None:
+ """Return the same class as self."""
+ return cls # no-op
+
+ def getDefaultFont(self) -> Font:
+ if self.context.instantiator is not None:
+ return self.context.fonts[self.context.instantiator.default_source_idx]
+ else:
+ # as good a guess as any...
+ return self.context.fonts[0]
+
+ def getDefaultGlyphSet(self) -> dict[str, Glyph]:
+ """Return the current glyphSet corresponding to the default location."""
+ if self.context.instantiator is not None:
+ default_idx = self.context.instantiator.default_source_idx
+ for i, glyphSet in enumerate(self.context.glyphSets):
+ if i == default_idx:
+ return glyphSet
+ else:
+ raise AssertionError("No default source?!")
+ else:
+ # we don't have enough info to know which glyphSet corresponds
+ # to the default source location so we just guess it's going to
+ # be the larger one given it contains all glyphs by definition.
+ return max(self.context.glyphSets, key=lambda glyphSet: len(glyphSet))
+
+ def getInterpolatedLayers(self) -> list[InterpolatedLayer] | list[None]:
+ """Return InterpolatedLayers at source locations or Nones if no Instantiator."""
+ if self.context.instantiator is not None:
+ return self.context.instantiator.interpolated_layers
+ else:
+ return [None] * len(self.context.glyphSets)
+
+ @staticmethod
+ def hashableLocation(location: dict[str, float]) -> HashableLocation:
+ """Convert (normalized) location dict to a hashable set of tuples."""
+ return frozenset((k, v) for k, v in location.items() if v != 0.0)
+
+ def glyphSourceLocations(self, glyphName) -> set[HashableLocation]:
+ """Return locations of all the sources that have a glyph."""
+ assert self.context.instantiator is not None
+ return {
+ self.hashableLocation(location)
+ for glyphSet, location in zip_strict(
+ self.context.glyphSets, self.context.instantiator.source_locations
+ )
+ if glyphName in glyphSet
+ }
+
+ def locationsFromComponentGlyphs(
+ self,
+ glyphName: str,
+ include: set[str] | None = None,
+ ) -> set[HashableLocation]:
+ """Return locations from all the components' base glyphs, recursively."""
+ assert self.context.instantiator is not None
+ locations = set()
+ for glyphSet in self.context.glyphSets:
+ if glyphName in glyphSet:
+ glyph = glyphSet[glyphName]
+ for component in glyph.components:
+ if include is None or component.baseGlyph in include:
+ locations |= self.glyphSourceLocations(component.baseGlyph)
+ locations |= self.locationsFromComponentGlyphs(
+ component.baseGlyph, include
+ )
+ return locations
+
+ def ensureCompositeDefinedAtComponentLocations(
+ self,
+ glyphName: str,
+ include: set[str] | None = None,
+ ):
+ """Ensure the composite glyph is defined at all its components' locations.
+
+ The Instantiator is used to interpolate the glyph at the missing locations.
+ If we have no Instantiator, we can't interpolate so this does nothing.
+ """
+ if self.context.instantiator is None:
+ return
+
+ haveLocations = self.glyphSourceLocations(glyphName)
+ needLocations = self.locationsFromComponentGlyphs(glyphName, include)
+ locationsToAdd = needLocations - haveLocations
+ if locationsToAdd:
+ for glyphSet, interpolatedLayer in zip_strict(
+ self.context.glyphSets, self.context.instantiator.interpolated_layers
+ ):
+ if self.hashableLocation(interpolatedLayer.location) in locationsToAdd:
+ assert glyphName not in glyphSet
+ glyphSet[glyphName] = interpolatedLayer[glyphName]
diff --git a/Lib/ufo2ft/filters/decomposeComponents.py b/Lib/ufo2ft/filters/decomposeComponents.py
index 79df4384e..911812985 100644
--- a/Lib/ufo2ft/filters/decomposeComponents.py
+++ b/Lib/ufo2ft/filters/decomposeComponents.py
@@ -1,13 +1,41 @@
-from fontTools.misc.transform import Transform
+from __future__ import annotations
-import ufo2ft.util
-from ufo2ft.filters import BaseFilter
+from typing import TYPE_CHECKING
+
+from ufo2ft.filters import BaseFilter, BaseIFilter
+from ufo2ft.util import decomposeCompositeGlyph, zip_strict
+
+if TYPE_CHECKING:
+ from ufoLib2.objects import Glyph
class DecomposeComponentsFilter(BaseFilter):
- def filter(self, glyph):
+ # pre=True so by default this is run before the RemoveOverlaps filter,
+ # in case a component overlaps other contours or components, to ensure
+ # the decomposed contours will be merged correctly:
+ # https://github.com/googlefonts/gftools/pull/425
+ _pre = True
+
+ def filter(self, glyph: Glyph) -> bool:
if not glyph.components:
return False
- ufo2ft.util.deepCopyContours(self.context.glyphSet, glyph, glyph, Transform())
- glyph.clearComponents()
+ decomposeCompositeGlyph(glyph, self.context.glyphSet)
+ return True
+
+
+class DecomposeComponentsIFilter(BaseIFilter):
+ _pre = True
+
+ def filter(self, glyphName: str, glyphs: list[Glyph]) -> bool:
+ if not any(glyph.components for glyph in glyphs):
+ return False
+
+ self.ensureCompositeDefinedAtComponentLocations(glyphName)
+
+ for glyphSet, interpolatedLayer in zip_strict(
+ self.context.glyphSets, self.getInterpolatedLayers()
+ ):
+ glyph = glyphSet.get(glyphName)
+ if glyph is not None:
+ decomposeCompositeGlyph(glyph, interpolatedLayer or glyphSet)
return True
diff --git a/Lib/ufo2ft/filters/decomposeTransformedComponents.py b/Lib/ufo2ft/filters/decomposeTransformedComponents.py
index 0c39769f1..f3227345a 100644
--- a/Lib/ufo2ft/filters/decomposeTransformedComponents.py
+++ b/Lib/ufo2ft/filters/decomposeTransformedComponents.py
@@ -1,20 +1,28 @@
-from fontTools.misc.transform import Identity, Transform
+from fontTools.misc.transform import Identity
-import ufo2ft.util
-from ufo2ft.filters import BaseFilter
+from ufo2ft.filters.decomposeComponents import (
+ DecomposeComponentsFilter,
+ DecomposeComponentsIFilter,
+)
+IDENTITY_2x2 = Identity[:4]
-class DecomposeTransformedComponentsFilter(BaseFilter):
+
+def _isTransformed(component):
+ return component.transformation[:4] != IDENTITY_2x2
+
+
+class DecomposeTransformedComponentsFilter(DecomposeComponentsFilter):
def filter(self, glyph):
- if not glyph.components:
- return False
- needs_decomposition = False
- for component in glyph.components:
- if component.transformation[:4] != Identity[:4]:
- needs_decomposition = True
- break
- if not needs_decomposition:
+ if any(_isTransformed(c) for c in glyph.components):
+ return super().filter(glyph)
+ return False
+
+
+class DecomposeTransformedComponentsIFilter(DecomposeComponentsIFilter):
+ def filter(self, glyphName, glyphs):
+ # We decomposes the glyph in *all* masters if any contains a transformed
+ # component: https://github.com/googlefonts/ufo2ft/issues/507
+ if not any(any(_isTransformed(c) for c in g.components) for g in glyphs):
return False
- ufo2ft.util.deepCopyContours(self.context.glyphSet, glyph, glyph, Transform())
- glyph.clearComponents()
- return True
+ return super().filter(glyphName, glyphs)
diff --git a/Lib/ufo2ft/filters/dottedCircle.py b/Lib/ufo2ft/filters/dottedCircle.py
index 806625006..1b416e292 100644
--- a/Lib/ufo2ft/filters/dottedCircle.py
+++ b/Lib/ufo2ft/filters/dottedCircle.py
@@ -55,7 +55,7 @@
from ufo2ft.featureWriters import ast
from ufo2ft.filters import BaseFilter
from ufo2ft.fontInfoData import getAttrWithFallback
-from ufo2ft.util import _getNewGlyphFactory, _GlyphSet, _LazyFontName, _setGlyphMargin
+from ufo2ft.util import _GlyphSet, _LazyFontName, _setGlyphMargin
logger = logging.getLogger(__name__)
@@ -114,7 +114,7 @@ def __call__(self, font, glyphSet=None):
dotted_circle_glyph = self.check_dotted_circle()
if dotted_circle_glyph == DO_NOTHING:
- return []
+ return set()
if not dotted_circle_glyph:
dotted_circle_glyph = self.draw_dotted_circle(glyphSet)
@@ -126,9 +126,9 @@ def __call__(self, font, glyphSet=None):
self.ensure_base(dotted_circle_glyph)
if added_glyph or added_anchors:
- return [dotted_circle_glyph.name]
+ return {dotted_circle_glyph.name}
else:
- return []
+ return set()
def check_dotted_circle(self):
"""Check for the presence of a dotted circle glyph and return it"""
@@ -150,8 +150,7 @@ def draw_dotted_circle(self, glyphSet):
font = self.context.font
logger.debug("Adding dotted circle glyph")
- proto = font.layers.defaultLayer.instantiateGlyphObject()
- glyph = _getNewGlyphFactory(proto)(name="uni25CC", unicodes=[0x25CC])
+ glyph = self.context.glyphFactory(name="uni25CC", unicodes=[0x25CC])
pen = glyph.getPen()
xHeight = getAttrWithFallback(font.info, "xHeight")
diff --git a/Lib/ufo2ft/filters/flattenComponents.py b/Lib/ufo2ft/filters/flattenComponents.py
index 03fda2143..62f774b68 100644
--- a/Lib/ufo2ft/filters/flattenComponents.py
+++ b/Lib/ufo2ft/filters/flattenComponents.py
@@ -1,37 +1,84 @@
+from __future__ import annotations
+
import logging
from fontTools.misc.transform import Transform
-from ufo2ft.filters import BaseFilter
+from ufo2ft.filters import BaseFilter, BaseIFilter
+from ufo2ft.util import zip_strict
logger = logging.getLogger(__name__)
class FlattenComponentsFilter(BaseFilter):
+ """Replace nested components with their referents so that max depth is 1."""
+
def __call__(self, font, glyphSet=None):
- if super().__call__(font, glyphSet):
- modified = self.context.modified
- if modified:
- logger.info("Flattened composite glyphs: %i" % len(modified))
- return modified
+ modified = super().__call__(font, glyphSet)
+ if modified:
+ logger.info("Flattened composite glyphs: %i" % len(modified))
+ return modified
def filter(self, glyph):
+ return _flattenGlyphComponents(glyph, self.context.glyphSet)
+
+
+class FlattenComponentsIFilter(BaseIFilter):
+ """Interpolatable variant of FlattenComponentsFilter."""
+
+ def __call__(self, fonts, glyphSets=None, instantiator=None, **kwargs):
+ modified = super().__call__(fonts, glyphSets, instantiator, **kwargs)
+ if modified:
+ logger.info("Flattened composite glyphs: %i" % len(modified))
+ return modified
+
+ def filter(self, glyphName: str, glyphs: list) -> bool:
flattened = False
- if not glyph.components:
+ if not any(glyph.components for glyph in glyphs):
+ return flattened
+
+ defaultGlyphSet = self.getDefaultGlyphSet()
+ if not any(_haveNestedComponents(g, defaultGlyphSet) for g in glyphs):
return flattened
- pen = glyph.getPen()
- for comp in list(glyph.components):
- flattened_tuples = _flattenComponent(
- self.context.glyphSet, comp, found_in=glyph
- )
- if flattened_tuples[0] != (comp.baseGlyph, comp.transformation):
- flattened = True
- glyph.removeComponent(comp)
- for flattened_tuple in flattened_tuples:
- pen.addComponent(*flattened_tuple)
- if flattened:
- self.context.modified.add(glyph.name)
+
+ for glyphSet, interpolatedLayer in zip_strict(
+ self.context.glyphSets, self.getInterpolatedLayers()
+ ):
+ glyph = glyphSet.get(glyphName)
+ if glyph is not None:
+ flattened = _flattenGlyphComponents(
+ glyph, interpolatedLayer or glyphSet
+ )
+
+ return flattened
+
+
+def _isSimpleOrMixed(glyph):
+ return not glyph.components or len(glyph) > 0
+
+
+def _haveNestedComponents(glyph, glyphSet):
+ return not _isSimpleOrMixed(glyph) and any(
+ glyphSet[compo.baseGlyph].components
+ for compo in glyph.components
+ if compo.baseGlyph in glyphSet
+ )
+
+
+def _flattenGlyphComponents(glyph, glyphSet):
+ flattened = False
+ if not glyph.components:
return flattened
+ components = list(glyph.components)
+ glyph.clearComponents()
+ pen = glyph.getPointPen()
+ for comp in components:
+ flattened_tuples = _flattenComponent(glyphSet, comp, found_in=glyph)
+ if flattened_tuples[0] != (comp.baseGlyph, comp.transformation):
+ flattened = True
+ for flattened_tuple in flattened_tuples:
+ pen.addComponent(*flattened_tuple)
+ return flattened
def _flattenComponent(glyphSet, component, found_in):
@@ -43,7 +90,7 @@ def _flattenComponent(glyphSet, component, found_in):
glyph = glyphSet[component.baseGlyph]
# Any contour will cause components to be decomposed
- if not glyph.components or len(glyph) > 0:
+ if _isSimpleOrMixed(glyph):
transformation = Transform(*component.transformation)
return [(component.baseGlyph, transformation)]
diff --git a/Lib/ufo2ft/filters/propagateAnchors.py b/Lib/ufo2ft/filters/propagateAnchors.py
index a4147102f..cc96c378b 100644
--- a/Lib/ufo2ft/filters/propagateAnchors.py
+++ b/Lib/ufo2ft/filters/propagateAnchors.py
@@ -17,8 +17,8 @@
import fontTools.pens.boundsPen
from fontTools.misc.transform import Transform
-from ufo2ft.filters import BaseFilter
-from ufo2ft.util import OpenTypeCategories
+from ufo2ft.filters import BaseFilter, BaseIFilter
+from ufo2ft.util import OpenTypeCategories, zip_strict
logger = logging.getLogger(__name__)
@@ -50,6 +50,39 @@ def filter(self, glyph):
return len(glyph.anchors) > before
+class PropagateAnchorsIFilter(BaseIFilter):
+ def set_context(self, *args, **kwargs):
+ ctx = super().set_context(*args, **kwargs)
+ ctx.processed = [set() for _ in range(len(ctx.glyphSets))]
+ ctx.categories = OpenTypeCategories.load(self.getDefaultFont())
+ return ctx
+
+ def __call__(self, fonts, glyphSets=None, instantiator=None, **kwargs):
+ modified = super().__call__(fonts, glyphSets, instantiator, **kwargs)
+ if modified:
+ logger.info("Glyphs with propagated anchors: %i" % len(modified))
+ return modified
+
+ def filter(self, glyphName, glyphs):
+ modified = False
+ if not any(glyph.components for glyph in glyphs):
+ return modified
+ before = len(self.context.modified)
+ for i, (glyphSet, interpolatedLayer) in enumerate(
+ zip_strict(self.context.glyphSets, self.getInterpolatedLayers())
+ ):
+ glyph = glyphSet.get(glyphName)
+ if glyph is not None:
+ _propagate_glyph_anchors(
+ interpolatedLayer or glyphSet,
+ glyph,
+ self.context.processed[i],
+ self.context.modified,
+ self.context.categories,
+ )
+ return len(self.context.modified) > before
+
+
def _propagate_glyph_anchors(glyphSet, composite, processed, modified, categories):
"""
Propagate anchors from base glyphs to a given composite
diff --git a/Lib/ufo2ft/filters/skipExportGlyphs.py b/Lib/ufo2ft/filters/skipExportGlyphs.py
new file mode 100644
index 000000000..83b78f355
--- /dev/null
+++ b/Lib/ufo2ft/filters/skipExportGlyphs.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from ufo2ft.filters import BaseFilter, BaseIFilter
+from ufo2ft.util import decomposeCompositeGlyph, zip_strict
+
+
+class SkipExportGlyphsFilter(BaseFilter):
+ """Subset a glyphSet while decomposing references to pruned component glyphs."""
+
+ _pre = True
+ _args = ("skipExportGlyphs",)
+
+ def start(self):
+ self.options.skipExportGlyphs = frozenset(self.options.skipExportGlyphs)
+
+ def filter(self, glyph) -> bool:
+ if not glyph.components or self.options.skipExportGlyphs.isdisjoint(
+ comp.baseGlyph for comp in glyph.components
+ ):
+ return False
+
+ # decomposeNested=False because at this stage we are only interested
+ # in pruning component references to specific non-export glyphs, not
+ # decomposing entire composite glyphs per se; it's conceivable that
+ # after a component is replaced by its direct referent and the latter
+ # in turn only comprises components, the parent can remain a composite
+ # glyph and need not be fully decomposed to contours; any further
+ # decompositions (e.g. of mixed glyphs) can be performed later.
+ decomposeCompositeGlyph(
+ glyph,
+ self.context.glyphSet,
+ decomposeNested=False,
+ include=self.options.skipExportGlyphs,
+ )
+ return True
+
+ def __call__(self, font, glyphSet=None):
+ if not self.options.skipExportGlyphs:
+ return self.context.modified # nothing to do
+
+ modified = super().__call__(font, glyphSet)
+
+ # now that all component references to non-export glyphs have been removed,
+ # the glyphSet can be subset in-place
+ glyphSet = self.context.glyphSet
+ for glyphName in self.options.skipExportGlyphs:
+ if glyphName in glyphSet:
+ del glyphSet[glyphName]
+ # technically this glyph was 'removed' rather than 'modified' but
+ # filters only return one set...
+ modified.add(glyphName)
+
+ return modified
+
+
+class SkipExportGlyphsIFilter(BaseIFilter):
+ """Interpolatable variant of SkipExportGlyphsFilter."""
+
+ _pre = True
+ _args = ("skipExportGlyphs",)
+
+ def start(self):
+ self.options.skipExportGlyphs = frozenset(self.options.skipExportGlyphs)
+
+ def filter(self, glyphName: str, glyphs: list) -> bool:
+ if not any(glyph.components for glyph in glyphs) or all(
+ self.options.skipExportGlyphs.isdisjoint(
+ comp.baseGlyph for comp in glyph.components
+ )
+ for glyph in glyphs
+ ):
+ return False
+
+ self.ensureCompositeDefinedAtComponentLocations(
+ glyphName, include=self.options.skipExportGlyphs
+ )
+
+ for glyphSet, interpolatedLayer in zip_strict(
+ self.context.glyphSets, self.getInterpolatedLayers()
+ ):
+ glyph = glyphSet.get(glyphName)
+ if glyph is not None:
+ decomposeCompositeGlyph(
+ glyph,
+ interpolatedLayer or glyphSet,
+ decomposeNested=False,
+ include=self.options.skipExportGlyphs,
+ )
+ return True
+
+ def __call__(self, fonts, glyphSets=None, instantiator=None, **kwargs):
+ if not self.options.skipExportGlyphs:
+ return self.context.modified # nothing to do
+
+ modified = super().__call__(
+ fonts, glyphSets, instantiator=instantiator, **kwargs
+ )
+
+ for glyphName in self.options.skipExportGlyphs:
+ for glyphSet in self.context.glyphSets:
+ if glyphName in glyphSet:
+ del glyphSet[glyphName]
+ # mark removed glyphs among the 'modified' ones
+ modified.add(glyphName)
+
+ return modified
diff --git a/Lib/ufo2ft/instantiator.py b/Lib/ufo2ft/instantiator.py
index d9f64891d..067eb1ff3 100644
--- a/Lib/ufo2ft/instantiator.py
+++ b/Lib/ufo2ft/instantiator.py
@@ -33,7 +33,8 @@
import copy
import logging
import typing
-from dataclasses import dataclass
+from dataclasses import dataclass, field
+from functools import cached_property
from typing import (
TYPE_CHECKING,
Any,
@@ -51,12 +52,17 @@
import fontTools.misc.fixedTools
from fontTools import designspaceLib, varLib
-from ufo2ft.util import _getNewGlyphFactory, importUfoModule, openFontFactory
+from ufo2ft.util import (
+ _getNewGlyphFactory,
+ importUfoModule,
+ openFontFactory,
+ zip_strict,
+)
if TYPE_CHECKING:
from collections.abc import Iterable, KeysView
- from ufoLib2.objects import Font, Glyph, Info, Layer
+ from ufoLib2.objects import Font, Glyph, Info
logger = logging.getLogger(__name__)
@@ -77,6 +83,9 @@
# `{"wght": (100.0, 400.0, 900.0), "wdth": (75.0, 100.0, 100.0)}`.
AxisBounds = Dict[str, Tuple[float, float, float]]
+# A bunch of glyphs at a given location (in design coordinates)
+SourceLayer = Tuple[Location, Dict[str, "Glyph"]]
+
# For mapping `wdth` axis user values to the OS2 table's width class field.
WDTH_VALUE_TO_OS2_WIDTH_CLASS = {
50: 1,
@@ -194,22 +203,43 @@ class Instantiator:
font instance object at an arbitary location within the design space."""
axis_bounds: AxisBounds # Design space!
- copy_feature_text: str
- copy_nonkerning_groups: Mapping[str, List[str]]
- copy_info: Optional[Info]
- copy_lib: Mapping[str, Any]
- default_design_location: Location
- designspace_rules: List[designspaceLib.RuleDescriptor]
- glyph_mutators: Mapping[str, Optional["Variator"]]
- glyph_name_to_unicodes: Dict[str, List[int]]
- info_mutator: Optional["Variator"]
- kerning_mutator: Optional["Variator"]
- round_geometry: bool
- skip_export_glyphs: List[str]
- special_axes: Mapping[str, designspaceLib.AxisDescriptor]
- source_layers: List[Tuple[Location, Layer]]
- default_source_idx: int
- glyph_factory: Optional[Callable[[str], Glyph]] # create new ufoLib/defcon Glyph
+ source_layers: List[SourceLayer] # at least 1 default layer required (can be empty)
+ copy_feature_text: str = ""
+ copy_nonkerning_groups: Mapping[str, List[str]] = field(default_factory=dict)
+ copy_info: Optional[Info] = None
+ copy_lib: Mapping[str, Any] = field(default_factory=dict)
+ designspace_rules: List[designspaceLib.RuleDescriptor] = field(default_factory=list)
+ glyph_mutators: Mapping[str, Optional["Variator"]] = field(default_factory=dict)
+ info_mutator: Optional["Variator"] = None
+ kerning_mutator: Optional["Variator"] = None
+ round_geometry: bool = False
+ skip_export_glyphs: List[str] = field(default_factory=list)
+ special_axes: Mapping[str, designspaceLib.AxisDescriptor] = field(
+ default_factory=dict
+ )
+ # computed attributes (see __post_init__ below)
+ default_source_idx: int = field(init=False)
+ default_design_location: Location = field(init=False)
+
+ def __post_init__(self):
+ default_location = {
+ axis: default for axis, (_, default, _) in self.axis_bounds.items()
+ }
+ default_location_items = default_location.items()
+ default_source_idx = None
+ for i, (location, _) in enumerate(self.source_layers):
+ if location.items() <= default_location_items:
+ default_source_idx = i
+ break
+ else:
+ raise InstantiatorError(
+ f"Missing source layer at default location: {default_location}"
+ )
+ assert default_source_idx is not None
+ object.__setattr__(self, "default_source_idx", default_source_idx)
+ object.__setattr__(
+ self, "default_design_location", {**default_location, **location}
+ )
@classmethod
def from_designspace(
@@ -286,6 +316,7 @@ def from_designspace(
# Construct Variators
axis_bounds: AxisBounds = {} # Design space!
+ default_design_location = {}
axis_order: List[str] = []
special_axes = {}
for axis in designspace.axes:
@@ -295,6 +326,7 @@ def from_designspace(
axis.map_forward(axis.default),
axis.map_forward(axis.maximum),
)
+ default_design_location[axis.name] = axis_bounds[axis.name][1]
# Some axes relate to existing OpenType fields and get special attention.
if axis.tag in {"wght", "wdth", "slnt"}:
special_axes[axis.tag] = axis
@@ -319,32 +351,22 @@ def from_designspace(
f"Cannot set up kerning for interpolation: {e}'"
) from e
+ # left empty as the glyph sources will be loaded later on-demand
glyph_mutators: Dict[str, Variator] = {}
- glyph_name_to_unicodes: Dict[str, List[int]] = {}
source_layers = []
- default_source_idx = -1
- glyph_factory = None
if do_glyphs:
- for glyph_name in glyph_names:
- # these will be loaded later on-demand
- glyph_mutators[glyph_name] = None
- glyph_name_to_unicodes[glyph_name] = default_font[glyph_name].unicodes
- # collect (normalized location, source layer) tuples
- for i, source in enumerate(designspace.sources):
+ # collect (location, source layer) tuples
+ for source in designspace.sources:
if source.layerName is None:
layer = source.font.layers.defaultLayer
else:
layer = source.font.layers[source.layerName]
- if glyph_factory is None and len(layer) > 0:
- glyph_factory = _getNewGlyphFactory(next(iter(layer)))
- normalized_location = varLib.models.normalizeLocation(
- source.location, axis_bounds
- )
+ source_location = source.location
if source is designspace.default:
- assert all(v == 0.0 for v in normalized_location.values())
- default_source_idx = i
- source_layers.append((normalized_location, {g.name: g for g in layer}))
- assert default_source_idx != -1
+ # sanity check that the source marked as the default really matches
+ # the default location (i.e. is a subset of)
+ assert source_location.items() <= default_design_location.items()
+ source_layers.append((source_location, {g.name: g for g in layer}))
# Construct defaults to copy over
copy_feature_text: str = default_font.features.text if do_info else ""
@@ -369,24 +391,24 @@ def from_designspace(
return cls(
axis_bounds,
+ source_layers,
copy_feature_text,
copy_nonkerning_groups,
copy_info,
copy_lib,
- designspace.default.location,
designspace.rules,
glyph_mutators,
- glyph_name_to_unicodes,
info_mutator,
kerning_mutator,
round_geometry,
skip_export_glyphs,
special_axes,
- source_layers,
- default_source_idx,
- glyph_factory,
)
+ @property
+ def axis_order(self):
+ return list(self.axis_bounds.keys())
+
@property
def default_source_glyphs(self) -> Dict[str, Glyph]:
return self.source_layers[self.default_source_idx][1]
@@ -397,9 +419,11 @@ def glyph_names(self) -> KeysView[str]:
@property
def source_locations(self) -> Iterable[Location]:
- return (loc for loc, _ in self.source_layers)
+ return (
+ {**self.default_design_location, **loc} for loc, _ in self.source_layers
+ )
- def normalize(self, location):
+ def normalize(self, location: Location) -> Location:
return varLib.models.normalizeLocation(location, self.axis_bounds)
def generate_instance(self, instance: designspaceLib.InstanceDescriptor) -> Font:
@@ -489,26 +513,31 @@ def generate_instance(self, instance: designspaceLib.InstanceDescriptor) -> Font
return font
- def new_glyph(self, name):
- assert self.glyph_factory is not None
+ @cached_property
+ def glyph_factory(self) -> Callable[[str], Glyph]:
+ glyphs = self.default_source_glyphs
+ if len(glyphs) > 0:
+ glyph_factory = _getNewGlyphFactory(next(iter(glyphs.values())))
+ else:
+ raise InstantiatorError("Default source has no glyphs, can't make new ones")
+ return glyph_factory
+
+ def new_glyph(self, name: str) -> Glyph:
return self.glyph_factory(name=name)
def generate_glyph_instance(
- self, glyph_name, normalized_location, output_glyph=None
- ):
+ self,
+ glyph_name: str,
+ normalized_location: Location,
+ output_glyph: Glyph | None = None,
+ ) -> Glyph:
"""Generate an instance of a single glyph at the given location.
- The location must be pecified using normalized coordinates.
+ The location must be specified using normalized coordinates.
If output_glyph is None, the instance is generated in a new Glyph object
and returned. Otherwise, the instance is extracted to the given Glyph object.
"""
- try:
- glyph_mutator = self.glyph_mutators[glyph_name]
- except KeyError as e:
- raise InstantiatorError(
- f"Failed to generate instance for glyph {glyph_name!r}: "
- f"not found in the default source."
- ) from e
+ glyph_mutator = self.glyph_mutators.get(glyph_name)
if glyph_mutator is None:
sources = collect_glyph_masters(
self.source_layers,
@@ -518,7 +547,7 @@ def generate_glyph_instance(
)
try:
glyph_mutator = self.glyph_mutators[glyph_name] = Variator.from_masters(
- sources, axis_order=list(self.axis_bounds)
+ sources, self.axis_order
)
except varLib.errors.VarLibError as e:
raise InstantiatorError(
@@ -534,9 +563,9 @@ def generate_glyph_instance(
output_glyph = self.new_glyph(glyph_name)
# onlyGeometry=True does not set name and unicodes, in ufoLib2 we can't
- # modify a glyph's name. Copy unicodes from default font.
+ # modify a glyph's name. Copy unicodes from default layer.
glyph_instance.extractGlyph(output_glyph, onlyGeometry=True)
- output_glyph.unicodes = list(self.glyph_name_to_unicodes[glyph_name])
+ output_glyph.unicodes = list(self.default_source_glyphs[glyph_name].unicodes)
return output_glyph
@@ -607,6 +636,27 @@ def _generate_instance_info(
slant_axis.map_backward(location[slant_axis.name])
)
+ @property
+ def interpolated_layers(self) -> list[InterpolatedLayer]:
+ """Return one InterpolatedLayer for each source location."""
+ default = self.default_design_location
+ return [
+ InterpolatedLayer(self, {**default, **loc}, source_layer)
+ for loc, source_layer in self.source_layers
+ ]
+
+ def replace_source_layers(self, new_layers: list[dict[str, Glyph]]):
+ """Replace source layers with `new_layers` and clear the cached glyph models.
+
+ Raises `ValueError` if len(new_layers) != len(self.source_layers).
+ """
+ self.source_layers[:] = [
+ (loc, new_glyphs)
+ for (loc, _), new_glyphs in zip_strict(self.source_layers, new_layers)
+ ]
+ # this forces to reload the glyph variation models when an instance is requested
+ self.glyph_mutators.clear()
+
def _error_msg_no_default(designspace: designspaceLib.DesignSpaceDocument) -> str:
if any(axis.map for axis in designspace.axes):
@@ -698,7 +748,7 @@ def collect_kerning_masters(
def collect_glyph_masters(
- source_layers: List[Tuple[Location, Dict[str, Glyph]]],
+ source_layers: List[SourceLayer],
glyph_name: str,
axis_bounds: AxisBounds,
default_source_idx: int,
@@ -714,19 +764,27 @@ def collect_glyph_masters(
default_glyph_empty = False
other_glyph_empty = False
- for i, (normalized_location, source_layer) in enumerate(source_layers):
- # Sparse fonts do not and layers may not contain every glyph.
+ for i, (location, source_layer) in enumerate(source_layers):
+ this_is_default = i == default_source_idx
if glyph_name not in source_layer:
- continue
+ if this_is_default:
+ # Default layer must contain every glyph by definition.
+ raise InstantiatorError(
+ f"glyph {glyph_name!r} not found in the default source"
+ )
+ else:
+ # Sparse fonts do not and layers may not contain every glyph.
+ continue
source_glyph = source_layer[glyph_name]
if not (len(source_glyph) or source_glyph.components):
- if i == default_source_idx:
+ if this_is_default:
default_glyph_empty = True
else:
other_glyph_empty = True
+ normalized_location = varLib.models.normalizeLocation(location, axis_bounds)
locations_and_masters.append(
(normalized_location, fontMath.MathGlyph(source_glyph, strict=True))
)
@@ -907,3 +965,59 @@ def instance_at(self, normalized_location: Location) -> FontMathObject:
return copy.deepcopy(self.location_to_master[normalized_location_key])
return self.model.interpolateFromMasters(normalized_location, self.masters)
+
+
+@dataclass(frozen=True, repr=False)
+class InterpolatedLayer(Mapping):
+ """Mapping of glyphs keyed by name, interpolated on demand.
+
+ If the given location corresponds to one of the source layers, and the
+ latter contains the glyph, this is used directly; otherwise, a new glyph
+ instance is generated on-the-fly at that location, and cached for subsequent
+ retrieval.
+
+ This is useful for APIs that expect a dict of glyphs for resolving component
+ references, e.g. FontTools pens.
+ """
+
+ instantiator: Instantiator
+ # axis coordinates for this layer in design space
+ location: Location
+ # source ufoLib2/defcon Layer (None if location isn't among the source locations)
+ source_layer: dict[str, Glyph] | None = None
+ _cache: dict[str, Glyph] = field(default_factory=dict)
+
+ @cached_property
+ def normalized_location(self):
+ return self.instantiator.normalize(self.location)
+
+ def __iter__(self) -> Iterable[str]:
+ return iter(self.instantiator.glyph_names)
+
+ def __len__(self) -> int:
+ return len(self.instantiator.glyph_names)
+
+ def __getitem__(self, glyph_name: str) -> Glyph:
+ try:
+ return self._cache.setdefault(
+ glyph_name, self._get(glyph_name) or self._interpolate(glyph_name)
+ )
+ except InstantiatorError as e:
+ raise KeyError(glyph_name) from e
+
+ def __repr__(self):
+ return (
+ f"<{self.__class__.__name__} {self.location} "
+ f"({len(self)} glyphs) at 0x{id(self):12x}>"
+ )
+
+ def _get(self, glyph_name: str) -> Glyph | None:
+ glyph = None
+ if self.source_layer is not None:
+ glyph = self.source_layer.get(glyph_name)
+ return glyph
+
+ def _interpolate(self, glyph_name: str) -> Glyph:
+ return self.instantiator.generate_glyph_instance(
+ glyph_name, self.normalized_location
+ )
diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py
index 38693c824..d8816c629 100644
--- a/Lib/ufo2ft/outlineCompiler.py
+++ b/Lib/ufo2ft/outlineCompiler.py
@@ -37,6 +37,7 @@
OPENTYPE_META_KEY,
OPENTYPE_POST_UNDERLINE_POSITION_KEY,
UNICODE_VARIATION_SEQUENCES_KEY,
+ CFFOptimization,
)
from ufo2ft.errors import InvalidFontData
from ufo2ft.fontInfoData import (
@@ -49,6 +50,7 @@
from ufo2ft.instructionCompiler import InstructionCompiler
from ufo2ft.util import (
_copyGlyph,
+ _getNewGlyphFactory,
calcCodePageRanges,
colrClipBoxQuantization,
getMaxComponentDepth,
@@ -114,11 +116,16 @@ def __init__(
colrAutoClipBoxes=True,
colrClipBoxQuantization=colrClipBoxQuantization,
ftConfig=None,
+ *,
+ compilingVFDefaultSource=True,
):
self.ufo = font
# use the previously filtered glyphSet, if any
if glyphSet is None:
glyphSet = {g.name: g for g in font}
+ # this is set to False by Interpolatable{O,T}TFCompiler when building a VF's
+ # non-default masters. E.g. it's used by makeMissingRequiredGlyphs method below.
+ self.compilingVFDefaultSource = compilingVFDefaultSource
self.makeMissingRequiredGlyphs(font, glyphSet, self.sfntVersion, notdefGlyph)
self.allGlyphs = glyphSet
# store the glyph order
@@ -258,8 +265,7 @@ def makeUnicodeToGlyphNameMapping(self):
"""
return makeUnicodeToGlyphNameMapping(self.allGlyphs, self.glyphOrder)
- @staticmethod
- def makeMissingRequiredGlyphs(font, glyphSet, sfntVersion, notdefGlyph=None):
+ def makeMissingRequiredGlyphs(self, font, glyphSet, sfntVersion, notdefGlyph=None):
"""
Add .notdef to the glyph set if it is not present.
@@ -289,6 +295,10 @@ def makeMissingRequiredGlyphs(font, glyphSet, sfntVersion, notdefGlyph=None):
glyphSet[".notdef"] = notdefGlyph
+ def glyphFactory(self):
+ layer = self.ufo.layers.defaultLayer
+ return _getNewGlyphFactory(layer.instantiateGlyphObject())
+
def makeOfficialGlyphOrder(self, glyphOrder):
"""
Make the final glyph order.
@@ -1287,6 +1297,8 @@ def __init__(
colrAutoClipBoxes=True,
colrClipBoxQuantization=colrClipBoxQuantization,
ftConfig=None,
+ *,
+ compilingVFDefaultSource=True,
):
if roundTolerance is not None:
self.roundTolerance = float(roundTolerance)
@@ -1303,7 +1315,10 @@ def __init__(
colrAutoClipBoxes=colrAutoClipBoxes,
colrClipBoxQuantization=colrClipBoxQuantization,
ftConfig=ftConfig,
+ compilingVFDefaultSource=compilingVFDefaultSource,
)
+ if not isinstance(optimizeCFF, bool):
+ optimizeCFF = optimizeCFF >= CFFOptimization.SPECIALIZE
self.optimizeCFF = optimizeCFF
self._defaultAndNominalWidths = None
@@ -1625,6 +1640,8 @@ def __init__(
roundCoordinates=True,
glyphDataFormat=0,
ftConfig=None,
+ *,
+ compilingVFDefaultSource=True,
):
super().__init__(
font,
@@ -1636,12 +1653,41 @@ def __init__(
colrAutoClipBoxes=colrAutoClipBoxes,
colrClipBoxQuantization=colrClipBoxQuantization,
ftConfig=ftConfig,
+ compilingVFDefaultSource=compilingVFDefaultSource,
)
self.autoUseMyMetrics = autoUseMyMetrics
self.dropImpliedOnCurves = dropImpliedOnCurves
self.roundCoordinates = roundCoordinates
self.glyphDataFormat = glyphDataFormat
+ def makeMissingRequiredGlyphs(self, font, glyphSet, sfntVersion, notdefGlyph=None):
+ """
+ Add .notdef to the glyph set if it is not present.
+
+ When compiling non-default interpolatable master TTFs used to build a VF,
+ if any 'sparse' composite glyphs reference missing components, we add empty base
+ glyphs so that the master TTFs' glyf table will keep the composites; varLib will
+ ignores these empty glyphs when building variations.
+ """
+ super().makeMissingRequiredGlyphs(font, glyphSet, sfntVersion, notdefGlyph)
+
+ if not self.compilingVFDefaultSource:
+ newGlyph = self.glyphFactory()
+ for glyphName in list(glyphSet.keys()):
+ glyph = glyphSet[glyphName]
+ for comp in glyph.components:
+ if comp.baseGlyph not in glyphSet:
+ logger.info(
+ "Added missing '%s' component base glyph, referenced from '%s'",
+ comp.baseGlyph,
+ glyphName,
+ )
+ # use sentinel value for width/height to signal varLib this glyph
+ # doesn't participate in {H,V}VAR glyph metrics variations
+ glyphSet[comp.baseGlyph] = newGlyph(
+ comp.baseGlyph, width=0xFFFF, height=0xFFFF
+ )
+
def compileGlyphs(self):
"""Compile and return the TrueType glyphs for this font."""
allGlyphs = self.allGlyphs
diff --git a/Lib/ufo2ft/postProcessor.py b/Lib/ufo2ft/postProcessor.py
index 7b7de4199..c5378c60a 100644
--- a/Lib/ufo2ft/postProcessor.py
+++ b/Lib/ufo2ft/postProcessor.py
@@ -10,6 +10,7 @@
GLYPHS_DONT_USE_PRODUCTION_NAMES,
KEEP_GLYPH_NAMES,
USE_PRODUCTION_NAMES,
+ CFFOptimization,
)
logger = logging.getLogger(__name__)
@@ -81,8 +82,9 @@ def process(
when this is present if the UFO lib and is set to True, this is
equivalent to 'useProductionNames' set to False.
- optimizeCFF (bool):
- Subroubtinize CFF or CFF2 table, if present.
+ optimizeCFF (bool | CFFOptimization):
+ If True or >= CFFOptimization.SUBROUTINIZE, subroubtinize CFF or CFF2 table
+ (if present).
cffVersion (Optiona[int]):
The output CFF format, choose between 1 or 2. By default, it's the same as
@@ -95,6 +97,8 @@ def process(
NOTE: compreffor currently doesn't support input fonts with CFF2 table.
"""
if self._get_cff_version(self.otf):
+ if not isinstance(optimizeCFF, bool):
+ optimizeCFF = optimizeCFF >= CFFOptimization.SUBROUTINIZE
self.process_cff(
optimizeCFF=optimizeCFF,
cffVersion=cffVersion,
diff --git a/Lib/ufo2ft/preProcessor.py b/Lib/ufo2ft/preProcessor.py
index 84a7fe90d..0792a07f1 100644
--- a/Lib/ufo2ft/preProcessor.py
+++ b/Lib/ufo2ft/preProcessor.py
@@ -1,4 +1,7 @@
+from __future__ import annotations
+
import itertools
+from typing import TYPE_CHECKING
from ufo2ft.constants import (
COLOR_LAYER_MAPPING_KEY,
@@ -6,12 +9,16 @@
COLOR_PALETTES_KEY,
)
from ufo2ft.filters import isValidFilter, loadFilters
-from ufo2ft.filters.decomposeComponents import DecomposeComponentsFilter
-from ufo2ft.filters.decomposeTransformedComponents import (
- DecomposeTransformedComponentsFilter,
+from ufo2ft.filters.base import BaseFilter, BaseIFilter
+from ufo2ft.filters.decomposeComponents import (
+ DecomposeComponentsFilter,
+ DecomposeComponentsIFilter,
)
from ufo2ft.fontInfoData import getAttrWithFallback
-from ufo2ft.util import _GlyphSet
+from ufo2ft.util import _GlyphSet, zip_strict
+
+if TYPE_CHECKING:
+ from ufo2ft.instantiator import Instantiator
def _load_custom_filters(ufo, filters=None):
@@ -240,7 +247,157 @@ def initDefaultFilters(
return filters
-class TTFInterpolatablePreProcessor:
+class BaseInterpolatablePreProcessor:
+ """Base class for interpolatable pre-processors.
+
+ These apply filters to same-named glyphs from multiple source layers at once,
+ ensuring that outlines are kept interpolation compatible.
+
+ The optional `instantiator` can be used by filters to interpolate glyph
+ instances (e.g. when decomposing composite glyphs defined at more or less
+ source locations as some of their components' base glyphs).
+ """
+
+ def __init__(
+ self,
+ ufos,
+ inplace=False,
+ layerNames=None,
+ skipExportGlyphs=None,
+ filters=None,
+ *,
+ instantiator: Instantiator | None = None,
+ **kwargs,
+ ):
+ self.ufos = ufos
+ self.inplace = inplace
+
+ if layerNames is None:
+ layerNames = [None] * len(ufos)
+ assert len(ufos) == len(layerNames)
+ self.layerNames = layerNames
+
+ if instantiator is not None and len(instantiator.source_layers) != len(ufos):
+ raise ValueError(
+ f"Expected {len(ufos)} sources for instantiator; "
+ f"found {len(instantiator.source_layers)}"
+ )
+ self.instantiator = instantiator
+
+ # For each UFO, make a mapping of name to glyph object (and ensure it
+ # contains none of the glyphs to be skipped, or any references to it).
+ self.glyphSets = [
+ _GlyphSet.from_layer(ufo, layerName, copy=not inplace)
+ for ufo, layerName in zip_strict(ufos, layerNames)
+ ]
+ if skipExportGlyphs:
+ from ufo2ft.filters.skipExportGlyphs import SkipExportGlyphsIFilter
+
+ self._run(SkipExportGlyphsIFilter(skipExportGlyphs))
+
+ self.defaultFilters = self.initDefaultFilters(**kwargs)
+
+ filterses = [_load_custom_filters(ufo, filters) for ufo in ufos]
+ self.preFilters = [[f for f in filters if f.pre] for filters in filterses]
+ self.postFilters = [[f for f in filters if not f.pre] for filters in filterses]
+
+ def initDefaultFilters(self, **kwargs):
+ filterses = []
+ for ufo in self.ufos:
+ filterses.append([])
+ _init_explode_color_layer_glyphs_filter(ufo, filterses[-1])
+ return filterses
+
+ def process(self):
+ # first apply all custom pre-filters, then all default filters, and finally
+ # all custom post-filters
+ for filterses in (self.preFilters, self.defaultFilters, self.postFilters):
+ for filters in itertools.zip_longest(*filterses):
+ self._run(*filters)
+ return self.glyphSets
+
+ def _update_instantiator(self):
+ # the instantiator's source layers must be updated after each filter is run,
+ # since each filter can modify/remove/add glyphs.
+ if self.instantiator is not None:
+ self.instantiator.replace_source_layers(self.glyphSets)
+
+ def _run_interpolatable(self, filter_: BaseIFilter) -> set[str]:
+ # apply a single, interpolatable filter to all the glyphSets
+ modified = filter_(self.ufos, self.glyphSets, self.instantiator)
+ if modified:
+ self._update_instantiator()
+ return modified
+
+ @staticmethod
+ def _try_as_interpolatable_filter(
+ filters: list[BaseFilter | None],
+ ) -> BaseIFilter | None:
+ # Try to combine multiple filters into a single interpolatable variant
+ assert len(filters) > 0
+ filter_ = next(filter(None, filters))
+ filter_class = type(filter_)
+
+ if not all(
+ (
+ type(f) is filter_class
+ and f.options == filter_.options
+ and f.pre == filter_.pre
+ )
+ for f in filters[1:]
+ ):
+ return None
+
+ if isinstance(filter_, BaseIFilter):
+ return filter_
+
+ ifilter_class = None
+ try:
+ ifilter_class = filter_class.getInterpolatableFilterClass()
+ except AttributeError:
+ pass
+ if ifilter_class is None:
+ return None
+
+ if not isValidFilter(ifilter_class, BaseIFilter):
+ raise ValueError(f"Invalid interpolatable filter class: {ifilter_class!r}")
+
+ # in the unlikely scenario individual filters have different includes,
+ # this effectively takes the union of those
+ def include(g):
+ return any(f.include(g) for f in filters)
+
+ return ifilter_class(
+ pre=filter_.pre,
+ include=include,
+ **filter_.options.__dict__,
+ )
+
+ def _run(self, *filters: tuple[BaseFilter | None]) -> set[str]:
+ # apply either multiple (one per glyphSet) or a single filter to all glyphSets
+ if len(filters) == 1:
+ assert filters[0] is not None
+ if isinstance(filters[0], BaseIFilter):
+ return self._run_interpolatable(filters[0])
+
+ filters = [filters[0]] * len(self.ufos)
+
+ # attempt to convert mutltiple filters to single interpolatable variant (if any)
+ if ifilter := self._try_as_interpolatable_filter(filters):
+ return self._run_interpolatable(ifilter)
+
+ # or else apply individual filters to the respective glyphSet, one at a time,
+ # and hope for the best...
+ modified = set()
+ for filter_, ufo, glyphSet in zip_strict(filters, self.ufos, self.glyphSets):
+ if filter_ is not None:
+ modified |= filter_(ufo, glyphSet)
+ if modified:
+ self._update_instantiator()
+ return modified
+
+
+class TTFInterpolatablePreProcessor(BaseInterpolatablePreProcessor):
"""Preprocessor for building TrueType-flavored OpenType fonts with
interpolatable quadratic outlines.
@@ -276,107 +433,82 @@ def __init__(
skipExportGlyphs=None,
filters=None,
allQuadratic=True,
+ *,
+ instantiator: Instantiator | None = None,
+ **kwargs,
):
from fontTools.cu2qu.ufo import DEFAULT_MAX_ERR
- self.ufos = ufos
- self.inplace = inplace
+ super().__init__(
+ ufos,
+ inplace=inplace,
+ layerNames=layerNames,
+ skipExportGlyphs=skipExportGlyphs,
+ filters=filters,
+ instantiator=instantiator,
+ **kwargs,
+ )
self.flattenComponents = flattenComponents
-
- if layerNames is None:
- layerNames = [None] * len(ufos)
- assert len(ufos) == len(layerNames)
- self.layerNames = layerNames
-
- # For each UFO, make a mapping of name to glyph object (and ensure it
- # contains none of the glyphs to be skipped, or any references to it).
- self.glyphSets = [
- _GlyphSet.from_layer(
- ufo, layerName, copy=not inplace, skipExportGlyphs=skipExportGlyphs
- )
- for ufo, layerName in zip(ufos, layerNames)
- ]
self.convertCubics = convertCubics
self._conversionErrors = [
(conversionError or DEFAULT_MAX_ERR)
* getAttrWithFallback(ufo.info, "unitsPerEm")
- for ufo in ufos
+ for ufo in self.ufos
]
self._reverseDirection = reverseDirection
self._rememberCurveType = rememberCurveType
self.allQuadratic = allQuadratic
- self.defaultFilters = []
- for ufo in ufos:
- self.defaultFilters.append([])
- _init_explode_color_layer_glyphs_filter(ufo, self.defaultFilters[-1])
-
- filterses = [_load_custom_filters(ufo, filters) for ufo in ufos]
- self.preFilters = [[f for f in filters if f.pre] for filters in filterses]
- self.postFilters = [[f for f in filters if not f.pre] for filters in filterses]
-
def process(self):
from fontTools.cu2qu.ufo import fonts_to_quadratic
- needs_decomposition = set()
-
# first apply all custom pre-filters
- for funcs, ufo, glyphSet in zip(self.preFilters, self.ufos, self.glyphSets):
- for func in funcs:
- if isinstance(func, DecomposeTransformedComponentsFilter):
- needs_decomposition |= func(ufo, glyphSet)
- else:
- func(ufo, glyphSet)
+ for funcs in itertools.zip_longest(*self.preFilters):
+ self._run(*funcs)
+ # TrueType fonts cannot mix contours and components, so pick out all glyphs
+ # that have both contours _and_ components.
+ needs_decomposition = {
+ gname
+ for glyphSet in self.glyphSets
+ for gname, glyph in glyphSet.items()
+ if len(glyph) > 0 and glyph.components
+ }
+ # Variable fonts can only variate glyf components' x or y offsets, not their
+ # 2x2 transformation matrix; decompose of these don't match across masters
self.check_for_nonmatching_components(needs_decomposition)
-
- # If we decomposed a glyph in some masters, we must ensure it is decomposed in
- # all masters. (https://github.com/googlefonts/ufo2ft/issues/507)
if needs_decomposition:
- decompose = DecomposeComponentsFilter(include=needs_decomposition)
- for ufo, glyphSet in zip(self.ufos, self.glyphSets):
- decompose(ufo, glyphSet)
+ self._run(DecomposeComponentsIFilter(include=needs_decomposition))
# then apply all default filters
- for funcs, ufo, glyphSet in zip(self.defaultFilters, self.ufos, self.glyphSets):
- for func in funcs:
- func(ufo, glyphSet)
+ for funcs in itertools.zip_longest(*self.defaultFilters):
+ self._run(*funcs)
if self.convertCubics:
- fonts_to_quadratic(
+ if fonts_to_quadratic(
self.glyphSets,
max_err=self._conversionErrors,
reverse_direction=self._reverseDirection,
dump_stats=True,
remember_curve_type=self._rememberCurveType and self.inplace,
all_quadratic=self.allQuadratic,
- )
+ ):
+ self._update_instantiator()
elif self._reverseDirection:
from ufo2ft.filters.reverseContourDirection import (
ReverseContourDirectionFilter,
)
- reverseDirection = ReverseContourDirectionFilter(include=lambda g: len(g))
- for ufo, glyphSet in zip(self.ufos, self.glyphSets):
- reverseDirection(ufo, glyphSet)
-
- # TrueType fonts cannot mix contours and components, so pick out all glyphs
- # that have contours (`bool(len(g)) == True`) and decompose their
- # components, if any.
- decompose = DecomposeComponentsFilter(include=lambda g: len(g))
- for ufo, glyphSet in zip(self.ufos, self.glyphSets):
- decompose(ufo, glyphSet)
+ self._run(ReverseContourDirectionFilter(include=lambda g: len(g)))
if self.flattenComponents:
- from ufo2ft.filters.flattenComponents import FlattenComponentsFilter
+ from ufo2ft.filters.flattenComponents import FlattenComponentsIFilter
- for ufo, glyphSet in zip(self.ufos, self.glyphSets):
- FlattenComponentsFilter()(ufo, glyphSet)
+ self._run(FlattenComponentsIFilter(include=lambda g: len(g.components)))
# finally apply all custom post-filters
- for funcs, ufo, glyphSet in zip(self.postFilters, self.ufos, self.glyphSets):
- for func in funcs:
- func(ufo, glyphSet)
+ for funcs in itertools.zip_longest(*self.postFilters):
+ self._run(*funcs)
return self.glyphSets
@@ -411,3 +543,23 @@ def check_for_nonmatching_components(self, needs_decomposition):
if any(transform != transforms[0] for transform in transforms):
needs_decomposition.add(glyph)
break
+
+
+class OTFInterpolatablePreProcessor(BaseInterpolatablePreProcessor):
+ """Interpolatable pre-processor for CFF-flavored fonts.
+
+ By default, besides any user-defined custom pre/post filters, this decomposes
+ all composite glyphs, which aren't a thing in PostScript outlines.
+
+ Unlike the non-interpolatable OTFPreProcessor, overlaps are *not* removed as
+ that could make outlines incompatible for interpolation.
+ """
+
+ def initDefaultFilters(self, **kwargs):
+ filterses = super().initDefaultFilters(**kwargs)
+ # this interpolatable filter will only run once on all the glyphSets,
+ # (see _try_as_interpolatable_filter)
+ decompose = DecomposeComponentsIFilter()
+ for filters in filterses:
+ filters.append(decompose)
+ return filterses
diff --git a/Lib/ufo2ft/util.py b/Lib/ufo2ft/util.py
index b245e74a0..cc9241c26 100644
--- a/Lib/ufo2ft/util.py
+++ b/Lib/ufo2ft/util.py
@@ -13,7 +13,8 @@
from fontTools.designspaceLib import DesignSpaceDocument
from fontTools.feaLib.builder import addOpenTypeFeatures
from fontTools.misc.fixedTools import otRound
-from fontTools.misc.transform import Identity, Transform
+from fontTools.misc.transform import Identity
+from fontTools.pens.filterPen import DecomposingFilterPointPen
from fontTools.pens.reverseContourPen import ReverseContourPen
from fontTools.pens.transformPen import TransformPen
@@ -49,6 +50,39 @@ def makeOfficialGlyphOrder(font, glyphOrder=None):
return order
+def decomposeCompositeGlyph(
+ glyph,
+ glyphSet,
+ skipMissing=False,
+ reverseFlipped=True,
+ include=None,
+ decomposeNested=True,
+):
+ """Decompose composite glyph in-place resolving references from glyphSet."""
+ if len(glyph.components) == 0:
+ return
+ pen = DecomposingFilterPointPen(
+ glyph.getPointPen(),
+ glyphSet,
+ reverseFlipped=reverseFlipped,
+ include=include,
+ decomposeNested=decomposeNested,
+ )
+ for component in list(glyph.components):
+ try:
+ component.drawPoints(pen)
+ except pen.MissingComponentError:
+ if skipMissing:
+ logger.warning(
+ "dropping non-existent component '%s' in glyph '%s'",
+ component.baseGlyph,
+ glyph.name,
+ )
+ else:
+ raise
+ glyph.removeComponent(component)
+
+
class _GlyphSet(dict):
@classmethod
def from_layer(cls, font, layerName=None, copy=False, skipExportGlyphs=None):
@@ -64,32 +98,15 @@ def from_layer(cls, font, layerName=None, copy=False, skipExportGlyphs=None):
else:
self = cls((g.name, g) for g in layer)
self.lib = layer.lib
+ self.name = layer.name if layerName is not None else None
# If any glyphs in the skipExportGlyphs list are used as components, decompose
# them in the containing glyphs...
if skipExportGlyphs:
- for glyph in self.values():
- if any(c.baseGlyph in skipExportGlyphs for c in glyph.components):
- deepCopyContours(self, glyph, glyph, Transform(), skipExportGlyphs)
- if hasattr(glyph, "removeComponent"): # defcon
- for c in [
- component
- for component in glyph.components
- if component.baseGlyph in skipExportGlyphs
- ]:
- glyph.removeComponent(c)
- else: # ufoLib2
- glyph.components[:] = [
- c
- for c in glyph.components
- if c.baseGlyph not in skipExportGlyphs
- ]
- # ... and then remove them from the glyph set, if even present.
- for glyph_name in skipExportGlyphs:
- if glyph_name in self:
- del self[glyph_name]
+ from ufo2ft.filters.skipExportGlyphs import SkipExportGlyphsFilter
+
+ SkipExportGlyphsFilter(skipExportGlyphs)(font, self)
- self.name = layer.name if layerName is not None else None
return self
@@ -166,6 +183,7 @@ def _setGlyphMargin(glyph, side, margin):
raise NotImplementedError(f"Unsupported Glyph class: {type(glyph)!r}")
+# DEPRECATED: use ufo2ft.util.decomposeCompositeGlyph above
def deepCopyContours(
glyphSet, parent, composite, transformation, specificComponents=None
):
@@ -583,7 +601,11 @@ def prune_unknown_kwargs(kwargs, *callables):
"""
known_args = set()
for func in callables:
- known_args.update(getfullargspec(func).args)
+ arg_spec = getfullargspec(func)
+ known_args.update(arg_spec.args)
+ # also handle optional keyword-only arguments
+ if arg_spec.kwonlydefaults:
+ known_args.update(arg_spec.kwonlydefaults)
return {k: v for k, v in kwargs.items() if k in known_args}
@@ -738,7 +760,11 @@ def load(cls, font):
openTypeCategories = font.lib.get(OPENTYPE_CATEGORIES_KEY, {})
# Handle case where we are a variable feature writer
if not openTypeCategories and isinstance(font, DesignSpaceDocument):
- font = font.sources[0].font
+ designspace = font
+ default = designspace.findDefault()
+ if default is None:
+ raise InvalidDesignSpaceData("No default source found in designspace")
+ font = default.font
openTypeCategories = font.lib.get(OPENTYPE_CATEGORIES_KEY, {})
for glyphName, category in openTypeCategories.items():
diff --git a/setup.py b/setup.py
index f4cb0ca2e..74b5fdab4 100644
--- a/setup.py
+++ b/setup.py
@@ -29,7 +29,7 @@
setup_requires=pytest_runner + wheel + ["setuptools_scm"],
tests_require=["pytest>=2.8"],
install_requires=[
- "fonttools[ufo]>=4.49.0",
+ "fonttools[ufo]>=4.50.0",
"cffsubr>=0.3.0",
"booleanOperations>=0.9.0",
"fontMath>=0.9.3",
diff --git a/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/a.glif b/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/a.glif
new file mode 100644
index 000000000..5b9c97216
--- /dev/null
+++ b/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/a.glif
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/contents.plist b/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/contents.plist
new file mode 100644
index 000000000..7ef307d14
--- /dev/null
+++ b/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/contents.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ a
+ a.glif
+ e
+ e.glif
+
+
diff --git a/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/e.glif b/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/e.glif
new file mode 100644
index 000000000..ce115a7b3
--- /dev/null
+++ b/tests/data/NestedComponents-Regular.ufo/glyphs.M_edium/e.glif
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/NestedComponents-Regular.ufo/layercontents.plist b/tests/data/NestedComponents-Regular.ufo/layercontents.plist
index cf95d3573..978410214 100644
--- a/tests/data/NestedComponents-Regular.ufo/layercontents.plist
+++ b/tests/data/NestedComponents-Regular.ufo/layercontents.plist
@@ -6,5 +6,9 @@
public.default
glyphs
+
+ Medium
+ glyphs.M_edium
+
diff --git a/tests/data/NestedComponents.designspace b/tests/data/NestedComponents.designspace
new file mode 100644
index 000000000..92ea6e262
--- /dev/null
+++ b/tests/data/NestedComponents.designspace
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/fontinfo.plist b/tests/data/SkipExportGlyphsTest-Bold.ufo/fontinfo.plist
new file mode 100644
index 000000000..82d703a63
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/fontinfo.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ ascender
+ 760
+ capHeight
+ 714
+ descender
+ -240
+ familyName
+ SkipExportGlyphsTest
+ styleName
+ Bold
+ unitsPerEm
+ 1000
+ xHeight
+ 553
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/A_.glif b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/A_.glif
new file mode 100644
index 000000000..50c751058
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/A_.glif
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/A_stroke.glif b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/A_stroke.glif
new file mode 100644
index 000000000..66b915887
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/A_stroke.glif
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/_stroke.glif b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/_stroke.glif
new file mode 100644
index 000000000..7192f585b
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/_stroke.glif
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/contents.plist b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/contents.plist
new file mode 100644
index 000000000..ecef523a7
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/glyphs/contents.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ A
+ A_.glif
+ Astroke
+ A_stroke.glif
+ _stroke
+ _stroke.glif
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/layercontents.plist b/tests/data/SkipExportGlyphsTest-Bold.ufo/layercontents.plist
new file mode 100644
index 000000000..b9c1a4f27
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/layercontents.plist
@@ -0,0 +1,10 @@
+
+
+
+
+
+ public.default
+ glyphs
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/lib.plist b/tests/data/SkipExportGlyphsTest-Bold.ufo/lib.plist
new file mode 100644
index 000000000..fa8de64a5
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/lib.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ public.glyphOrder
+
+ A
+ Astroke
+ _stroke
+
+ public.postscriptNames
+
+ Astroke
+ uni023A
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Bold.ufo/metainfo.plist b/tests/data/SkipExportGlyphsTest-Bold.ufo/metainfo.plist
new file mode 100644
index 000000000..7b8b34ac6
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Bold.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ creator
+ com.github.fonttools.ufoLib
+ formatVersion
+ 3
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/fontinfo.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/fontinfo.plist
new file mode 100644
index 000000000..c973724ec
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/fontinfo.plist
@@ -0,0 +1,20 @@
+
+
+
+
+ ascender
+ 760
+ capHeight
+ 714
+ descender
+ -240
+ familyName
+ SkipExportGlyphsTest
+ styleName
+ Regular
+ unitsPerEm
+ 1000
+ xHeight
+ 536
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{151}/_stroke.glif b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{151}/_stroke.glif
new file mode 100644
index 000000000..e8588c9f9
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{151}/_stroke.glif
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{151}/contents.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{151}/contents.plist
new file mode 100644
index 000000000..b6d785466
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{151}/contents.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ _stroke
+ _stroke.glif
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{170}/A_stroke.glif b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{170}/A_stroke.glif
new file mode 100644
index 000000000..5de9f2436
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{170}/A_stroke.glif
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{170}/contents.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{170}/contents.plist
new file mode 100644
index 000000000..387ea4af9
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs.{170}/contents.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ Astroke
+ A_stroke.glif
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/A_.glif b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/A_.glif
new file mode 100644
index 000000000..9735a33c4
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/A_.glif
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/A_stroke.glif b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/A_stroke.glif
new file mode 100644
index 000000000..c6234223d
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/A_stroke.glif
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/_stroke.glif b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/_stroke.glif
new file mode 100644
index 000000000..24bcb3d1e
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/_stroke.glif
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/contents.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/contents.plist
new file mode 100644
index 000000000..ecef523a7
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/glyphs/contents.plist
@@ -0,0 +1,12 @@
+
+
+
+
+ A
+ A_.glif
+ Astroke
+ A_stroke.glif
+ _stroke
+ _stroke.glif
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/layercontents.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/layercontents.plist
new file mode 100644
index 000000000..c10175d2f
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/layercontents.plist
@@ -0,0 +1,18 @@
+
+
+
+
+
+ public.default
+ glyphs
+
+
+ {170}
+ glyphs.{170}
+
+
+ {151}
+ glyphs.{151}
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/lib.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/lib.plist
new file mode 100644
index 000000000..fa8de64a5
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/lib.plist
@@ -0,0 +1,17 @@
+
+
+
+
+ public.glyphOrder
+
+ A
+ Astroke
+ _stroke
+
+ public.postscriptNames
+
+ Astroke
+ uni023A
+
+
+
diff --git a/tests/data/SkipExportGlyphsTest-Regular.ufo/metainfo.plist b/tests/data/SkipExportGlyphsTest-Regular.ufo/metainfo.plist
new file mode 100644
index 000000000..7b8b34ac6
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest-Regular.ufo/metainfo.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ creator
+ com.github.fonttools.ufoLib
+ formatVersion
+ 3
+
+
diff --git a/tests/data/SkipExportGlyphsTest.designspace b/tests/data/SkipExportGlyphsTest.designspace
new file mode 100644
index 000000000..ca60eaf15
--- /dev/null
+++ b/tests/data/SkipExportGlyphsTest.designspace
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ public.skipExportGlyphs
+
+ _stroke
+
+
+
+
diff --git a/tests/data/TestVariableFont-CFF2-cffsubr.ttx b/tests/data/TestVariableFont-CFF2-cffsubr.ttx
index b2a8804a7..501f4f6c5 100644
--- a/tests/data/TestVariableFont-CFF2-cffsubr.ttx
+++ b/tests/data/TestVariableFont-CFF2-cffsubr.ttx
@@ -206,17 +206,17 @@
- 2 blend
+ 1 vsindex
+ 127 228 -1 70 -25 1 2 blend
rmoveto
- -16 -94 78 -2 9 88 -19 -48 44 7 -4 29 6 blend
+ 449 -2 1 -45 -2 -2 2 blend
+ -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -280 -54 -82 188 170 153 163 -124 -355 6 27 0 0 -27 0 36 0 -29 0 -34 0 31 0 -1 0 2 0 -45 -2 13 28 100 37 0 13 0 -2 55 -40 -54 -32 -86 -30 -57 -85 -60 34 57 84 146 -5 0 21 blend
rlineto
- -280 -54 -82 188 170 153 163 -124 -355
-
- 1 2 blend
+ 2 blend
rmoveto
- 449 -2
+ -16 -94 78 -2 9 88
@@ -241,24 +241,18 @@
rlineto
- -21 597 -8 28 -107 callsubr
+ -21 597 -8 28 -106 callsubr
+ -19 -48 44 7 -4 29 6 blend
+ rlineto
- 1 vsindex
- 127 228 -1 70 -25 -105 callsubr
- 1 -45 -2 -2 2 blend
- -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -106 callsubr
- 6 27 0 0 -27 0 36 0 -29 0 -34 0 31 0 -1 0 2 0 -45 -2 13 28 100 37 0 13 0 -2 55 -40 -54 -32 -86 -30 -57 -85 -60 34 57 84 146 -5 0 21 blend
- rlineto
+ -107 callsubr
- 127 228 70 -105 callsubr
- -45 -2 2 blend
- -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -27 36 -29 -34 31 -1 2 -45 13 100 10 blend
- -106 callsubr
- 55 -54 -86 -57 -60 57 146 7 blend
- 6 rlineto
- 167 395 -84 118 -107 callsubr
+ -107 callsubr
+ 167 395 -5 -84 43 118 -106 callsubr
+ -7 -19 -17 -48 16 44 3 7 -1 -4 10 29 6 blend
+ rlineto
559 459 rmoveto
diff --git a/tests/data/TestVariableFont-CFF2-post3.ttx b/tests/data/TestVariableFont-CFF2-post3.ttx
index d8f17e020..ddbd8b290 100644
--- a/tests/data/TestVariableFont-CFF2-post3.ttx
+++ b/tests/data/TestVariableFont-CFF2-post3.ttx
@@ -220,15 +220,15 @@
rlineto
- 127 228 70 1 2 blend
+ 1 vsindex
+ 127 228 -1 70 -25 1 2 blend
rmoveto
- 449 -2 -45 -2 2 blend
- -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -27 36 -29 -34 31 -1 2 -45 13 100 10 blend
- -280 -54 -82 188 170 153 163 -124 -355 55 -54 -86 -57 -60 57 146 7 blend
- 6 rlineto
- 167 395 -84 118 2 blend
+ 449 -2 1 -45 -2 -2 2 blend
+ -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -280 -54 -82 188 170 153 163 -124 -355 6 27 0 0 -27 0 36 0 -29 0 -34 0 31 0 -1 0 2 0 -45 -2 13 28 100 37 0 13 0 -2 55 -40 -54 -32 -86 -30 -57 -85 -60 34 57 84 146 -5 0 21 blend
+ rlineto
+ 167 395 -5 -84 43 118 2 blend
rmoveto
- -16 -94 78 -2 9 88 -19 -48 44 7 -4 29 6 blend
+ -16 -94 78 -2 9 88 -7 -19 -17 -48 16 44 3 7 -1 -4 10 29 6 blend
rlineto
diff --git a/tests/data/TestVariableFont-CFF2-sparse-notdefGlyph.ttx b/tests/data/TestVariableFont-CFF2-sparse-notdefGlyph.ttx
index 34f117879..94c9532a4 100644
--- a/tests/data/TestVariableFont-CFF2-sparse-notdefGlyph.ttx
+++ b/tests/data/TestVariableFont-CFF2-sparse-notdefGlyph.ttx
@@ -55,15 +55,15 @@
rlineto
- 127 228 70 1 2 blend
+ 1 vsindex
+ 127 228 -1 70 -25 1 2 blend
rmoveto
- 449 -2 -45 -2 2 blend
- -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -27 36 -29 -34 31 -1 2 -45 13 100 10 blend
- -280 -54 -82 188 170 153 163 -124 -355 55 -54 -86 -57 -60 57 146 7 blend
- 6 rlineto
- 167 395 -84 118 2 blend
+ 449 -2 1 -45 -2 -2 2 blend
+ -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -280 -54 -82 188 170 153 163 -124 -355 6 27 0 0 -27 0 36 0 -29 0 -34 0 31 0 -1 0 2 0 -45 -2 13 28 100 37 0 13 0 -2 55 -40 -54 -32 -86 -30 -57 -85 -60 34 57 84 146 -5 0 21 blend
+ rlineto
+ 167 395 -5 -84 43 118 2 blend
rmoveto
- -16 -94 78 -2 9 88 -19 -48 44 7 -4 29 6 blend
+ -16 -94 78 -2 9 88 -7 -19 -17 -48 16 44 3 7 -1 -4 10 29 6 blend
rlineto
diff --git a/tests/data/TestVariableFont-CFF2-useProductionNames.ttx b/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
index 9bf821052..b01e99396 100644
--- a/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
+++ b/tests/data/TestVariableFont-CFF2-useProductionNames.ttx
@@ -242,15 +242,15 @@
213 -66 rlineto
- 127 228 70 1 2 blend
+ 1 vsindex
+ 127 228 -1 70 -25 1 2 blend
rmoveto
- 449 -2 -45 -2 2 blend
- -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -27 36 -29 -34 31 -1 2 -45 13 100 10 blend
- -280 -54 -82 188 170 153 163 -124 -355 55 -54 -86 -57 -60 57 146 7 blend
- 6 rlineto
- 167 395 -84 118 2 blend
+ 449 -2 1 -45 -2 -2 2 blend
+ -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -280 -54 -82 188 170 153 163 -124 -355 6 27 0 0 -27 0 36 0 -29 0 -34 0 31 0 -1 0 2 0 -45 -2 13 28 100 37 0 13 0 -2 55 -40 -54 -32 -86 -30 -57 -85 -60 34 57 84 146 -5 0 21 blend
+ rlineto
+ 167 395 -5 -84 43 118 2 blend
rmoveto
- -16 -94 78 -2 9 88 -19 -48 44 7 -4 29 6 blend
+ -16 -94 78 -2 9 88 -7 -19 -17 -48 16 44 3 7 -1 -4 10 29 6 blend
rlineto
diff --git a/tests/data/TestVariableFont-CFF2.ttx b/tests/data/TestVariableFont-CFF2.ttx
index c53ebc96a..bd4501df8 100644
--- a/tests/data/TestVariableFont-CFF2.ttx
+++ b/tests/data/TestVariableFont-CFF2.ttx
@@ -240,15 +240,15 @@
rlineto
- 127 228 70 1 2 blend
+ 1 vsindex
+ 127 228 -1 70 -25 1 2 blend
rmoveto
- 449 -2 -45 -2 2 blend
- -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -27 36 -29 -34 31 -1 2 -45 13 100 10 blend
- -280 -54 -82 188 170 153 163 -124 -355 55 -54 -86 -57 -60 57 146 7 blend
- 6 rlineto
- 167 395 -84 118 2 blend
+ 449 -2 1 -45 -2 -2 2 blend
+ -5 79 -255 208 -276 -252 148 -279 338 63 -17 84 -280 -54 -82 188 170 153 163 -124 -355 6 27 0 0 -27 0 36 0 -29 0 -34 0 31 0 -1 0 2 0 -45 -2 13 28 100 37 0 13 0 -2 55 -40 -54 -32 -86 -30 -57 -85 -60 34 57 84 146 -5 0 21 blend
+ rlineto
+ 167 395 -5 -84 43 118 2 blend
rmoveto
- -16 -94 78 -2 9 88 -19 -48 44 7 -4 29 6 blend
+ -16 -94 78 -2 9 88 -7 -19 -17 -48 16 44 3 7 -1 -4 10 29 6 blend
rlineto
diff --git a/tests/filters/decomposeComponents_test.py b/tests/filters/decomposeComponents_test.py
index 70deaa446..1d0bbce18 100644
--- a/tests/filters/decomposeComponents_test.py
+++ b/tests/filters/decomposeComponents_test.py
@@ -1,10 +1,15 @@
-import logging
+import pytest
+from fontTools.pens.basePen import MissingComponentError
-from ufo2ft.filters.decomposeComponents import DecomposeComponentsFilter
-from ufo2ft.util import logger
+from ufo2ft.filters.decomposeComponents import (
+ DecomposeComponentsFilter,
+ DecomposeComponentsIFilter,
+)
+from ufo2ft.instantiator import Instantiator
+from ufo2ft.util import _GlyphSet
-def test_missing_component_is_dropped(FontClass, caplog):
+def test_missing_component_error(FontClass, caplog):
ufo = FontClass()
a = ufo.newGlyph("a")
a.width = 100
@@ -24,15 +29,10 @@ def test_missing_component_is_dropped(FontClass, caplog):
assert len(ufo["aacute"]) == 0
assert len(ufo["aacute"].components) == 2
- with caplog.at_level(logging.WARNING, logger=logger.name):
- filter_ = DecomposeComponentsFilter()
-
- assert filter_(ufo)
- assert len(ufo["aacute"]) == 1
- assert len(ufo["aacute"].components) == 0
+ filter_ = DecomposeComponentsFilter()
- assert len(caplog.records) == 1
- assert "dropping non-existent component" in caplog.text
+ with pytest.raises(MissingComponentError, match="'acute'"):
+ filter_(ufo)
def test_nested_components(FontClass):
@@ -65,3 +65,256 @@ def test_nested_components(FontClass):
assert not ufo["nine.lf"].components
assert len(ufo["nine"]) == 1
assert not ufo["nine"].components
+
+
+@pytest.fixture
+def ufos_and_glyphSets(FontClass):
+ """Return two parallel lists of UFOs and glyphSets for testing.
+
+ This fixture creates two UFOs, a Regular and a Bold, each containing 5 glyphs:
+ "agrave" composite glyph composed from "a" and "gravecomb" components,
+ both in turn simple contour glyphs, and "igrave" composed of "dotlessi" and
+ "gravecomb" components.
+ The Regular UFO also contains a 'sparse' Medium layer with only two glyphs:
+ a different "agrave" composite glyph, but no "a" nor "gravecomb" components;
+ and a different shape for the "dotlessi" glyph, but no "igrave" nor "gravecomb".
+
+ The decomposing (interpolatable) filter should interpolate the missing
+ components or composites on-the-fly using the instantiator, when available.
+ """
+
+ regular_ufo = FontClass()
+
+ a = regular_ufo.newGlyph("a")
+ a.width = 500
+ a.unicodes = [ord("a")]
+ pen = a.getPointPen()
+ pen.beginPath()
+ pen.addPoint((100, 0), "line")
+ pen.addPoint((400, 0), "line")
+ pen.addPoint((400, 500), "line")
+ pen.addPoint((100, 500), "line")
+ pen.endPath()
+
+ i = regular_ufo.newGlyph("dotlessi")
+ i.width = 300
+ i.unicodes = [0x0131]
+ pen = i.getPointPen()
+ pen.beginPath()
+ pen.addPoint((100, 0), "line")
+ pen.addPoint((200, 0), "line")
+ pen.addPoint((200, 500), "line")
+ pen.addPoint((100, 500), "line")
+ pen.endPath()
+
+ gravecomb = regular_ufo.newGlyph("gravecomb")
+ gravecomb.unicodes = [0x0300]
+ pen = gravecomb.getPointPen()
+ pen.beginPath()
+ pen.addPoint((30, 550), "line")
+ pen.addPoint((0, 750), "line")
+ pen.addPoint((-50, 750), "line")
+ pen.addPoint((0, 550), "line")
+ pen.endPath()
+
+ agrave = regular_ufo.newGlyph("agrave")
+ agrave.width = a.width
+ agrave.unicodes = [0x00E0]
+ pen = agrave.getPointPen()
+ pen.addComponent("a", (1, 0, 0, 1, 0, 0))
+ pen.addComponent("gravecomb", (1, 0, 0, 1, 250, 0))
+
+ igrave = regular_ufo.newGlyph("igrave")
+ igrave.width = i.width
+ igrave.unicodes = [0x00EC]
+ pen = igrave.getPointPen()
+ pen.addComponent("dotlessi", (1, 0, 0, 1, 0, 0))
+ pen.addComponent("gravecomb", (1, 0, 0, 1, 150, 0))
+
+ # The Medium layer has "agrave" but does not have "a" and "gravecomb"
+ medium_layer = regular_ufo.newLayer("Medium")
+
+ agrave = medium_layer.newGlyph("agrave")
+ agrave.width = 550
+ pen = agrave.getPointPen()
+ pen.addComponent("a", (1, 0, 0, 1, 0, 0))
+ pen.addComponent("gravecomb", (1, 0, 0, 1, 275, 0))
+
+ # The Medium layer also has a different "dotlessi" glyph, which the
+ # other layers don't have.
+ i = medium_layer.newGlyph("dotlessi")
+ i.width = 350
+ pen = i.getPointPen()
+ pen.beginPath()
+ pen.addPoint((100, 0), "line")
+ pen.addPoint((250, 0), "line")
+ pen.addPoint((175, 500), "line")
+ pen.addPoint((175, 500), "line")
+ pen.endPath()
+
+ bold_ufo = FontClass()
+
+ a = bold_ufo.newGlyph("a")
+ a.width = 600
+ pen = a.getPointPen()
+ pen.beginPath()
+ pen.addPoint((150, 0), "line")
+ pen.addPoint((450, 0), "line")
+ pen.addPoint((450, 500), "line")
+ pen.addPoint((150, 500), "line")
+ pen.endPath()
+
+ i = bold_ufo.newGlyph("dotlessi")
+ i.width = 400
+ pen = i.getPointPen()
+ pen.beginPath()
+ pen.addPoint((100, 0), "line")
+ pen.addPoint((300, 0), "line")
+ pen.addPoint((300, 500), "line")
+ pen.addPoint((100, 500), "line")
+ pen.endPath()
+
+ gravecomb = bold_ufo.newGlyph("gravecomb")
+ pen = gravecomb.getPointPen()
+ pen.beginPath()
+ pen.addPoint((40, 550), "line")
+ pen.addPoint((0, 750), "line")
+ pen.addPoint((-70, 750), "line")
+ pen.addPoint((0, 550), "line")
+ pen.endPath()
+
+ agrave = bold_ufo.newGlyph("agrave")
+ agrave.width = a.width
+ pen = agrave.getPointPen()
+ pen.addComponent("a", (1, 0, 0, 1, 0, 0))
+ pen.addComponent("gravecomb", (1, 0, 0, 1, 300, 0))
+
+ igrave = bold_ufo.newGlyph("igrave")
+ igrave.width = i.width
+ pen = igrave.getPointPen()
+ pen.addComponent("dotlessi", (1, 0, 0, 1, 0, 0))
+ pen.addComponent("gravecomb", (1, 0, 0, 1, 200, 0))
+
+ ufos = [regular_ufo, regular_ufo, bold_ufo]
+ glyphSets = [
+ _GlyphSet.from_layer(regular_ufo),
+ _GlyphSet.from_layer(regular_ufo, layerName="Medium"),
+ _GlyphSet.from_layer(bold_ufo),
+ ]
+ return ufos, glyphSets
+
+
+class DecomposeComponentsIFilterTest:
+ def test_composite_with_intermediate_master(self, ufos_and_glyphSets):
+ ufos, glyphSets = ufos_and_glyphSets
+ regular_glyphs, medium_glyphs, bold_glyphs = glyphSets
+ assert "agrave" in medium_glyphs
+ assert {"a", "gravecomb"}.isdisjoint(medium_glyphs)
+
+ instantiator = Instantiator(
+ {"Weight": (100, 100, 200)},
+ [
+ ({"Weight": 100}, regular_glyphs),
+ ({"Weight": 150}, medium_glyphs),
+ ({"Weight": 200}, bold_glyphs),
+ ],
+ )
+ filter_ = DecomposeComponentsIFilter(include={"agrave"})
+
+ modified = filter_(ufos, glyphSets, instantiator=instantiator)
+
+ assert modified == {"agrave"}
+
+ agrave = regular_glyphs["agrave"]
+ assert len(agrave.components) == 0
+ assert [[(p.x, p.y) for p in c] for c in agrave] == [
+ [(100, 0), (400, 0), (400, 500), (100, 500)],
+ [(280, 550), (250, 750), (200, 750), (250, 550)],
+ ]
+
+ # 'agrave' was fully decomposed also in the medium layer, despite the
+ # latter not containing sources for the "a" and "gravecomb" component glyphs.
+ # These were interpolated on-the-fly while decomposing the composite glyph.
+ agrave = medium_glyphs["agrave"]
+ assert len(agrave.components) == 0
+ assert [[(p.x, p.y) for p in c] for c in agrave] == [
+ [(125, 0), (425, 0), (425, 500), (125, 500)],
+ [(310, 550), (275, 750), (215, 750), (275, 550)],
+ ]
+
+ agrave = bold_glyphs["agrave"]
+ assert len(agrave.components) == 0
+ assert [[(p.x, p.y) for p in c] for c in agrave] == [
+ [(150, 0), (450, 0), (450, 500), (150, 500)],
+ [(340, 550), (300, 750), (230, 750), (300, 550)],
+ ]
+
+ def test_component_with_intermediate_master(self, ufos_and_glyphSets):
+ ufos, glyphSets = ufos_and_glyphSets
+ regular_glyphs, medium_glyphs, bold_glyphs = glyphSets
+ assert {"dotlessi", "gravecomb", "igrave"}.issubset(regular_glyphs)
+ assert {"dotlessi", "gravecomb", "igrave"}.issubset(bold_glyphs)
+ assert "dotlessi" in medium_glyphs
+ assert {"igrave", "gravecomb"}.isdisjoint(medium_glyphs)
+
+ instantiator = Instantiator(
+ {"Weight": (100, 100, 200)},
+ [
+ ({"Weight": 100}, regular_glyphs),
+ ({"Weight": 150}, medium_glyphs),
+ ({"Weight": 200}, bold_glyphs),
+ ],
+ )
+ filter_ = DecomposeComponentsIFilter(include={"igrave"})
+
+ modified = filter_(ufos, glyphSets, instantiator=instantiator)
+
+ assert modified == {"igrave"}
+
+ igrave = regular_glyphs["igrave"]
+ assert len(igrave.components) == 0
+ assert [[(p.x, p.y) for p in c] for c in igrave] == [
+ [(100, 0), (200, 0), (200, 500), (100, 500)],
+ [(180, 550), (150, 750), (100, 750), (150, 550)],
+ ]
+
+ # 'igrave' was also decomposed in the Medium layer, despite it was not
+ # originally present; it was added by the filter and interpolated on-the-fly,
+ # because Medium contained a different 'dotlessi' used as a component.
+ igrave = medium_glyphs["igrave"]
+ assert len(igrave.components) == 0
+ assert [[(p.x, p.y) for p in c] for c in igrave] == [
+ [(100, 0), (250, 0), (175, 500), (175, 500)],
+ [(210, 550), (175, 750), (115, 750), (175, 550)],
+ ]
+ assert {"dotlessi", "igrave"}.issubset(medium_glyphs)
+ assert "gravecomb" not in medium_glyphs
+
+ igrave = bold_glyphs["igrave"]
+ assert len(igrave.components) == 0
+ assert [[(p.x, p.y) for p in c] for c in igrave] == [
+ [(100, 0), (300, 0), (300, 500), (100, 500)],
+ [(240, 550), (200, 750), (130, 750), (200, 550)],
+ ]
+
+ def test_without_instantiator(self, ufos_and_glyphSets):
+ # without an instantiator (i.e. when the filter is run from the legacy
+ # `compileInterpolatableTTFs` without a designspace as input but only a buch
+ # of UFOs), the filter will raise a MissingComponentError while
+ # trying to decompose 'agrave', because it can't interpolate the missing
+ # components 'a' and 'gravecomb'
+ ufos, glyphSets = ufos_and_glyphSets
+ medium_glyphs = glyphSets[1]
+ assert {"agrave", "dotlessi"}.issubset(medium_glyphs)
+ assert {"a", "gravecomb", "igrave"}.isdisjoint(medium_glyphs)
+
+ with pytest.raises(MissingComponentError, match="'a'"):
+ DecomposeComponentsIFilter(include={"agrave"})(ufos, glyphSets)
+
+ # the filter will not fail to decompose 'igrave' in Regular or Bold, however the
+ # Medium master will not contain decomposed outlines for 'igrave', and
+ # in the VF produced from these masters the 'igrave' will appear different
+ # at runtime from 'dotlessi' when the Medium instance is selected.
+ modified = DecomposeComponentsIFilter(include={"igrave"})(ufos, glyphSets)
+ assert modified == {"igrave"}
+ assert "igrave" not in medium_glyphs
diff --git a/tests/filters/propagateAnchors_test.py b/tests/filters/propagateAnchors_test.py
index 6b28682a3..2bb59f609 100644
--- a/tests/filters/propagateAnchors_test.py
+++ b/tests/filters/propagateAnchors_test.py
@@ -1,8 +1,15 @@
import pytest
+from fontTools.designspaceLib import DesignSpaceDocument
from fontTools.misc.loggingTools import CapturingLogHandler
import ufo2ft.filters
-from ufo2ft.filters.propagateAnchors import PropagateAnchorsFilter, logger
+from ufo2ft.filters.propagateAnchors import (
+ PropagateAnchorsFilter,
+ PropagateAnchorsIFilter,
+ logger,
+)
+from ufo2ft.instantiator import Instantiator
+from ufo2ft.util import _GlyphSet
@pytest.fixture(
@@ -257,3 +264,50 @@ def test_CantarellAnchorPropagation_reduced_filter(FontClass, datadir):
anchors_o = {(a.name, a.x, a.y) for a in ufo["ocircumflextilde"].anchors}
assert ("top", 284.0, 730.0) in anchors_o
+
+
+class PropagateAnchorsIFilterTest:
+ def test_propagate_from_interpolated_components(self, FontClass, data_dir):
+ ds_path = data_dir / "SkipExportGlyphsTest.designspace"
+ ds = DesignSpaceDocument.fromfile(ds_path)
+ ds.loadSourceFonts(FontClass)
+
+ ufos = [s.font for s in ds.sources]
+ glyphSets = [_GlyphSet.from_layer(s.font, s.layerName) for s in ds.sources]
+
+ assert len(ufos) == len(glyphSets) == 4
+
+ # the composite glyph 'Astroke' has no anchors, but 'A' has some
+ for glyphSet in glyphSets:
+ if "Astroke" in glyphSet:
+ assert not glyphSet["Astroke"].anchors
+ if "A" in glyphSet:
+ assert glyphSet["A"].anchors
+
+ # in glyphSets[2] the 'Astroke' component base glyphs are missing so their
+ # propagated anchors are supposed to be interpolated on the fly
+ assert "Astroke" in glyphSets[2]
+ assert {c.baseGlyph for c in glyphSets[2]["Astroke"].components}.isdisjoint(
+ glyphSets[2].keys()
+ )
+ assert not glyphSets[2]["Astroke"].anchors
+
+ instantiator = Instantiator.from_designspace(
+ ds, do_kerning=False, do_info=False
+ )
+
+ philter = PropagateAnchorsIFilter()
+
+ modified = philter(ufos, glyphSets, instantiator)
+
+ assert modified == {"Astroke"}
+
+ assert [dict(a) for a in glyphSets[2]["Astroke"].anchors] == [
+ {"name": "bottom", "x": 458, "y": 0},
+ {"name": "center", "x": 457, "y": 358},
+ {"name": "top", "x": 457, "y": 714},
+ {"name": "topright", "x": 716, "y": 714},
+ ]
+ assert {c.baseGlyph for c in glyphSets[2]["Astroke"].components}.isdisjoint(
+ glyphSets[2].keys()
+ )
diff --git a/tests/integration_test.py b/tests/integration_test.py
index 41bef3197..33ea57d2a 100644
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -165,6 +165,14 @@ def test_nestedComponents_interpolatable(self, FontClass):
for ttf in ttfs:
assert ttf["maxp"].maxComponentDepth == 1
+ def test_nestedComponents_variable(self, FontClass):
+ designspace = DesignSpaceDocument.fromfile(
+ getpath("NestedComponents.designspace")
+ )
+ designspace.loadSourceFonts(FontClass)
+ vf = compileVariableTTF(designspace, flattenComponents=True)
+ assert vf["maxp"].maxComponentDepth == 1
+
def test_interpolatableTTFs_lazy(self, FontClass):
# two same UFOs **must** be interpolatable
ufos = [FontClass(getpath("TestFont.ufo")) for _ in range(2)]
diff --git a/tests/outlineCompiler_test.py b/tests/outlineCompiler_test.py
index 03760e8b5..5755ce5d2 100644
--- a/tests/outlineCompiler_test.py
+++ b/tests/outlineCompiler_test.py
@@ -1324,7 +1324,12 @@ def test_custom_layer_compilation_interpolatable_otf_from_ds(designspace, inplac
"dotabovecomb",
"edotabove",
]
- assert master_otfs[1].getGlyphOrder() == [".notdef", "e"]
+ # 'edotabove' composite glyph needed to be decomposed because these are CFF fonts;
+ # and because one of its components 'e' has an additional intermediate master, the
+ # latter 'bubbled up' to the parent glyph when this got decomposed; hence why
+ # we see 'edotabove' in master_otfs[1] below, but we do not in the previous test
+ # with interpolatalbe TTFs where 'edotabove' stays a composite glyph.
+ assert master_otfs[1].getGlyphOrder() == [".notdef", "e", "edotabove"]
assert master_otfs[2].getGlyphOrder() == [
".notdef",
"a",
diff --git a/tests/preProcessor_test.py b/tests/preProcessor_test.py
index eec9ec5e3..a58c37984 100644
--- a/tests/preProcessor_test.py
+++ b/tests/preProcessor_test.py
@@ -338,8 +338,11 @@ def test_skip_export_glyphs_filter_nested(self, FontClass):
skipExportGlyphs = ["_o.numero"]
glyphSet = _GlyphSet.from_layer(ufo, skipExportGlyphs=skipExportGlyphs)
- assert len(glyphSet["numero"].components) == 1 # The "N" component
- assert len(glyphSet["numero"]) == 2 # The two contours of "o" and "_o.numero"
+ # "numero" now contains two components "N" and "o", and one contour from the
+ # decomposed "_o.numero"
+ assert {c.baseGlyph for c in glyphSet["numero"].components} == {"N", "o"}
+ assert len(glyphSet["numero"]) == 1
+ assert set(glyphSet.keys()) == {"N", "numero", "o"} # "_o.numero" is gone
def test_skip_export_glyphs_designspace(self, FontClass):
# Designspace has a public.skipExportGlyphs lib key excluding "b" and "d".
@@ -371,6 +374,78 @@ def test_skip_export_glyphs_designspace(self, FontClass):
assert not hasattr(glyphs["e"], "components")
assert glyphs["f"].isComposite()
+ def test_skip_export_glyphs_designspace_variable(self, FontClass):
+ # The designspace has a public.skipExportGlyphs lib key excluding "_stroke";
+ # there are four sources, a Regular, Medium, Semibold and Bold; there is a
+ # composite glyph "Astroke" that is composed of "A" and "_stroke".
+ designspace = designspaceLib.DesignSpaceDocument.fromfile(
+ getpath("SkipExportGlyphsTest.designspace")
+ )
+ designspace.loadSourceFonts(FontClass)
+
+ vf = ufo2ft.compileVariableTTF(designspace, useProductionNames=False)
+
+ # We expect that "_stroke" glyph is not exported and "Astroke" is decomposed to
+ # simple contour glyph.
+ glyf = vf["glyf"]
+ assert "_stroke" not in glyf
+ assert "Astroke" in glyf
+
+ Astroke = glyf["Astroke"]
+ assert not Astroke.isComposite()
+ assert Astroke.numberOfContours == 3
+
+ # 'Astroke' composite glyph should have 3 delta sets in gvar: two (corresponding
+ # to the Semibold and Bold masters) were already in the sources, and a third one
+ # was added by the preprocessor for the Medium master, because "_stroke" was
+ # present in the Medium and marked as non-export, to be decomposed.
+ gvar = vf["gvar"]
+ assert len(gvar.variations["Astroke"]) == 3
+
+ # Now we add a new composite glyph "_stroke.alt" to the full Regular and Bold
+ # sources and replace reference to the "_stroke" component in the "Astroke"
+ # with the new "_stroke.alt". So "Astroke" has now a component which in turn
+ # references another component (i.e. nested components). We also set the
+ # public.skipExportGlyphs to exclude "_stroke.alt" from the export, but not
+ # "_stroke" anymore, which should now be exported.
+ designspace.lib["public.skipExportGlyphs"] = ["_stroke.alt"]
+ num_Astroke_sources = 0
+ for source in designspace.sources:
+ if source.layerName is None:
+ layer = source.font.layers.defaultLayer
+ stroke_alt = layer.newGlyph("_stroke.alt")
+ stroke_alt.getPen().addComponent("_stroke", (1, 0, 0, 1, 0, -100))
+ else:
+ layer = source.font.layers[source.layerName]
+ if "Astroke" in layer:
+ Astroke = layer["Astroke"]
+ for component in Astroke.components:
+ if component.baseGlyph == "_stroke":
+ component.baseGlyph = "_stroke.alt"
+ num_Astroke_sources += 1
+
+ vf = ufo2ft.compileVariableTTF(designspace, useProductionNames=False)
+
+ # we expect that "_stroke.alt" glyph is not exported and the reference to it
+ # in "Astroke" is replaced with "_stroke" with the offset adjusted. "Astroke"
+ # itself should NOT be decomposed to simple glyph.
+ glyf = vf["glyf"]
+ assert "_stroke.alt" not in glyf
+ assert "_stroke" in glyf
+ assert "Astroke" in glyf
+
+ Astroke = glyf["Astroke"]
+ assert Astroke.isComposite()
+ assert [c.glyphName for c in Astroke.components] == ["A", "_stroke"]
+ stroke_comp = Astroke.components[1]
+ assert (stroke_comp.x, stroke_comp.y) == (0, -100)
+
+ # 'Astroke' composite glyph should have 2 delta sets in gvar: i.e. one for each
+ # of the non-default masters it was originally present in. No additional
+ # master should be added by the preprocessor in this case.
+ gvar = vf["gvar"]
+ assert len(gvar.variations["Astroke"]) == num_Astroke_sources - 1
+
def test_skip_export_glyphs_multi_ufo(self, FontClass):
# Bold has a public.skipExportGlyphs lib key excluding "b", "d" and "f".
ufo1 = FontClass(getpath("IncompatibleMasters/NewFont-Regular.ufo"))
diff --git a/tox.ini b/tox.ini
index fed40c4a9..570b2811e 100644
--- a/tox.ini
+++ b/tox.ini
@@ -22,7 +22,7 @@ deps =
-r dev-requirements.txt
commands =
black --check --diff .
- isort --check-only --diff .
+ isort --gitignore --check-only --diff .
flake8
[testenv:htmlcov]