Skip to content

Commit

Permalink
Merge pull request #826 from googlefonts/interpolatable-filters
Browse files Browse the repository at this point in the history
use instantiator when decomposing "sparse" composite glyphs
  • Loading branch information
anthrotype authored Mar 25, 2024
2 parents d0df0ab + ac624aa commit 4d9aca9
Show file tree
Hide file tree
Showing 57 changed files with 1,982 additions and 392 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,11 @@ htmlcov/
# autosaved emacs files
*~

# virtual environments dirs
venv/
.venv/
venv3*/
.venv3*/

# autogenerated by setuptools-scm
Lib/ufo2ft/_version.py
66 changes: 63 additions & 3 deletions Lib/ufo2ft/_compilers/baseCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
48 changes: 9 additions & 39 deletions Lib/ufo2ft/_compilers/interpolatableOTFCompiler.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,32 @@
import dataclasses
from dataclasses import dataclass
from typing import Optional, Type

from fontTools import varLib

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(
Expand Down
43 changes: 1 addition & 42 deletions Lib/ufo2ft/_compilers/interpolatableTTFCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
21 changes: 0 additions & 21 deletions Lib/ufo2ft/_compilers/otfCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
4 changes: 2 additions & 2 deletions Lib/ufo2ft/_compilers/variableCFF2sCompiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions Lib/ufo2ft/filters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -85,24 +96,30 @@ 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")
return False
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):
Expand Down
Loading

0 comments on commit 4d9aca9

Please sign in to comment.